mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-02-19 15:26:22 -05:00
Compare commits
41 Commits
v2.5.0
...
add_authen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18dc0bb7e4 | ||
|
|
dd38b576f7 | ||
|
|
94215cee00 | ||
|
|
197bd0d444 | ||
|
|
d20773ab7b | ||
|
|
f4e92a68ee | ||
|
|
18dc2813eb | ||
|
|
63ef979d0d | ||
|
|
a72f01fe4c | ||
|
|
9699e0fc29 | ||
|
|
0be7e125c9 | ||
|
|
49f0ce9969 | ||
|
|
4d8e27b01e | ||
|
|
d822f7ef32 | ||
|
|
3d7ed0e702 | ||
|
|
f514523de1 | ||
|
|
7160838ab4 | ||
|
|
cf495b5aac | ||
|
|
6388677244 | ||
|
|
9d46c0ae12 | ||
|
|
dad8dd9eee | ||
|
|
5ea3b5273f | ||
|
|
8864207b8e | ||
|
|
94acd9afa4 | ||
|
|
65d25a72a9 | ||
|
|
97eb2fce44 | ||
|
|
701829001c | ||
|
|
8aeeca111c | ||
|
|
c43936ce81 | ||
|
|
f35eb0c922 | ||
|
|
b2b0626b44 | ||
|
|
40f108d7ca | ||
|
|
6570f74b7e | ||
|
|
16f216cf84 | ||
|
|
69551edeff | ||
|
|
7192796e89 | ||
|
|
1d1ee7972f | ||
|
|
8bd6b86018 | ||
|
|
6abb542271 | ||
|
|
2aceae3078 | ||
|
|
65b200a68e |
4
.github/workflows/build-macos-installer.yml
vendored
4
.github/workflows/build-macos-installer.yml
vendored
@@ -21,12 +21,12 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-13
|
||||
runner: macos-15-intel
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-14
|
||||
runner: macos-15
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
350
CLAUDE.md
Normal file
350
CLAUDE.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Cleanuparr - Claude AI Rules
|
||||
|
||||
## 🚨 Critical Guidelines
|
||||
|
||||
**READ THIS FIRST:**
|
||||
1. ⚠️ **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||
2. ❓ **When in doubt, ASK** - Always clarify before implementing uncertain changes
|
||||
3. 📋 **Follow existing patterns** - Study the codebase style before making changes
|
||||
4. 🆕 **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
|
||||
**Key Features:**
|
||||
- Strike system for bad downloads
|
||||
- Malware detection and blocking
|
||||
- Automatic search triggering after removal
|
||||
- Orphaned download cleanup with cross-seed support
|
||||
- Support for multiple notification providers (Discord, etc.)
|
||||
|
||||
## Architecture & Tech Stack
|
||||
|
||||
### Backend
|
||||
- **.NET 10.0** (C#) with ASP.NET Core
|
||||
- **Architecture**: Clean Architecture pattern
|
||||
- `Cleanuparr.Domain` - Domain models and business logic
|
||||
- `Cleanuparr.Application` - Application services and use cases
|
||||
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
|
||||
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
|
||||
- `Cleanuparr.Api` - REST API and web host
|
||||
- `Cleanuparr.Shared` - Shared utilities
|
||||
- **Database**: SQLite with Entity Framework Core 10.0
|
||||
- Two separate contexts: `DataContext` and `EventsContext`
|
||||
- **Key Libraries**:
|
||||
- MassTransit (messaging)
|
||||
- Quartz.NET (scheduling)
|
||||
- Serilog (logging)
|
||||
- SignalR (real-time communication)
|
||||
|
||||
### Frontend
|
||||
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
|
||||
- **UI**: Custom glassmorphism design system (no external UI frameworks)
|
||||
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
|
||||
- **Design System**: 3-layer SCSS (`_variables` → `_tokens` → `_themes`), dark/light themes
|
||||
- **State Management**: @ngrx/signals (Angular signals-based)
|
||||
- **Real-time Updates**: SignalR (@microsoft/signalr)
|
||||
- **PWA**: Service Worker support enabled
|
||||
|
||||
### Documentation
|
||||
- **Docusaurus** (TypeScript-based static site)
|
||||
- Hosted at https://cleanuparr.github.io/Cleanuparr/
|
||||
|
||||
### Deployment
|
||||
- **Docker** (primary distribution method)
|
||||
- Standalone executables for Windows, macOS, and Linux
|
||||
- Platform installers for Windows (.exe) and macOS (.pkg)
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- .NET 10.0 SDK
|
||||
- Node.js 18+
|
||||
- Git
|
||||
- (Optional) Make for database migrations
|
||||
- (Optional) JetBrains Rider or Visual Studio
|
||||
|
||||
### GitHub Packages Authentication
|
||||
Cleanuparr uses GitHub Packages for NuGet dependencies. Configure access:
|
||||
|
||||
```bash
|
||||
dotnet nuget add source \
|
||||
--username YOUR_GITHUB_USERNAME \
|
||||
--password YOUR_GITHUB_PAT \
|
||||
--store-password-in-clear-text \
|
||||
--name Cleanuparr \
|
||||
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
```
|
||||
|
||||
You need a GitHub PAT with `read:packages` permission.
|
||||
|
||||
### Running the Backend
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
API runs at http://localhost:5000
|
||||
|
||||
### Running the Frontend
|
||||
```bash
|
||||
cd code/frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
UI runs at http://localhost:4200
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet test
|
||||
```
|
||||
|
||||
### Running Documentation
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
Docs run at http://localhost:3000
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Cleanuparr/
|
||||
├── code/
|
||||
│ ├── backend/
|
||||
│ │ ├── Cleanuparr.Api/ # API entry point
|
||||
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||
│ │ ├── Cleanuparr.Infrastructure/ # External integrations
|
||||
│ │ ├── Cleanuparr.Persistence/ # Database & EF Core
|
||||
│ │ ├── Cleanuparr.Shared/ # Shared utilities
|
||||
│ │ └── *.Tests/ # Unit tests
|
||||
│ ├── frontend/ # Angular 21 application
|
||||
│ ├── ui/ # Built frontend assets
|
||||
│ ├── Dockerfile # Multi-stage Docker build
|
||||
│ ├── entrypoint.sh # Docker entrypoint
|
||||
│ └── Makefile # Build & migration helpers
|
||||
├── docs/ # Docusaurus documentation
|
||||
├── Logo/ # Branding assets
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── blacklist # Default malware patterns
|
||||
├── blacklist_permissive # Alternative blacklist
|
||||
├── whitelist # Safe file patterns
|
||||
└── CONTRIBUTING.md # Contribution guidelines
|
||||
```
|
||||
|
||||
## Code Standards & Conventions
|
||||
|
||||
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
|
||||
|
||||
### Backend (C#)
|
||||
- Follow [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||
- Add XML documentation comments for public APIs
|
||||
- Write unit tests for business logic
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- **Study existing service implementations before creating new ones**
|
||||
|
||||
### Frontend (TypeScript/Angular)
|
||||
- Follow [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||
- Use TypeScript strict mode
|
||||
- All components must be **standalone** (no NgModules) with **ChangeDetectionStrategy.OnPush**
|
||||
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
|
||||
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
|
||||
- Follow the 3-layer SCSS design system (`_variables` → `_tokens` → `_themes`) for styling
|
||||
- Component naming: `{feature}.component.ts`
|
||||
- Service naming: `{feature}.service.ts`
|
||||
- **Look at similar existing components before creating new ones**
|
||||
|
||||
### Testing
|
||||
- Write unit tests for new features and bug fixes
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- Backend: xUnit or NUnit conventions
|
||||
- Frontend: Jasmine/Karma
|
||||
- **Test that existing functionality still works after changes**
|
||||
|
||||
### Git Commit Messages
|
||||
- Use clear, descriptive messages in imperative mood
|
||||
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
|
||||
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
|
||||
|
||||
### Discovering Issues
|
||||
If you encounter potential gotchas, common mistakes, or areas that need special attention during development:
|
||||
- **Flag them to the maintainer immediately**
|
||||
- Document them if confirmed
|
||||
- Consider if they should be added to this guide
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Cleanuparr uses two separate database contexts:
|
||||
- **DataContext**: Main application data
|
||||
- **EventsContext**: Event logging and audit trail
|
||||
|
||||
### Creating Migrations
|
||||
From the `code` directory:
|
||||
|
||||
```bash
|
||||
# Data migrations
|
||||
make migrate-data name=YourMigrationName
|
||||
|
||||
# Events migrations
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddDownloadClientConfig
|
||||
make migrate-events name=AddStrikeEvents
|
||||
```
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding a New *arr Application Integration
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
|
||||
2. Update domain models in `Cleanuparr.Domain/`
|
||||
3. Create/update services in `Cleanuparr.Application/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/`
|
||||
5. Update frontend in `code/frontend/src/app/`
|
||||
6. Document in `docs/docs/`
|
||||
|
||||
### Adding a New Download Client
|
||||
1. Add client implementation in `Cleanuparr.Infrastructure/DownloadClients/`
|
||||
2. Follow existing patterns (qBittorrent, Transmission, etc.)
|
||||
3. Add configuration models to `Cleanuparr.Domain/`
|
||||
4. Update API and frontend as above
|
||||
|
||||
### Adding a New Notification Provider
|
||||
1. Add provider in `Cleanuparr.Infrastructure/Notifications/`
|
||||
2. Update configuration models
|
||||
3. Add UI configuration in frontend
|
||||
4. Test with actual service
|
||||
|
||||
## Important Files
|
||||
|
||||
### Configuration Files
|
||||
- `code/backend/Cleanuparr.Api/appsettings.json` - Backend configuration
|
||||
- `code/frontend/angular.json` - Angular build configuration
|
||||
- `code/Dockerfile` - Docker multi-stage build
|
||||
- `docs/docusaurus.config.ts` - Documentation site config
|
||||
|
||||
### CI/CD Workflows
|
||||
- `.github/workflows/test.yml` - Run tests
|
||||
- `.github/workflows/build-docker.yml` - Build Docker images
|
||||
- `.github/workflows/build-executable.yml` - Build standalone executables
|
||||
- `.github/workflows/release.yml` - Create releases
|
||||
- `.github/workflows/docs.yml` - Deploy documentation
|
||||
|
||||
### Malware Protection
|
||||
- `blacklist` - Default malware file patterns (strict)
|
||||
- `blacklist_permissive` - Less strict patterns
|
||||
- `whitelist` - Known safe file extensions
|
||||
- `whitelist_with_subtitles` - Includes subtitle formats
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
### Before Starting Work
|
||||
1. **Announce your intent** - Comment on an issue or create a new one
|
||||
2. **Wait for approval** from maintainers
|
||||
3. Fork the repository and create a feature branch
|
||||
4. Make your changes following code standards
|
||||
5. Test thoroughly (both manual and automated tests)
|
||||
6. Submit a PR with clear description and testing notes
|
||||
|
||||
### Pull Request Requirements
|
||||
- Link to related issue
|
||||
- Clear description of changes
|
||||
- Evidence of testing
|
||||
- Updated documentation if needed
|
||||
- No breaking changes without discussion
|
||||
|
||||
## Docker Development
|
||||
|
||||
### Build Local Docker Image
|
||||
```bash
|
||||
cd code
|
||||
docker build \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
### Multi-Architecture Build
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
When running via Docker:
|
||||
- `PORT` - API port (default: 11011)
|
||||
- `PUID` - User ID for file permissions
|
||||
- `PGID` - Group ID for file permissions
|
||||
- `TZ` - Timezone (e.g., `America/New_York`)
|
||||
|
||||
## Security & Safety
|
||||
|
||||
- Never commit sensitive data (API keys, tokens, passwords)
|
||||
- All *arr and download client credentials are stored encrypted
|
||||
- The malware detection system uses pattern matching on file extensions and names
|
||||
- Always validate user input on both frontend and backend
|
||||
- Follow OWASP guidelines for web application security
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Documentation**: https://cleanuparr.github.io/Cleanuparr/
|
||||
- **Discord**: https://discord.gg/SCtMCgtsc4
|
||||
- **GitHub Issues**: https://github.com/Cleanuparr/Cleanuparr/issues
|
||||
- **Releases**: https://github.com/Cleanuparr/Cleanuparr/releases
|
||||
|
||||
## Working with Claude - IMPORTANT
|
||||
|
||||
### Core Principles
|
||||
1. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||
2. **Don't break existing functionality** - Everything is important and needs to work
|
||||
3. **Follow existing coding style** - Study the codebase patterns before making changes
|
||||
4. **Use current coding standards** - If you want to introduce something new, ask first
|
||||
|
||||
### When Modifying Code
|
||||
- **ALWAYS read existing files before suggesting changes**
|
||||
- Understand the current architecture and patterns
|
||||
- Prefer editing existing files over creating new ones
|
||||
- Follow the established conventions in the codebase exactly
|
||||
- Test changes locally when possible
|
||||
- **If you're unsure about an approach, ask before implementing**
|
||||
|
||||
### When Adding Features
|
||||
- Review similar existing features first to understand patterns
|
||||
- Maintain consistency with existing UI/UX patterns
|
||||
- Update both backend and frontend together
|
||||
- Add/update documentation
|
||||
- Consider backwards compatibility
|
||||
- **Ask about architectural decisions before implementing new patterns**
|
||||
|
||||
### When Fixing Bugs
|
||||
- Understand the root cause before proposing a fix
|
||||
- **Be careful not to break other functionality** - test related areas
|
||||
- Add tests to prevent regression
|
||||
- Update relevant documentation if behavior changes
|
||||
- Consider if other parts of the codebase might have similar issues
|
||||
- **Flag any potential gotchas or issues you discover**
|
||||
|
||||
## Notes
|
||||
|
||||
- The project uses **Clean Architecture** - respect layer boundaries
|
||||
- Database migrations require both contexts - don't forget EventsContext
|
||||
- Frontend uses a **custom glassmorphism design system** - don't introduce external UI frameworks (no PrimeNG, Material, etc.)
|
||||
- All frontend components are **standalone** with **OnPush** change detection
|
||||
- All downloads from *arr apps are processed through a **strike system**
|
||||
- The malware blocker is a critical security feature - changes require careful testing
|
||||
- Cross-seed integration allows keeping torrents that are actively seeding
|
||||
- Real-time updates use **SignalR** - maintain websocket patterns when adding features
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
|
||||
@@ -15,6 +15,12 @@ ifndef name
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
migrate-users:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-users name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public CustomWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
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 =>
|
||||
{
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the authentication flow.
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AuthControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "another",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
|
||||
|
||||
// Store the secret for the next test
|
||||
_totpSecret = body.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
|
||||
{
|
||||
// If we don't have the secret from the previous test, generate it again
|
||||
if (string.IsNullOrEmpty(_totpSecret))
|
||||
{
|
||||
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_totpSecret = genBody.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
var code = GenerateTotpCode(_totpSecret);
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Setup_Complete_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Login_ValidCredentials_RequiresTwoFactor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_BruteForce_ReturnsRetryAfter()
|
||||
{
|
||||
// Make multiple failed attempts
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
|
||||
retry.GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
|
||||
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
|
||||
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task HealthEndpoint_WithoutAuth_Returns200()
|
||||
{
|
||||
var response = await _client.GetAsync("/health");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
private static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(timestepBytes);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0) continue;
|
||||
for (var i = 4; i >= 0; i--)
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
37
code/backend/Cleanuparr.Api.Tests/PriorityOrderer.cs
Normal file
37
code/backend/Cleanuparr.Api.Tests/PriorityOrderer.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
public sealed class PriorityOrderer : ITestCaseOrderer
|
||||
{
|
||||
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
|
||||
where TTestCase : ITestCase
|
||||
{
|
||||
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
|
||||
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var priority = testCase.TestMethod.Method
|
||||
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
|
||||
.FirstOrDefault()
|
||||
?.GetNamedArgument<int>("Priority") ?? 0;
|
||||
|
||||
if (!sortedMethods.TryGetValue(priority, out var list))
|
||||
{
|
||||
list = [];
|
||||
sortedMethods[priority] = list;
|
||||
}
|
||||
|
||||
list.Add(testCase);
|
||||
}
|
||||
|
||||
foreach (var list in sortedMethods.Values)
|
||||
{
|
||||
foreach (var testCase in list)
|
||||
{
|
||||
yield return testCase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
code/backend/Cleanuparr.Api.Tests/TestPriorityAttribute.cs
Normal file
12
code/backend/Cleanuparr.Api.Tests/TestPriorityAttribute.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class TestPriorityAttribute : Attribute
|
||||
{
|
||||
public int Priority { get; }
|
||||
|
||||
public TestPriorityAttribute(int priority)
|
||||
{
|
||||
Priority = priority;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.Auth;
|
||||
|
||||
public static class ApiKeyAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "ApiKey";
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
public const string QueryParameterName = "apikey";
|
||||
}
|
||||
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Try header first, then query string
|
||||
string? apiKey = null;
|
||||
|
||||
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue))
|
||||
{
|
||||
apiKey = headerValue.ToString();
|
||||
}
|
||||
else if (Request.Query.TryGetValue(ApiKeyAuthenticationDefaults.QueryParameterName, out var queryValue))
|
||||
{
|
||||
apiKey = queryValue.ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.ApiKey == apiKey && u.SetupCompleted);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid API key");
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("auth_method", "apikey")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -29,12 +29,24 @@ public class EventsController : ControllerBase
|
||||
[FromQuery] string? eventType = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] string? search = null)
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? jobRunId = null)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 100;
|
||||
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
|
||||
if (pageSize < 1)
|
||||
{
|
||||
pageSize = 100;
|
||||
}
|
||||
|
||||
if (pageSize > 1000)
|
||||
{
|
||||
pageSize = 1000; // Cap at 1000 for performance
|
||||
}
|
||||
|
||||
var query = _context.Events.AsQueryable();
|
||||
|
||||
@@ -62,6 +74,12 @@ public class EventsController : ControllerBase
|
||||
query = query.Where(e => e.Timestamp <= toDate.Value);
|
||||
}
|
||||
|
||||
// Apply job run ID exact-match filter
|
||||
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
|
||||
{
|
||||
query = query.Where(e => e.JobRunId == jobRunGuid);
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
@@ -69,7 +87,10 @@ public class EventsController : ControllerBase
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern) ||
|
||||
EF.Functions.Like(e.TrackingId.ToString(), pattern)
|
||||
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
|
||||
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||
EF.Functions.Like(e.DownloadClientName, pattern) ||
|
||||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Api.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -66,7 +66,9 @@ public class ManualEventsController : ControllerBase
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern)
|
||||
EF.Functions.Like(e.Data, pattern) ||
|
||||
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||
EF.Functions.Like(e.DownloadClientName, pattern)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
189
code/backend/Cleanuparr.Api/Controllers/StrikesController.cs
Normal file
189
code/backend/Cleanuparr.Api/Controllers/StrikesController.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class StrikesController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public StrikesController(EventsContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets download items with their strikes (grouped), with pagination and filtering
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<DownloadItemStrikesDto>>> GetStrikes(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? type = null)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 50;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
var query = _context.DownloadItems
|
||||
.Include(d => d.Strikes)
|
||||
.Where(d => d.Strikes.Any());
|
||||
|
||||
// Filter by strike type: only show items that have strikes of this type
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
if (Enum.TryParse<StrikeType>(type, true, out var strikeType))
|
||||
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
|
||||
}
|
||||
|
||||
// Apply search filter on title or download hash
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(d =>
|
||||
EF.Functions.Like(d.Title, pattern) ||
|
||||
EF.Functions.Like(d.DownloadId, pattern));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
var skip = (page - 1) * pageSize;
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(d => d.Strikes.Max(s => s.CreatedAt))
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var dtos = items.Select(d => new DownloadItemStrikesDto
|
||||
{
|
||||
DownloadItemId = d.Id,
|
||||
DownloadId = d.DownloadId,
|
||||
Title = d.Title,
|
||||
TotalStrikes = d.Strikes.Count,
|
||||
StrikesByType = d.Strikes
|
||||
.GroupBy(s => s.Type)
|
||||
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
|
||||
LatestStrikeAt = d.Strikes.Max(s => s.CreatedAt),
|
||||
FirstStrikeAt = d.Strikes.Min(s => s.CreatedAt),
|
||||
IsMarkedForRemoval = d.IsMarkedForRemoval,
|
||||
IsRemoved = d.IsRemoved,
|
||||
IsReturning = d.IsReturning,
|
||||
Strikes = d.Strikes
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Select(s => new StrikeDetailDto
|
||||
{
|
||||
Id = s.Id,
|
||||
Type = s.Type.ToString(),
|
||||
CreatedAt = s.CreatedAt,
|
||||
LastDownloadedBytes = s.LastDownloadedBytes,
|
||||
JobRunId = s.JobRunId,
|
||||
}).ToList(),
|
||||
}).ToList();
|
||||
|
||||
return Ok(new PaginatedResult<DownloadItemStrikesDto>
|
||||
{
|
||||
Items = dtos,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent individual strikes with download item info (for dashboard)
|
||||
/// </summary>
|
||||
[HttpGet("recent")]
|
||||
public async Task<ActionResult<List<RecentStrikeDto>>> GetRecentStrikes(
|
||||
[FromQuery] int count = 5)
|
||||
{
|
||||
if (count < 1) count = 1;
|
||||
if (count > 50) count = 50;
|
||||
|
||||
var strikes = await _context.Strikes
|
||||
.Include(s => s.DownloadItem)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Take(count)
|
||||
.Select(s => new RecentStrikeDto
|
||||
{
|
||||
Id = s.Id,
|
||||
Type = s.Type.ToString(),
|
||||
CreatedAt = s.CreatedAt,
|
||||
DownloadId = s.DownloadItem.DownloadId,
|
||||
Title = s.DownloadItem.Title,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(strikes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available strike types
|
||||
/// </summary>
|
||||
[HttpGet("types")]
|
||||
public ActionResult<List<string>> GetStrikeTypes()
|
||||
{
|
||||
var types = Enum.GetNames(typeof(StrikeType)).ToList();
|
||||
return Ok(types);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all strikes for a specific download item
|
||||
/// </summary>
|
||||
[HttpDelete("{downloadItemId:guid}")]
|
||||
public async Task<IActionResult> DeleteStrikesForItem(Guid downloadItemId)
|
||||
{
|
||||
var item = await _context.DownloadItems
|
||||
.Include(d => d.Strikes)
|
||||
.FirstOrDefaultAsync(d => d.Id == downloadItemId);
|
||||
|
||||
if (item == null)
|
||||
return NotFound();
|
||||
|
||||
_context.Strikes.RemoveRange(item.Strikes);
|
||||
_context.DownloadItems.Remove(item);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public class DownloadItemStrikesDto
|
||||
{
|
||||
public Guid DownloadItemId { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int TotalStrikes { get; set; }
|
||||
public Dictionary<string, int> StrikesByType { get; set; } = new();
|
||||
public DateTime LatestStrikeAt { get; set; }
|
||||
public DateTime FirstStrikeAt { get; set; }
|
||||
public bool IsMarkedForRemoval { get; set; }
|
||||
public bool IsRemoved { get; set; }
|
||||
public bool IsReturning { get; set; }
|
||||
public List<StrikeDetailDto> Strikes { get; set; } = [];
|
||||
}
|
||||
|
||||
public class StrikeDetailDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public long? LastDownloadedBytes { get; set; }
|
||||
public Guid JobRunId { get; set; }
|
||||
}
|
||||
|
||||
public class RecentStrikeDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -65,9 +65,13 @@ public static class ApiDI
|
||||
// Add the global exception handling middleware first
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
// Block non-auth requests until setup is complete
|
||||
app.UseMiddleware<SetupGuardMiddleware>();
|
||||
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -108,11 +112,11 @@ public static class ApiDI
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
// Map SignalR hubs
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
||||
app.MapHub<AppHub>("/api/hubs/app");
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health").RequireAuthorization();
|
||||
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
|
||||
|
||||
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
|
||||
{
|
||||
@@ -144,7 +148,7 @@ public static class ApiDI
|
||||
};
|
||||
|
||||
return Results.Json(manifest, contentType: "application/manifest+json");
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
81
code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs
Normal file
81
code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class AuthDI
|
||||
{
|
||||
private const string SmartScheme = "Smart";
|
||||
|
||||
public static IServiceCollection AddAuthServices(this IServiceCollection services)
|
||||
{
|
||||
// Get the signing key from the JwtService
|
||||
var jwtService = new JwtService();
|
||||
var signingKey = jwtService.GetOrCreateSigningKey();
|
||||
|
||||
services
|
||||
.AddAuthentication(SmartScheme)
|
||||
.AddPolicyScheme(SmartScheme, "JWT or API Key", options =>
|
||||
{
|
||||
// Route to the correct auth handler based on the request
|
||||
options.ForwardDefaultSelector = context =>
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.HeaderName) ||
|
||||
context.Request.Query.ContainsKey(ApiKeyAuthenticationDefaults.QueryParameterName))
|
||||
{
|
||||
return ApiKeyAuthenticationDefaults.AuthenticationScheme;
|
||||
}
|
||||
|
||||
return JwtBearerDefaults.AuthenticationScheme;
|
||||
};
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "Cleanuparr",
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "Cleanuparr",
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(signingKey),
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Support SignalR token via query string
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/api/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
var defaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
options.DefaultPolicy = defaultPolicy;
|
||||
options.FallbackPolicy = defaultPolicy;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,13 @@ public static class MainDI
|
||||
{
|
||||
// Add the dynamic HTTP client system - this replaces all the previous static configurations
|
||||
services.AddDynamicHttpClients();
|
||||
|
||||
|
||||
// Add the dynamic HTTP client provider that uses the new system
|
||||
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
||||
|
||||
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -18,6 +20,8 @@ public static class NotificationsDI
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<ITelegramProxy, TelegramProxy>()
|
||||
.AddScoped<IDiscordProxy, DiscordProxy>()
|
||||
.AddScoped<IGotifyProxy, GotifyProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
|
||||
@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
@@ -26,6 +27,11 @@ public static class ServicesDI
|
||||
services
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<UsersContext>()
|
||||
.AddSingleton<IJwtService, JwtService>()
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
|
||||
@@ -21,11 +21,14 @@ public sealed record ArrInstanceRequest
|
||||
[Required]
|
||||
public required float Version { get; init; }
|
||||
|
||||
public string? ExternalUrl { get; init; }
|
||||
|
||||
public ArrInstance ToEntity(Guid configId) => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
Url = new Uri(Url),
|
||||
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
Version = Version,
|
||||
@@ -36,6 +39,7 @@ public sealed record ArrInstanceRequest
|
||||
instance.Enabled = Enabled;
|
||||
instance.Name = Name;
|
||||
instance.Url = new Uri(Url);
|
||||
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
|
||||
instance.ApiKey = ApiKey;
|
||||
instance.Version = Version;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record ChangePasswordRequest
|
||||
{
|
||||
[Required]
|
||||
public required string CurrentPassword { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string NewPassword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record CreateAccountRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(3)]
|
||||
[MaxLength(50)]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record PlexPinRequest
|
||||
{
|
||||
[Required]
|
||||
public required int PinId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record RefreshTokenRequest
|
||||
{
|
||||
[Required]
|
||||
public required string RefreshToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Regenerate2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record TwoFactorRequest
|
||||
{
|
||||
[Required]
|
||||
public required string LoginToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
|
||||
public bool IsRecoveryCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record VerifyTotpRequest
|
||||
{
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AccountInfoResponse
|
||||
{
|
||||
public required string Username { get; init; }
|
||||
public required bool PlexLinked { get; init; }
|
||||
public string? PlexUsername { get; init; }
|
||||
public required bool TwoFactorEnabled { get; init; }
|
||||
public required string ApiKeyPreview { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AuthStatusResponse
|
||||
{
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record LoginResponse
|
||||
{
|
||||
public required bool RequiresTwoFactor { get; init; }
|
||||
public string? LoginToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record PlexPinStatusResponse
|
||||
{
|
||||
public required int PinId { get; init; }
|
||||
public required string AuthUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexVerifyResponse
|
||||
{
|
||||
public required bool Completed { get; init; }
|
||||
public TokenResponse? Tokens { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record TokenResponse
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record TotpSetupResponse
|
||||
{
|
||||
public required string Secret { get; init; }
|
||||
public required string QrCodeUri { get; init; }
|
||||
public required List<string> RecoveryCodes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/account")]
|
||||
[Authorize]
|
||||
public sealed class AccountController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
UsersContext usersContext,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAccountInfo()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new AccountInfoResponse
|
||||
{
|
||||
Username = user.Username,
|
||||
PlexLinked = user.PlexAccountId is not null,
|
||||
PlexUsername = user.PlexUsername,
|
||||
TwoFactorEnabled = user.TotpEnabled,
|
||||
ApiKeyPreview = user.ApiKey[..8] + "..."
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[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) return Unauthorized();
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("api-key")]
|
||||
public async Task<IActionResult> GetApiKey()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
|
||||
[HttpPost("api-key/regenerate")]
|
||||
public async Task<IActionResult> RegenerateApiKey()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("plex/link")]
|
||||
public async Task<IActionResult> StartPlexLink()
|
||||
{
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
|
||||
}
|
||||
|
||||
[HttpPost("plex/link/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new { completed = false });
|
||||
}
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
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 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null || !Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var query = _usersContext.Users.AsQueryable();
|
||||
|
||||
if (includeRecoveryCodes)
|
||||
{
|
||||
query = query.Include(u => u.RecoveryCodes);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[AllowAnonymous]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
IJwtService jwtService,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_jwtService = jwtService;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
return Ok(new AuthStatusResponse
|
||||
{
|
||||
SetupCompleted = user is { SetupCompleted: true },
|
||||
PlexLinked = user?.PlexAccountId is not null
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup/account")]
|
||||
public async Task<IActionResult> CreateAccount([FromBody] CreateAccountRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Conflict(new { error = "Account already exists" });
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username,
|
||||
PasswordHash = _passwordService.HashPassword(request.Password),
|
||||
TotpSecret = string.Empty,
|
||||
TotpEnabled = false,
|
||||
ApiKey = GenerateApiKey(),
|
||||
SetupCompleted = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_usersContext.Users.Add(user);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Admin account created for user {Username}", request.Username);
|
||||
|
||||
return Created("", new { userId = user.Id });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/2fa/generate")]
|
||||
public async Task<IActionResult> GenerateTotpSetup()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted && user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already configured" });
|
||||
}
|
||||
|
||||
// Generate new TOTP secret
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
|
||||
// Generate recovery codes
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
// Store secret (will be finalized on verify)
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove old recovery codes and add new ones
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/2fa/verify")]
|
||||
public async Task<IActionResult> VerifyTotpSetup([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return Unauthorized(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 verified and enabled" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/complete")]
|
||||
public async Task<IActionResult> CompleteSetup()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return BadRequest(new { error = "2FA must be configured before completing setup" });
|
||||
}
|
||||
|
||||
user.SetupCompleted = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Setup completed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Setup complete" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid credentials" });
|
||||
}
|
||||
|
||||
// Check lockout
|
||||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
|
||||
{
|
||||
var remaining = (int)(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))
|
||||
{
|
||||
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful password verification
|
||||
await ResetFailedAttempts(user.Id);
|
||||
|
||||
// Password valid - require 2FA
|
||||
var loginToken = _jwtService.GenerateLoginToken(user.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = true,
|
||||
LoginToken = loginToken
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login/2fa")]
|
||||
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
|
||||
{
|
||||
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||
if (userId is null)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired login token" });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid login token" });
|
||||
}
|
||||
|
||||
bool codeValid;
|
||||
|
||||
if (request.IsRecoveryCode)
|
||||
{
|
||||
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||
}
|
||||
|
||||
if (!codeValid)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
return Ok(await GenerateTokenResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||
|
||||
var storedToken = await _usersContext.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||
|
||||
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired refresh token" });
|
||||
}
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
|
||||
// Generate new tokens
|
||||
var response = await GenerateTokenResponse(storedToken.User);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||
|
||||
var storedToken = await _usersContext.RefreshTokens
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||
|
||||
if (storedToken is not null)
|
||||
{
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { message = "Logged out" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/plex/pin")]
|
||||
public async Task<IActionResult> RequestSetupPlexPin()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
PinId = pin.PinId,
|
||||
AuthUrl = pin.AuthUrl
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup/plex/verify")]
|
||||
public async Task<IActionResult> VerifySetupPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new PlexVerifyResponse { Completed = false });
|
||||
}
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
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 during setup for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new PlexVerifyResponse { Completed = true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login/plex/pin")]
|
||||
public async Task<IActionResult> RequestPlexPin()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
PinId = pin.PinId,
|
||||
AuthUrl = pin.AuthUrl
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login/plex/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new PlexVerifyResponse { Completed = false });
|
||||
}
|
||||
|
||||
// Verify the Plex account matches the linked one
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
if (plexAccount.AccountId != user.PlexAccountId)
|
||||
{
|
||||
return Unauthorized(new { error = "Plex account does not match the linked account" });
|
||||
}
|
||||
|
||||
// Plex login bypasses 2FA
|
||||
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
|
||||
return Ok(new PlexVerifyResponse
|
||||
{
|
||||
Completed = true,
|
||||
Tokens = tokenResponse
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> GenerateTokenResponse(User user)
|
||||
{
|
||||
var accessToken = _jwtService.GenerateAccessToken(user);
|
||||
var refreshToken = _jwtService.GenerateRefreshToken();
|
||||
|
||||
_usersContext.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
TokenHash = HashRefreshToken(refreshToken),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(7),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = 60 // seconds
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> TryUseRecoveryCode(User user, string code)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
foreach (var recoveryCode in user.RecoveryCodes.Where(r => !r.IsUsed))
|
||||
{
|
||||
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||
{
|
||||
recoveryCode.IsUsed = true;
|
||||
recoveryCode.UsedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> IncrementFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts++;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||
user.FailedLoginAttempts, user.Username, user.FailedLoginAttempts * 2);
|
||||
|
||||
return user.FailedLoginAttempts * 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string HashRefreshToken(string token)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(token);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed record CreateDownloadClientRequest
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
public string? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed record CreateDownloadClientRequest
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public string? ExternalUrl { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
@@ -31,10 +33,20 @@ public sealed record CreateDownloadClientRequest
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
if (string.IsNullOrWhiteSpace(Host))
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
|
||||
{
|
||||
throw new ValidationException("Host is not a valid URL");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
|
||||
{
|
||||
throw new ValidationException("External URL is not a valid URL");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToEntity() => new()
|
||||
@@ -43,9 +55,10 @@ public sealed record CreateDownloadClientRequest
|
||||
Name = Name,
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed record TestDownloadClientRequest
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
public string? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
@@ -22,10 +22,15 @@ public sealed record TestDownloadClientRequest
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Host is null)
|
||||
if (string.IsNullOrWhiteSpace(Host))
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
|
||||
{
|
||||
throw new ValidationException("Host is not a valid URL");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToTestConfig() => new()
|
||||
@@ -35,7 +40,7 @@ public sealed record TestDownloadClientRequest
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed record UpdateDownloadClientRequest
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
public string? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed record UpdateDownloadClientRequest
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public string? ExternalUrl { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
@@ -31,10 +33,20 @@ public sealed record UpdateDownloadClientRequest
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
if (string.IsNullOrWhiteSpace(Host))
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
|
||||
{
|
||||
throw new ValidationException("Host is not a valid URL");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
|
||||
{
|
||||
throw new ValidationException("External URL is not a valid URL");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
|
||||
@@ -43,9 +55,10 @@ public sealed record UpdateDownloadClientRequest
|
||||
Name = Name,
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||
|
||||
|
||||
@@ -24,10 +24,14 @@ public sealed record UpdateGeneralConfigRequest
|
||||
|
||||
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
|
||||
|
||||
public bool StatusCheckEnabled { get; init; } = true;
|
||||
|
||||
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
|
||||
public ushort StrikeInactivityWindowHours { get; init; } = 24;
|
||||
|
||||
public UpdateLoggingConfigRequest Log { get; init; } = new();
|
||||
|
||||
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
|
||||
@@ -39,8 +43,10 @@ public sealed record UpdateGeneralConfigRequest
|
||||
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
|
||||
existingConfig.SearchEnabled = SearchEnabled;
|
||||
existingConfig.SearchDelay = SearchDelay;
|
||||
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
|
||||
existingConfig.EncryptionKey = EncryptionKey;
|
||||
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
|
||||
|
||||
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
|
||||
|
||||
@@ -58,6 +64,16 @@ public sealed record UpdateGeneralConfigRequest
|
||||
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
|
||||
}
|
||||
|
||||
if (config.StrikeInactivityWindowHours is 0)
|
||||
{
|
||||
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be greater than 0");
|
||||
}
|
||||
|
||||
if (config.StrikeInactivityWindowHours > 168)
|
||||
{
|
||||
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be less than or equal to 168");
|
||||
}
|
||||
|
||||
config.Log.Validate();
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,21 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("strikes/purge")]
|
||||
public async Task<IActionResult> PurgeAllStrikes(
|
||||
[FromServices] EventsContext eventsContext)
|
||||
{
|
||||
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
|
||||
var deletedItems = await eventsContext.DownloadItems
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
_logger.LogWarning("Purged all strikes: {strikes} strikes, {items} download items removed",
|
||||
deletedStrikes, deletedItems);
|
||||
|
||||
return Ok(new { DeletedStrikes = deletedStrikes, DeletedItems = deletedItems });
|
||||
}
|
||||
|
||||
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
|
||||
{
|
||||
if (!wasDryRun || isDryRun)
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateDiscordProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string WebhookUrl { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string AvatarUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateGotifyProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestDiscordProviderRequest
|
||||
{
|
||||
public string WebhookUrl { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string AvatarUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestGotifyProviderRequest
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateDiscordProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string WebhookUrl { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string AvatarUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateGotifyProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
}
|
||||
@@ -5,8 +5,10 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -50,6 +52,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -76,6 +80,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
|
||||
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
|
||||
NotificationProviderType.Discord => p.DiscordConfiguration ?? new object(),
|
||||
NotificationProviderType.Gotify => p.GotifyConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
@@ -694,6 +700,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
@@ -926,11 +934,201 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
|
||||
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? new object(),
|
||||
NotificationProviderType.Discord => provider.DiscordConfiguration ?? new object(),
|
||||
NotificationProviderType.Gotify => provider.GotifyConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("discord")]
|
||||
public async Task<IActionResult> CreateDiscordProvider([FromBody] CreateDiscordProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = newProvider.WebhookUrl,
|
||||
Username = newProvider.Username,
|
||||
AvatarUrl = newProvider.AvatarUrl
|
||||
};
|
||||
discordConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Discord,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
DiscordConfiguration = discordConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Discord provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("discord/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateDiscordProvider(Guid id, [FromBody] UpdateDiscordProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Discord);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Discord provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = updatedProvider.WebhookUrl,
|
||||
Username = updatedProvider.Username,
|
||||
AvatarUrl = updatedProvider.AvatarUrl
|
||||
};
|
||||
|
||||
if (existingProvider.DiscordConfiguration != null)
|
||||
{
|
||||
discordConfig = discordConfig with { Id = existingProvider.DiscordConfiguration.Id };
|
||||
}
|
||||
discordConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
DiscordConfiguration = discordConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Discord provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("discord/test")]
|
||||
public async Task<IActionResult> TestDiscordProvider([FromBody] TestDiscordProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = testRequest.WebhookUrl,
|
||||
Username = testRequest.Username,
|
||||
AvatarUrl = testRequest.AvatarUrl
|
||||
};
|
||||
discordConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Discord,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = discordConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (DiscordException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to test Discord provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Discord provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("pushover")]
|
||||
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
|
||||
{
|
||||
@@ -1128,4 +1326,192 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("gotify")]
|
||||
public async Task<IActionResult> CreateGotifyProvider([FromBody] CreateGotifyProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
ApplicationToken = newProvider.ApplicationToken,
|
||||
Priority = newProvider.Priority
|
||||
};
|
||||
gotifyConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
GotifyConfiguration = gotifyConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Gotify provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("gotify/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateGotifyProvider(Guid id, [FromBody] UpdateGotifyProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Gotify);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Gotify provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = updatedProvider.ServerUrl,
|
||||
ApplicationToken = updatedProvider.ApplicationToken,
|
||||
Priority = updatedProvider.Priority
|
||||
};
|
||||
|
||||
if (existingProvider.GotifyConfiguration != null)
|
||||
{
|
||||
gotifyConfig = gotifyConfig with { Id = existingProvider.GotifyConfiguration.Id };
|
||||
}
|
||||
gotifyConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
GotifyConfiguration = gotifyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Gotify provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("gotify/test")]
|
||||
public async Task<IActionResult> TestGotifyProvider([FromBody] TestGotifyProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
ApplicationToken = testRequest.ApplicationToken,
|
||||
Priority = testRequest.Priority
|
||||
};
|
||||
gotifyConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = gotifyConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (GotifyException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to test Gotify provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Gotify provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
|
||||
@@ -63,7 +63,14 @@ public static class HostExtensions
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
|
||||
// Apply users db migrations
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await usersContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Quartz;
|
||||
using Serilog.Context;
|
||||
@@ -14,48 +19,73 @@ public sealed class GenericJob<T> : IJob
|
||||
{
|
||||
private readonly ILogger<GenericJob<T>> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
|
||||
|
||||
|
||||
Guid jobRunId = Guid.CreateVersion7();
|
||||
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
|
||||
JobRunStatus? status = null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
|
||||
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, false);
|
||||
|
||||
|
||||
var jobRun = new JobRun { Id = jobRunId, Type = jobType };
|
||||
eventsContext.JobRuns.Add(jobRun);
|
||||
await eventsContext.SaveChangesAsync();
|
||||
|
||||
ContextProvider.SetJobRunId(jobRunId);
|
||||
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
await handler.ExecuteAsync();
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, true);
|
||||
|
||||
status = JobRunStatus.Completed;
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{name} failed", typeof(T).Name);
|
||||
status = JobRunStatus.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var finalScope = _scopeFactory.CreateAsyncScope();
|
||||
var eventsContext = finalScope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
var jobRun = await eventsContext.JobRuns.FindAsync(jobRunId);
|
||||
if (jobRun is not null)
|
||||
{
|
||||
jobRun.CompletedAt = DateTime.UtcNow;
|
||||
jobRun.Status = status;
|
||||
await eventsContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
|
||||
|
||||
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
|
||||
{
|
||||
try
|
||||
{
|
||||
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
|
||||
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
|
||||
|
||||
if (isFinished)
|
||||
{
|
||||
jobInfo.Status = "Scheduled";
|
||||
}
|
||||
|
||||
|
||||
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Middleware;
|
||||
|
||||
public class SetupGuardMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private volatile bool _setupCompleted;
|
||||
|
||||
public SetupGuardMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Fast path: setup already completed
|
||||
if (_setupCompleted)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow these paths regardless of setup state
|
||||
if (IsAllowedPath(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check database for setup completion
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is { SetupCompleted: true })
|
||||
{
|
||||
_setupCompleted = true;
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup not complete - block non-auth requests
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached setup state. Call this if the user database is reset.
|
||||
/// </summary>
|
||||
public void ResetSetupState()
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private static bool IsAllowedPath(string path)
|
||||
{
|
||||
return path.StartsWith("/api/auth/")
|
||||
|| path == "/api/auth"
|
||||
|| path.StartsWith("/health")
|
||||
|| !path.StartsWith("/api/");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
@@ -70,12 +71,19 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
// Add services to the container
|
||||
builder.Services
|
||||
.AddInfrastructure(builder.Configuration)
|
||||
.AddApiServices();
|
||||
.AddApiServices()
|
||||
.AddAuthServices();
|
||||
|
||||
// Persist Data Protection keys to the config directory
|
||||
builder.Services
|
||||
.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "DataProtection-Keys")))
|
||||
.SetApplicationName("Cleanuparr");
|
||||
|
||||
// Add CORS before SignalR
|
||||
builder.Services.AddCors(options =>
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Any", policy =>
|
||||
options.AddPolicy("Any", policy =>
|
||||
{
|
||||
policy
|
||||
// https://github.com/dotnet/aspnetcore/issues/4457#issuecomment-465669576
|
||||
@@ -146,14 +154,14 @@ app.Init();
|
||||
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
|
||||
SignalRLogSink.Instance.SetAppHubContext(appHub);
|
||||
|
||||
// Configure health check endpoints before the API configuration
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
// Configure health check endpoints as middleware (before auth pipeline) so they don't require authentication
|
||||
app.UseHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("liveness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
app.UseHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("readiness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Entities.HealthCheck;
|
||||
|
||||
public sealed record HealthCheckResult
|
||||
{
|
||||
public bool IsHealthy { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public TimeSpan ResponseTime { get; set; }
|
||||
}
|
||||
7
code/backend/Cleanuparr.Domain/Enums/JobRunStatus.cs
Normal file
7
code/backend/Cleanuparr.Domain/Enums/JobRunStatus.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum JobRunStatus
|
||||
{
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
9
code/backend/Cleanuparr.Domain/Enums/JobType.cs
Normal file
9
code/backend/Cleanuparr.Domain/Enums/JobType.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum JobType
|
||||
{
|
||||
QueueCleaner,
|
||||
MalwareBlocker,
|
||||
DownloadCleaner,
|
||||
BlacklistSynchronizer,
|
||||
}
|
||||
@@ -7,4 +7,6 @@ public enum NotificationProviderType
|
||||
Ntfy,
|
||||
Pushover,
|
||||
Telegram,
|
||||
Discord,
|
||||
Gotify,
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ public class EventPublisherTests : IDisposable
|
||||
_loggerMock.Object,
|
||||
_notificationPublisherMock.Object,
|
||||
_dryRunInterceptorMock.Object);
|
||||
|
||||
// Setup JobRunId in context for tests
|
||||
ContextProvider.SetJobRunId(Guid.NewGuid());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -339,8 +342,8 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishQueueItemDeleted_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "abc123");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: true, DeleteReason.Stalled);
|
||||
@@ -360,8 +363,8 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishQueueItemDeleted_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "abc123");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
|
||||
@@ -378,8 +381,8 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishDownloadCleaned_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Cleaned Download");
|
||||
ContextProvider.Set("hash", "def456");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Cleaned Download");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "def456");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishDownloadCleaned(
|
||||
@@ -404,8 +407,8 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishDownloadCleaned_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test");
|
||||
ContextProvider.Set("hash", "xyz");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
|
||||
|
||||
var ratio = 1.5;
|
||||
var seedingTime = TimeSpan.FromHours(24);
|
||||
@@ -428,7 +431,7 @@ public class EventPublisherTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:8989"));
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:8989"));
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchNotTriggered("abc123", "Test Item");
|
||||
@@ -452,7 +455,7 @@ public class EventPublisherTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:7878"));
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:7878"));
|
||||
|
||||
// Act
|
||||
await _publisher.PublishRecurringItem("hash123", "Recurring Item", 5);
|
||||
@@ -475,8 +478,8 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishCategoryChanged_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Category Test");
|
||||
ContextProvider.Set("hash", "cat123");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Category Test");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "cat123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("oldCat", "newCat", isTag: false);
|
||||
@@ -493,8 +496,8 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishCategoryChanged_WithTag_SavesCorrectMessage()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Tag Test");
|
||||
ContextProvider.Set("hash", "tag123");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Tag Test");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "tag123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("", "cleanuperr-done", isTag: true);
|
||||
@@ -509,8 +512,8 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishCategoryChanged_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test");
|
||||
ContextProvider.Set("hash", "xyz");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
|
||||
|
||||
@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
public class DelugeServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<DelugeService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IBlocklistProvider> BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
|
||||
@@ -32,14 +29,13 @@ public class DelugeServiceFixture : IDisposable
|
||||
public DelugeServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<DelugeService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
BlocklistProvider = new Mock<IBlocklistProvider>();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IDelugeClientWrapper>();
|
||||
@@ -74,14 +70,13 @@ public class DelugeServiceFixture : IDisposable
|
||||
|
||||
return new DelugeService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
BlocklistProvider.Object,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
@@ -112,7 +107,6 @@ public class DelugeServiceFixture : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
public class QBitServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<QBitService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IBlocklistProvider> BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
|
||||
@@ -32,14 +29,13 @@ public class QBitServiceFixture : IDisposable
|
||||
public QBitServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<QBitService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
BlocklistProvider =new Mock<IBlocklistProvider>();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IQBittorrentClientWrapper>();
|
||||
@@ -76,14 +72,13 @@ public class QBitServiceFixture : IDisposable
|
||||
|
||||
return new QBitService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
BlocklistProvider.Object,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
@@ -115,7 +110,6 @@ public class QBitServiceFixture : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.Striker
|
||||
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata))
|
||||
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
@@ -479,7 +479,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.Striker.Verify(
|
||||
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata),
|
||||
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.Striker
|
||||
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata))
|
||||
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true); // Strike limit exceeded
|
||||
|
||||
// Act
|
||||
@@ -600,7 +600,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.Striker.Verify(
|
||||
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>()),
|
||||
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()),
|
||||
Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of BlocklistProvider for testing purposes
|
||||
/// </summary>
|
||||
public static class TestBlocklistProviderFactory
|
||||
{
|
||||
public static BlocklistProvider Create()
|
||||
{
|
||||
var logger = new Mock<ILogger<BlocklistProvider>>().Object;
|
||||
var scopeFactory = new Mock<IServiceScopeFactory>().Object;
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
return new BlocklistProvider(logger, scopeFactory, cache);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
public class TransmissionServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<TransmissionService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IBlocklistProvider> BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
|
||||
@@ -32,14 +29,13 @@ public class TransmissionServiceFixture : IDisposable
|
||||
public TransmissionServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<TransmissionService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
BlocklistProvider = new Mock<IBlocklistProvider>();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<ITransmissionClientWrapper>();
|
||||
@@ -74,14 +70,13 @@ public class TransmissionServiceFixture : IDisposable
|
||||
|
||||
return new TransmissionService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
BlocklistProvider.Object,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
@@ -112,7 +107,6 @@ public class TransmissionServiceFixture : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
public class UTorrentServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<UTorrentService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IBlocklistProvider> BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
|
||||
@@ -32,14 +29,13 @@ public class UTorrentServiceFixture : IDisposable
|
||||
public UTorrentServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<UTorrentService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
BlocklistProvider = new Mock<IBlocklistProvider>();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IUTorrentClientWrapper>();
|
||||
@@ -74,14 +70,13 @@ public class UTorrentServiceFixture : IDisposable
|
||||
|
||||
return new UTorrentService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
BlocklistProvider.Object,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
@@ -112,7 +107,6 @@ public class UTorrentServiceFixture : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,8 @@ public class DownloadHunterConsumerTests
|
||||
InstanceType = InstanceType.Lidarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 999 },
|
||||
Record = CreateQueueRecord()
|
||||
Record = CreateQueueRecord(),
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
@@ -128,7 +129,8 @@ public class DownloadHunterConsumerTests
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord()
|
||||
Record = CreateQueueRecord(),
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -282,7 +282,8 @@ public class DownloadHunterTests : IDisposable
|
||||
InstanceType = instanceType,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord()
|
||||
Record = CreateQueueRecord(),
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ public class DownloadRemoverConsumerTests
|
||||
SearchItem = new SearchItem { Id = 456 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
DeleteReason = DeleteReason.Stalled,
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
@@ -134,7 +135,8 @@ public class DownloadRemoverConsumerTests
|
||||
SearchItem = new SearchItem { Id = 789 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = false,
|
||||
DeleteReason = DeleteReason.FailedImport
|
||||
DeleteReason = DeleteReason.FailedImport,
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
@@ -162,7 +164,8 @@ public class DownloadRemoverConsumerTests
|
||||
SearchItem = new SearchItem { Id = 111 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.SlowSpeed
|
||||
DeleteReason = DeleteReason.SlowSpeed,
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
@@ -191,7 +194,8 @@ public class DownloadRemoverConsumerTests
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
DeleteReason = DeleteReason.Stalled,
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -48,10 +48,7 @@ public class QueueItemRemoverTests : IDisposable
|
||||
.Returns(_arrClientMock.Object);
|
||||
|
||||
// Create real EventPublisher with mocked dependencies
|
||||
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_eventsContext = new EventsContext(eventsContextOptions);
|
||||
_eventsContext = TestEventsContextFactory.Create();
|
||||
|
||||
var hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
@@ -59,18 +56,10 @@ public class QueueItemRemoverTests : IDisposable
|
||||
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
// Setup interceptor to execute the action with params using DynamicInvoke
|
||||
// Setup interceptor to skip actual database saves (these tests verify QueueItemRemover, not EventPublisher)
|
||||
dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
var result = action.DynamicInvoke(parameters);
|
||||
if (result is Task task)
|
||||
{
|
||||
return task;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_eventPublisher = new EventPublisher(
|
||||
_eventsContext,
|
||||
@@ -84,7 +73,8 @@ public class QueueItemRemoverTests : IDisposable
|
||||
_busMock.Object,
|
||||
_memoryCache,
|
||||
_arrClientFactoryMock.Object,
|
||||
_eventPublisher
|
||||
_eventPublisher,
|
||||
_eventsContext
|
||||
);
|
||||
|
||||
// Clear static RecurringHashes before each test
|
||||
@@ -455,7 +445,8 @@ public class QueueItemRemoverTests : IDisposable
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason
|
||||
DeleteReason = deleteReason,
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ public class JobHandlerFixture : IDisposable
|
||||
|
||||
// Setup default behaviors
|
||||
SetupDefaultBehaviors();
|
||||
|
||||
// Setup JobRunId in context for tests
|
||||
ContextProvider.SetJobRunId(Guid.NewGuid());
|
||||
}
|
||||
|
||||
private void SetupDefaultBehaviors()
|
||||
@@ -56,6 +59,7 @@ public class JobHandlerFixture : IDisposable
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Domain.Enums.EventSeverity>(),
|
||||
It.IsAny<object?>(),
|
||||
It.IsAny<Guid?>(),
|
||||
It.IsAny<Guid?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
@@ -123,6 +127,9 @@ public class JobHandlerFixture : IDisposable
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
|
||||
SetupDefaultBehaviors();
|
||||
|
||||
// Setup fresh JobRunId for each test
|
||||
ContextProvider.SetJobRunId(Guid.NewGuid());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory EventsContext instances for testing.
|
||||
/// SQLite in-memory supports ExecuteUpdateAsync, ExecuteDeleteAsync, and EF.Functions.Like,
|
||||
/// unlike the EF Core InMemory provider.
|
||||
/// </summary>
|
||||
public static class TestEventsContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new SQLite in-memory EventsContext with schema initialized
|
||||
/// </summary>
|
||||
public static EventsContext Create()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Discord;
|
||||
|
||||
public class DiscordProxyTests
|
||||
{
|
||||
private readonly Mock<ILogger<DiscordProxy>> _loggerMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public DiscordProxyTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DiscordProxy>>();
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private DiscordProxy CreateProxy()
|
||||
{
|
||||
return new DiscordProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static DiscordPayload CreatePayload()
|
||||
{
|
||||
return new DiscordPayload
|
||||
{
|
||||
Embeds = new List<DiscordEmbed>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = "Test Title",
|
||||
Description = "Test Description",
|
||||
Color = 0x28a745
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static DiscordConfig CreateConfig()
|
||||
{
|
||||
return new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
|
||||
Username = "Test Bot",
|
||||
AvatarUrl = "https://example.com/avatar.png"
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_BuildsCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123/abc"
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Equal("https://discord.com/api/webhooks/123/abc", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_LogsTraceWithContent()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
public async Task SendNotification_WhenUnauthorized_ThrowsDiscordExceptionWithInvalidWebhook(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("invalid or unauthorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When404_ThrowsDiscordExceptionWithNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.NotFound);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("not found", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When429_ThrowsDiscordExceptionWithRateLimited()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse((HttpStatusCode)429);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("rate limited", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task SendNotification_WhenServiceUnavailable_ThrowsDiscordException(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsDiscordException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsDiscordException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Gotify;
|
||||
|
||||
public class GotifyProxyTests
|
||||
{
|
||||
private readonly Mock<ILogger<GotifyProxy>> _loggerMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public GotifyProxyTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<GotifyProxy>>();
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private GotifyProxy CreateProxy()
|
||||
{
|
||||
return new GotifyProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static GotifyPayload CreatePayload()
|
||||
{
|
||||
return new GotifyPayload
|
||||
{
|
||||
Title = "Test Title",
|
||||
Message = "Test Message",
|
||||
Priority = 5
|
||||
};
|
||||
}
|
||||
|
||||
private static GotifyConfig CreateConfig()
|
||||
{
|
||||
return new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "test-app-token",
|
||||
Priority = 5
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_BuildsCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "my-token",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_TrimsTrailingSlashFromServerUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com/",
|
||||
ApplicationToken = "my-token",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_LogsTraceWithContent()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
public async Task SendNotification_WhenUnauthorized_ThrowsGotifyExceptionWithInvalidToken(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("invalid or unauthorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When404_ThrowsGotifyExceptionWithNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.NotFound);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("not found", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task SendNotification_WhenServiceUnavailable_ThrowsGotifyException(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsGotifyException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsGotifyException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public class NotifiarrProxyTests
|
||||
return new NotifiarrPayload
|
||||
{
|
||||
Notification = new NotifiarrNotification { Update = false },
|
||||
Discord = new Discord
|
||||
Discord = new NotifiarrDiscord
|
||||
{
|
||||
Color = "#FF0000",
|
||||
Text = new Text { Title = "Test", Content = "Test content" },
|
||||
|
||||
@@ -358,12 +358,9 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
|
||||
#region Provider Type Mapping Tests
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
[InlineData(NotificationProviderType.Telegram)]
|
||||
[MemberData(nameof(NotificationProviderTypes))]
|
||||
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
@@ -383,11 +380,7 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
[InlineData(NotificationProviderType.Telegram)]
|
||||
[MemberData(nameof(NotificationProviderTypes))]
|
||||
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
@@ -420,6 +413,8 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
|
||||
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
|
||||
NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled),
|
||||
NotificationProviderType.Discord => CreateDiscordConfig(name, isEnabled),
|
||||
NotificationProviderType.Gotify => CreateGotifyConfig(name, isEnabled),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
|
||||
};
|
||||
}
|
||||
@@ -549,5 +544,60 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateDiscordConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Discord,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
DiscordConfiguration = new DiscordConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WebhookUrl = "http://localhost:8000",
|
||||
AvatarUrl = "https://example.com/avatar.png",
|
||||
Username = "test_username",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateGotifyConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
GotifyConfiguration = new GotifyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://localhost:8000",
|
||||
ApplicationToken = "test_application_token",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public static IEnumerable<object[]> NotificationProviderTypes =>
|
||||
[
|
||||
..Enum.GetValues<NotificationProviderType>()
|
||||
.Cast<Object>()
|
||||
.Select(x => new[] { x })
|
||||
.ToList()
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
@@ -21,6 +23,8 @@ public class NotificationProviderFactoryTests
|
||||
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
|
||||
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
|
||||
private readonly Mock<ITelegramProxy> _telegramProxyMock;
|
||||
private readonly Mock<IDiscordProxy> _discordProxyMock;
|
||||
private readonly Mock<IGotifyProxy> _gotifyProxyMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationProviderFactory _factory;
|
||||
|
||||
@@ -32,6 +36,8 @@ public class NotificationProviderFactoryTests
|
||||
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
|
||||
_pushoverProxyMock = new Mock<IPushoverProxy>();
|
||||
_telegramProxyMock = new Mock<ITelegramProxy>();
|
||||
_discordProxyMock = new Mock<IDiscordProxy>();
|
||||
_gotifyProxyMock = new Mock<IGotifyProxy>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_appriseProxyMock.Object);
|
||||
@@ -40,6 +46,8 @@ public class NotificationProviderFactoryTests
|
||||
services.AddSingleton(_notifiarrProxyMock.Object);
|
||||
services.AddSingleton(_pushoverProxyMock.Object);
|
||||
services.AddSingleton(_telegramProxyMock.Object);
|
||||
services.AddSingleton(_discordProxyMock.Object);
|
||||
services.AddSingleton(_gotifyProxyMock.Object);
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new NotificationProviderFactory(_serviceProvider);
|
||||
@@ -194,6 +202,61 @@ public class NotificationProviderFactoryTests
|
||||
Assert.Equal(NotificationProviderType.Telegram, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_DiscordType_CreatesDiscordProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestDiscord",
|
||||
Type = NotificationProviderType.Discord,
|
||||
IsEnabled = true,
|
||||
Configuration = new DiscordConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WebhookUrl = "test-webhook-url",
|
||||
AvatarUrl = "test-avatar-url",
|
||||
Username = "test-username",
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<DiscordProvider>(provider);
|
||||
Assert.Equal("TestDiscord", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Discord, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_GotifyType_CreatesGotifyProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestGotify",
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = true,
|
||||
Configuration = new GotifyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "test-server-url",
|
||||
ApplicationToken = "test-application-token",
|
||||
}
|
||||
};
|
||||
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<GotifyProvider>(provider);
|
||||
Assert.Equal("TestGotify", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Gotify, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
|
||||
{
|
||||
|
||||
@@ -59,14 +59,15 @@ public class NotificationPublisherTests
|
||||
|
||||
ContextProvider.Set(nameof(QueueRecord), record);
|
||||
ContextProvider.Set(nameof(InstanceType), instanceType);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
|
||||
ContextProvider.Set("version", 1f);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://sonarr.local"));
|
||||
ContextProvider.Set(ContextProvider.Keys.Version, 1f);
|
||||
}
|
||||
|
||||
private void SetupDownloadCleanerContext()
|
||||
{
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "HASH123");
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("http://downloadclient.local"));
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "HASH123");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
@@ -204,6 +205,28 @@ public class NotificationPublisherTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WithoutExternalUrl_UsesInternalUrlInNotification()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Data["Url"] == "http://sonarr.local/")), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyQueueItemDeleted Tests
|
||||
@@ -312,6 +335,30 @@ public class NotificationPublisherTests
|
||||
Assert.Equal("25", capturedContext.Data["Seeding hours"]); // Rounds to 25
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDownloadCleaned_WithDownloadClientUrl_IncludesUrlInNotification()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("https://qbit.external.com"));
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Data.ContainsKey("Url") &&
|
||||
c.Data["Url"] == "https://qbit.external.com/")), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyCategoryChanged Tests
|
||||
|
||||
@@ -5,6 +5,7 @@ using Cleanuparr.Domain.Entities.AppStatus;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
@@ -20,6 +21,7 @@ public class AppStatusRefreshServiceTests : IDisposable
|
||||
private readonly AppStatusSnapshot _snapshot;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private readonly Mock<IServiceScopeFactory> _scopeFactoryMock;
|
||||
private AppStatusRefreshService? _service;
|
||||
|
||||
public AppStatusRefreshServiceTests()
|
||||
@@ -30,6 +32,7 @@ public class AppStatusRefreshServiceTests : IDisposable
|
||||
_snapshot = new AppStatusSnapshot();
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_scopeFactoryMock = new Mock<IServiceScopeFactory>();
|
||||
|
||||
// Setup hub context
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
@@ -50,7 +53,8 @@ public class AppStatusRefreshServiceTests : IDisposable
|
||||
_hubContextMock.Object,
|
||||
_httpClientFactoryMock.Object,
|
||||
_snapshot,
|
||||
_jsonOptions);
|
||||
_jsonOptions,
|
||||
_scopeFactoryMock.Object);
|
||||
return _service;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
|
||||
@@ -7,25 +7,48 @@ using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class RuleEvaluatorTests
|
||||
public class RuleEvaluatorTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public RuleEvaluatorTests()
|
||||
{
|
||||
_context = CreateInMemoryEventsContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
private static EventsContext CreateInMemoryEventsContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new EventsContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStrikes_ShouldRespectMinimumProgressThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = new StallRule
|
||||
{
|
||||
@@ -47,7 +70,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
strikerMock
|
||||
@@ -64,9 +87,14 @@ public class RuleEvaluatorTests
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
|
||||
|
||||
// Seed cache with initial observation (no reset expected)
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
|
||||
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
|
||||
context.DownloadItems.Add(downloadItem);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
|
||||
context.Strikes.Add(initialStrike);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Progress below threshold should not reset strikes
|
||||
downloadedBytes = ByteSize.Parse("1 MB").Bytes;
|
||||
@@ -84,10 +112,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
@@ -98,7 +126,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Never);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,10 +134,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
|
||||
|
||||
@@ -118,7 +146,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -126,7 +154,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
}
|
||||
|
||||
@@ -135,10 +163,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
|
||||
|
||||
@@ -147,7 +175,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -155,7 +183,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -163,10 +191,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
@@ -175,14 +203,14 @@ public class RuleEvaluatorTests
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateStallRulesAsync(torrentMock.Object));
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -190,10 +218,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
@@ -204,7 +232,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Never);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -212,10 +240,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
@@ -224,7 +252,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -232,7 +260,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -240,10 +268,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
|
||||
|
||||
@@ -252,7 +280,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -260,7 +288,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -268,10 +296,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
|
||||
|
||||
@@ -295,10 +323,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
@@ -307,14 +335,14 @@ public class RuleEvaluatorTests
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("slow fail"));
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateSlowRulesAsync(torrentMock.Object));
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -322,10 +350,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Rule",
|
||||
@@ -339,7 +367,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -348,7 +376,7 @@ public class RuleEvaluatorTests
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(
|
||||
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed),
|
||||
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()),
|
||||
Times.Once);
|
||||
strikerMock.Verify(
|
||||
x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()),
|
||||
@@ -360,10 +388,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Both Rule",
|
||||
@@ -377,7 +405,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -385,7 +413,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -393,10 +421,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
// Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null)
|
||||
var slowRule = CreateSlowRule(
|
||||
@@ -415,7 +443,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>()), Times.Never);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -423,10 +451,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Reset",
|
||||
@@ -455,10 +483,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed No Reset",
|
||||
@@ -483,10 +511,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Time No Reset",
|
||||
@@ -511,10 +539,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Strike",
|
||||
@@ -528,7 +556,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -537,7 +565,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -545,10 +573,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Time Strike",
|
||||
@@ -562,7 +590,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -571,7 +599,7 @@ public class RuleEvaluatorTests
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime), Times.Once);
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -579,10 +607,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
@@ -591,7 +619,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
long downloadedBytes = ByteSize.Parse("50 MB").Bytes;
|
||||
@@ -609,12 +637,22 @@ public class RuleEvaluatorTests
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WithProgressAndNoMinimumThreshold_ShouldReset()
|
||||
{
|
||||
// Arrange
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
|
||||
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
|
||||
context.DownloadItems.Add(downloadItem);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
|
||||
context.Strikes.Add(initialStrike);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
|
||||
|
||||
@@ -623,23 +661,19 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
long downloadedBytes = 0;
|
||||
// Act - Any progress should trigger reset when no minimum is set
|
||||
long downloadedBytes = ByteSize.Parse("1 KB").Bytes;
|
||||
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
|
||||
|
||||
// Seed cache
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
|
||||
// Any progress should trigger reset when no minimum is set
|
||||
downloadedBytes = ByteSize.Parse("1 KB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
// Assert
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
@@ -712,10 +746,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
@@ -735,10 +769,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
@@ -747,7 +781,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -764,10 +798,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
@@ -776,7 +810,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -793,10 +827,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
@@ -805,7 +839,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -822,10 +856,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
@@ -845,10 +879,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
@@ -857,7 +891,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -874,10 +908,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
@@ -886,7 +920,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -903,10 +937,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
"Speed Delete True",
|
||||
@@ -921,7 +955,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -939,10 +973,10 @@ public class RuleEvaluatorTests
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
var context = CreateInMemoryEventsContext();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
@@ -951,7 +985,7 @@ public class RuleEvaluatorTests
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
@@ -19,18 +18,18 @@ namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class StrikerTests : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly EventsContext _strikerContext;
|
||||
private readonly ILogger<Striker> _logger;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly Striker _striker;
|
||||
|
||||
public StrikerTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_strikerContext = CreateInMemoryEventsContext();
|
||||
_logger = Substitute.For<ILogger<Striker>>();
|
||||
|
||||
// Create EventPublisher with mocked dependencies
|
||||
var eventsContext = CreateMockEventsContext();
|
||||
var eventsContext = CreateInMemoryEventsContext();
|
||||
var hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
var hubClients = Substitute.For<IHubClients>();
|
||||
var clientProxy = Substitute.For<IClientProxy>();
|
||||
@@ -53,14 +52,17 @@ public class StrikerTests : IDisposable
|
||||
notificationPublisher,
|
||||
dryRunInterceptor);
|
||||
|
||||
_striker = new Striker(_logger, _cache, _eventPublisher);
|
||||
_striker = new Striker(_logger, _strikerContext, _eventPublisher);
|
||||
|
||||
// Clear static state before each test
|
||||
Striker.RecurringHashes.Clear();
|
||||
|
||||
// Set up required JobRunId for tests
|
||||
ContextProvider.SetJobRunId(Guid.NewGuid());
|
||||
|
||||
// Set up required context for recurring item events and FailedImport strikes
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
|
||||
ContextProvider.Set("ArrInstanceUrl", new Uri("http://localhost:8989"));
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:8989"));
|
||||
ContextProvider.Set(new QueueRecord
|
||||
{
|
||||
Title = "Test Item",
|
||||
@@ -71,7 +73,7 @@ public class StrikerTests : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
private static EventsContext CreateMockEventsContext()
|
||||
private static EventsContext CreateInMemoryEventsContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
@@ -81,7 +83,7 @@ public class StrikerTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
_strikerContext.Dispose();
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
@@ -336,4 +338,64 @@ public class StrikerTests : IDisposable
|
||||
Striker.RecurringHashes.Count.ShouldBe(1);
|
||||
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_CreatesNewStrikeRowForEachStrike()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "strike-rows-test";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 5;
|
||||
|
||||
// Act - Strike 3 times
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - Should have 3 strike rows
|
||||
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
|
||||
downloadItem.ShouldNotBeNull();
|
||||
|
||||
var strikeCount = await _strikerContext.Strikes
|
||||
.CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == StrikeType.Stalled);
|
||||
strikeCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_StoresTitleOnDownloadItem()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "title-test";
|
||||
const string itemName = "My Movie Title 2024";
|
||||
const ushort maxStrikes = 3;
|
||||
|
||||
// Act
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
|
||||
downloadItem.ShouldNotBeNull();
|
||||
downloadItem.Title.ShouldBe(itemName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_UpdatesTitleOnDownloadItem_WhenTitleChanges()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "title-update-test";
|
||||
const string initialTitle = "Initial Title";
|
||||
const string updatedTitle = "Updated Title";
|
||||
const ushort maxStrikes = 5;
|
||||
|
||||
// Act - Strike with initial title
|
||||
await _striker.StrikeAndCheckLimit(hash, initialTitle, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Strike with updated title
|
||||
await _striker.StrikeAndCheckLimit(hash, updatedTitle, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - Title should be updated
|
||||
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
|
||||
downloadItem.ShouldNotBeNull();
|
||||
downloadItem.Title.ShouldBe(updatedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// using Data.Models.Configuration.ContentBlocker;
|
||||
// using Data.Models.Configuration.DownloadCleaner;
|
||||
// using Data.Models.Configuration.QueueCleaner;
|
||||
// using Infrastructure.Interceptors;
|
||||
// using Infrastructure.Verticals.ContentBlocker;
|
||||
// using Infrastructure.Verticals.DownloadClient;
|
||||
// using Infrastructure.Verticals.Files;
|
||||
// using Infrastructure.Verticals.ItemStriker;
|
||||
// using Infrastructure.Verticals.Notifications;
|
||||
// using Microsoft.Extensions.Caching.Memory;
|
||||
// using Microsoft.Extensions.Logging;
|
||||
// using Microsoft.Extensions.Options;
|
||||
// using NSubstitute;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Verticals.DownloadClient;
|
||||
//
|
||||
// public class DownloadServiceFixture : IDisposable
|
||||
// {
|
||||
// public ILogger<DownloadService> Logger { get; set; }
|
||||
// public IMemoryCache Cache { get; set; }
|
||||
// public IStriker Striker { get; set; }
|
||||
//
|
||||
// public DownloadServiceFixture()
|
||||
// {
|
||||
// Logger = Substitute.For<ILogger<DownloadService>>();
|
||||
// Cache = Substitute.For<IMemoryCache>();
|
||||
// Striker = Substitute.For<IStriker>();
|
||||
// }
|
||||
//
|
||||
// public TestDownloadService CreateSut(
|
||||
// QueueCleanerConfig? queueCleanerConfig = null,
|
||||
// ContentBlockerConfig? contentBlockerConfig = null
|
||||
// )
|
||||
// {
|
||||
// queueCleanerConfig ??= new QueueCleanerConfig
|
||||
// {
|
||||
// Enabled = true,
|
||||
// RunSequentially = true,
|
||||
// StalledResetStrikesOnProgress = true,
|
||||
// StalledMaxStrikes = 3
|
||||
// };
|
||||
//
|
||||
// var queueCleanerOptions = Substitute.For<IOptions<QueueCleanerConfig>>();
|
||||
// queueCleanerOptions.Value.Returns(queueCleanerConfig);
|
||||
//
|
||||
// contentBlockerConfig ??= new ContentBlockerConfig
|
||||
// {
|
||||
// Enabled = true
|
||||
// };
|
||||
//
|
||||
// var contentBlockerOptions = Substitute.For<IOptions<ContentBlockerConfig>>();
|
||||
// contentBlockerOptions.Value.Returns(contentBlockerConfig);
|
||||
//
|
||||
// var downloadCleanerOptions = Substitute.For<IOptions<DownloadCleanerConfig>>();
|
||||
// downloadCleanerOptions.Value.Returns(new DownloadCleanerConfig());
|
||||
//
|
||||
// var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
// var notifier = Substitute.For<INotificationPublisher>();
|
||||
// var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
// var hardlinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
//
|
||||
// return new TestDownloadService(
|
||||
// Logger,
|
||||
// queueCleanerOptions,
|
||||
// contentBlockerOptions,
|
||||
// downloadCleanerOptions,
|
||||
// Cache,
|
||||
// filenameEvaluator,
|
||||
// Striker,
|
||||
// notifier,
|
||||
// dryRunInterceptor,
|
||||
// hardlinkFileService
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// public void Dispose()
|
||||
// {
|
||||
// // Cleanup if needed
|
||||
// }
|
||||
// }
|
||||
@@ -1,214 +0,0 @@
|
||||
// using Data.Models.Configuration.DownloadCleaner;
|
||||
// using Data.Enums;
|
||||
// using Data.Models.Cache;
|
||||
// using Infrastructure.Helpers;
|
||||
// using Infrastructure.Verticals.Context;
|
||||
// using Infrastructure.Verticals.DownloadClient;
|
||||
// using NSubstitute;
|
||||
// using NSubstitute.ClearExtensions;
|
||||
// using Shouldly;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Verticals.DownloadClient;
|
||||
//
|
||||
// public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
||||
// {
|
||||
// private readonly DownloadServiceFixture _fixture;
|
||||
//
|
||||
// public DownloadServiceTests(DownloadServiceFixture fixture)
|
||||
// {
|
||||
// _fixture = fixture;
|
||||
// _fixture.Cache.ClearSubstitute();
|
||||
// _fixture.Striker.ClearSubstitute();
|
||||
// }
|
||||
//
|
||||
// public class ResetStrikesOnProgressTests : DownloadServiceTests
|
||||
// {
|
||||
// public ResetStrikesOnProgressTests(DownloadServiceFixture fixture) : base(fixture)
|
||||
// {
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void WhenStalledStrikeDisabled_ShouldNotResetStrikes()
|
||||
// {
|
||||
// // Arrange
|
||||
// TestDownloadService sut = _fixture.CreateSut(queueCleanerConfig: new()
|
||||
// {
|
||||
// Enabled = true,
|
||||
// RunSequentially = true,
|
||||
// StalledResetStrikesOnProgress = false,
|
||||
// });
|
||||
//
|
||||
// // Act
|
||||
// sut.ResetStalledStrikesOnProgress("test-hash", 100);
|
||||
//
|
||||
// // Assert
|
||||
// _fixture.Cache.ReceivedCalls().ShouldBeEmpty();
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void WhenProgressMade_ShouldResetStrikes()
|
||||
// {
|
||||
// // Arrange
|
||||
// const string hash = "test-hash";
|
||||
// StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 100 };
|
||||
//
|
||||
// _fixture.Cache.TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
|
||||
// .Returns(x =>
|
||||
// {
|
||||
// x[1] = stalledCacheItem;
|
||||
// return true;
|
||||
// });
|
||||
//
|
||||
// TestDownloadService sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// sut.ResetStalledStrikesOnProgress(hash, 200);
|
||||
//
|
||||
// // Assert
|
||||
// _fixture.Cache.Received(1).Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void WhenNoProgress_ShouldNotResetStrikes()
|
||||
// {
|
||||
// // Arrange
|
||||
// const string hash = "test-hash";
|
||||
// StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 200 };
|
||||
//
|
||||
// _fixture.Cache
|
||||
// .TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
|
||||
// .Returns(x =>
|
||||
// {
|
||||
// x[1] = stalledCacheItem;
|
||||
// return true;
|
||||
// });
|
||||
//
|
||||
// TestDownloadService sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// sut.ResetStalledStrikesOnProgress(hash, 100);
|
||||
//
|
||||
// // Assert
|
||||
// _fixture.Cache.DidNotReceive().Remove(Arg.Any<object>());
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class StrikeAndCheckLimitTests : DownloadServiceTests
|
||||
// {
|
||||
// public StrikeAndCheckLimitTests(DownloadServiceFixture fixture) : base(fixture)
|
||||
// {
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class ShouldCleanDownloadTests : DownloadServiceTests
|
||||
// {
|
||||
// public ShouldCleanDownloadTests(DownloadServiceFixture fixture) : base(fixture)
|
||||
// {
|
||||
// ContextProvider.Set("downloadName", "test-download");
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
|
||||
// {
|
||||
// // Arrange
|
||||
// CleanCategory category = new()
|
||||
// {
|
||||
// Name = "test",
|
||||
// MaxRatio = 1.0,
|
||||
// MinSeedTime = 1,
|
||||
// MaxSeedTime = -1
|
||||
// };
|
||||
// const double ratio = 1.5;
|
||||
// TimeSpan seedingTime = TimeSpan.FromHours(2);
|
||||
//
|
||||
// TestDownloadService sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.ShouldClean.ShouldBeTrue(),
|
||||
// () => result.Reason.ShouldBe(CleanReason.MaxRatioReached)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse()
|
||||
// {
|
||||
// // Arrange
|
||||
// CleanCategory category = new()
|
||||
// {
|
||||
// Name = "test",
|
||||
// MaxRatio = 1.0,
|
||||
// MinSeedTime = 3,
|
||||
// MaxSeedTime = -1
|
||||
// };
|
||||
// const double ratio = 1.5;
|
||||
// TimeSpan seedingTime = TimeSpan.FromHours(2);
|
||||
//
|
||||
// TestDownloadService sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.ShouldClean.ShouldBeFalse(),
|
||||
// () => result.Reason.ShouldBe(CleanReason.None)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void WhenMaxSeedTimeReached_ShouldReturnTrue()
|
||||
// {
|
||||
// // Arrange
|
||||
// CleanCategory category = new()
|
||||
// {
|
||||
// Name = "test",
|
||||
// MaxRatio = -1,
|
||||
// MinSeedTime = 0,
|
||||
// MaxSeedTime = 1
|
||||
// };
|
||||
// const double ratio = 0.5;
|
||||
// TimeSpan seedingTime = TimeSpan.FromHours(2);
|
||||
//
|
||||
// TestDownloadService sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// SeedingCheckResult result = sut.ShouldCleanDownload(ratio, seedingTime, category);
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.ShouldClean.ShouldBeTrue(),
|
||||
// () => result.Reason.ShouldBe(CleanReason.MaxSeedTimeReached)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void WhenNeitherConditionMet_ShouldReturnFalse()
|
||||
// {
|
||||
// // Arrange
|
||||
// CleanCategory category = new()
|
||||
// {
|
||||
// Name = "test",
|
||||
// MaxRatio = 2.0,
|
||||
// MinSeedTime = 0,
|
||||
// MaxSeedTime = 3
|
||||
// };
|
||||
// const double ratio = 1.0;
|
||||
// TimeSpan seedingTime = TimeSpan.FromHours(1);
|
||||
//
|
||||
// TestDownloadService sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.ShouldClean.ShouldBeFalse(),
|
||||
// () => result.Reason.ShouldBe(CleanReason.None)
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -7,12 +7,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<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.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" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
|
||||
@@ -14,7 +14,7 @@ public class EventCleanupService : BackgroundService
|
||||
private readonly ILogger<EventCleanupService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours
|
||||
private readonly int _retentionDays = 30; // Keep events for 30 days
|
||||
private readonly int _eventRetentionDays = 30; // Keep events for 30 days
|
||||
|
||||
public EventCleanupService(ILogger<EventCleanupService> logger, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
@@ -25,7 +25,7 @@ public class EventCleanupService : BackgroundService
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Event cleanup service started. Interval: {interval}, Retention: {retention} days",
|
||||
_cleanupInterval, _retentionDays);
|
||||
_cleanupInterval, _eventRetentionDays);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -59,16 +59,19 @@ public class EventCleanupService : BackgroundService
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);
|
||||
await context.Events
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-_eventRetentionDays);
|
||||
await eventsContext.Events
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
await context.ManualEvents
|
||||
await eventsContext.ManualEvents
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
.Where(e => e.IsResolved)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await CleanupStrikesAsync(eventsContext, dataContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -76,6 +79,48 @@ public class EventCleanupService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupStrikesAsync(EventsContext eventsContext, DataContext dataContext)
|
||||
{
|
||||
var config = await dataContext.GeneralConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
var inactivityWindowHours = config.StrikeInactivityWindowHours;
|
||||
var cutoffDate = DateTime.UtcNow.AddHours(-inactivityWindowHours);
|
||||
|
||||
// Sliding window: find items whose most recent strike is older than the inactivity window.
|
||||
// As long as a download keeps receiving new strikes, all its strikes are preserved.
|
||||
var inactiveItemIds = await eventsContext.Strikes
|
||||
.GroupBy(s => s.DownloadItemId)
|
||||
.Where(g => g.Max(s => s.CreatedAt) < cutoffDate)
|
||||
.Select(g => g.Key)
|
||||
.ToListAsync();
|
||||
|
||||
if (inactiveItemIds.Count > 0)
|
||||
{
|
||||
var deletedStrikesCount = await eventsContext.Strikes
|
||||
.Where(s => inactiveItemIds.Contains(s.DownloadItemId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deletedStrikesCount > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cleaned up {count} strikes from {items} inactive items (no new strikes for {hours} hours)",
|
||||
deletedStrikesCount, inactiveItemIds.Count, inactivityWindowHours);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphaned DownloadItems (those with no strikes)
|
||||
int deletedDownloadItemsCount = await eventsContext.DownloadItems
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deletedDownloadItemsCount > 0)
|
||||
{
|
||||
_logger.LogTrace("Cleaned up {count} download items with 0 strikes", deletedDownloadItemsCount);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Event cleanup service stopping...");
|
||||
|
||||
@@ -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)
|
||||
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null)
|
||||
{
|
||||
AppEvent eventEntity = new()
|
||||
{
|
||||
@@ -54,7 +54,13 @@ public class EventPublisher : IEventPublisher
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
}) : null,
|
||||
TrackingId = trackingId
|
||||
TrackingId = trackingId,
|
||||
StrikeId = strikeId,
|
||||
JobRunId = ContextProvider.TryGetJobRunId(),
|
||||
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
|
||||
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
|
||||
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
|
||||
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
|
||||
};
|
||||
|
||||
// Save to database with dry run interception
|
||||
@@ -65,7 +71,7 @@ public class EventPublisher : IEventPublisher
|
||||
|
||||
_logger.LogTrace("Published event: {eventType}", eventType);
|
||||
}
|
||||
|
||||
|
||||
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null)
|
||||
{
|
||||
ManualEvent eventEntity = new()
|
||||
@@ -76,21 +82,26 @@ public class EventPublisher : IEventPublisher
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
}) : null,
|
||||
JobRunId = ContextProvider.TryGetJobRunId(),
|
||||
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
|
||||
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
|
||||
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
|
||||
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
|
||||
};
|
||||
|
||||
|
||||
// Save to database with dry run interception
|
||||
await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity);
|
||||
|
||||
|
||||
// Always send to SignalR clients (not affected by dry run)
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
|
||||
|
||||
_logger.LogTrace("Published manual event: {message}", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a strike event with context data and notifications
|
||||
/// </summary>
|
||||
public async Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName)
|
||||
public async Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null)
|
||||
{
|
||||
// Determine the appropriate EventType based on StrikeType
|
||||
EventType eventType = strikeType switch
|
||||
@@ -133,7 +144,11 @@ public class EventPublisher : IEventPublisher
|
||||
eventType,
|
||||
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
|
||||
EventSeverity.Important,
|
||||
data: data);
|
||||
data: data,
|
||||
strikeId: strikeId);
|
||||
|
||||
// Broadcast strike to SignalR clients for real-time dashboard updates
|
||||
await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName);
|
||||
|
||||
// Send notification (uses ContextProvider internally)
|
||||
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
|
||||
@@ -145,15 +160,15 @@ public class EventPublisher : IEventPublisher
|
||||
public async Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason)
|
||||
{
|
||||
// Get context data for the event
|
||||
string downloadName = ContextProvider.Get<string>("downloadName") ?? "Unknown";
|
||||
string hash = ContextProvider.Get<string>("hash") ?? "Unknown";
|
||||
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
|
||||
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
|
||||
|
||||
// Publish the event
|
||||
await PublishAsync(
|
||||
EventType.QueueItemDeleted,
|
||||
$"Deleting item from queue with reason: {deleteReason}",
|
||||
EventSeverity.Important,
|
||||
data: new { downloadName, hash, removeFromClient, deleteReason });
|
||||
data: new { itemName, hash, removeFromClient, deleteReason });
|
||||
|
||||
// Send notification (uses ContextProvider internally)
|
||||
await _notificationPublisher.NotifyQueueItemDeleted(removeFromClient, deleteReason);
|
||||
@@ -165,15 +180,15 @@ public class EventPublisher : IEventPublisher
|
||||
public async Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
|
||||
{
|
||||
// Get context data for the event
|
||||
string downloadName = ContextProvider.Get<string>("downloadName");
|
||||
string hash = ContextProvider.Get<string>("hash");
|
||||
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
|
||||
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
|
||||
|
||||
// Publish the event
|
||||
await PublishAsync(
|
||||
EventType.DownloadCleaned,
|
||||
$"Cleaned item from download client with reason: {reason}",
|
||||
EventSeverity.Important,
|
||||
data: new { downloadName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
|
||||
data: new { itemName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
|
||||
|
||||
// Send notification (uses ContextProvider internally)
|
||||
await _notificationPublisher.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
|
||||
@@ -185,15 +200,15 @@ public class EventPublisher : IEventPublisher
|
||||
public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
|
||||
{
|
||||
// Get context data for the event
|
||||
string downloadName = ContextProvider.Get<string>("downloadName");
|
||||
string hash = ContextProvider.Get<string>("hash");
|
||||
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
|
||||
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
|
||||
|
||||
// Publish the event
|
||||
await PublishAsync(
|
||||
EventType.CategoryChanged,
|
||||
isTag ? $"Tag '{newCategory}' added to download" : $"Category changed from '{oldCategory}' to '{newCategory}'",
|
||||
EventSeverity.Information,
|
||||
data: new { downloadName, hash, oldCategory, newCategory, isTag });
|
||||
data: new { itemName, hash, oldCategory, newCategory, isTag });
|
||||
|
||||
// Send notification (uses ContextProvider internally)
|
||||
await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag);
|
||||
@@ -204,14 +219,10 @@ public class EventPublisher : IEventPublisher
|
||||
/// </summary>
|
||||
public async Task PublishRecurringItem(string hash, string itemName, int strikeCount)
|
||||
{
|
||||
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
|
||||
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
|
||||
|
||||
// Publish the event
|
||||
await PublishManualAsync(
|
||||
"Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/",
|
||||
EventSeverity.Important,
|
||||
data: new { itemName, hash, strikeCount, instanceType, instanceUrl }
|
||||
data: new { itemName, hash, strikeCount }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,13 +231,10 @@ public class EventPublisher : IEventPublisher
|
||||
/// </summary>
|
||||
public async Task PublishSearchNotTriggered(string hash, string itemName)
|
||||
{
|
||||
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
|
||||
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
|
||||
|
||||
await PublishManualAsync(
|
||||
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
|
||||
EventSeverity.Warning,
|
||||
data: new { itemName, hash, instanceType, instanceUrl }
|
||||
data: new { itemName, hash }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,4 +275,24 @@ public class EventPublisher : IEventPublisher
|
||||
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var strike = new
|
||||
{
|
||||
Id = strikeId ?? Guid.Empty,
|
||||
Type = strikeType.ToString(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
DownloadId = hash,
|
||||
Title = itemName,
|
||||
};
|
||||
await _appHubContext.Clients.All.SendAsync("StrikeReceived", strike);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send strike to SignalR clients");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ namespace Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
|
||||
public interface IEventPublisher
|
||||
{
|
||||
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null);
|
||||
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null);
|
||||
|
||||
Task PublishManualAsync(string message, EventSeverity severity, object? data = null);
|
||||
|
||||
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName);
|
||||
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null);
|
||||
|
||||
Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason);
|
||||
|
||||
|
||||
@@ -24,4 +24,6 @@ public record ArrInstanceDto
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public string? ExternalUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateAccessToken(User user);
|
||||
string GenerateLoginToken(Guid userId);
|
||||
string GenerateRefreshToken();
|
||||
ClaimsPrincipal? ValidateAccessToken(string token);
|
||||
Guid? ValidateLoginToken(string token);
|
||||
byte[] GetOrCreateSigningKey();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hash);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed record PlexPinResult
|
||||
{
|
||||
public required int PinId { get; init; }
|
||||
public required string PinCode { get; init; }
|
||||
public required string AuthUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexPinCheckResult
|
||||
{
|
||||
public required bool Completed { get; init; }
|
||||
public string? AuthToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexAccountInfo
|
||||
{
|
||||
public required string AccountId { get; init; }
|
||||
public required string Username { get; init; }
|
||||
public string? Email { get; init; }
|
||||
}
|
||||
|
||||
public interface IPlexAuthService
|
||||
{
|
||||
Task<PlexPinResult> RequestPin();
|
||||
Task<PlexPinCheckResult> CheckPin(int pinId);
|
||||
Task<PlexAccountInfo> GetAccount(string authToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface ITotpService
|
||||
{
|
||||
string GenerateSecret();
|
||||
string GetQrCodeUri(string secret, string username);
|
||||
bool ValidateCode(string secret, string code);
|
||||
List<string> GenerateRecoveryCodes(int count = 10);
|
||||
string HashRecoveryCode(string code);
|
||||
bool VerifyRecoveryCode(string code, string hash);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class JwtService : IJwtService
|
||||
{
|
||||
private const string Issuer = "Cleanuparr";
|
||||
private const string Audience = "Cleanuparr";
|
||||
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan LoginTokenLifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly byte[] _signingKey;
|
||||
|
||||
public JwtService()
|
||||
{
|
||||
_signingKey = GetOrCreateSigningKey();
|
||||
}
|
||||
|
||||
public string GenerateAccessToken(User user)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("token_type", "access")
|
||||
};
|
||||
|
||||
return GenerateToken(claims, AccessTokenLifetime);
|
||||
}
|
||||
|
||||
public string GenerateLoginToken(Guid userId)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("token_type", "login")
|
||||
};
|
||||
|
||||
return GenerateToken(claims, LoginTokenLifetime);
|
||||
}
|
||||
|
||||
public string GenerateRefreshToken()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateAccessToken(string token)
|
||||
{
|
||||
var principal = ValidateToken(token);
|
||||
if (principal is null) return null;
|
||||
|
||||
var tokenType = principal.FindFirst("token_type")?.Value;
|
||||
return tokenType == "access" ? principal : null;
|
||||
}
|
||||
|
||||
public Guid? ValidateLoginToken(string token)
|
||||
{
|
||||
var principal = ValidateToken(token);
|
||||
if (principal is null) return null;
|
||||
|
||||
var tokenType = principal.FindFirst("token_type")?.Value;
|
||||
if (tokenType != "login") return null;
|
||||
|
||||
var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
return Guid.TryParse(userIdClaim, out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
public byte[] GetOrCreateSigningKey()
|
||||
{
|
||||
var keyPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "jwt-key.bin");
|
||||
|
||||
if (File.Exists(keyPath))
|
||||
{
|
||||
return File.ReadAllBytes(keyPath);
|
||||
}
|
||||
|
||||
var key = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(key);
|
||||
|
||||
var directory = Path.GetDirectoryName(keyPath);
|
||||
if (directory is not null && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllBytes(keyPath, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private string GenerateToken(Claim[] claims, TimeSpan lifetime)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(_signingKey);
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.Add(lifetime),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(_signingKey);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
return handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
}, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class PasswordService : IPasswordService
|
||||
{
|
||||
private const int WorkFactor = 12;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BCrypt.Net.BCrypt.Verify(password, hash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class PlexAuthService : IPlexAuthService
|
||||
{
|
||||
private const string PlexApiBaseUrl = "https://plex.tv/api/v2";
|
||||
private const string PlexProduct = "Cleanuparr";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<PlexAuthService> _logger;
|
||||
private readonly string _clientIdentifier;
|
||||
|
||||
public PlexAuthService(IHttpClientFactory httpClientFactory, ILogger<PlexAuthService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("PlexAuth");
|
||||
_logger = logger;
|
||||
_clientIdentifier = GetOrCreateClientIdentifier();
|
||||
}
|
||||
|
||||
public async Task<PlexPinResult> RequestPin()
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
|
||||
AddPlexHeaders(request);
|
||||
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["strong"] = "true"
|
||||
});
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
|
||||
|
||||
if (pin is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex PIN response");
|
||||
}
|
||||
|
||||
var authUrl = $"https://app.plex.tv/auth#?clientID={Uri.EscapeDataString(_clientIdentifier)}&code={Uri.EscapeDataString(pin.Code)}&context%5Bdevice%5D%5Bproduct%5D={Uri.EscapeDataString(PlexProduct)}";
|
||||
|
||||
return new PlexPinResult
|
||||
{
|
||||
PinId = pin.Id,
|
||||
PinCode = pin.Code,
|
||||
AuthUrl = authUrl
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlexPinCheckResult> CheckPin(int pinId)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/pins/{pinId}");
|
||||
AddPlexHeaders(request);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new PlexPinCheckResult { Completed = false };
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
|
||||
|
||||
if (pin is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex PIN response");
|
||||
}
|
||||
|
||||
return new PlexPinCheckResult
|
||||
{
|
||||
Completed = !string.IsNullOrEmpty(pin.AuthToken),
|
||||
AuthToken = pin.AuthToken
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlexAccountInfo> GetAccount(string authToken)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/user");
|
||||
AddPlexHeaders(request);
|
||||
request.Headers.Add("X-Plex-Token", authToken);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var account = JsonSerializer.Deserialize<PlexAccountResponse>(json);
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex account response");
|
||||
}
|
||||
|
||||
return new PlexAccountInfo
|
||||
{
|
||||
AccountId = account.Id.ToString(),
|
||||
Username = account.Username,
|
||||
Email = account.Email
|
||||
};
|
||||
}
|
||||
|
||||
private void AddPlexHeaders(HttpRequestMessage request)
|
||||
{
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
request.Headers.Add("X-Plex-Client-Identifier", _clientIdentifier);
|
||||
request.Headers.Add("X-Plex-Product", PlexProduct);
|
||||
}
|
||||
|
||||
private static string GetOrCreateClientIdentifier()
|
||||
{
|
||||
var path = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "plex-client-id.txt");
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return File.ReadAllText(path).Trim();
|
||||
}
|
||||
|
||||
var clientId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (directory is not null && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(path, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
// JSON deserialization models
|
||||
private sealed class PlexPinResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("authToken")]
|
||||
public string? AuthToken { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PlexAccountResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user