Lock download client type after creation (#831)

This commit is contained in:
Flaminel authored and GitHub committed 2026-10-12 02:17:43 +03:00
1 parent de16a41b6c
commit 85e956dd92
5 files changed
+72 -3

No files matched your search

@@ -302,6 +302,30 @@ public class DownloadClientControllerTests : IDisposable
() => _controller.UpdateDownloadClientConfig(client.Id, request));
}
[Fact]
public async Task UpdateDownloadClientConfig_ChangedTypeName_PropagatesValidationException()
{
// Arrange
DownloadClientConfig client = NewClient("orig", DownloadClientTypeName.qBittorrent);
_dataContext.DownloadClients.Add(client);
await _dataContext.SaveChangesAsync();
UpdateDownloadClientRequest request = new()
{
Name = "renamed",
Host = "http://newhost:9090",
TypeName = DownloadClientTypeName.rTorrent,
};
// Act / Assert
Cleanuparr.Domain.Exceptions.ValidationException exception = await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _controller.UpdateDownloadClientConfig(client.Id, request));
exception.Message.ShouldContain("cannot be changed");
DownloadClientConfig saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id);
saved.TypeName.ShouldBe(DownloadClientTypeName.qBittorrent);
}
[Fact]
public async Task DeleteDownloadClientConfig_ExistingClient_RemovesAndUnregistersHttpClient()
{
@@ -5,6 +5,7 @@ using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
@@ -101,6 +102,11 @@ public sealed class DownloadClientController : ControllerBase
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {id} not found");
}
if (updatedClient.TypeName != existingClient.TypeName)
{
throw new ValidationException("Download client type cannot be changed");
}
var clientToPersist = updatedClient.ApplyTo(existingClient);
clientToPersist.Validate();
@@ -116,12 +116,15 @@ function hintFor(fixture: ComponentFixture<DownloadClientsComponent>, label: str
return field?.querySelector('.input-hint')?.textContent!.trim() ?? '';
}
function clientTypeTrigger(fixture: ComponentFixture<DownloadClientsComponent>): HTMLButtonElement {
return fixture.nativeElement.querySelector('.select-trigger') as HTMLButtonElement;
}
function chooseClientType(
fixture: ComponentFixture<DownloadClientsComponent>,
label: string,
): void {
const trigger = fixture.nativeElement.querySelector('.select-trigger') as HTMLButtonElement;
trigger.click();
clientTypeTrigger(fixture).click();
fixture.detectChanges();
const option = Array.from(fixture.nativeElement.querySelectorAll('.select-option')).find(
@@ -417,6 +420,22 @@ describe('DownloadClientsComponent', () => {
expect(fixture.nativeElement.textContent).toContain('Edit Client');
});
it('locks the client type while editing and unlocks it when adding', () => {
const { fixture, component } = setup();
component.openEditModal(QBIT);
fixture.detectChanges();
expect(component.clientForm.typeName().disabled()).toBe(true);
expect(clientTypeTrigger(fixture).disabled).toBe(true);
component.openAddModal();
fixture.detectChanges();
expect(component.clientForm.typeName().disabled()).toBe(false);
expect(clientTypeTrigger(fixture).disabled).toBe(false);
});
it('creates a client from the modal values and reloads the list', () => {
const { fixture, component, api, toast } = setup();
@@ -1,6 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core';
import { forkJoin } from 'rxjs';
import { form, required, FormField } from '@angular/forms/signals';
import { form, required, disabled, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -114,6 +114,7 @@ export class DownloadClientsComponent implements HasPendingChanges {
when: ({ valueOf }) => valueOf(p.typeName) === DownloadClientTypeName.Sabnzbd,
message: 'API key is required',
});
disabled(p.typeName, () => this.editingClient() !== null);
});
readonly hasModalErrors = computed(() => this.clientForm().invalid());
@@ -137,6 +137,25 @@ test.describe('DownloadClient: invalid type validation', () => {
await api.downloadClient.delete(created.id);
});
test('update changing typeName returns 400 and keeps the original type', async ({ api }) => {
const payload = buildDownloadClientPayload('qbittorrent', {
name: 'base-for-type-change',
host: TEST_CONFIG.mocks.downloadClientUrl,
username: 'admin',
password: 'admin',
});
const created = await (await api.downloadClient.create(payload)).json();
const res = await api.downloadClient.update(created.id, { ...payload, typeName: 'rTorrent' });
expect(res.status).toBe(400);
const list = await (await api.downloadClient.list()).json();
const stored = list.clients.find((c: { id: string }) => c.id === created.id);
expect(stored.typeName.toLowerCase()).toBe(payload.typeName?.toLowerCase());
await api.downloadClient.delete(created.id);
});
});
test.describe('DownloadClient: API key validation', () => {