mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-08 11:28:02 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
156dc8bf34 | ||
|
|
bba4e943bc | ||
|
|
eee5af9268 | ||
|
|
d4485748a0 | ||
|
|
68aac966f2 | ||
|
|
1057d25e5f | ||
|
|
2ae82cbeab | ||
|
|
d0f309931d | ||
|
|
cb7e9116f1 | ||
|
|
2dca20aa59 | ||
|
|
3b32311ab6 | ||
|
|
be03a74005 | ||
|
|
8c9b8bd252 | ||
|
|
a0a37a5a8a | ||
|
|
caebcf550d | ||
|
|
47bd8b0e50 | ||
|
|
0ba32e7bad | ||
|
|
6d6d11914d | ||
|
|
41dbbf155b | ||
|
|
8f8f30c37a | ||
|
|
f2b119c724 | ||
|
|
0c02bf57d7 | ||
|
|
d87fdbf974 | ||
|
|
d7d8ff8afd | ||
|
|
237b02b1f1 | ||
|
|
0ad587a7e1 | ||
|
|
7907ae846d | ||
|
|
ea7ea9630d | ||
|
|
a502eb6407 | ||
|
|
323c2e3bda | ||
|
|
934b19efc1 | ||
|
|
dd7d83837b | ||
|
|
88e882f72d | ||
|
|
88f0103527 | ||
|
|
b7c0d211eb | ||
|
|
4b74aa23f3 | ||
|
|
5d18f203ad | ||
|
|
a798eeb129 | ||
|
|
8164d910e7 | ||
|
|
50bbf5bf5d | ||
|
|
557dde83e1 | ||
|
|
a4a4ad0f14 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
github: Flaminel
|
||||
github: Cleanuparr
|
||||
@@ -39,6 +39,19 @@ jobs:
|
||||
- name: clients
|
||||
make-target: up-clients
|
||||
projects: '--project=download-cleaner --project=malware-blocker'
|
||||
# This folder needs the real arrs, the fake indexer and qBittorrent.
|
||||
- name: live-arr
|
||||
make-target: up-arr
|
||||
projects: '--project=live-arr'
|
||||
# Same stack, but the app is built from patched sources.
|
||||
# See e2e/patches: the Seeker can be triggered instead of waited for.
|
||||
- name: live-arr-fast
|
||||
make-target: up-arr-fast
|
||||
projects: '--project=live-arr-fast'
|
||||
# This folder needs the real LazyLibrarian, the fake indexer and qBittorrent.
|
||||
- name: live-lazylibrarian
|
||||
make-target: up-lazylibrarian
|
||||
projects: '--project=live-lazylibrarian'
|
||||
|
||||
name: e2e (${{ matrix.suite.name }})
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: PR Approve (Comment Triggered)
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.issue.pull_request != null
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Approve PR
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment.body.trim();
|
||||
|
||||
const ALLOWED_USERS = ['flaminel'];
|
||||
|
||||
if (comment !== '/approve') {
|
||||
console.log(`Comment "${comment}" is not the approve command, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const login = context.payload.comment.user.login;
|
||||
if (!ALLOWED_USERS.includes(login.toLowerCase())) {
|
||||
console.log(`User ${login} is not allowed to approve, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
|
||||
if (pr.data.state !== 'open') {
|
||||
console.log('PR is not open, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.data.draft) {
|
||||
console.log('PR is a draft, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.data.number,
|
||||
event: 'APPROVE',
|
||||
body: `Approved on behalf of @${login}.`
|
||||
});
|
||||
|
||||
console.log(`${login} approved PR #${pr.data.number} @ ${pr.data.head.sha}.`);
|
||||
|
||||
try {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: '+1'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not add reaction: ${e}`);
|
||||
}
|
||||
@@ -95,6 +95,9 @@ Cleanuparr/
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- New integrations go under `Features/` subdirectories (e.g., `Infrastructure/Features/Arr/`)
|
||||
- **One type per file** - every class, record, struct and enum lives in its own file, named after it
|
||||
- **Split as you go** - when a change touches a file holding several types, split that file as part of the change
|
||||
- Exception: a test double used by a single spec may stay nested in that spec; doubles shared across specs go in `TestHelpers/`
|
||||
|
||||
### Frontend (TypeScript/Angular)
|
||||
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
|
||||
@@ -116,7 +119,8 @@ Cleanuparr/
|
||||
- **Frontend**: Vitest via the `@angular/build:unit-test` builder in jsdom (`cd code/frontend && npm test`). Specs live next to the source as `{feature}.component.spec.ts`
|
||||
- Vitest globals are enabled in `tsconfig.spec.json`, so do NOT import `describe`/`it`/`expect`/`vi`
|
||||
- `angular.json` sets `skipTests: true` for all schematics, so `ng generate` never creates a spec. Write them by hand
|
||||
- Test components through `TestBed.createComponent` and the rendered DOM. For inputs/outputs, declare a standalone host component in the spec. Stub API classes with a plain object of methods returning `of(...)`, never mock `HttpClient` or use `provideHttpClientTesting`
|
||||
- Test components through `TestBed.createComponent` and the rendered DOM. For inputs/outputs, declare a standalone host component in the spec. In a **component** spec, stub the API class with a plain object of methods returning `of(...)`, never `HttpClient`
|
||||
- A spec for an **api class itself** (`{feature}.api.spec.ts`) is the one exception: override the `HttpClient` token with `vi.fn()` stubs and assert the URL and body, as `events.api.spec.ts` and `account.api.spec.ts` do. `provideHttpClientTesting` is banned everywhere
|
||||
- Keep stub observables synchronous: an `rxResource` fed by `of(...)` resolves inside one `fixture.detectChanges()`, an async source needs `await fixture.whenStable()`
|
||||
- Call `fixture.detectChanges()` after every interaction (zoneless + OnPush). For a bare `effect()`, use `TestBed.runInInjectionContext()` then `TestBed.tick()`
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
- **Readarr**
|
||||
- **Whisparr v2**
|
||||
- **Whisparr v3**
|
||||
- **Sportarr**
|
||||
- **LazyLibrarian**
|
||||
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
public sealed class EnumSentinelTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectableNames_omits_the_sentinel()
|
||||
{
|
||||
List<string> names = EnumSentinel.SelectableNames<EventType>();
|
||||
|
||||
names.ShouldNotContain(EnumSentinel.Unknown);
|
||||
names.ShouldContain(nameof(EventType.StrikeReset));
|
||||
names.Count.ShouldBe(Enum.GetNames<EventType>().Length - 1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(InstanceType))]
|
||||
[InlineData(typeof(DownloadClientTypeName))]
|
||||
[InlineData(typeof(DownloadClientType))]
|
||||
[InlineData(typeof(NotificationProviderType))]
|
||||
[InlineData(typeof(EventType))]
|
||||
[InlineData(typeof(EventSeverity))]
|
||||
[InlineData(typeof(ManualEventType))]
|
||||
[InlineData(typeof(StrikeType))]
|
||||
[InlineData(typeof(JobType))]
|
||||
[InlineData(typeof(SearchCommandStatus))]
|
||||
[InlineData(typeof(SeedingRuleAction))]
|
||||
public void Identity_enums_pin_the_sentinel_to_its_own_value(Type enumType)
|
||||
{
|
||||
Convert.ToInt32(Enum.Parse(enumType, EnumSentinel.Unknown))
|
||||
.ShouldBe(EnumSentinel.UnknownValue);
|
||||
|
||||
// A member added later cannot take the sentinel's place.
|
||||
Enum.GetNames(enumType)[^1].ShouldBe(EnumSentinel.Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The sentinel is not a database value.
|
||||
/// A filter naming it has to be dropped before the query.
|
||||
/// </summary>
|
||||
public class EventsControllerFilterTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly EventsController _controller;
|
||||
|
||||
public EventsControllerFilterTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new EventsController(_context, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task SeedOneEventAsync()
|
||||
{
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "an event",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static PaginatedResult<EventListItem> GetEvents(ActionResult<PaginatedResult<EventListItem>> action)
|
||||
{
|
||||
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
|
||||
return ok.Value.ShouldBeOfType<PaginatedResult<EventListItem>>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EnumSentinel.Unknown)]
|
||||
[InlineData("999")]
|
||||
public async Task GetEvents_WithAnUnusableEventTypeFilter_IgnoresIt(string eventType)
|
||||
{
|
||||
await SeedOneEventAsync();
|
||||
|
||||
PaginatedResult<EventListItem> result = GetEvents(await _controller.GetEvents(eventType: eventType));
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EnumSentinel.Unknown)]
|
||||
[InlineData("999")]
|
||||
public async Task GetEvents_WithAnUnusableSeverityFilter_IgnoresIt(string severity)
|
||||
{
|
||||
await SeedOneEventAsync();
|
||||
|
||||
PaginatedResult<EventListItem> result = GetEvents(await _controller.GetEvents(severity: severity));
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
@@ -132,4 +133,42 @@ public class EventsControllerTimelineTests : IDisposable
|
||||
timeline.Types.ShouldBeEmpty();
|
||||
timeline.Buckets.ShouldAllBe(b => b.Counts.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_SumsEveryUnknownTypeInABucket()
|
||||
{
|
||||
DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime);
|
||||
DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero);
|
||||
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "first unknown",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay,
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "second unknown",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay.AddHours(-1),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Two types a newer version wrote.
|
||||
// This build reads both as one.
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE events SET event_type = 'fromthefuture' WHERE message = 'first unknown'");
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE events SET event_type = 'alsofromthefuture' WHERE message = 'second unknown'");
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30));
|
||||
|
||||
timeline.Types.ShouldBe([EnumSentinel.Unknown]);
|
||||
|
||||
DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
||||
EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart);
|
||||
todayBucket.Counts[EnumSentinel.Unknown].ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
public class StatusControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
private readonly StatusController _controller;
|
||||
|
||||
public StatusControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
_healthChecker = Substitute.For<IInstanceHealthChecker>();
|
||||
_controller = new StatusController(
|
||||
Substitute.For<ILogger<StatusController>>(),
|
||||
_dataContext,
|
||||
_healthChecker);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
private async Task<ArrInstance> AddEnabledInstance(InstanceType type, string name = "instance")
|
||||
{
|
||||
Guid configId = await _dataContext.ArrConfigs
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Type == type)
|
||||
.Select(x => x.Id)
|
||||
.FirstAsync();
|
||||
|
||||
ArrInstance instance = new()
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri("http://instance.local"),
|
||||
ApiKey = "key",
|
||||
Enabled = true,
|
||||
ArrConfigId = configId,
|
||||
};
|
||||
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<InstanceConnectionResponse>> AsDictionary(IActionResult result) =>
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<Dictionary<string, List<InstanceConnectionResponse>>>();
|
||||
|
||||
[Fact]
|
||||
public async Task GetMediaManagersStatus_CoversEveryInstanceType()
|
||||
{
|
||||
// Act
|
||||
IActionResult result = await _controller.GetMediaManagersStatus();
|
||||
|
||||
// Assert: the list drives the response, so a forgotten member would vanish from the UI.
|
||||
// The Unknown sentinel is not a media manager.
|
||||
Dictionary<string, List<InstanceConnectionResponse>> status = AsDictionary(result);
|
||||
foreach (InstanceType type in EnumSentinel.SelectableValues<InstanceType>())
|
||||
{
|
||||
status.ShouldContainKey(type.ToString());
|
||||
}
|
||||
|
||||
status.ShouldNotContainKey(EnumSentinel.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMediaManagersStatus_ProbesAnEnabledInstance()
|
||||
{
|
||||
// Arrange
|
||||
await AddEnabledInstance(InstanceType.LazyLibrarian);
|
||||
|
||||
// Act
|
||||
await _controller.GetMediaManagersStatus();
|
||||
|
||||
// Assert
|
||||
await _healthChecker.Received(1).CheckAsync(InstanceType.LazyLibrarian, Arg.Any<ArrInstance>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMediaManagersStatus_ReportsTheFailureReason()
|
||||
{
|
||||
// Arrange
|
||||
await AddEnabledInstance(InstanceType.Sonarr);
|
||||
_healthChecker
|
||||
.CheckAsync(Arg.Any<InstanceType>(), Arg.Any<ArrInstance>())
|
||||
.ThrowsAsync(new Exception("connection refused"));
|
||||
|
||||
// Act
|
||||
IActionResult result = await _controller.GetMediaManagersStatus();
|
||||
|
||||
// Assert
|
||||
InstanceConnectionResponse sonarr = AsDictionary(result)[nameof(InstanceType.Sonarr)].ShouldHaveSingleItem();
|
||||
sonarr.IsConnected.ShouldBeFalse();
|
||||
sonarr.Message.ShouldContain("connection refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSystemStatus_CountsInstancesPerType()
|
||||
{
|
||||
// Arrange
|
||||
await AddEnabledInstance(InstanceType.Radarr, "one");
|
||||
await AddEnabledInstance(InstanceType.Radarr, "two");
|
||||
|
||||
// Act
|
||||
IActionResult result = await _controller.GetSystemStatus();
|
||||
|
||||
// Assert
|
||||
SystemStatusResponse status = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<SystemStatusResponse>();
|
||||
status.MediaManagers[nameof(InstanceType.Radarr)].InstanceCount.ShouldBe(2);
|
||||
status.MediaManagers[nameof(InstanceType.LazyLibrarian)].InstanceCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadClientStatus_ReturnsTheClientsKey()
|
||||
{
|
||||
// Act
|
||||
IActionResult result = await _controller.GetDownloadClientStatus();
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<Dictionary<string, List<DownloadClientStatusResponse>>>()
|
||||
.ShouldContainKey("Clients");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Arr.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
@@ -19,8 +21,8 @@ public class ArrConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IArrClient _arrClient;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly ArrConfigController _controller;
|
||||
|
||||
public ArrConfigControllerTests()
|
||||
@@ -28,10 +30,9 @@ public class ArrConfigControllerTests : IDisposable
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = ConfigControllerTestDataFactory.CreateEventsContext();
|
||||
var logger = Substitute.For<ILogger<ArrConfigController>>();
|
||||
_arrClientFactory = Substitute.For<IArrClientFactory>();
|
||||
_arrClient = Substitute.For<IArrClient>();
|
||||
_arrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(_arrClient);
|
||||
_controller = new ArrConfigController(logger, _dataContext, _eventsContext, _arrClientFactory);
|
||||
_healthChecker = Substitute.For<IInstanceHealthChecker>();
|
||||
_eventPublisher = Substitute.For<IEventPublisher>();
|
||||
_controller = new ArrConfigController(logger, _dataContext, _eventsContext, _healthChecker, _eventPublisher);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
@@ -50,6 +51,8 @@ public class ArrConfigControllerTests : IDisposable
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
[InlineData(InstanceType.Sportarr)]
|
||||
[InlineData(InstanceType.LazyLibrarian)]
|
||||
public async Task GetArrConfig_AllTypes_ReturnOk(InstanceType type)
|
||||
{
|
||||
// Act
|
||||
@@ -256,6 +259,29 @@ public class ArrConfigControllerTests : IDisposable
|
||||
(await _dataContext.ArrInstances.CountAsync(i => i.Id == instance.Id)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_FailsSearchEventsThatAreStillInFlight()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "doomed",
|
||||
Url = new Uri("http://doomed:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
await _controller.DeleteSonarrInstance(instance.Id);
|
||||
|
||||
// Assert
|
||||
await _eventPublisher.Received(1).FailStrandedSearchEvents(instance.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_WhenStateCleanupFails_KeepsInstance()
|
||||
{
|
||||
@@ -302,14 +328,14 @@ public class ArrConfigControllerTests : IDisposable
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _arrClient.Received(1).HealthCheckAsync(Arg.Any<ArrInstance>());
|
||||
await _healthChecker.Received(1).CheckAsync(InstanceType.Sonarr, Arg.Any<ArrInstance>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_HealthCheckThrows_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
_arrClient.HealthCheckAsync(Arg.Any<ArrInstance>())
|
||||
_healthChecker.CheckAsync(Arg.Any<InstanceType>(), Arg.Any<ArrInstance>())
|
||||
.Returns(Task.FromException(new HttpRequestException("unreachable")));
|
||||
|
||||
var request = new TestArrInstanceRequest
|
||||
@@ -373,11 +399,111 @@ public class ArrConfigControllerTests : IDisposable
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _arrClient.Received(1).HealthCheckAsync(Arg.Is<ArrInstance>(i => i.ApiKey == "stored-key"));
|
||||
await _healthChecker.Received(1).CheckAsync(InstanceType.Sonarr, Arg.Is<ArrInstance>(i => i.ApiKey == "stored-key"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Route wiring
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
[InlineData(InstanceType.Sportarr)]
|
||||
[InlineData(InstanceType.LazyLibrarian)]
|
||||
public async Task InstanceRoutes_AllTypes_CreateUpdateDeleteAndTest(InstanceType type)
|
||||
{
|
||||
// Arrange: every route delegates to the same helper, so this covers the wiring per type.
|
||||
ArrInstanceRequest request = new()
|
||||
{
|
||||
Name = "wired",
|
||||
Url = "http://instance.test:1234",
|
||||
ApiKey = "abc",
|
||||
Version = 1f,
|
||||
};
|
||||
|
||||
// Act + Assert: create
|
||||
IActionResult created = await DispatchCreate(type, request);
|
||||
ArrInstanceDto dto = created.ShouldBeOfType<CreatedAtActionResult>().Value.ShouldBeOfType<ArrInstanceDto>();
|
||||
|
||||
// Act + Assert: update
|
||||
Guid id = dto.Id.ShouldNotBeNull();
|
||||
IActionResult updated = await DispatchUpdate(type, id, request with { Name = "rewired" });
|
||||
updated.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ArrInstanceDto>().Name.ShouldBe("rewired");
|
||||
|
||||
// Act + Assert: connection test
|
||||
IActionResult tested = await DispatchTest(type, new TestArrInstanceRequest
|
||||
{
|
||||
Url = request.Url,
|
||||
ApiKey = request.ApiKey,
|
||||
Version = request.Version,
|
||||
});
|
||||
tested.ShouldBeOfType<OkObjectResult>();
|
||||
await _healthChecker.Received(1).CheckAsync(type, Arg.Any<ArrInstance>());
|
||||
|
||||
// Act + Assert: delete
|
||||
IActionResult deleted = await DispatchDelete(type, id);
|
||||
deleted.ShouldBeOfType<NoContentResult>();
|
||||
|
||||
ArrConfig config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(c => c.Type == type);
|
||||
config.Instances.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Task<IActionResult> DispatchCreate(InstanceType type, ArrInstanceRequest request) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.CreateSonarrInstance(request),
|
||||
InstanceType.Radarr => _controller.CreateRadarrInstance(request),
|
||||
InstanceType.Lidarr => _controller.CreateLidarrInstance(request),
|
||||
InstanceType.Readarr => _controller.CreateReadarrInstance(request),
|
||||
InstanceType.Whisparr => _controller.CreateWhisparrInstance(request),
|
||||
InstanceType.Sportarr => _controller.CreateSportarrInstance(request),
|
||||
InstanceType.LazyLibrarian => _controller.CreateLazyLibrarianInstance(request),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchUpdate(InstanceType type, Guid id, ArrInstanceRequest request) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.UpdateSonarrInstance(id, request),
|
||||
InstanceType.Radarr => _controller.UpdateRadarrInstance(id, request),
|
||||
InstanceType.Lidarr => _controller.UpdateLidarrInstance(id, request),
|
||||
InstanceType.Readarr => _controller.UpdateReadarrInstance(id, request),
|
||||
InstanceType.Whisparr => _controller.UpdateWhisparrInstance(id, request),
|
||||
InstanceType.Sportarr => _controller.UpdateSportarrInstance(id, request),
|
||||
InstanceType.LazyLibrarian => _controller.UpdateLazyLibrarianInstance(id, request),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchDelete(InstanceType type, Guid id) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.DeleteSonarrInstance(id),
|
||||
InstanceType.Radarr => _controller.DeleteRadarrInstance(id),
|
||||
InstanceType.Lidarr => _controller.DeleteLidarrInstance(id),
|
||||
InstanceType.Readarr => _controller.DeleteReadarrInstance(id),
|
||||
InstanceType.Whisparr => _controller.DeleteWhisparrInstance(id),
|
||||
InstanceType.Sportarr => _controller.DeleteSportarrInstance(id),
|
||||
InstanceType.LazyLibrarian => _controller.DeleteLazyLibrarianInstance(id),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchTest(InstanceType type, TestArrInstanceRequest request) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.TestSonarrInstance(request),
|
||||
InstanceType.Radarr => _controller.TestRadarrInstance(request),
|
||||
InstanceType.Lidarr => _controller.TestLidarrInstance(request),
|
||||
InstanceType.Readarr => _controller.TestReadarrInstance(request),
|
||||
InstanceType.Whisparr => _controller.TestWhisparrInstance(request),
|
||||
InstanceType.Sportarr => _controller.TestSportarrInstance(request),
|
||||
InstanceType.LazyLibrarian => _controller.TestLazyLibrarianInstance(request),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchGet(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.GetSonarrConfig(),
|
||||
@@ -385,6 +511,8 @@ public class ArrConfigControllerTests : IDisposable
|
||||
InstanceType.Lidarr => _controller.GetLidarrConfig(),
|
||||
InstanceType.Readarr => _controller.GetReadarrConfig(),
|
||||
InstanceType.Whisparr => _controller.GetWhisparrConfig(),
|
||||
InstanceType.Sportarr => _controller.GetSportarrConfig(),
|
||||
InstanceType.LazyLibrarian => _controller.GetLazyLibrarianConfig(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the credential endpoints when a correctly signed access token names a user that does not exist.
|
||||
/// Authentication accepts the token, so the action itself has to reject the request.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerMissingUserTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private const string Username = "ghostadmin";
|
||||
private const string Password = "GhostPassword123!";
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AccountControllerMissingUserTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccount()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task ChangePassword_ForAnUnknownUserId_ReturnsUnauthorized()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = TokenForAnUnknownUser();
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newPassword = "AnotherPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task ChangeUsername_ForAnUnknownUserId_ReturnsUnauthorized()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = TokenForAnUnknownUser();
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = "renamedadmin"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task UpdateOidcConfig_ForAnUnknownUserId_ReturnsUnauthorized()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = TokenForAnUnknownUser();
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
private AuthenticationHeaderValue TokenForAnUnknownUser()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var jwtService = scope.ServiceProvider.GetRequiredService<IJwtService>();
|
||||
|
||||
// Signed with the running app's key, so authentication passes and the lookup inside the action is what fails
|
||||
string token = jwtService.GenerateAccessToken(new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "gone",
|
||||
PasswordHash = string.Empty,
|
||||
TotpSecret = string.Empty,
|
||||
ApiKey = string.Empty
|
||||
});
|
||||
|
||||
return new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,18 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task ChangeUsername_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newUsername = "renamedadmin"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
@@ -221,7 +233,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
@@ -229,7 +241,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
@@ -248,7 +260,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
@@ -260,7 +272,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
@@ -276,6 +288,26 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task ChangePassword_WithWrongPassword_ReturnsBadRequestAndKeepsThePassword()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "NotThePassword123!",
|
||||
newPassword = "RejectedPassword000!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var login = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "NewPassword789!"
|
||||
});
|
||||
|
||||
login.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that 2FA disable and regenerate accept a recovery code.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerTwoFactorTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private const string Username = "twofaadmin";
|
||||
private const string Password = "TwoFactorPassword123!";
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
private static string? _accessToken;
|
||||
private static string _secret = "";
|
||||
private static List<string> _recoveryCodes = [];
|
||||
|
||||
public AccountControllerTwoFactorTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndLogin()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Enable2fa_WithGeneratedTotpCode_TurnsTwoFactorOn()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Disable2fa_WithRecoveryCode_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = _recoveryCodes[0]
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Regenerate2fa_WithRecoveryCode_RotatesSecretAndCodes()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
var previousSecret = _secret;
|
||||
var previousCodes = _recoveryCodes;
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/regenerate", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = previousCodes[0]
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_secret = body.GetProperty("secret").GetString()!;
|
||||
_recoveryCodes = ReadRecoveryCodes(body);
|
||||
|
||||
_secret.ShouldNotBe(previousSecret);
|
||||
_recoveryCodes.Count.ShouldBe(10);
|
||||
_recoveryCodes.ShouldNotContain(previousCodes[0]);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Disable2fa_WithCodeFromRegeneratedBatch_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = _recoveryCodes[0]
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Disable2fa_WithRecoveryCodeAlreadyConsumedAtLogin_IsRejected()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
var anonymousClient = _factory.CreateClient();
|
||||
|
||||
var loginResponse = await anonymousClient.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginBody = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
loginBody.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
|
||||
var twoFactorResponse = await anonymousClient.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = loginBody.GetProperty("loginToken").GetString(),
|
||||
code = _recoveryCodes[0],
|
||||
isRecoveryCode = true
|
||||
});
|
||||
twoFactorResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var disableResponse = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = _recoveryCodes[0]
|
||||
});
|
||||
|
||||
disableResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Disable2fa_WithUnknownCode_IsRejected()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Regenerate2fa_WithUnknownCode_IsRejected()
|
||||
{
|
||||
HttpResponseMessage response = await _client.PostAsJsonAsync("/api/account/2fa/regenerate", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Disable2fa_WithTotpCode_StillSucceeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task Disable2fa_WithRepeatedBadCodes_EventuallyRateLimits()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
try
|
||||
{
|
||||
var first = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
first.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<JsonElement>();
|
||||
firstBody.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
|
||||
var second = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
second.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task Disable2fa_AfterLockoutCleared_ResetsTheCounterOnSuccess()
|
||||
{
|
||||
await SeedFailedAttempts(3);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts.ShouldBe(0);
|
||||
user.LockoutEnd.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Login_WithTwoFactorEnabled_KeepsTheFailedAttemptCounter()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
try
|
||||
{
|
||||
await SeedFailedAttempts(3);
|
||||
|
||||
await RequestLoginToken();
|
||||
|
||||
(await ReadFailedAttempts()).ShouldBe(3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
await DisableTwoFactor();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Login_WithConcurrentWrongPasswords_LocksOutTheSecondRequest()
|
||||
{
|
||||
await ClearLockout();
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login", new { username = Username, password = "WrongPassword123!" }),
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login", new { username = Username, password = "WrongPassword123!" }));
|
||||
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.Unauthorized).ShouldBe(1);
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.TooManyRequests).ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task Regenerate2fa_WhenIssuedConcurrently_AppliesOnce()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
string sharedCode = _recoveryCodes[0];
|
||||
|
||||
const int attempts = 8;
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
Enumerable.Range(0, attempts).Select(_ =>
|
||||
_client.PostAsJsonAsync("/api/account/2fa/regenerate", new { password = Password, totpCode = sharedCode })));
|
||||
|
||||
// Losers are rejected as a spent code or as a lockout, depending on which increment lands first
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.OK).ShouldBe(1);
|
||||
responses.Count(response => response.StatusCode is not HttpStatusCode.OK).ShouldBe(attempts - 1);
|
||||
(await CountRecoveryCodes()).ShouldBe(10);
|
||||
|
||||
HttpResponseMessage accepted = responses.First(response => response.StatusCode is HttpStatusCode.OK);
|
||||
JsonElement body = await accepted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_secret = body.GetProperty("secret").GetString()!;
|
||||
_recoveryCodes = ReadRecoveryCodes(body);
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task Login2fa_WithSameRecoveryCodeConcurrently_SucceedsOnce()
|
||||
{
|
||||
string sharedCode = _recoveryCodes[0];
|
||||
|
||||
string firstToken = await RequestLoginToken();
|
||||
string secondToken = await RequestLoginToken();
|
||||
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login/2fa", new { loginToken = firstToken, code = sharedCode, isRecoveryCode = true }),
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login/2fa", new { loginToken = secondToken, code = sharedCode, isRecoveryCode = true }));
|
||||
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.OK).ShouldBe(1);
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task Disable2fa_WithConcurrentBadCodes_LocksOutTheSecondRequest()
|
||||
{
|
||||
if (!await IsTwoFactorEnabled())
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
}
|
||||
|
||||
await ClearLockout();
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
_client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }),
|
||||
_client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }));
|
||||
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.BadRequest).ShouldBe(1);
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.TooManyRequests).ShouldBe(1);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RequestLoginToken()
|
||||
{
|
||||
HttpResponseMessage response = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
JsonElement body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
|
||||
return body.GetProperty("loginToken").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<int> CountRecoveryCodes()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
UsersContext context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
return await context.RecoveryCodes.CountAsync();
|
||||
}
|
||||
|
||||
private async Task SeedFailedAttempts(int attempts)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
UsersContext context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
User user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts = attempts;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<int> ReadFailedAttempts()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
UsersContext context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
return (await context.Users.AsNoTracking().FirstAsync()).FailedLoginAttempts;
|
||||
}
|
||||
|
||||
private async Task ClearLockout()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task EnableTwoFactor()
|
||||
{
|
||||
var enableResponse = await _client.PostAsJsonAsync("/api/account/2fa/enable", new
|
||||
{
|
||||
password = Password
|
||||
});
|
||||
enableResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await enableResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_secret = body.GetProperty("secret").GetString()!;
|
||||
_recoveryCodes = ReadRecoveryCodes(body);
|
||||
_recoveryCodes.Count.ShouldBe(10);
|
||||
|
||||
var verifyResponse = await _client.PostAsJsonAsync("/api/account/2fa/enable/verify", new
|
||||
{
|
||||
code = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
verifyResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task DisableTwoFactor()
|
||||
{
|
||||
HttpResponseMessage response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task<bool> IsTwoFactorEnabled()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return body.GetProperty("twoFactorEnabled").GetBoolean();
|
||||
}
|
||||
|
||||
private static List<string> ReadRecoveryCodes(JsonElement body)
|
||||
{
|
||||
return body.GetProperty("recoveryCodes")
|
||||
.EnumerateArray()
|
||||
.Select(code => code.GetString()!)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PUT /api/account/username.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerUsernameTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private const string Username = "renameadmin";
|
||||
private const string NewUsername = "renamedadmin";
|
||||
private const string Password = "RenamePassword123!";
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerUsernameTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndLogin()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task ChangeUsername_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _factory.CreateClient().PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = NewUsername
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task ChangeUsername_WithWrongPassword_ReturnsBadRequestAndKeepsUsername()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = "WrongPassword123!",
|
||||
newUsername = NewUsername
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await GetStoredUsername()).ShouldBe(Username);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task ChangeUsername_TooShortAfterTrimming_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = " ab "
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await GetStoredUsername()).ShouldBe(Username);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task ChangeUsername_WithCurrentUsername_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = Username
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task ChangeUsername_ShorterThanThreeCharacters_IsRejectedByModelValidation()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = "ab"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await GetStoredUsername()).ShouldBe(Username);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task ChangeUsername_WithValidPassword_TrimsStoresAndRevokesRefreshTokens()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = $" {NewUsername} "
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await GetStoredUsername()).ShouldBe(NewUsername);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
List<RefreshToken> tokens = await context.RefreshTokens.ToListAsync();
|
||||
|
||||
tokens.ShouldNotBeEmpty();
|
||||
tokens.ShouldAllBe(t => t.RevokedAt != null);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_WithOldUsername_IsRejected()
|
||||
{
|
||||
var response = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_WithNewUsername_Succeeds()
|
||||
{
|
||||
await ClearLockout();
|
||||
|
||||
var response = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = NewUsername,
|
||||
password = Password
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task<string> GetStoredUsername()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
User user = await context.Users.AsNoTracking().FirstAsync();
|
||||
|
||||
return user.Username;
|
||||
}
|
||||
|
||||
private async Task ClearLockout()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
User user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -268,55 +268,7 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
private static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(timestepBytes);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0) continue;
|
||||
for (var i = 4; i >= 0; i--)
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
private static string GenerateTotpCode(string base32Secret) => TotpTestHelper.GenerateTotpCode(base32Secret);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Cleanuparr.Api.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
public sealed class LoginAttemptTrackerTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly LoginAttemptTracker _sut;
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
|
||||
public LoginAttemptTrackerTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
DbContextOptions<UsersContext> options = new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_usersContext = new UsersContext(options);
|
||||
_usersContext.Database.EnsureCreated();
|
||||
|
||||
_usersContext.Users.Add(new User
|
||||
{
|
||||
Id = _userId,
|
||||
Username = "admin",
|
||||
PasswordHash = "hash",
|
||||
TotpSecret = string.Empty,
|
||||
ApiKey = "key",
|
||||
SetupCompleted = true
|
||||
});
|
||||
_usersContext.SaveChanges();
|
||||
|
||||
_sut = new LoginAttemptTracker(_usersContext, NullLogger<LoginAttemptTracker>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementFailedAttempts_GrowsTheLockoutWindowWithEachAttempt()
|
||||
{
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(2);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(4);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(6);
|
||||
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
user.FailedLoginAttempts.ShouldBe(3);
|
||||
user.LockoutEnd.ShouldNotBeNull();
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementFailedAttempts_StopsGrowingAtTheMaximumWindow()
|
||||
{
|
||||
User seeded = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
seeded.FailedLoginAttempts = 148;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(298);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(300);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(300);
|
||||
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
user.FailedLoginAttempts.ShouldBe(151);
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user)!.Value.ShouldBeLessThanOrEqualTo(300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedAttempts_ClearsTheCounterAndLockout()
|
||||
{
|
||||
await _sut.IncrementFailedAttempts(_userId);
|
||||
await _sut.IncrementFailedAttempts(_userId);
|
||||
|
||||
await _sut.ResetFailedAttempts(_userId);
|
||||
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
user.FailedLoginAttempts.ShouldBe(0);
|
||||
user.LockoutEnd.ShouldBeNull();
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLockoutSecondsRemaining_WithoutLockout_ReturnsNull()
|
||||
{
|
||||
User user = CreateUser(lockoutEnd: null);
|
||||
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLockoutSecondsRemaining_WithExpiredLockout_ReturnsNull()
|
||||
{
|
||||
User user = CreateUser(DateTimeOffset.UtcNow.AddSeconds(-1));
|
||||
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLockoutSecondsRemaining_WithActiveLockout_RoundsUpToWholeSeconds()
|
||||
{
|
||||
User user = CreateUser(DateTimeOffset.UtcNow.AddSeconds(9.9));
|
||||
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBe(10);
|
||||
}
|
||||
|
||||
private static User CreateUser(DateTimeOffset? lockoutEnd)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "admin",
|
||||
PasswordHash = "hash",
|
||||
TotpSecret = string.Empty,
|
||||
ApiKey = "key",
|
||||
LockoutEnd = lockoutEnd
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_usersContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
internal static class TotpTestHelper
|
||||
{
|
||||
public static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
Array.Reverse(timestepBytes);
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 4; i >= 0; i--)
|
||||
{
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
{
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
+97
-1
@@ -6,6 +6,7 @@ using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -46,7 +47,8 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0,
|
||||
double maxInactiveDays = -1,
|
||||
bool deleteSourceFiles = true)
|
||||
bool deleteSourceFiles = true,
|
||||
SeedingRuleAction action = SeedingRuleAction.Delete)
|
||||
{
|
||||
return new SeedingRuleRequest
|
||||
{
|
||||
@@ -63,6 +65,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
MinSeeders = minSeeders,
|
||||
MaxInactiveDays = maxInactiveDays,
|
||||
DeleteSourceFiles = deleteSourceFiles,
|
||||
Action = action,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,6 +280,58 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
GetCreatedRule<TransmissionSeedingRule>(result).TagsAny.ShouldBe(new List<string> { "tag1" });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DownloadClientTypeName.qBittorrent)]
|
||||
[InlineData(DownloadClientTypeName.Transmission)]
|
||||
[InlineData(DownloadClientTypeName.Deluge)]
|
||||
[InlineData(DownloadClientTypeName.uTorrent)]
|
||||
[InlineData(DownloadClientTypeName.rTorrent)]
|
||||
public async Task CreateSeedingRule_WithStopAction_ReturnsStopAction(DownloadClientTypeName typeName)
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, typeName, $"Test {typeName}");
|
||||
SeedingRuleRequest request = CreateValidRequest(action: SeedingRuleAction.Stop);
|
||||
|
||||
IActionResult result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
SeedingRuleResponse rule = GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).Single();
|
||||
rule.Action.ShouldBe(SeedingRuleAction.Stop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ActionOmitted_DefaultsToDelete()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRuleRequest request = new()
|
||||
{
|
||||
Name = "Rule without an action",
|
||||
Categories = ["movies"],
|
||||
MaxRatio = 2.0,
|
||||
};
|
||||
|
||||
IActionResult result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Action.ShouldBe(SeedingRuleAction.Delete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_UnknownAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRuleRequest request = CreateValidRequest(action: SeedingRuleAction.Unknown);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_UndefinedAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRuleRequest request = CreateValidRequest(action: (SeedingRuleAction)5);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// UpdateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -379,6 +434,47 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DownloadClientTypeName.qBittorrent)]
|
||||
[InlineData(DownloadClientTypeName.Transmission)]
|
||||
[InlineData(DownloadClientTypeName.Deluge)]
|
||||
[InlineData(DownloadClientTypeName.uTorrent)]
|
||||
[InlineData(DownloadClientTypeName.rTorrent)]
|
||||
public async Task UpdateSeedingRule_ChangedToStopAction_ReturnsStopAction(DownloadClientTypeName typeName)
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, typeName, $"Test {typeName}");
|
||||
await _controller.CreateSeedingRule(client.Id, CreateValidRequest());
|
||||
Guid ruleId = GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).Single().Id;
|
||||
|
||||
IActionResult result = await _controller.UpdateSeedingRule(ruleId, CreateValidRequest(action: SeedingRuleAction.Stop));
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
SeedingRuleResponse rule = GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).Single();
|
||||
rule.Action.ShouldBe(SeedingRuleAction.Stop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UnknownAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
QBitSeedingRule rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
SeedingRuleRequest request = CreateValidRequest(action: SeedingRuleAction.Unknown);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UndefinedAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
QBitSeedingRule rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
SeedingRuleRequest request = CreateValidRequest(action: (SeedingRuleAction)5);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ReorderSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
+4
-2
@@ -48,7 +48,8 @@ public static class SeedingRulesTestDataFactory
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
@@ -67,7 +68,8 @@ public static class SeedingRulesTestDataFactory
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
|
||||
@@ -69,6 +69,7 @@ public class GeneralConfigControllerTests : IDisposable
|
||||
DryRun = false,
|
||||
HttpMaxRetries = 5,
|
||||
HttpTimeout = 60,
|
||||
HttpSendUserAgent = true,
|
||||
StatusCheckEnabled = false,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
IgnoredDownloads = new List<string> { "ignored-item" },
|
||||
@@ -88,6 +89,7 @@ public class GeneralConfigControllerTests : IDisposable
|
||||
saved.DisplaySupportBanner.ShouldBeFalse();
|
||||
saved.HttpMaxRetries.ShouldBe((ushort)5);
|
||||
saved.HttpTimeout.ShouldBe((ushort)60);
|
||||
saved.HttpSendUserAgent.ShouldBeTrue();
|
||||
saved.StrikeInactivityWindowHours.ShouldBe((ushort)48);
|
||||
saved.IgnoredDownloads.ShouldContain("ignored-item");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features;
|
||||
|
||||
public class LogSanitizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SanitizeForLog_LeavesAPlainValueUntouched()
|
||||
{
|
||||
"admin".SanitizeForLog().ShouldBe("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_KeepsPrintableSymbolsAndAccents()
|
||||
{
|
||||
"admin.user+1_ó".SanitizeForLog().ShouldBe("admin.user+1_ó");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void SanitizeForLog_MapsMissingValuesToEmpty(string? value)
|
||||
{
|
||||
value.SanitizeForLog().ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_StripsTheLineBreaksUsedToForgeAnEntry()
|
||||
{
|
||||
string forged = "admin\n2026-01-01 00:00:00.000 [ERR] Injected entry";
|
||||
|
||||
forged.SanitizeForLog().ShouldBe("admin2026-01-01 00:00:00.000 [ERR] Injected entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_StripsCarriageReturns()
|
||||
{
|
||||
"admin\r\nsecond line".SanitizeForLog().ShouldBe("adminsecond line");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_StripsEscapeSequencesAndOtherControlCharacters()
|
||||
{
|
||||
"adm\u001b[31min\tuser\0".SanitizeForLog().ShouldBe("adm[31minuser");
|
||||
}
|
||||
}
|
||||
+4
@@ -58,6 +58,7 @@ public class MalwareBlockerConfigControllerTests : IDisposable
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -86,6 +87,7 @@ public class MalwareBlockerConfigControllerTests : IDisposable
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -111,6 +113,7 @@ public class MalwareBlockerConfigControllerTests : IDisposable
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
@@ -134,6 +137,7 @@ public class MalwareBlockerConfigControllerTests : IDisposable
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
IgnoredDownloads = new List<string> { "foo" },
|
||||
};
|
||||
|
||||
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Notifications.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationProvidersControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly NotificationProvidersController _controller;
|
||||
|
||||
public NotificationProvidersControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
|
||||
INotificationConfigurationService configurationService =
|
||||
Substitute.For<INotificationConfigurationService>();
|
||||
|
||||
// NotificationService is sealed; the endpoints under test never reach it.
|
||||
NotificationService notificationService = new(
|
||||
Substitute.For<ILogger<NotificationService>>(),
|
||||
configurationService,
|
||||
Substitute.For<INotificationProviderFactory>());
|
||||
|
||||
_controller = new NotificationProvidersController(
|
||||
Substitute.For<ILogger<NotificationProvidersController>>(),
|
||||
_dataContext,
|
||||
configurationService,
|
||||
notificationService,
|
||||
Substitute.For<IAppriseCliDetector>());
|
||||
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static NotificationProviderResponse Created(IActionResult result) =>
|
||||
result.ShouldBeOfType<CreatedAtActionResult>().Value.ShouldBeOfType<NotificationProviderResponse>();
|
||||
|
||||
private static NotificationProviderResponse Updated(IActionResult result) =>
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<NotificationProviderResponse>();
|
||||
|
||||
#region Notifiarr
|
||||
|
||||
[Fact]
|
||||
public async Task CreateNotifiarrProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateNotifiarrProviderRequest request = new()
|
||||
{
|
||||
Name = "Notifiarr",
|
||||
ApiKey = "0123456789abcdef",
|
||||
ChannelId = "123456789",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateNotifiarrProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Notifiarr);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateNotifiarrProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateNotifiarrProvider(new CreateNotifiarrProviderRequest
|
||||
{
|
||||
Name = "Notifiarr",
|
||||
ApiKey = "0123456789abcdef",
|
||||
ChannelId = "123456789",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateNotifiarrProvider(id,
|
||||
new UpdateNotifiarrProviderRequest
|
||||
{
|
||||
Name = "Notifiarr",
|
||||
ApiKey = "0123456789abcdef",
|
||||
ChannelId = "123456789",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Apprise
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAppriseProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateAppriseProviderRequest request = new()
|
||||
{
|
||||
Name = "Apprise",
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "config-key",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateAppriseProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Apprise);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppriseProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateAppriseProvider(new CreateAppriseProviderRequest
|
||||
{
|
||||
Name = "Apprise",
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "config-key",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateAppriseProvider(id,
|
||||
new UpdateAppriseProviderRequest
|
||||
{
|
||||
Name = "Apprise",
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "config-key",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ntfy
|
||||
|
||||
[Fact]
|
||||
public async Task CreateNtfyProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateNtfyProviderRequest request = new()
|
||||
{
|
||||
Name = "Ntfy",
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["cleanuparr"],
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateNtfyProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Ntfy);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateNtfyProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateNtfyProvider(new CreateNtfyProviderRequest
|
||||
{
|
||||
Name = "Ntfy",
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["cleanuparr"],
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateNtfyProvider(id,
|
||||
new UpdateNtfyProviderRequest
|
||||
{
|
||||
Name = "Ntfy",
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["cleanuparr"],
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Telegram
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTelegramProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateTelegramProviderRequest request = new()
|
||||
{
|
||||
Name = "Telegram",
|
||||
BotToken = "0123456789:token",
|
||||
ChatId = "-1001234567890",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateTelegramProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Telegram);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTelegramProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateTelegramProvider(new CreateTelegramProviderRequest
|
||||
{
|
||||
Name = "Telegram",
|
||||
BotToken = "0123456789:token",
|
||||
ChatId = "-1001234567890",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateTelegramProvider(id,
|
||||
new UpdateTelegramProviderRequest
|
||||
{
|
||||
Name = "Telegram",
|
||||
BotToken = "0123456789:token",
|
||||
ChatId = "-1001234567890",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Discord
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDiscordProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateDiscordProviderRequest request = new()
|
||||
{
|
||||
Name = "Discord",
|
||||
WebhookUrl = "https://discord.com/api/webhooks/1/token",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateDiscordProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Discord);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDiscordProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateDiscordProvider(new CreateDiscordProviderRequest
|
||||
{
|
||||
Name = "Discord",
|
||||
WebhookUrl = "https://discord.com/api/webhooks/1/token",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateDiscordProvider(id,
|
||||
new UpdateDiscordProviderRequest
|
||||
{
|
||||
Name = "Discord",
|
||||
WebhookUrl = "https://discord.com/api/webhooks/1/token",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Pushover
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePushoverProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreatePushoverProviderRequest request = new()
|
||||
{
|
||||
Name = "Pushover",
|
||||
ApiToken = "api-token",
|
||||
UserKey = "user-key",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreatePushoverProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Pushover);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePushoverProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreatePushoverProvider(new CreatePushoverProviderRequest
|
||||
{
|
||||
Name = "Pushover",
|
||||
ApiToken = "api-token",
|
||||
UserKey = "user-key",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdatePushoverProvider(id,
|
||||
new UpdatePushoverProviderRequest
|
||||
{
|
||||
Name = "Pushover",
|
||||
ApiToken = "api-token",
|
||||
UserKey = "user-key",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gotify
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGotifyProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateGotifyProviderRequest request = new()
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateGotifyProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Gotify);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGotifyProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateGotifyProvider(new CreateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateGotifyProvider(id,
|
||||
new UpdateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
[Fact]
|
||||
public async Task GetNotificationProviders_ReturnsTheDownloadStoppedEvent()
|
||||
{
|
||||
await _controller.CreateGotifyProvider(new CreateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = true,
|
||||
});
|
||||
|
||||
NotificationProvidersResponse response = (await _controller.GetNotificationProviders())
|
||||
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<NotificationProvidersResponse>();
|
||||
|
||||
response.Providers.ShouldHaveSingleItem().Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGotifyProvider_WithoutEvents_LeavesDownloadStoppedDisabled()
|
||||
{
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateGotifyProvider(
|
||||
new CreateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,19 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
// The sentinel is not a database value.
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithTheUnknownItemType_IgnoresTheFilter()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(itemType: InstanceType.Unknown);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
|
||||
{
|
||||
|
||||
@@ -52,9 +52,29 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
instance.Enabled.ShouldBeFalse();
|
||||
instance.SkipTags.ShouldBeEmpty();
|
||||
instance.ActiveDownloadLimit.ShouldBe(3);
|
||||
instance.IgnoreStruckDownloads.ShouldBeFalse();
|
||||
instance.MinCycleTimeDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithIgnoreStruckDownloadsEnabled_ReturnsEnabledValue()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
IgnoreStruckDownloads = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.ShouldHaveSingleItem().IgnoreStruckDownloads.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
{
|
||||
@@ -354,6 +374,7 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
Enabled = true,
|
||||
SkipTags = ["new-tag"],
|
||||
ActiveDownloadLimit = 5,
|
||||
IgnoreStruckDownloads = true,
|
||||
MinCycleTimeDays = 14
|
||||
},
|
||||
// Create new sonarr config
|
||||
@@ -377,11 +398,13 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
radarrConfig.Enabled.ShouldBeTrue();
|
||||
radarrConfig.SkipTags.ShouldContain("new-tag");
|
||||
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
|
||||
radarrConfig.IgnoreStruckDownloads.ShouldBeTrue();
|
||||
radarrConfig.MinCycleTimeDays.ShouldBe(14);
|
||||
|
||||
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
|
||||
sonarrConfig.Enabled.ShouldBeTrue();
|
||||
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
|
||||
sonarrConfig.IgnoreStruckDownloads.ShouldBeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
+4
-2
@@ -66,7 +66,8 @@ public static class SeekerTestDataFactory
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
@@ -85,7 +86,8 @@ public static class SeekerTestDataFactory
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Json;
|
||||
|
||||
/// <summary>
|
||||
/// The in-memory bus serializes with these options, so a broken discriminator
|
||||
/// only shows up at runtime.
|
||||
/// </summary>
|
||||
public class RemovalRequestSerializationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = CreateOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
JsonSerializerOptions options = new();
|
||||
CleanuparrJsonConfiguration.ConfigureCore(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static QueueItemRemoveRequest CreateRequest(SearchItem searchItem)
|
||||
{
|
||||
return new QueueItemRemoveRequest
|
||||
{
|
||||
Instance = new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://sonarr.local"),
|
||||
ApiKey = "test-api-key",
|
||||
ArrConfig = new ArrConfig { Type = InstanceType.Sonarr },
|
||||
},
|
||||
Target = new ArrRemovalTarget
|
||||
{
|
||||
Record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123",
|
||||
},
|
||||
SearchItem = searchItem,
|
||||
RemoveFromClient = true,
|
||||
ChangeCategory = false,
|
||||
},
|
||||
DeleteReason = DeleteReason.Stalled,
|
||||
JobRunId = Guid.NewGuid(),
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueItemRemoveRequest RoundTrip(QueueItemRemoveRequest request)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(request, Options);
|
||||
return JsonSerializer.Deserialize<QueueItemRemoveRequest>(json, Options)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrTarget_SurvivesRoundTrip()
|
||||
{
|
||||
QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SearchItem { Id = 42 }));
|
||||
|
||||
ArrRemovalTarget target = result.Target.ShouldBeOfType<ArrRemovalTarget>();
|
||||
target.Record.DownloadId.ShouldBe("ABC123");
|
||||
target.RemoveFromClient.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BaseSearchItem_SurvivesRoundTrip()
|
||||
{
|
||||
QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SearchItem { Id = 42 }));
|
||||
|
||||
SearchItem item = ArrTargetOf(result).SearchItem;
|
||||
item.ShouldBeOfType<SearchItem>();
|
||||
item.Id.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeriesSearchItem_KeepsItsDerivedType()
|
||||
{
|
||||
QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SeriesSearchItem
|
||||
{
|
||||
Id = 100,
|
||||
SeriesId = 10,
|
||||
SearchType = SeriesSearchType.Episode,
|
||||
}));
|
||||
|
||||
SeriesSearchItem item = ArrTargetOf(result).SearchItem.ShouldBeOfType<SeriesSearchItem>();
|
||||
item.Id.ShouldBe(100);
|
||||
item.SeriesId.ShouldBe(10);
|
||||
item.SearchType.ShouldBe(SeriesSearchType.Episode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_CarriesTheDiscriminator_AndOmitsDerivedMembers()
|
||||
{
|
||||
string json = JsonSerializer.Serialize(CreateRequest(new SearchItem { Id = 42 }), Options);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
JsonElement target = document.RootElement.GetProperty("Target");
|
||||
|
||||
target.GetProperty("$target").GetString().ShouldBe("arr");
|
||||
target.TryGetProperty("DownloadId", out _).ShouldBeFalse();
|
||||
target.TryGetProperty("Title", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static ArrRemovalTarget ArrTargetOf(QueueItemRemoveRequest request) =>
|
||||
(ArrRemovalTarget)request.Target;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
@@ -96,7 +96,9 @@ public static class ConfigControllerTestDataFactory
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.LazyLibrarian, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
@@ -116,6 +118,8 @@ public static class ConfigControllerTestDataFactory
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
LazyLibrarian = new BlocklistSettings { Enabled = false },
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
|
||||
@@ -62,7 +62,7 @@ public class EventsController : ControllerBase
|
||||
// Apply filters
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out EventSeverity severityEnum))
|
||||
if (EnumSentinel.TryParseSelectable(severity, out EventSeverity severityEnum))
|
||||
{
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class EventsController : ControllerBase
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
if (Enum.TryParse<EventType>(eventType, true, out EventType eventTypeEnum))
|
||||
if (EnumSentinel.TryParseSelectable(eventType, out EventType eventTypeEnum))
|
||||
{
|
||||
query = query.Where(e => e.EventType == eventTypeEnum);
|
||||
}
|
||||
@@ -166,8 +166,7 @@ public class EventsController : ControllerBase
|
||||
[HttpGet("types")]
|
||||
public async Task<ActionResult<List<string>>> GetEventTypes()
|
||||
{
|
||||
var types = Enum.GetNames(typeof(EventType)).ToList();
|
||||
return Ok(types);
|
||||
return Ok(EnumSentinel.SelectableNames<EventType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -176,8 +175,7 @@ public class EventsController : ControllerBase
|
||||
[HttpGet("severities")]
|
||||
public async Task<ActionResult<List<string>>> GetSeverities()
|
||||
{
|
||||
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
|
||||
return Ok(severities);
|
||||
return Ok(EnumSentinel.SelectableNames<EventSeverity>());
|
||||
}
|
||||
|
||||
[HttpGet("timeline")]
|
||||
@@ -207,8 +205,12 @@ public class EventsController : ControllerBase
|
||||
foreach (BucketTypeCount row in rows)
|
||||
{
|
||||
DateTimeOffset bucket = TimelineBucketing.ParseKey(row.Bucket, size);
|
||||
EventType type = Enum.Parse<EventType>(row.EventType, ignoreCase: true);
|
||||
byBucketType[(bucket, type)] = row.Count;
|
||||
// Raw SQL skips the value converters, so mirror what they do with unknown text.
|
||||
EventType type = EnumSentinel.ParseOrUnknown<EventType>(row.EventType);
|
||||
// Several unrecognised types read as one.
|
||||
// Their rows have to add up.
|
||||
byBucketType.TryGetValue((bucket, type), out int running);
|
||||
byBucketType[(bucket, type)] = running + row.Count;
|
||||
presentSet.Add(type);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,12 @@ public class JobsController : ControllerBase
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually controlled");
|
||||
}
|
||||
|
||||
// Get the schedule from the request body if provided
|
||||
JobSchedule jobSchedule = scheduleRequest.Schedule;
|
||||
if (scheduleRequest?.Schedule is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
|
||||
}
|
||||
|
||||
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
|
||||
var result = await _jobManagementService.StartJob(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
|
||||
@@ -59,7 +59,7 @@ public class ManualEventsController : ControllerBase
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
|
||||
if (EnumSentinel.TryParseSelectable(severity, out EventSeverity severityEnum))
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,6 @@ public class ManualEventsController : ControllerBase
|
||||
[HttpGet("severities")]
|
||||
public async Task<ActionResult<List<string>>> GetSeverities()
|
||||
{
|
||||
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
|
||||
return Ok(severities);
|
||||
return Ok(EnumSentinel.SelectableNames<EventSeverity>());
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics endpoint for dashboard integrations.
|
||||
/// Deprecated. Use <c>GET /api/v2/stats</c> instead.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StatsController : ControllerBase
|
||||
{
|
||||
private static readonly DateTimeOffset SunsetDate = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly IStatsService _statsService;
|
||||
|
||||
public StatsController(IStatsService statsService)
|
||||
{
|
||||
_statsService = statsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated application statistics for the specified timeframe.
|
||||
/// Deprecated. Use <c>GET /api/v2/stats</c> instead. Responses carry Deprecation/Link headers.
|
||||
/// </summary>
|
||||
/// <param name="hours">Timeframe in hours (default 24, range 1-720)</param>
|
||||
/// <param name="includeEvents">Number of recent events to include (0 = none, max 100)</param>
|
||||
/// <param name="includeStrikes">Number of recent strikes to include (0 = none, max 100)</param>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetStats(
|
||||
[FromQuery] int hours = 24,
|
||||
[FromQuery] int includeEvents = 0,
|
||||
[FromQuery] int includeStrikes = 0)
|
||||
{
|
||||
Response.Headers["Deprecation"] = "true";
|
||||
Response.Headers["Sunset"] = SunsetDate.ToString("R");
|
||||
Response.Headers["Link"] =
|
||||
"</api/v2/stats>; rel=\"successor-version\", " +
|
||||
"<https://cleanuparr.github.io/Cleanuparr/docs/configuration/stats>; rel=\"deprecation\"";
|
||||
|
||||
if (DateTimeOffset.UtcNow >= SunsetDate)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
hours = Math.Clamp(hours, 1, 720);
|
||||
includeEvents = Math.Clamp(includeEvents, 0, 100);
|
||||
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
|
||||
|
||||
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
|
||||
return Ok(stats);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -13,15 +16,21 @@ namespace Cleanuparr.Api.Controllers;
|
||||
[Authorize]
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
|
||||
// Every member is seeded in arr_configs, so a new one must not be forgotten here.
|
||||
private static readonly IReadOnlyList<InstanceType> ArrTypes = EnumSentinel.SelectableValues<InstanceType>();
|
||||
|
||||
public StatusController(
|
||||
ILogger<StatusController> logger,
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
IInstanceHealthChecker healthChecker)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
_healthChecker = healthChecker;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -29,57 +38,30 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
using var process = Process.GetCurrentProcess();
|
||||
|
||||
// Get configuration
|
||||
var sonarrConfig = await _dataContext.ArrConfigs
|
||||
Dictionary<InstanceType, ArrConfig> configsByType = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => ArrTypes.Contains(x.Type))
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Sonarr);
|
||||
var radarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Radarr);
|
||||
var lidarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Lidarr);
|
||||
var readarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
.ToDictionaryAsync(x => x.Type);
|
||||
|
||||
var status = new
|
||||
Dictionary<string, MediaManagerStatusResponse> mediaManagers = ArrTypes.ToDictionary(
|
||||
type => type.ToString(),
|
||||
type => new MediaManagerStatusResponse
|
||||
{
|
||||
InstanceCount = configsByType.TryGetValue(type, out ArrConfig? config) ? config.Instances.Count : 0,
|
||||
});
|
||||
|
||||
SystemStatusResponse status = new()
|
||||
{
|
||||
Application = new
|
||||
Application = new ApplicationStatusResponse
|
||||
{
|
||||
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
|
||||
process.StartTime,
|
||||
StartTime = process.StartTime,
|
||||
UpTime = DateTimeOffset.UtcNow - process.StartTime.ToUniversalTime(),
|
||||
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
|
||||
ProcessorTime = process.TotalProcessorTime
|
||||
ProcessorTime = process.TotalProcessorTime,
|
||||
},
|
||||
DownloadClient = new
|
||||
{
|
||||
// TODO
|
||||
},
|
||||
MediaManagers = new
|
||||
{
|
||||
Sonarr = new
|
||||
{
|
||||
InstanceCount = sonarrConfig.Instances.Count
|
||||
},
|
||||
Radarr = new
|
||||
{
|
||||
InstanceCount = radarrConfig.Instances.Count
|
||||
},
|
||||
Lidarr = new
|
||||
{
|
||||
InstanceCount = lidarrConfig.Instances.Count
|
||||
},
|
||||
Readarr = new
|
||||
{
|
||||
InstanceCount = readarrConfig.Instances.Count
|
||||
}
|
||||
}
|
||||
MediaManagers = mediaManagers,
|
||||
};
|
||||
|
||||
return Ok(status);
|
||||
@@ -88,158 +70,77 @@ public class StatusController : ControllerBase
|
||||
[HttpGet("download-client")]
|
||||
public async Task<IActionResult> GetDownloadClientStatus()
|
||||
{
|
||||
var downloadClients = await _dataContext.DownloadClients
|
||||
List<DownloadClientConfig> downloadClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var result = new Dictionary<string, object>();
|
||||
|
||||
// Check for configured clients
|
||||
if (downloadClients.Count > 0)
|
||||
{
|
||||
var clientsStatus = new List<object>();
|
||||
foreach (var client in downloadClients)
|
||||
List<DownloadClientStatusResponse> clients = downloadClients
|
||||
.Select(client => new DownloadClientStatusResponse
|
||||
{
|
||||
clientsStatus.Add(new
|
||||
{
|
||||
client.Id,
|
||||
client.Name,
|
||||
Type = client.TypeName,
|
||||
client.Host,
|
||||
client.Enabled,
|
||||
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
|
||||
});
|
||||
}
|
||||
Id = client.Id,
|
||||
Name = client.Name,
|
||||
Type = client.TypeName,
|
||||
Host = client.Host,
|
||||
Enabled = client.Enabled,
|
||||
IsConnected = client.Enabled,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
result["Clients"] = clientsStatus;
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
return Ok(new Dictionary<string, List<DownloadClientStatusResponse>> { ["Clients"] = clients });
|
||||
}
|
||||
|
||||
[HttpGet("arrs")]
|
||||
public async Task<IActionResult> GetMediaManagersStatus()
|
||||
{
|
||||
var status = new Dictionary<string, object>();
|
||||
Dictionary<string, List<InstanceConnectionResponse>> status = new();
|
||||
|
||||
// Get configurations
|
||||
var enabledSonarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Sonarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var enabledRadarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Radarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var enabledLidarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Lidarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
// Check Sonarr instances
|
||||
var sonarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledSonarrInstances)
|
||||
foreach (InstanceType type in ArrTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
List<ArrInstance> enabledInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == type)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
status[type.ToString()] = await CheckInstancesAsync(type, enabledInstances);
|
||||
}
|
||||
|
||||
status["Sonarr"] = sonarrStatus;
|
||||
|
||||
// Check Radarr instances
|
||||
var radarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledRadarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
|
||||
await radarrClient.HealthCheckAsync(instance);
|
||||
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Radarr"] = radarrStatus;
|
||||
|
||||
// Check Lidarr instances
|
||||
var lidarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledLidarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
|
||||
await lidarrClient.HealthCheckAsync(instance);
|
||||
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Lidarr"] = lidarrStatus;
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
private async Task<List<InstanceConnectionResponse>> CheckInstancesAsync(InstanceType type, IReadOnlyList<ArrInstance> instances)
|
||||
{
|
||||
List<InstanceConnectionResponse> results = new(instances.Count);
|
||||
|
||||
foreach (ArrInstance instance in instances)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _healthChecker.CheckAsync(type, instance);
|
||||
|
||||
results.Add(new InstanceConnectionResponse
|
||||
{
|
||||
Name = instance.Name,
|
||||
Url = instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected",
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "health check failed for {Type} instance | {Url}", type, instance.Url);
|
||||
results.Add(new InstanceConnectionResponse
|
||||
{
|
||||
Name = instance.Name,
|
||||
Url = instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public class StrikesController : ControllerBase
|
||||
// Filter by strike type: only show items that have strikes of this type
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
if (Enum.TryParse<StrikeType>(type, true, out var strikeType))
|
||||
if (EnumSentinel.TryParseSelectable(type, out StrikeType strikeType))
|
||||
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
|
||||
}
|
||||
|
||||
@@ -148,8 +148,7 @@ public class StrikesController : ControllerBase
|
||||
[HttpGet("types")]
|
||||
public ActionResult<List<string>> GetStrikeTypes()
|
||||
{
|
||||
var types = Enum.GetNames(typeof(StrikeType)).ToList();
|
||||
return Ok(types);
|
||||
return Ok(EnumSentinel.SelectableNames<StrikeType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -26,14 +27,14 @@ public static class MainDI
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer>();
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadStoppedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
|
||||
config.UsingInMemory((context, cfg) =>
|
||||
@@ -47,8 +48,7 @@ public static class MainDI
|
||||
|
||||
cfg.ReceiveEndpoint("download-remover-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
@@ -61,6 +61,7 @@ public static class MainDI
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadStoppedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
@@ -76,12 +77,6 @@ public static class MainDI
|
||||
// Add the dynamic HTTP client provider that uses the new system
|
||||
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
||||
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
// Add HTTP client for OIDC authentication
|
||||
services.AddHttpClient("OidcAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using Cleanuparr.Api.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Features.LazyLibrarian;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
@@ -33,6 +36,7 @@ public static class ServicesDI
|
||||
.AddSingleton<IJwtService, JwtService>()
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<LoginAttemptTracker>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IOidcAuthService, OidcAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
@@ -40,11 +44,16 @@ public static class ServicesDI
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<ISonarrClient, SonarrClient>()
|
||||
.AddScoped<ISportarrClient, SportarrClient>()
|
||||
.AddScoped<IRadarrClient, RadarrClient>()
|
||||
.AddScoped<ILidarrClient, LidarrClient>()
|
||||
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||
.AddScoped<IWhisparrV2Client, WhisparrV2Client>()
|
||||
.AddScoped<IWhisparrV3Client, WhisparrV3Client>()
|
||||
.AddScoped<ILazyLibrarianService, LazyLibrarianService>()
|
||||
.AddScoped<IInstanceHealthChecker, InstanceHealthChecker>()
|
||||
.AddKeyedScoped<ILazyLibrarianEvaluator, LazyLibrarianServiceQC>(ILazyLibrarianEvaluator.QueueCleanerKey)
|
||||
.AddKeyedScoped<ILazyLibrarianEvaluator, LazyLibrarianServiceCB>(ILazyLibrarianEvaluator.MalwareBlockerKey)
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
@@ -21,18 +22,24 @@ public sealed class ArrConfigController : ControllerBase
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the controller with its injected dependencies.
|
||||
/// </summary>
|
||||
public ArrConfigController(
|
||||
ILogger<ArrConfigController> logger,
|
||||
DataContext dataContext,
|
||||
EventsContext eventsContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
IInstanceHealthChecker healthChecker,
|
||||
IEventPublisher eventPublisher)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_eventsContext = eventsContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
_healthChecker = healthChecker;
|
||||
_eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
[HttpGet("sonarr")]
|
||||
@@ -50,6 +57,11 @@ public sealed class ArrConfigController : ControllerBase
|
||||
[HttpGet("whisparr")]
|
||||
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
|
||||
|
||||
[HttpGet("sportarr")]
|
||||
public Task<IActionResult> GetSportarrConfig() => GetArrConfig(InstanceType.Sportarr);
|
||||
[HttpGet("lazylibrarian")]
|
||||
public Task<IActionResult> GetLazyLibrarianConfig() => GetArrConfig(InstanceType.LazyLibrarian);
|
||||
|
||||
[HttpPut("sonarr")]
|
||||
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Sonarr, request);
|
||||
@@ -70,6 +82,13 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPut("sportarr")]
|
||||
public Task<IActionResult> UpdateSportarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Sportarr, request);
|
||||
[HttpPut("lazylibrarian")]
|
||||
public Task<IActionResult> UpdateLazyLibrarianConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.LazyLibrarian, request);
|
||||
|
||||
[HttpPost("sonarr/instances")]
|
||||
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Sonarr, request);
|
||||
@@ -130,6 +149,29 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||
|
||||
[HttpPost("sportarr/instances")]
|
||||
public Task<IActionResult> CreateSportarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Sportarr, request);
|
||||
|
||||
[HttpPut("sportarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateSportarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Sportarr, id, request);
|
||||
|
||||
[HttpDelete("sportarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteSportarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Sportarr, id);
|
||||
[HttpPost("lazylibrarian/instances")]
|
||||
public Task<IActionResult> CreateLazyLibrarianInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.LazyLibrarian, request);
|
||||
|
||||
[HttpPut("lazylibrarian/instances/{id}")]
|
||||
public Task<IActionResult> UpdateLazyLibrarianInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.LazyLibrarian, id, request);
|
||||
|
||||
[HttpDelete("lazylibrarian/instances/{id}")]
|
||||
public Task<IActionResult> DeleteLazyLibrarianInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.LazyLibrarian, id);
|
||||
|
||||
[HttpPost("sonarr/instances/test")]
|
||||
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sonarr, request);
|
||||
@@ -150,6 +192,13 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPost("sportarr/instances/test")]
|
||||
public Task<IActionResult> TestSportarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sportarr, request);
|
||||
[HttpPost("lazylibrarian/instances/test")]
|
||||
public Task<IActionResult> TestLazyLibrarianInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.LazyLibrarian, request);
|
||||
|
||||
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
@@ -271,7 +320,9 @@ public sealed class ArrConfigController : ControllerBase
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
|
||||
await FailStrandedSearchEventsForInstanceAsync(id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
finally
|
||||
@@ -306,7 +357,7 @@ public sealed class ArrConfigController : ControllerBase
|
||||
await _eventsContext.SeekerCommandTrackers
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
@@ -316,6 +367,20 @@ public sealed class ArrConfigController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FailStrandedSearchEventsForInstanceAsync(Guid arrInstanceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _eventPublisher.FailStrandedSearchEvents(arrInstanceId);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception,
|
||||
"Failed to mark the search events of instance {InstanceId} as failed, the seeker command monitor will retry",
|
||||
arrInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
|
||||
{
|
||||
try
|
||||
@@ -337,8 +402,7 @@ public sealed class ArrConfigController : ControllerBase
|
||||
}
|
||||
|
||||
var testInstance = request.ToTestInstance(resolvedApiKey);
|
||||
var client = _arrClientFactory.GetClient(type, request.Version);
|
||||
await client.HealthCheckAsync(testInstance);
|
||||
await _healthChecker.CheckAsync(type, testInstance);
|
||||
|
||||
return Ok(new { Message = $"Connection to {type} instance successful" });
|
||||
}
|
||||
@@ -375,6 +439,8 @@ public sealed class ArrConfigController : ControllerBase
|
||||
InstanceType.Lidarr => nameof(GetLidarrConfig),
|
||||
InstanceType.Readarr => nameof(GetReadarrConfig),
|
||||
InstanceType.Whisparr => nameof(GetWhisparrConfig),
|
||||
InstanceType.Sportarr => nameof(GetSportarrConfig),
|
||||
InstanceType.LazyLibrarian => nameof(GetLazyLibrarianConfig),
|
||||
_ => nameof(GetSonarrConfig),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record ChangeUsernameRequest
|
||||
{
|
||||
[Required]
|
||||
public required string CurrentPassword { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(3)]
|
||||
[MaxLength(50)]
|
||||
public required string NewUsername { get; init; }
|
||||
}
|
||||
@@ -8,6 +8,6 @@ public sealed record Disable2faRequest
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
[StringLength(32)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
+1
-1
@@ -8,6 +8,6 @@ public sealed record Regenerate2faRequest
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
[StringLength(32)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,6 @@ namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
public sealed record VerifyTotpRequest
|
||||
{
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
[StringLength(32)]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -25,6 +26,7 @@ public sealed class AccountController : ControllerBase
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly LoginAttemptTracker _loginAttemptTracker;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
@@ -33,6 +35,7 @@ public sealed class AccountController : ControllerBase
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
LoginAttemptTracker loginAttemptTracker,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
@@ -40,6 +43,7 @@ public sealed class AccountController : ControllerBase
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_loginAttemptTracker = loginAttemptTracker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -65,217 +69,314 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Password changes are disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Password changes are disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
User? user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
await RevokeActiveRefreshTokens(user.Id, now);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
return Unauthorized();
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
[HttpPut("username")]
|
||||
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Username changes are disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
|
||||
User? user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
|
||||
}
|
||||
|
||||
// Login compares the stored value, so surrounding whitespace would lock the user out
|
||||
string newUsername = request.NewUsername.Trim();
|
||||
|
||||
if (newUsername.Length < 3)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Username must be at least 3 characters");
|
||||
}
|
||||
|
||||
if (string.Equals(newUsername, user.Username, StringComparison.Ordinal))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "New username must be different from the current username");
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
string previousUsername = user.Username;
|
||||
|
||||
user.Username = newUsername;
|
||||
user.UpdatedAt = now;
|
||||
|
||||
await RevokeActiveRefreshTokens(user.Id, now);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Username changed from {PreviousUsername} to {Username}",
|
||||
previousUsername.SanitizeForLog(), newUsername.SanitizeForLog());
|
||||
|
||||
return Ok(new { message = "Username changed" });
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
// Revoke all existing refresh tokens so old sessions can't be reused
|
||||
var activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in activeTokens)
|
||||
finally
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/regenerate")]
|
||||
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
User user;
|
||||
string? failureMessage = null;
|
||||
int retryAfterSeconds = 0;
|
||||
TotpSetupResponse? setup = null;
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "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 = DateTimeOffset.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
User? currentUser = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (currentUser is null)
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user = currentUser;
|
||||
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(user) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
failureMessage = "Incorrect password";
|
||||
}
|
||||
else if (!_totpService.VerifySecondFactor(user, request.TotpCode))
|
||||
{
|
||||
failureMessage = "Invalid authenticator or recovery code";
|
||||
}
|
||||
else
|
||||
{
|
||||
setup = TwoFactorSecretRotation.Rotate(_totpService, _usersContext, user);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(user.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _loginAttemptTracker.ResetFailedAttempts(user.Id);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, failureMessage,
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
return Ok(setup);
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable")]
|
||||
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
|
||||
}
|
||||
|
||||
TotpSetupResponse setup = TwoFactorSecretRotation.Rotate(_totpService, _usersContext, user);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(setup);
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
finally
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable/verify")]
|
||||
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid verification code");
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid verification code");
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
User user;
|
||||
string? failureMessage = null;
|
||||
int retryAfterSeconds = 0;
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
User? currentUser = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (currentUser is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user = currentUser;
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "2FA is not enabled");
|
||||
}
|
||||
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(user) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
failureMessage = "Incorrect password";
|
||||
}
|
||||
else if (!_totpService.VerifySecondFactor(user, request.TotpCode))
|
||||
{
|
||||
failureMessage = "Invalid authenticator or recovery code";
|
||||
}
|
||||
else
|
||||
{
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(user.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _loginAttemptTracker.ResetFailedAttempts(user.Id);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "2FA is not enabled");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, failureMessage,
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid 2FA code");
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
@@ -405,18 +506,27 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPut("oidc")]
|
||||
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
User? user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
request.ApplyTo(user.Oidc);
|
||||
user.Oidc.Validate();
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "OIDC configuration updated" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
request.ApplyTo(user.Oidc);
|
||||
user.Oidc.Validate();
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "OIDC configuration updated" });
|
||||
}
|
||||
|
||||
[HttpPost("oidc/link")]
|
||||
@@ -613,6 +723,21 @@ public sealed class AccountController : ControllerBase
|
||||
return $"{baseUrl}/api/account/oidc/link/callback";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes every active refresh token so sessions opened before a credential change cannot be reused.
|
||||
/// </summary>
|
||||
private async Task RevokeActiveRefreshTokens(Guid userId, DateTimeOffset now)
|
||||
{
|
||||
List<RefreshToken> activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == userId && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (RefreshToken token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
@@ -8,6 +8,7 @@ using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -27,6 +28,7 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly LoginAttemptTracker _loginAttemptTracker;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
@@ -38,6 +40,7 @@ public sealed class AuthController : ControllerBase
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
LoginAttemptTracker loginAttemptTracker,
|
||||
ILogger<AuthController> logger,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
@@ -48,6 +51,7 @@ public sealed class AuthController : ControllerBase
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_loginAttemptTracker = loginAttemptTracker;
|
||||
_logger = logger;
|
||||
_environment = environment;
|
||||
}
|
||||
@@ -115,7 +119,7 @@ public sealed class AuthController : ControllerBase
|
||||
_usersContext.Users.Add(user);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Admin account created for user {Username}", request.Username);
|
||||
_logger.LogInformation("Admin account created for user {Username}", request.Username.SanitizeForLog());
|
||||
|
||||
return Created("", new { userId = user.Id });
|
||||
}
|
||||
@@ -145,39 +149,12 @@ public sealed class AuthController : ControllerBase
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
|
||||
}
|
||||
|
||||
// Generate new TOTP secret
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
|
||||
// Generate recovery codes
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
// Store secret (will be finalized on verify)
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Remove old recovery codes and add new ones
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
// Secret is finalized on verify
|
||||
TotpSetupResponse setup = TwoFactorSecretRotation.Rotate(_totpService, _usersContext, user);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
return Ok(setup);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -269,55 +246,66 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
// Always verify the submitted password to prevent timing-based username enumeration
|
||||
var userHasPassword = user?.PasswordHash is not null;
|
||||
var passwordHash = user?.PasswordHash ?? _passwordService.DummyHash;
|
||||
var passwordValid = _passwordService.VerifyPassword(request.Password, passwordHash) && userHasPassword;
|
||||
var verifiedHash = user?.PasswordHash ?? _passwordService.DummyHash;
|
||||
var passwordValid = _passwordService.VerifyPassword(request.Password, verifiedHash) && userHasPassword;
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials");
|
||||
}
|
||||
|
||||
// Check lockout
|
||||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTimeOffset.UtcNow)
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
int remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTimeOffset.UtcNow).TotalSeconds);
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
User current = await _usersContext.Users.FirstAsync(u => u.Id == user.Id);
|
||||
|
||||
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials",
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
// Check lockout
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(current) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful password verification
|
||||
await ResetFailedAttempts(user.Id);
|
||||
bool credentialsValid = passwordValid
|
||||
&& string.Equals(current.Username, request.Username, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(current.PasswordHash, verifiedHash, StringComparison.Ordinal);
|
||||
|
||||
// If 2FA is not enabled, issue tokens directly
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
// Re-fetch with tracking since the query above used AsNoTracking
|
||||
var trackedUser = await _usersContext.Users.FirstAsync(u => u.Id == user.Id);
|
||||
var tokenResponse = await GenerateTokenResponse(trackedUser);
|
||||
if (!credentialsValid)
|
||||
{
|
||||
int retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(current.Id);
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials",
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {Username} logged in (2FA disabled)", user.Username);
|
||||
// If 2FA is not enabled, issue tokens directly
|
||||
if (!current.TotpEnabled)
|
||||
{
|
||||
await _loginAttemptTracker.ResetFailedAttempts(current.Id);
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(current);
|
||||
|
||||
_logger.LogInformation("User {Username} logged in (2FA disabled)", current.Username);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = false,
|
||||
Tokens = tokenResponse
|
||||
});
|
||||
}
|
||||
|
||||
// Password valid - require 2FA
|
||||
var loginToken = _jwtService.GenerateLoginToken(current.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = false,
|
||||
Tokens = tokenResponse
|
||||
RequiresTwoFactor = true,
|
||||
LoginToken = loginToken
|
||||
});
|
||||
}
|
||||
|
||||
// Password valid - require 2FA
|
||||
var loginToken = _jwtService.GenerateLoginToken(user.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
finally
|
||||
{
|
||||
RequiresTwoFactor = true,
|
||||
LoginToken = loginToken
|
||||
});
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login/2fa")]
|
||||
@@ -334,32 +322,50 @@ public sealed class AuthController : ControllerBase
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired login token");
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
if (user is null)
|
||||
try
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid login token");
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid login token");
|
||||
}
|
||||
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(user) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
bool codeValid;
|
||||
|
||||
if (request.IsRecoveryCode)
|
||||
{
|
||||
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||
}
|
||||
|
||||
if (!codeValid)
|
||||
{
|
||||
int retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(user.Id);
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code",
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
await _loginAttemptTracker.ResetFailedAttempts(user.Id);
|
||||
|
||||
return Ok(await GenerateTokenResponse(user));
|
||||
}
|
||||
|
||||
bool codeValid;
|
||||
|
||||
if (request.IsRecoveryCode)
|
||||
finally
|
||||
{
|
||||
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
else
|
||||
{
|
||||
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||
}
|
||||
|
||||
if (!codeValid)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
|
||||
}
|
||||
|
||||
return Ok(await GenerateTokenResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
@@ -699,67 +705,30 @@ public sealed class AuthController : ControllerBase
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claims an unused recovery code.
|
||||
/// Call this while holding <see cref="UsersContext.Lock"/>.
|
||||
/// </summary>
|
||||
private async Task<bool> TryUseRecoveryCode(User user, string code)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
List<RecoveryCode> unusedCodes = await _usersContext.RecoveryCodes
|
||||
.Where(r => r.UserId == user.Id && !r.IsUsed)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var recoveryCode in unusedCodes)
|
||||
{
|
||||
foreach (var recoveryCode in user.RecoveryCodes.Where(r => !r.IsUsed))
|
||||
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||
{
|
||||
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||
{
|
||||
recoveryCode.IsUsed = true;
|
||||
recoveryCode.UsedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
recoveryCode.IsUsed = true;
|
||||
recoveryCode.UsedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> IncrementFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts++;
|
||||
user.LockoutEnd = DateTimeOffset.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||
user.FailedLoginAttempts, user.Username, user.FailedLoginAttempts * 2);
|
||||
|
||||
return user.FailedLoginAttempts * 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Counts failed credential attempts and locks the account for a growing window.
|
||||
/// </summary>
|
||||
public sealed class LoginAttemptTracker
|
||||
{
|
||||
private const int MaxLockoutSeconds = 300;
|
||||
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly ILogger<LoginAttemptTracker> _logger;
|
||||
|
||||
public LoginAttemptTracker(UsersContext usersContext, ILogger<LoginAttemptTracker> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the seconds left on the lockout, or null when the account is not locked.
|
||||
/// </summary>
|
||||
public static int? GetLockoutSecondsRemaining(User user)
|
||||
{
|
||||
if (user.LockoutEnd is null || user.LockoutEnd.Value <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)Math.Ceiling((user.LockoutEnd.Value - DateTimeOffset.UtcNow).TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records one failed attempt and returns the new lockout length in seconds.
|
||||
/// The window grows by two seconds per attempt, up to five minutes.
|
||||
/// Call this while holding <see cref="UsersContext.Lock"/>.
|
||||
/// </summary>
|
||||
public async Task<int> IncrementFailedAttempts(Guid userId)
|
||||
{
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts++;
|
||||
|
||||
int lockoutSeconds = Math.Min(user.FailedLoginAttempts * 2, MaxLockoutSeconds);
|
||||
user.LockoutEnd = DateTimeOffset.UtcNow.AddSeconds(lockoutSeconds);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||
user.FailedLoginAttempts, user.Username, lockoutSeconds);
|
||||
|
||||
return lockoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the counter and the lockout once every factor has been verified.
|
||||
/// Call this while holding <see cref="UsersContext.Lock"/>.
|
||||
/// </summary>
|
||||
public async Task ResetFailedAttempts(Guid userId)
|
||||
{
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
|
||||
if (user.FailedLoginAttempts is 0 && user.LockoutEnd is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth;
|
||||
|
||||
internal static class TwoFactorSecretRotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces the user's TOTP secret and recovery codes.
|
||||
/// </summary>
|
||||
internal static TotpSetupResponse Rotate(ITotpService totpService, UsersContext usersContext, User user)
|
||||
{
|
||||
string secret = totpService.GenerateSecret();
|
||||
string qrUri = totpService.GetQrCodeUri(secret, user.Username);
|
||||
List<string> recoveryCodes = totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (string code in recoveryCodes)
|
||||
{
|
||||
usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
return new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
};
|
||||
}
|
||||
}
|
||||
+5
@@ -70,4 +70,9 @@ public record SeedingRuleRequest
|
||||
/// Whether to delete the source files when cleaning the download.
|
||||
/// </summary>
|
||||
public bool DeleteSourceFiles { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// What happens to a download that matched this rule.
|
||||
/// </summary>
|
||||
public SeedingRuleAction Action { get; init; } = SeedingRuleAction.Delete;
|
||||
}
|
||||
+3
@@ -33,6 +33,8 @@ public sealed record SeedingRuleResponse
|
||||
|
||||
public bool DeleteSourceFiles { get; init; }
|
||||
|
||||
public SeedingRuleAction Action { get; init; }
|
||||
|
||||
public static SeedingRuleResponse From(ISeedingRule rule) => new()
|
||||
{
|
||||
Id = rule.Id,
|
||||
@@ -49,5 +51,6 @@ public sealed record SeedingRuleResponse
|
||||
MinSeeders = (rule as ISeedersFilterable)?.MinSeeders,
|
||||
MaxInactiveDays = (rule as IInactivityFilterable)?.MaxInactiveDays,
|
||||
DeleteSourceFiles = rule.DeleteSourceFiles,
|
||||
Action = rule.Action,
|
||||
};
|
||||
}
|
||||
+6
@@ -115,6 +115,7 @@ public class SeedingRulesController : ControllerBase
|
||||
existingRule.MinSeedTime = ruleDto.MinSeedTime;
|
||||
existingRule.MaxSeedTime = ruleDto.MaxSeedTime;
|
||||
existingRule.DeleteSourceFiles = ruleDto.DeleteSourceFiles;
|
||||
existingRule.Action = ruleDto.Action;
|
||||
// Priority is intentionally NOT updated here — use the reorder endpoint
|
||||
|
||||
if (existingRule is ITagFilterable tagFilterable)
|
||||
@@ -253,6 +254,7 @@ public class SeedingRulesController : ControllerBase
|
||||
MinSeeders = dto.MinSeeders,
|
||||
MaxInactiveDays = dto.MaxInactiveDays,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
Action = dto.Action,
|
||||
},
|
||||
DownloadClientTypeName.Deluge => new DelugeSeedingRule
|
||||
{
|
||||
@@ -267,6 +269,7 @@ public class SeedingRulesController : ControllerBase
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
MinSeeders = dto.MinSeeders,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
Action = dto.Action,
|
||||
},
|
||||
DownloadClientTypeName.Transmission => new TransmissionSeedingRule
|
||||
{
|
||||
@@ -283,6 +286,7 @@ public class SeedingRulesController : ControllerBase
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
MinSeeders = dto.MinSeeders,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
Action = dto.Action,
|
||||
},
|
||||
DownloadClientTypeName.uTorrent => new UTorrentSeedingRule
|
||||
{
|
||||
@@ -297,6 +301,7 @@ public class SeedingRulesController : ControllerBase
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
MinSeeders = dto.MinSeeders,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
Action = dto.Action,
|
||||
},
|
||||
DownloadClientTypeName.rTorrent => new RTorrentSeedingRule
|
||||
{
|
||||
@@ -310,6 +315,7 @@ public class SeedingRulesController : ControllerBase
|
||||
MinSeedTime = dto.MinSeedTime,
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
Action = dto.Action,
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(typeName), typeName, "Unsupported download client type")
|
||||
};
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
@@ -46,6 +47,7 @@ public sealed class DownloadClientController : ControllerBase
|
||||
.ToListAsync();
|
||||
|
||||
clients = clients
|
||||
.Where(c => !EnumSentinel.IsUnknown(c.TypeName) && !EnumSentinel.IsUnknown(c.Type))
|
||||
.OrderBy(c => c.TypeName)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateAuthConfigRequest
|
||||
{
|
||||
public bool DisableAuthForLocalAddresses { get; init; }
|
||||
|
||||
public bool TrustForwardedHeaders { get; init; }
|
||||
|
||||
public List<string> TrustedNetworks { get; init; } = [];
|
||||
|
||||
public void ApplyTo(AuthConfig existingConfig)
|
||||
{
|
||||
existingConfig.DisableAuthForLocalAddresses = DisableAuthForLocalAddresses;
|
||||
existingConfig.TrustForwardedHeaders = TrustForwardedHeaders;
|
||||
existingConfig.TrustedNetworks = TrustedNetworks;
|
||||
}
|
||||
}
|
||||
+3
-62
@@ -2,7 +2,6 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
|
||||
@@ -18,6 +17,8 @@ public sealed record UpdateGeneralConfigRequest
|
||||
|
||||
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
|
||||
|
||||
public bool HttpSendUserAgent { get; init; }
|
||||
|
||||
public bool StatusCheckEnabled { get; init; } = true;
|
||||
|
||||
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
|
||||
@@ -43,6 +44,7 @@ public sealed record UpdateGeneralConfigRequest
|
||||
existingConfig.HttpMaxRetries = HttpMaxRetries;
|
||||
existingConfig.HttpTimeout = HttpTimeout;
|
||||
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
|
||||
existingConfig.HttpSendUserAgent = HttpSendUserAgent;
|
||||
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
|
||||
existingConfig.EncryptionKey = EncryptionKey;
|
||||
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||
@@ -84,64 +86,3 @@ public sealed record UpdateGeneralConfigRequest
|
||||
LoggingConfigManager.ReconfigureLogging(config);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateLoggingConfigRequest
|
||||
{
|
||||
public LogEventLevel Level { get; init; } = LogEventLevel.Information;
|
||||
|
||||
public ushort RollingSizeMB { get; init; } = 10;
|
||||
|
||||
public ushort RetainedFileCount { get; init; } = 5;
|
||||
|
||||
public ushort TimeLimitHours { get; init; } = 24;
|
||||
|
||||
public bool ArchiveEnabled { get; init; } = true;
|
||||
|
||||
public ushort ArchiveRetainedCount { get; init; } = 60;
|
||||
|
||||
public ushort ArchiveTimeLimitHours { get; init; } = 24 * 30;
|
||||
|
||||
public bool ApplyTo(LoggingConfig existingConfig)
|
||||
{
|
||||
bool levelChanged = existingConfig.Level != Level;
|
||||
bool otherPropertiesChanged =
|
||||
existingConfig.RollingSizeMB != RollingSizeMB ||
|
||||
existingConfig.RetainedFileCount != RetainedFileCount ||
|
||||
existingConfig.TimeLimitHours != TimeLimitHours ||
|
||||
existingConfig.ArchiveEnabled != ArchiveEnabled ||
|
||||
existingConfig.ArchiveRetainedCount != ArchiveRetainedCount ||
|
||||
existingConfig.ArchiveTimeLimitHours != ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Level = Level;
|
||||
existingConfig.RollingSizeMB = RollingSizeMB;
|
||||
existingConfig.RetainedFileCount = RetainedFileCount;
|
||||
existingConfig.TimeLimitHours = TimeLimitHours;
|
||||
existingConfig.ArchiveEnabled = ArchiveEnabled;
|
||||
existingConfig.ArchiveRetainedCount = ArchiveRetainedCount;
|
||||
existingConfig.ArchiveTimeLimitHours = ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Validate();
|
||||
|
||||
LevelOnlyChange = levelChanged && !otherPropertiesChanged;
|
||||
|
||||
return levelChanged || otherPropertiesChanged;
|
||||
}
|
||||
|
||||
public bool LevelOnlyChange { get; private set; }
|
||||
}
|
||||
|
||||
public sealed record UpdateAuthConfigRequest
|
||||
{
|
||||
public bool DisableAuthForLocalAddresses { get; init; }
|
||||
|
||||
public bool TrustForwardedHeaders { get; init; }
|
||||
|
||||
public List<string> TrustedNetworks { get; init; } = [];
|
||||
|
||||
public void ApplyTo(AuthConfig existingConfig)
|
||||
{
|
||||
existingConfig.DisableAuthForLocalAddresses = DisableAuthForLocalAddresses;
|
||||
existingConfig.TrustForwardedHeaders = TrustForwardedHeaders;
|
||||
existingConfig.TrustedNetworks = TrustedNetworks;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateLoggingConfigRequest
|
||||
{
|
||||
public LogEventLevel Level { get; init; } = LogEventLevel.Information;
|
||||
|
||||
public ushort RollingSizeMB { get; init; } = 10;
|
||||
|
||||
public ushort RetainedFileCount { get; init; } = 5;
|
||||
|
||||
public ushort TimeLimitHours { get; init; } = 24;
|
||||
|
||||
public bool ArchiveEnabled { get; init; } = true;
|
||||
|
||||
public ushort ArchiveRetainedCount { get; init; } = 60;
|
||||
|
||||
public ushort ArchiveTimeLimitHours { get; init; } = 24 * 30;
|
||||
|
||||
public bool ApplyTo(LoggingConfig existingConfig)
|
||||
{
|
||||
bool levelChanged = existingConfig.Level != Level;
|
||||
bool otherPropertiesChanged =
|
||||
existingConfig.RollingSizeMB != RollingSizeMB ||
|
||||
existingConfig.RetainedFileCount != RetainedFileCount ||
|
||||
existingConfig.TimeLimitHours != TimeLimitHours ||
|
||||
existingConfig.ArchiveEnabled != ArchiveEnabled ||
|
||||
existingConfig.ArchiveRetainedCount != ArchiveRetainedCount ||
|
||||
existingConfig.ArchiveTimeLimitHours != ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Level = Level;
|
||||
existingConfig.RollingSizeMB = RollingSizeMB;
|
||||
existingConfig.RetainedFileCount = RetainedFileCount;
|
||||
existingConfig.TimeLimitHours = TimeLimitHours;
|
||||
existingConfig.ArchiveEnabled = ArchiveEnabled;
|
||||
existingConfig.ArchiveRetainedCount = ArchiveRetainedCount;
|
||||
existingConfig.ArchiveTimeLimitHours = ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Validate();
|
||||
|
||||
LevelOnlyChange = levelChanged && !otherPropertiesChanged;
|
||||
|
||||
return levelChanged || otherPropertiesChanged;
|
||||
}
|
||||
|
||||
public bool LevelOnlyChange { get; private set; }
|
||||
}
|
||||
+6
@@ -30,6 +30,10 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
|
||||
public BlocklistSettings Whisparr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Sportarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings LazyLibrarian { get; init; } = new();
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
|
||||
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
|
||||
@@ -46,6 +50,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
config.Lidarr = Lidarr;
|
||||
config.Readarr = Readarr;
|
||||
config.Whisparr = Whisparr;
|
||||
config.Sportarr = Sportarr;
|
||||
config.LazyLibrarian = LazyLibrarian;
|
||||
config.IgnoredDownloads = IgnoredDownloads;
|
||||
|
||||
return config;
|
||||
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public abstract record CreateNotificationProviderRequestBase
|
||||
{
|
||||
@@ -16,6 +16,8 @@ public abstract record CreateNotificationProviderRequestBase
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnDownloadStopped { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public abstract record UpdateNotificationProviderRequestBase
|
||||
{
|
||||
@@ -16,6 +16,8 @@ public abstract record UpdateNotificationProviderRequestBase
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnDownloadStopped { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
|
||||
+25
-1
@@ -1,4 +1,4 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
@@ -58,6 +58,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.ToListAsync();
|
||||
|
||||
var providerDtos = providers
|
||||
.Where(p => !EnumSentinel.IsUnknown(p.Type))
|
||||
.Select(p => new NotificationProviderResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
@@ -71,6 +72,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = p.OnSlowStrike,
|
||||
OnQueueItemDeleted = p.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = p.OnDownloadCleaned,
|
||||
OnDownloadStopped = p.OnDownloadStopped,
|
||||
OnCategoryChanged = p.OnCategoryChanged,
|
||||
OnSearchTriggered = p.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = p.OnSearchItemGrabbed
|
||||
@@ -151,6 +153,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = newProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
@@ -218,6 +221,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = newProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
@@ -288,6 +292,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = newProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
@@ -349,6 +354,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = newProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
@@ -421,6 +427,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = updatedProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
@@ -500,6 +507,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = updatedProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
@@ -582,6 +590,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = updatedProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
@@ -658,6 +667,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = updatedProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
@@ -757,6 +767,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnDownloadStopped = false,
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
@@ -825,6 +836,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnDownloadStopped = false,
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
@@ -896,6 +908,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnDownloadStopped = false,
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
@@ -954,6 +967,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnDownloadStopped = false,
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
@@ -985,6 +999,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = provider.OnSlowStrike,
|
||||
OnQueueItemDeleted = provider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = provider.OnDownloadCleaned,
|
||||
OnDownloadStopped = provider.OnDownloadStopped,
|
||||
OnCategoryChanged = provider.OnCategoryChanged,
|
||||
OnSearchTriggered = provider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = provider.OnSearchItemGrabbed
|
||||
@@ -1043,6 +1058,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = newProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
@@ -1116,6 +1132,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = updatedProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
@@ -1179,6 +1196,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnDownloadStopped = false,
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
@@ -1245,6 +1263,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = newProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
@@ -1325,6 +1344,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = updatedProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
@@ -1402,6 +1422,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnDownloadStopped = false,
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
@@ -1458,6 +1479,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = newProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
@@ -1531,6 +1553,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnDownloadStopped = updatedProvider.OnDownloadStopped,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
@@ -1594,6 +1617,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnDownloadStopped = false,
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ public sealed record UpdateSeekerInstanceConfigRequest
|
||||
|
||||
public int ActiveDownloadLimit { get; init; } = 3;
|
||||
|
||||
public bool IgnoreStruckDownloads { get; init; }
|
||||
|
||||
public int MinCycleTimeDays { get; init; } = 7;
|
||||
|
||||
public bool MonitoredOnly { get; init; } = true;
|
||||
|
||||
+2
@@ -20,6 +20,8 @@ public sealed record SeekerInstanceConfigResponse
|
||||
|
||||
public int ActiveDownloadLimit { get; init; }
|
||||
|
||||
public bool IgnoreStruckDownloads { get; init; }
|
||||
|
||||
public int MinCycleTimeDays { get; init; }
|
||||
|
||||
public bool MonitoredOnly { get; init; }
|
||||
|
||||
+2
-2
@@ -76,7 +76,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
query = query.Where(e => e.QualityProfileName == qualityProfile);
|
||||
}
|
||||
|
||||
if (itemType.HasValue)
|
||||
if (itemType.HasValue && !EnumSentinel.IsUnknown(itemType.Value))
|
||||
{
|
||||
InstanceType typeValue = itemType.Value;
|
||||
query = query.Where(e => e.ItemType == typeValue);
|
||||
@@ -291,7 +291,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
ArrInstanceId = r.ArrInstanceId,
|
||||
ExternalItemId = r.ExternalItemId,
|
||||
EpisodeId = r.EpisodeId,
|
||||
ItemType = Enum.Parse<InstanceType>(r.ItemType, ignoreCase: true),
|
||||
ItemType = EnumSentinel.ParseOrUnknown<InstanceType>(r.ItemType),
|
||||
Title = r.Title,
|
||||
PreviousScore = r.PreviousScore,
|
||||
NewScore = r.NewScore,
|
||||
|
||||
@@ -64,6 +64,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
LastProcessedAt = seekerConfig?.LastProcessedAt,
|
||||
ArrInstanceEnabled = instance.Enabled,
|
||||
ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 3,
|
||||
IgnoreStruckDownloads = seekerConfig?.IgnoreStruckDownloads ?? false,
|
||||
MinCycleTimeDays = seekerConfig?.MinCycleTimeDays ?? 7,
|
||||
MonitoredOnly = seekerConfig?.MonitoredOnly ?? true,
|
||||
UseCutoff = seekerConfig?.UseCutoff ?? false,
|
||||
@@ -126,6 +127,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
existing.Enabled = instanceReq.Enabled;
|
||||
existing.SkipTags = instanceReq.SkipTags;
|
||||
existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit;
|
||||
existing.IgnoreStruckDownloads = instanceReq.IgnoreStruckDownloads;
|
||||
existing.MinCycleTimeDays = instanceReq.MinCycleTimeDays;
|
||||
existing.MonitoredOnly = instanceReq.MonitoredOnly;
|
||||
existing.UseCutoff = instanceReq.UseCutoff;
|
||||
@@ -139,6 +141,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
Enabled = instanceReq.Enabled,
|
||||
SkipTags = instanceReq.SkipTags,
|
||||
ActiveDownloadLimit = instanceReq.ActiveDownloadLimit,
|
||||
IgnoreStruckDownloads = instanceReq.IgnoreStruckDownloads,
|
||||
MinCycleTimeDays = instanceReq.MinCycleTimeDays,
|
||||
MonitoredOnly = instanceReq.MonitoredOnly,
|
||||
UseCutoff = instanceReq.UseCutoff,
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
namespace Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
|
||||
public sealed record ApplicationStatusResponse
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
|
||||
public required DateTime StartTime { get; init; }
|
||||
|
||||
public required TimeSpan UpTime { get; init; }
|
||||
|
||||
public required double MemoryUsageMB { get; init; }
|
||||
|
||||
public required TimeSpan ProcessorTime { get; init; }
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
|
||||
public sealed record DownloadClientStatusResponse
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required DownloadClientTypeName Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors Enabled. Nothing probes a download client here.
|
||||
/// </summary>
|
||||
public required bool IsConnected { get; init; }
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
|
||||
public sealed record InstanceConnectionResponse
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required Uri Url { get; init; }
|
||||
|
||||
public required bool IsConnected { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
|
||||
public sealed record MediaManagerStatusResponse
|
||||
{
|
||||
public required int InstanceCount { get; init; }
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
|
||||
public sealed record SystemStatusResponse
|
||||
{
|
||||
public required ApplicationStatusResponse Application { get; init; }
|
||||
|
||||
public required Dictionary<string, MediaManagerStatusResponse> MediaManagers { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
@@ -35,7 +36,7 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
_ => (StatusCodes.Status500InternalServerError, "An error occurred", "An unexpected error occurred"),
|
||||
};
|
||||
|
||||
string path = Sanitize(context.Request.Path);
|
||||
string path = context.Request.Path.Value.SanitizeForLog();
|
||||
|
||||
if (status >= StatusCodes.Status500InternalServerError)
|
||||
{
|
||||
@@ -44,7 +45,7 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(exception, "Handled {Status} during request to {Path}: {Message}",
|
||||
status, path, Sanitize(exception.Message));
|
||||
status, path, exception.Message.SanitizeForLog());
|
||||
}
|
||||
|
||||
context.Response.StatusCode = status;
|
||||
@@ -65,12 +66,4 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
Exception = exception,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips line breaks from user-controlled values before they reach the logs to prevent log forging.
|
||||
/// </summary>
|
||||
private static string Sanitize(string? value)
|
||||
{
|
||||
return value is null ? string.Empty : value.Replace("\r", string.Empty).Replace("\n", string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record ArrCommandStatus(long Id, string Status, string? Message);
|
||||
/// <summary>
|
||||
/// The status of a command in an arr instance.
|
||||
/// </summary>
|
||||
public sealed record ArrCommandStatus(long Id, ArrCommandState Status, string? Message);
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// One item in the queue of an *arr application.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$item")]
|
||||
[JsonDerivedType(typeof(SearchItem), "base")]
|
||||
[JsonDerivedType(typeof(SeriesSearchItem), "series")]
|
||||
public class SearchItem
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -27,6 +29,18 @@ public interface ITorrentItemWrapper
|
||||
/// </summary>
|
||||
int? SeederCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a tracker vouches for the torrent right now.
|
||||
/// Qualifies <see cref="SeederCount"/>, which clients keep reporting from the last tracker answer they saw.
|
||||
/// </summary>
|
||||
TrackerHealth TrackerHealth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the download client added the torrent.
|
||||
/// Null when the client reports no added time.
|
||||
/// </summary>
|
||||
DateTimeOffset? AddedOn { get; }
|
||||
|
||||
long Eta { get; }
|
||||
|
||||
long SeedingTimeSeconds { get; }
|
||||
@@ -49,6 +63,11 @@ public interface ITorrentItemWrapper
|
||||
/// </summary>
|
||||
IReadOnlyList<string> Tags { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the download client has this torrent stopped or paused.
|
||||
/// </summary>
|
||||
bool IsStopped { get; }
|
||||
|
||||
bool IsDownloading();
|
||||
|
||||
bool IsStalled();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.LazyLibrarian;
|
||||
|
||||
/// <summary>
|
||||
/// The error LazyLibrarian reports inside a successful HTTP response.
|
||||
/// </summary>
|
||||
public sealed record LazyLibrarianApiError
|
||||
{
|
||||
[JsonPropertyName("Message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.LazyLibrarian;
|
||||
|
||||
/// <summary>
|
||||
/// The envelope LazyLibrarian returns when a command fails.
|
||||
/// It answers HTTP 200, so the body is the only signal.
|
||||
/// </summary>
|
||||
public sealed record LazyLibrarianApiResponse
|
||||
{
|
||||
[JsonPropertyName("Success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("Error")]
|
||||
public LazyLibrarianApiError? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.LazyLibrarian;
|
||||
|
||||
/// <summary>
|
||||
/// One book the queueBook and searchBook commands accept.
|
||||
/// The audio status is separate, so the library travels with the id.
|
||||
/// </summary>
|
||||
public sealed record LazyLibrarianBookRef
|
||||
{
|
||||
public required string BookId { get; init; }
|
||||
|
||||
public required BookLibrary Library { get; init; }
|
||||
|
||||
public bool IsAudioBook => Library is BookLibrary.AudioBook;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.LazyLibrarian;
|
||||
|
||||
public sealed record LazyLibrarianDownloadProgress
|
||||
{
|
||||
/// <summary>
|
||||
/// -1 when the client no longer holds the download.
|
||||
/// -2 when LazyLibrarian could not reach the client.
|
||||
/// </summary>
|
||||
[JsonPropertyName("progress")]
|
||||
public int Progress { get; init; }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.LazyLibrarian;
|
||||
|
||||
public sealed record LazyLibrarianDownloadProgressResponse
|
||||
{
|
||||
[JsonPropertyName("Data")]
|
||||
public LazyLibrarianDownloadProgress? Data { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.LazyLibrarian;
|
||||
|
||||
/// <summary>
|
||||
/// A snatched LazyLibrarian download that Cleanuparr can act on.
|
||||
/// </summary>
|
||||
public sealed record LazyLibrarianQueueItem
|
||||
{
|
||||
public required string DownloadId { get; init; }
|
||||
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Every book this download was snatched for.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<LazyLibrarianBookRef> Books { get; init; }
|
||||
|
||||
public required LazyLibrarianSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Adopted when any row sharing this DownloadId reports an origin other than new.
|
||||
/// LazyLibrarian refuses to remove such a task.
|
||||
/// </summary>
|
||||
public required LazyLibrarianOrigin Origin { get; init; }
|
||||
|
||||
public bool WasAdoptedByLazyLibrarian => Origin is not LazyLibrarianOrigin.New;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.LazyLibrarian;
|
||||
|
||||
/// <summary>
|
||||
/// A row from LazyLibrarian's getHistory command.
|
||||
/// </summary>
|
||||
public sealed record LazyLibrarianWantedRecord
|
||||
{
|
||||
[JsonPropertyName("BookID")]
|
||||
public string? BookId { get; init; }
|
||||
|
||||
[JsonPropertyName("NZBtitle")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[JsonPropertyName("DownloadID")]
|
||||
public string? DownloadId { get; init; }
|
||||
|
||||
[JsonPropertyName("Source")]
|
||||
public LazyLibrarianSource Source { get; init; }
|
||||
|
||||
[JsonPropertyName("Status")]
|
||||
public LazyLibrarianStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The provider type, not the protocol.
|
||||
/// </summary>
|
||||
[JsonPropertyName("NZBmode")]
|
||||
public LazyLibrarianDownloadMode Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A magazine row carries an issue date here, so it reads as Unknown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("AuxInfo")]
|
||||
public BookLibrary Library { get; init; }
|
||||
|
||||
[JsonPropertyName("Origin")]
|
||||
public LazyLibrarianOrigin Origin { get; init; }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Whisparr;
|
||||
|
||||
public sealed record WhisparrV2Command
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
public long? SeriesId { get; set; }
|
||||
|
||||
public long? SeasonNumber { get; set; }
|
||||
|
||||
public List<long>? EpisodeIds { get; set; }
|
||||
|
||||
public SeriesSearchType SearchType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Command state values reported by the arr command endpoint
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(TolerantEnumConverter<ArrCommandState>))]
|
||||
public enum ArrCommandState
|
||||
{
|
||||
Unknown = 0,
|
||||
Queued,
|
||||
Started,
|
||||
Completed,
|
||||
Failed,
|
||||
Aborted,
|
||||
Cancelled,
|
||||
Orphaned,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The LazyLibrarian library a wanted row belongs to, read from its AuxInfo column.
|
||||
/// A magazine issue date or a comic issue key reads as Unknown.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(TolerantEnumConverter<BookLibrary>))]
|
||||
public enum BookLibrary
|
||||
{
|
||||
Unknown = 0,
|
||||
EBook,
|
||||
AudioBook,
|
||||
}
|
||||
@@ -8,16 +8,33 @@ namespace Cleanuparr.Domain.Enums;
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CfScoresSortBy
|
||||
{
|
||||
/// <summary>Sort by item title.</summary>
|
||||
/// <summary>
|
||||
/// Sort by item title.
|
||||
/// </summary>
|
||||
Title,
|
||||
/// <summary>Sort by the item's current custom format score.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the item's current custom format score.
|
||||
/// </summary>
|
||||
CurrentScore,
|
||||
/// <summary>Sort by the quality profile's configured cutoff score.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the quality profile's configured cutoff score.
|
||||
/// </summary>
|
||||
CutoffScore,
|
||||
/// <summary>Sort by quality profile name.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by quality profile name.
|
||||
/// </summary>
|
||||
QualityProfile,
|
||||
/// <summary>Sort by the timestamp of the last score sync.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the timestamp of the last score sync.
|
||||
/// </summary>
|
||||
LastSyncedAt,
|
||||
/// <summary>Sort by the timestamp of the most recent score upgrade.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the timestamp of the most recent score upgrade.
|
||||
/// </summary>
|
||||
LastUpgradedAt,
|
||||
}
|
||||
@@ -8,16 +8,33 @@ namespace Cleanuparr.Domain.Enums;
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CfUpgradesSortBy
|
||||
{
|
||||
/// <summary>Sort by the timestamp at which the upgrade was recorded.</summary>
|
||||
/// <summary>
|
||||
/// Sort by the timestamp at which the upgrade was recorded.
|
||||
/// </summary>
|
||||
UpgradedAt,
|
||||
/// <summary>Sort by item title.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by item title.
|
||||
/// </summary>
|
||||
Title,
|
||||
/// <summary>Sort by the score recorded after the upgrade.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the score recorded after the upgrade.
|
||||
/// </summary>
|
||||
NewScore,
|
||||
/// <summary>Sort by the score recorded immediately before the upgrade.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the score recorded immediately before the upgrade.
|
||||
/// </summary>
|
||||
PreviousScore,
|
||||
/// <summary>Sort by the difference between the new and previous scores.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the difference between the new and previous scores.
|
||||
/// </summary>
|
||||
ScoreDelta,
|
||||
/// <summary>Sort by the quality profile's configured cutoff score.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the quality profile's configured cutoff score.
|
||||
/// </summary>
|
||||
CutoffScore,
|
||||
}
|
||||
@@ -8,10 +8,18 @@ namespace Cleanuparr.Domain.Enums;
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CutoffFilter
|
||||
{
|
||||
/// <summary>Include all items regardless of cutoff status.</summary>
|
||||
/// <summary>
|
||||
/// Include all items regardless of cutoff status.
|
||||
/// </summary>
|
||||
All,
|
||||
/// <summary>Include only items whose current score is below the cutoff.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Include only items whose current score is below the cutoff.
|
||||
/// </summary>
|
||||
Below,
|
||||
/// <summary>Include only items whose current score meets or exceeds the cutoff.</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Include only items whose current score meets or exceeds the cutoff.
|
||||
/// </summary>
|
||||
Met,
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace Cleanuparr.Domain.Enums;
|
||||
/// <summary>
|
||||
/// Torrent state values reported by Deluge
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(DelugeStateConverter))]
|
||||
[JsonConverter(typeof(TolerantEnumConverter<DelugeState>))]
|
||||
public enum DelugeState
|
||||
{
|
||||
Unknown = 0,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Maps Deluge wire state strings to <see cref="DelugeState"/>, falling back to <see cref="DelugeState.Unknown"/> for any value not present in the enum
|
||||
/// </summary>
|
||||
public sealed class DelugeStateConverter : JsonConverter<DelugeState>
|
||||
{
|
||||
public override bool HandleNull => true;
|
||||
|
||||
public override DelugeState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
{
|
||||
return DelugeState.Unknown;
|
||||
}
|
||||
|
||||
string? raw = reader.GetString();
|
||||
|
||||
return raw is not null && Enum.TryParse(raw, ignoreCase: true, out DelugeState parsed)
|
||||
? parsed
|
||||
: DelugeState.Unknown;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DelugeState value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.ToString());
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,10 @@
|
||||
public enum DownloadClientType
|
||||
{
|
||||
Torrent,
|
||||
Usenet
|
||||
}
|
||||
Usenet,
|
||||
|
||||
/// <summary>
|
||||
/// Text this build does not know.
|
||||
/// </summary>
|
||||
Unknown = EnumSentinel.UnknownValue,
|
||||
}
|
||||
@@ -7,4 +7,9 @@ public enum DownloadClientTypeName
|
||||
Transmission,
|
||||
uTorrent,
|
||||
rTorrent,
|
||||
|
||||
/// <summary>
|
||||
/// Text this build does not know.
|
||||
/// </summary>
|
||||
Unknown = EnumSentinel.UnknownValue,
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The member every persisted enum keeps for a value written by a newer version.
|
||||
/// </summary>
|
||||
public static class EnumSentinel
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the member standing in for a value written by a newer version of the app.
|
||||
/// </summary>
|
||||
public const string Unknown = "Unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Value every sentinel member takes.
|
||||
/// Pinned so a member added later cannot shift it.
|
||||
/// </summary>
|
||||
public const int UnknownValue = 999;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a value came from text this build does not recognise.
|
||||
/// Never usable in a SQL predicate: the column holds the original text.
|
||||
/// Filter in memory.
|
||||
/// </summary>
|
||||
public static bool IsUnknown<TEnum>(TEnum value)
|
||||
where TEnum : struct, Enum =>
|
||||
value.ToString() == Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a stored member name, falling back to the sentinel.
|
||||
/// For raw SQL, which never passes through the EF value converters.
|
||||
/// Throws unless the enum declares the sentinel.
|
||||
/// </summary>
|
||||
public static TEnum ParseOrUnknown<TEnum>(string? value)
|
||||
where TEnum : struct, Enum =>
|
||||
LowercaseEnumName.TryParse(value, out TEnum parsed)
|
||||
? parsed
|
||||
: Enum.Parse<TEnum>(Unknown);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a member a user asked to filter by, refusing the sentinel.
|
||||
/// The column keeps the text a newer version wrote.
|
||||
/// The EF converter throws when the sentinel reaches a query.
|
||||
/// </summary>
|
||||
public static bool TryParseSelectable<TEnum>(string? value, out TEnum parsed)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (LowercaseEnumName.TryParse(value, out parsed) && !IsUnknown(parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
parsed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Members a user can pick from, without the sentinel.
|
||||
/// </summary>
|
||||
public static List<TEnum> SelectableValues<TEnum>()
|
||||
where TEnum : struct, Enum =>
|
||||
Enum.GetValues<TEnum>().Where(value => !IsUnknown(value)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Member names a user can pick from, without the sentinel.
|
||||
/// </summary>
|
||||
public static List<string> SelectableNames<TEnum>()
|
||||
where TEnum : struct, Enum =>
|
||||
Enum.GetNames<TEnum>().Where(name => name != Unknown).ToList();
|
||||
}
|
||||
@@ -7,4 +7,9 @@ public enum EventSeverity
|
||||
Warning,
|
||||
Important,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Text this build does not know.
|
||||
/// </summary>
|
||||
Unknown = EnumSentinel.UnknownValue,
|
||||
}
|
||||
@@ -14,4 +14,10 @@ public enum EventType
|
||||
DownloadMarkedForDeletion,
|
||||
SearchTriggered,
|
||||
StrikeReset,
|
||||
}
|
||||
DownloadStopped,
|
||||
|
||||
/// <summary>
|
||||
/// Text this build does not know.
|
||||
/// </summary>
|
||||
Unknown = EnumSentinel.UnknownValue,
|
||||
}
|
||||
@@ -7,4 +7,11 @@ public enum InstanceType
|
||||
Lidarr,
|
||||
Readarr,
|
||||
Whisparr,
|
||||
}
|
||||
Sportarr,
|
||||
LazyLibrarian,
|
||||
|
||||
/// <summary>
|
||||
/// Text this build does not know.
|
||||
/// </summary>
|
||||
Unknown = EnumSentinel.UnknownValue,
|
||||
}
|
||||
@@ -8,4 +8,9 @@ public enum JobType
|
||||
BlacklistSynchronizer,
|
||||
Seeker,
|
||||
CustomFormatScoreSyncer,
|
||||
|
||||
/// <summary>
|
||||
/// Text this build does not know.
|
||||
/// </summary>
|
||||
Unknown = EnumSentinel.UnknownValue,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The provider type LazyLibrarian recorded for a snatch, from its NZBmode column.
|
||||
/// Torrent, Torznab and Magnet go to a torrent client.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(TolerantEnumConverter<LazyLibrarianDownloadMode>))]
|
||||
public enum LazyLibrarianDownloadMode
|
||||
{
|
||||
Unknown = 0,
|
||||
Torrent,
|
||||
Torznab,
|
||||
Magnet,
|
||||
Nzb,
|
||||
Direct,
|
||||
Irc,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Whether LazyLibrarian created the download task itself.
|
||||
/// It refuses to remove a task it adopted.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(TolerantEnumConverter<LazyLibrarianOrigin>))]
|
||||
public enum LazyLibrarianOrigin
|
||||
{
|
||||
Unknown = 0,
|
||||
New,
|
||||
Adopted,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The downloader LazyLibrarian sent a snatch to, from the Source column.
|
||||
/// The value round-trips: getDownloadProgress takes it back verbatim.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(LazyLibrarianSourceConverter))]
|
||||
public enum LazyLibrarianSource
|
||||
{
|
||||
Unknown = 0,
|
||||
QBittorrent,
|
||||
Transmission,
|
||||
DelugeWebUi,
|
||||
DelugeRpc,
|
||||
UTorrent,
|
||||
RTorrent,
|
||||
Blackhole,
|
||||
Direct,
|
||||
Irc,
|
||||
Synology,
|
||||
SynologyTorrent,
|
||||
SynologyNzb,
|
||||
Sabnzbd,
|
||||
NzbGet,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the LazyLibrarian Source column to <see cref="LazyLibrarianSource"/> and back.
|
||||
/// The map is explicit because two wire values carry an underscore.
|
||||
/// </summary>
|
||||
public sealed class LazyLibrarianSourceConverter : JsonConverter<LazyLibrarianSource>
|
||||
{
|
||||
private static readonly Dictionary<string, LazyLibrarianSource> ByWireValue = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["QBITTORRENT"] = LazyLibrarianSource.QBittorrent,
|
||||
["TRANSMISSION"] = LazyLibrarianSource.Transmission,
|
||||
["DELUGEWEBUI"] = LazyLibrarianSource.DelugeWebUi,
|
||||
["DELUGERPC"] = LazyLibrarianSource.DelugeRpc,
|
||||
["UTORRENT"] = LazyLibrarianSource.UTorrent,
|
||||
["RTORRENT"] = LazyLibrarianSource.RTorrent,
|
||||
["BLACKHOLE"] = LazyLibrarianSource.Blackhole,
|
||||
["DIRECT"] = LazyLibrarianSource.Direct,
|
||||
["IRC"] = LazyLibrarianSource.Irc,
|
||||
["SYNOLOGY"] = LazyLibrarianSource.Synology,
|
||||
["SYNOLOGY_TOR"] = LazyLibrarianSource.SynologyTorrent,
|
||||
["SYNOLOGY_NZB"] = LazyLibrarianSource.SynologyNzb,
|
||||
["SABNZBD"] = LazyLibrarianSource.Sabnzbd,
|
||||
["NZBGET"] = LazyLibrarianSource.NzbGet,
|
||||
};
|
||||
|
||||
private static readonly Dictionary<LazyLibrarianSource, string> ByMember =
|
||||
ByWireValue.ToDictionary(pair => pair.Value, pair => pair.Key);
|
||||
|
||||
public static string ToWireValue(LazyLibrarianSource source) =>
|
||||
ByMember.TryGetValue(source, out string? wire) ? wire : string.Empty;
|
||||
|
||||
public override bool HandleNull => true;
|
||||
|
||||
public override LazyLibrarianSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
{
|
||||
return LazyLibrarianSource.Unknown;
|
||||
}
|
||||
|
||||
string? raw = reader.GetString();
|
||||
|
||||
return raw is not null && ByWireValue.TryGetValue(raw, out LazyLibrarianSource parsed)
|
||||
? parsed
|
||||
: LazyLibrarianSource.Unknown;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, LazyLibrarianSource value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(ToWireValue(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public static class LazyLibrarianSourceExtensions
|
||||
{
|
||||
private static readonly HashSet<LazyLibrarianSource> TorrentClients =
|
||||
[
|
||||
LazyLibrarianSource.QBittorrent,
|
||||
LazyLibrarianSource.Transmission,
|
||||
LazyLibrarianSource.DelugeWebUi,
|
||||
LazyLibrarianSource.DelugeRpc,
|
||||
LazyLibrarianSource.UTorrent,
|
||||
LazyLibrarianSource.RTorrent,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// A blackhole or Synology row keeps a torrent NZBmode but never reaches a client we can query.
|
||||
/// Its DownloadID is a path or a task id, so it collides across unrelated rows.
|
||||
/// </summary>
|
||||
public static bool IsTorrentClient(this LazyLibrarianSource source) => TorrentClients.Contains(source);
|
||||
|
||||
public static string ToWireValue(this LazyLibrarianSource source) =>
|
||||
LazyLibrarianSourceConverter.ToWireValue(source);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The status of a LazyLibrarian wanted row.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(TolerantEnumConverter<LazyLibrarianStatus>))]
|
||||
public enum LazyLibrarianStatus
|
||||
{
|
||||
Unknown = 0,
|
||||
Snatched,
|
||||
Seeding,
|
||||
Aborted,
|
||||
Failed,
|
||||
Processed,
|
||||
Have,
|
||||
Wanted,
|
||||
Skipped,
|
||||
Ignored,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the lowercase member names enum columns hold.
|
||||
/// </summary>
|
||||
public static class LowercaseEnumName
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a stored value looks like an enum member name rather than a number.
|
||||
/// Enum.TryParse takes numbers, whitespace and a leading sign.
|
||||
/// </summary>
|
||||
public static bool IsName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
char first = value.AsSpan().TrimStart()[0];
|
||||
|
||||
return !char.IsAsciiDigit(first) && first != '-' && first != '+';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a stored member name, rejecting anything that is not one.
|
||||
/// </summary>
|
||||
public static bool TryParse<TEnum>(string? value, out TEnum parsed)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (IsName(value) && Enum.TryParse(value, true, out parsed) && Enum.IsDefined(parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
parsed = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 581 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user