From 02769b0d63fde5d68de1658e2847f98c702c3a2c Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:02:35 +0200 Subject: [PATCH 01/33] test: define premium account domain contract --- CHANGELOG.md | 6 ++ src-tauri/src/domain/model/account.rs | 58 +++++++++++++++++++ src-tauri/src/domain/model/download.rs | 4 +- .../domain/ports/driven/account_validator.rs | 4 +- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8d0616..9f73c06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Premium account runtime wiring (MAT-132)**: validate configured accounts + through hoster plugins, keep selected credentials scoped to the plugin call, + persist typed account availability, rotate on account failures, and associate + premium downloads with their account without exposing secrets to SQLite, + logs, or frontend events. + - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. - **Plugin `vortex-mod-containers` v1.0.0** (scope `plugin`, sprint task 41, PRD-v2 §P1.22 / PRD §4.4): new official Vortex plugin in a sibling repo `vortex-mod-containers/`. Container category, no `http` capability — pure transformation `bytes → Vec`, the host routes the resulting URLs through the regular hoster pipeline. Decrypts the four legacy link-container formats: **DLC v1** (JDownloader `cb99b5cbc24db398` AES-128-CBC key + `9bc24cb995cb98b3` IV, base64-wrapped XML carrying a base64-encrypted `` whose plaintext is `BASE64URLBASE64NAMENNN…`), **CCF v1** (Cryptload-style `CCF1\n` magic prefix + base64 AES-128-CBC ciphertext over a `…` XML payload, Vortex-specific embedded key documented for round-trip until a Cryptload v1/v2 corpus is captured), **RSDF** (RapidShare legacy `8C 35 19 2D 96 4D C3 18 2C 6F 84 F3 25 22 39 EB` key/IV pair, hex line per encrypted URL), and **Metalink** (RFC 5854 v4 + community v3 — namespaced `` root, `` carrying ``, `` plus dashed-form variants normalised to the same `ChecksumAlgo` enum, and one or more `` resolutions; first URL becomes `ContainerLink.url`, the rest stack into `ContainerLink.mirrors`). Format detection (`dispatch::detect`) is structural: CCF magic first (unambiguous), then `` substring, then base64 → DLC outer XML probe, then hex-line-shape RSDF probe; non-matching bytes return `Option::None` so the host can show "Unsupported container variant" instead of guessing wrong. Errors map to a typed `PluginError` (`UnsupportedFormat`, `Malformed(String)`, `Decrypt(String)`, `Xml(String)`, `Base64(String)`, `Hex(String)`, `Utf8(String)`, `MissingField(&'static str)`) with `From` impls for every upstream error type — no `.unwrap()` outside tests, all upstream parsers are funnelled through `?`. The crypto core (`src/crypto.rs`) wraps `cbc::Encryptor` / `Decryptor` against the in-place `encrypt_padded_mut::` / `decrypt_padded_mut::` API (pre-sized `Vec` buffer, no `alloc`-feature dependency on `cbc`/`cipher`); decryption rejects non-`% 16` ciphertext lengths up-front so a misaligned blob fails fast. Plugin contract exposes three exports through `extism-pdk`'s `#[plugin_fn]`: `can_decrypt(bytes) -> "true" | "false"` (magic-byte + structural detection), `detect(bytes) -> JSON DetectResponse` (explicit format report including `null` on no match), and `decrypt(bytes) -> JSON DecryptResponse { format, links: [{url, filename?, sizeBytes?, mirrors[], checksums[]}] }` — all DTOs `#[serde(rename_all = "camelCase")]` for the Tauri IPC bridge. Privacy hard-line documented in `docs/ADR-001-container-keys.md`: the plugin **never** reaches out to `service.jdownloader.org/dlcrypt.php` (the JD service path used for DLC v3 per-container keys), so the WASM module declares `http = false` and modern DLC v3 captures fail cleanly with `PluginError::Decrypt(...)` rather than silently leaking the container hash to a third party. WASM artefact weighs **206 KB** under `--release` (well below the ≤500 KB acceptance budget) thanks to the existing `opt-level = "z"` + `lto = true` + `codegen-units = 1` + `strip = true` profile. 54 native unit tests across `crypto` (AES round-trip, wrong-key rejection, block-size invariants, empty-plaintext padding, misaligned-input rejection), `metalink` (v3 + v4 + namespace + dashed hash type + multi-file + missing-`` rejection + zero-files rejection + magic-hint detection), `rsdf` (encode/decode round-trip, magic detection, garbage rejection, short-hex rejection, empty-input rejection, CRLF tolerance, blank-line tolerance, all-empty-decrypt rejection), `dlc` (encode/decode round-trip, magic detection, plain-XML rejection, random-base64 rejection, short-blob rejection, invalid-base64-outer rejection, missing-`` rejection, zero-files rejection, Unicode filename support), `ccf` (encode/decode round-trip, magic prefix detection, missing-magic rejection, invalid-base64 rejection, zero-files-after-decrypt rejection, XML-escape correctness for ampersand/angle-bracket URLs), `dispatch` (per-format detection routing + priority for CCF over Metalink + None on unknown), and `lib` (top-level `can_decrypt` / `detect` / `decrypt` smoke tests per format + `UnsupportedFormat` on garbage) + 8 integration tests in `tests/synthetic_corpus.rs` exercising a 20-container corpus (5 DLC + 5 CCF + 5 RSDF + 5 Metalink — the corpus generator covers single-file, multi-file packs of up to 10 volumes, Unicode filenames, ampersand-laden URLs, mirror-rich Metalink, and dashed-form `` variants) round-tripped through the public `decrypt()` API; specifically `corpus_has_at_least_twenty_containers_across_four_formats` enforces the ≥20 / ≥5-per-format invariant, `every_corpus_entry_decrypts_correctly` asserts format + link count + first-URL identity for each entry, `metalink_corpus_carries_checksums` proves every Metalink fixture surfaces at least one checksum, `metalink_corpus_recognises_sha256_when_present` proves dashed/upper SHA-256 attribute variants normalise to the `Sha256` algo enum, the per-format suites prove the magic-prefix and order-preservation invariants, and `unknown_blobs_are_rejected` proves `can_decrypt` + `decrypt` reject empty/HTML/binary inputs symmetrically. Test corpus is **synthetic** rather than redistributing JDownloader-era proprietary fixtures (rationale recorded in the ADR — historic captures point to dead hosters and many reference copyrighted material; future releases can drop real captures into a guarded `tests/fixtures/real-world/` if needed). `cargo clippy --all-targets -- -D warnings` clean (collapsible-if + manual-`is_multiple_of` clippy hits fixed up-front, complex-type alias `EncodeCase` extracted in the integration suite). `cargo fmt --check` clean. Registered in `vortex/registry/registry.toml` with `checksum_sha256 = 28ba16ce…12ede6` (wasm) and `checksum_sha256_toml = 6d7e0152…758dd6` (manifest); category `container`, official, `min_vortex_version = "0.1.0"`. Acceptance criteria status (5/5 verified) and per-criterion verification methods recorded in `.claude/output/sprints/prd-v2-roadmap/tasks/41-plugin-containers.md`. Unblocks task 42 (Link Grabber drop-zone container import UI). diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index 06aac749..b9cbe355 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -506,6 +506,64 @@ mod tests { assert!(matches!(result, Err(DomainError::ValidationError(_)))); } + #[test] + fn test_account_status_round_trip_via_string() { + for status in [ + AccountStatus::Unverified, + AccountStatus::Valid, + AccountStatus::InvalidCredentials, + AccountStatus::MissingCredential, + AccountStatus::Expired, + AccountStatus::QuotaExhausted, + AccountStatus::Cooldown, + AccountStatus::Error, + ] { + let rendered = status.to_string(); + let parsed: AccountStatus = rendered.parse().expect("round trip"); + assert_eq!(parsed, status); + } + } + + #[test] + fn test_only_valid_or_elapsed_cooldown_accounts_are_selectable() { + let mut account = make_account(); + assert!(!account.is_selectable(1_000)); + + account.set_status(AccountStatus::Valid); + assert!(account.is_selectable(1_000)); + + account.set_status(AccountStatus::InvalidCredentials); + assert!(!account.is_selectable(1_000)); + + account.mark_unavailable(AccountStatus::Cooldown, 2_000); + assert!(!account.is_selectable(1_999)); + assert!(account.is_selectable(2_000)); + + account.mark_unavailable(AccountStatus::QuotaExhausted, 3_000); + assert!(!account.is_selectable(2_999)); + assert!(account.is_selectable(3_000)); + } + + #[test] + fn test_reconstruct_with_status_preserves_operational_state() { + let account = Account::reconstruct_with_status( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + None, + Some(500), + 100, + AccountStatus::Cooldown, + Some(2_000), + ); + assert_eq!(account.status(), AccountStatus::Cooldown); + assert_eq!(account.exhausted_until(), Some(2_000)); + } + #[test] fn test_account_reconstruct_preserves_all_fields() { let acc = Account::reconstruct( diff --git a/src-tauri/src/domain/model/download.rs b/src-tauri/src/domain/model/download.rs index a7d8a93d..a5ebadbe 100644 --- a/src-tauri/src/domain/model/download.rs +++ b/src-tauri/src/domain/model/download.rs @@ -1152,8 +1152,8 @@ mod tests { fn test_with_account_id_stores_account_link() { let d = make_download(); assert_eq!(d.account_id(), None); - let d = d.with_account_id(42); - assert_eq!(d.account_id(), Some(42)); + let d = d.with_account_id(crate::domain::model::account::AccountId::new("account-42")); + assert_eq!(d.account_id().map(|id| id.as_str()), Some("account-42")); } fn mk_mirror(host: &str, priority: u8) -> Mirror { diff --git a/src-tauri/src/domain/ports/driven/account_validator.rs b/src-tauri/src/domain/ports/driven/account_validator.rs index aeec1e25..cee192a4 100644 --- a/src-tauri/src/domain/ports/driven/account_validator.rs +++ b/src-tauri/src/domain/ports/driven/account_validator.rs @@ -65,6 +65,7 @@ mod tests { fn test_validation_outcome_ok_marks_valid_with_no_error() { let out = ValidationOutcome::ok(); assert!(out.valid); + assert_eq!(out.status, AccountStatus::Valid); assert!(out.error_message.is_none()); assert!(out.latency_ms.is_none()); assert!(out.traffic_left.is_none()); @@ -72,8 +73,9 @@ mod tests { #[test] fn test_validation_outcome_rejected_records_message_and_invalid_flag() { - let out = ValidationOutcome::rejected("wrong password"); + let out = ValidationOutcome::rejected(AccountStatus::InvalidCredentials, "wrong password"); assert!(!out.valid); + assert_eq!(out.status, AccountStatus::InvalidCredentials); assert_eq!(out.error_message.as_deref(), Some("wrong password")); } From 7ec68fefb939382c09cd43e835a2ec7f7e7e2743 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:07:15 +0200 Subject: [PATCH 02/33] test: require premium account persistence columns --- CHANGELOG.md | 3 +- .../src/adapters/driven/sqlite/connection.rs | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f73c06e..1f27ecc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 through hoster plugins, keep selected credentials scoped to the plugin call, persist typed account availability, rotate on account failures, and associate premium downloads with their account without exposing secrets to SQLite, - logs, or frontend events. + logs, or frontend events. SQLite stores only typed status, cooldown deadline, + and the opaque account UUID reference. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driven/sqlite/connection.rs b/src-tauri/src/adapters/driven/sqlite/connection.rs index c845c968..360e753b 100644 --- a/src-tauri/src/adapters/driven/sqlite/connection.rs +++ b/src-tauri/src/adapters/driven/sqlite/connection.rs @@ -189,6 +189,38 @@ mod tests { assert!(other.is_ok(), "different service must be allowed"); } + #[tokio::test] + async fn test_premium_account_wiring_columns_exist() { + let db = setup_test_db().await.unwrap(); + + let account_columns = db + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "PRAGMA table_info(accounts)".to_string(), + )) + .await + .unwrap(); + let account_names: Vec = account_columns + .iter() + .map(|row| row.try_get_by_index::(1).unwrap()) + .collect(); + assert!(account_names.contains(&"status".to_string())); + assert!(account_names.contains(&"cooldown_until".to_string())); + + let download_columns = db + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "PRAGMA table_info(downloads)".to_string(), + )) + .await + .unwrap(); + let download_names: Vec = download_columns + .iter() + .map(|row| row.try_get_by_index::(1).unwrap()) + .collect(); + assert!(download_names.contains(&"account_ref".to_string())); + } + #[tokio::test] async fn test_packages_migration_applies_cleanly_on_existing_db() { // Stand up a DB at the schema state immediately before the From d076a8144ccf31bcaca62aa5896c6e98e2bca218 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:08:54 +0200 Subject: [PATCH 03/33] feat: persist typed premium account state --- CHANGELOG.md | 3 +- .../adapters/driven/sqlite/account_repo.rs | 2 + .../driven/sqlite/entities/account.rs | 13 +- .../driven/sqlite/entities/download.rs | 8 +- .../m20260716_000010_wire_premium_accounts.rs | 79 ++++++++++ .../adapters/driven/sqlite/migrations/mod.rs | 2 + .../src/application/commands/redownload.rs | 13 +- .../src/application/commands/tests_support.rs | 5 +- .../application/commands/validate_account.rs | 1 + src-tauri/src/domain/error.rs | 10 ++ src-tauri/src/domain/model/account.rs | 145 +++++++++++++++++- src-tauri/src/domain/model/download.rs | 11 +- .../domain/ports/driven/account_validator.rs | 6 +- 13 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f27ecc4..93217494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,7 +74,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 persist typed account availability, rotate on account failures, and associate premium downloads with their account without exposing secrets to SQLite, logs, or frontend events. SQLite stores only typed status, cooldown deadline, - and the opaque account UUID reference. + and the opaque account UUID reference; legacy downloads keep their unused + numeric account column while new associations use `account_ref` text. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driven/sqlite/account_repo.rs b/src-tauri/src/adapters/driven/sqlite/account_repo.rs index 189bd43f..64b57066 100644 --- a/src-tauri/src/adapters/driven/sqlite/account_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/account_repo.rs @@ -58,6 +58,8 @@ impl AccountRepository for SqliteAccountRepo { account::Column::TrafficTotal, account::Column::ValidUntil, account::Column::LastValidated, + account::Column::Status, + account::Column::CooldownUntil, ]) .to_owned(), ) diff --git a/src-tauri/src/adapters/driven/sqlite/entities/account.rs b/src-tauri/src/adapters/driven/sqlite/entities/account.rs index b05e75ec..2e3949f5 100644 --- a/src-tauri/src/adapters/driven/sqlite/entities/account.rs +++ b/src-tauri/src/adapters/driven/sqlite/entities/account.rs @@ -1,7 +1,7 @@ use sea_orm::entity::prelude::*; use crate::domain::error::DomainError; -use crate::domain::model::account::{Account, AccountId, AccountType}; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::adapters::driven::sqlite::util::safe_u64; @@ -18,6 +18,8 @@ pub struct Model { pub traffic_total: Option, pub valid_until: Option, pub last_validated: Option, + pub status: String, + pub cooldown_until: Option, pub created_at: i64, } @@ -29,7 +31,8 @@ impl ActiveModelBehavior for ActiveModel {} impl Model { pub fn into_domain(self) -> Result { let account_type: AccountType = self.account_type.parse()?; - Ok(Account::reconstruct( + let status: AccountStatus = self.status.parse()?; + Ok(Account::reconstruct_with_status( AccountId::new(self.id), self.service_name, self.username, @@ -40,6 +43,8 @@ impl Model { self.valid_until.map(safe_u64), self.last_validated.map(safe_u64), safe_u64(self.created_at), + status, + self.cooldown_until.map(safe_u64), )) } } @@ -55,6 +60,8 @@ impl ActiveModel { let valid_until = checked_to_i64_opt(account.valid_until(), "valid_until", &id_str)?; let last_validated = checked_to_i64_opt(account.last_validated(), "last_validated", &id_str)?; + let cooldown_until = + checked_to_i64_opt(account.exhausted_until(), "cooldown_until", &id_str)?; let created_at = i64::try_from(account.created_at()).map_err(|_| { DomainError::ValidationError(format!("account {id_str}: created_at exceeds i64::MAX")) })?; @@ -69,6 +76,8 @@ impl ActiveModel { traffic_total: Set(traffic_total), valid_until: Set(valid_until), last_validated: Set(last_validated), + status: Set(account.status().to_string()), + cooldown_until: Set(cooldown_until), created_at: Set(created_at), }) } diff --git a/src-tauri/src/adapters/driven/sqlite/entities/download.rs b/src-tauri/src/adapters/driven/sqlite/entities/download.rs index 2852de9d..2bc9c216 100644 --- a/src-tauri/src/adapters/driven/sqlite/entities/download.rs +++ b/src-tauri/src/adapters/driven/sqlite/entities/download.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::adapters::driven::sqlite::util::safe_u32; use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; use crate::domain::model::checksum::ChecksumAlgorithm; use crate::domain::model::download::{Download, DownloadId, DownloadState, FileSize, Url}; use crate::domain::model::mirror::Mirror; @@ -79,7 +80,8 @@ pub struct Model { pub protocol: String, pub resume_supported: i32, pub module_name: Option, - pub account_id: Option, + #[sea_orm(column_name = "account_ref")] + pub account_id: Option, pub destination_path: String, pub error_message: Option, /// `None` keeps storage compact for the common single-source path — @@ -154,7 +156,7 @@ impl Model { self.protocol, self.resume_supported != 0, self.module_name, - self.account_id.map(|id| id as u64), + self.account_id.map(AccountId::new), self.destination_path, deserialize_mirrors(self.mirrors_json.as_deref())?, safe_u32(self.current_mirror_index as i64), @@ -188,7 +190,7 @@ impl ActiveModel { protocol: Set(download.protocol().to_string()), resume_supported: Set(if download.resume_supported() { 1 } else { 0 }), module_name: Set(download.module_name().map(|s| s.to_string())), - account_id: Set(download.account_id().map(|id| id as i64)), + account_id: Set(download.account_id().map(|id| id.as_str().to_string())), destination_path: Set(download.destination_path().to_string()), error_message: Set(None), mirrors_json: Set(serialize_mirrors(download.mirrors())), diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs new file mode 100644 index 00000000..fa64be4d --- /dev/null +++ b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs @@ -0,0 +1,79 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .add_column( + ColumnDef::new(Accounts::Status) + .text() + .not_null() + .default("unverified"), + ) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .add_column(ColumnDef::new(Accounts::CooldownUntil).big_integer().null()) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Downloads::Table) + .add_column(ColumnDef::new(Downloads::AccountRef).text().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Downloads::Table) + .drop_column(Downloads::AccountRef) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .drop_column(Accounts::CooldownUntil) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .drop_column(Accounts::Status) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Accounts { + Table, + Status, + CooldownUntil, +} + +#[derive(DeriveIden)] +enum Downloads { + Table, + AccountRef, +} diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs index 6afba2fe..9b7cc7b1 100644 --- a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs +++ b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs @@ -9,6 +9,7 @@ mod m20260428_000006_create_accounts; mod m20260429_000007_create_packages; mod m20260430_000008_add_package_external_id; mod m20260505_000009_add_mirrors; +mod m20260716_000010_wire_premium_accounts; pub struct Migrator; @@ -25,6 +26,7 @@ impl MigratorTrait for Migrator { Box::new(m20260429_000007_create_packages::Migration), Box::new(m20260430_000008_add_package_external_id::Migration), Box::new(m20260505_000009_add_mirrors::Migration), + Box::new(m20260716_000010_wire_premium_accounts::Migration), ] } } diff --git a/src-tauri/src/application/commands/redownload.rs b/src-tauri/src/application/commands/redownload.rs index ee9fd4f1..89a641b7 100644 --- a/src-tauri/src/application/commands/redownload.rs +++ b/src-tauri/src/application/commands/redownload.rs @@ -10,6 +10,7 @@ use crate::application::command_bus::CommandBus; use crate::application::commands::RedownloadSource; use crate::application::error::AppError; use crate::domain::event::DomainEvent; +use crate::domain::model::account::AccountId; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; impl CommandBus { @@ -77,7 +78,7 @@ impl CommandBus { priority: Some(*download.priority()), segments_count: Some(download.segments_count()), module_name: download.module_name().map(str::to_string), - account_id: download.account_id(), + account_id: download.account_id().cloned(), }) } RedownloadSource::History(history_id) => { @@ -110,7 +111,7 @@ struct RedownloadTemplate { priority: Option, segments_count: Option, module_name: Option, - account_id: Option, + account_id: Option, } #[cfg(test)] @@ -126,6 +127,7 @@ mod tests { use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::Priority; + use crate::domain::model::account::AccountId; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; @@ -370,7 +372,7 @@ mod tests { .with_segments_count(4) .with_priority(Priority::new(9).unwrap()) .with_module_name("vortex-mod-example".to_string()) - .with_account_id(7); + .with_account_id(AccountId::new("account-7")); d.start().unwrap(); d.complete().unwrap(); d @@ -404,7 +406,10 @@ mod tests { assert_eq!(created.segments_count(), 4); assert_eq!(*created.priority(), Priority::new(9).unwrap()); assert_eq!(created.module_name(), Some("vortex-mod-example")); - assert_eq!(created.account_id(), Some(7)); + assert_eq!( + created.account_id().map(AccountId::as_str), + Some("account-7") + ); assert_eq!( created.state(), DownloadState::Queued, diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 71eb1a6a..5ed29435 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -237,7 +237,10 @@ impl AccountValidator for FakeAccountValidator { .unwrap_or(ValidatorBehavior::Missing); match behavior { ValidatorBehavior::Ok(outcome) => Ok(outcome), - ValidatorBehavior::Reject(msg) => Ok(ValidationOutcome::rejected(msg)), + ValidatorBehavior::Reject(msg) => Ok(ValidationOutcome::rejected( + crate::domain::model::account::AccountStatus::InvalidCredentials, + msg, + )), ValidatorBehavior::Missing => Err(DomainError::NotFound(format!( "no plugin for service {service_name}" ))), diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index c771c53f..64543b51 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -186,6 +186,7 @@ mod tests { "real-debrid", ValidatorBehavior::Ok(ValidationOutcome { valid: true, + status: crate::domain::model::account::AccountStatus::Valid, latency_ms: Some(120), traffic_left: Some(50_000), traffic_total: Some(100_000), diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index 3e42e7ee..669637b4 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -22,6 +22,10 @@ pub enum DomainError { NetworkError(String), ValidationError(String), PluginError(String), + AccountInvalidCredentials, + AccountExpired, + AccountCooldown, + AccountQuotaExceeded, AdaptiveStreamOnly, /// Computed checksum did not match the expected value. ChecksumMismatch { @@ -69,6 +73,12 @@ impl std::fmt::Display for DomainError { DomainError::PluginError(msg) => { write!(f, "Plugin error: {msg}") } + DomainError::AccountInvalidCredentials => { + write!(f, "Account credentials were rejected") + } + DomainError::AccountExpired => write!(f, "Account is expired"), + DomainError::AccountCooldown => write!(f, "Account is temporarily rate-limited"), + DomainError::AccountQuotaExceeded => write!(f, "Account quota is exhausted"), DomainError::AdaptiveStreamOnly => write!( f, "Video is only available as adaptive stream (DASH/HLS); use download_to_file" diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index b9cbe355..8b452bdc 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -55,6 +55,55 @@ impl FromStr for AccountType { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AccountStatus { + #[default] + Unverified, + Valid, + InvalidCredentials, + MissingCredential, + Expired, + QuotaExhausted, + Cooldown, + Error, +} + +impl fmt::Display for AccountStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::Unverified => "unverified", + Self::Valid => "valid", + Self::InvalidCredentials => "invalid_credentials", + Self::MissingCredential => "missing_credential", + Self::Expired => "expired", + Self::QuotaExhausted => "quota_exhausted", + Self::Cooldown => "cooldown", + Self::Error => "error", + }; + f.write_str(value) + } +} + +impl FromStr for AccountStatus { + type Err = DomainError; + + fn from_str(value: &str) -> Result { + match value { + "unverified" => Ok(Self::Unverified), + "valid" => Ok(Self::Valid), + "invalid_credentials" => Ok(Self::InvalidCredentials), + "missing_credential" => Ok(Self::MissingCredential), + "expired" => Ok(Self::Expired), + "quota_exhausted" => Ok(Self::QuotaExhausted), + "cooldown" => Ok(Self::Cooldown), + "error" => Ok(Self::Error), + other => Err(DomainError::ValidationError(format!( + "invalid account status: {other}" + ))), + } + } +} + /// Strategy used by `AccountSelector` to pick the next account when several /// exist for the same service. `BestTraffic` is the default. /// @@ -115,11 +164,9 @@ pub struct Account { valid_until: Option, last_validated: Option, created_at: u64, - /// Transient quota-exhaustion deadline (Unix epoch ms). Set by the - /// `AccountRotator` when the upstream signals quota exhaustion - /// (HTTP 429, traffic below threshold, …) and cleared when a - /// fresh traffic refresh confirms the account is usable again. - /// NOT persisted in SQLite — always `None` after `reconstruct`. + status: AccountStatus, + /// Quota/rate-limit deadline (Unix epoch ms), persisted so the Accounts + /// view and a restarted selector observe the same availability state. exhausted_until: Option, } @@ -142,6 +189,7 @@ impl Account { valid_until: None, last_validated: None, created_at, + status: AccountStatus::Unverified, exhausted_until: None, } } @@ -158,6 +206,42 @@ impl Account { valid_until: Option, last_validated: Option, created_at: u64, + ) -> Self { + let status = if last_validated.is_some() { + AccountStatus::Valid + } else { + AccountStatus::Unverified + }; + Self::reconstruct_with_status( + id, + service_name, + username, + account_type, + enabled, + traffic_left, + traffic_total, + valid_until, + last_validated, + created_at, + status, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn reconstruct_with_status( + id: AccountId, + service_name: String, + username: String, + account_type: AccountType, + enabled: bool, + traffic_left: Option, + traffic_total: Option, + valid_until: Option, + last_validated: Option, + created_at: u64, + status: AccountStatus, + exhausted_until: Option, ) -> Self { Self { id, @@ -170,7 +254,8 @@ impl Account { valid_until, last_validated, created_at, - exhausted_until: None, + status, + exhausted_until, } } @@ -209,6 +294,46 @@ impl Account { self.last_validated = Some(timestamp); } + pub fn set_status(&mut self, status: AccountStatus) { + self.status = status; + if !matches!( + status, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown + ) { + self.exhausted_until = None; + } + } + + pub fn status(&self) -> AccountStatus { + self.status + } + + pub fn mark_unavailable(&mut self, status: AccountStatus, until_ms: u64) { + debug_assert!(matches!( + status, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown + )); + self.status = status; + self.exhausted_until = Some(until_ms); + } + + pub fn is_selectable(&self, now_ms: u64) -> bool { + if !self.enabled || self.is_expired(now_ms) { + return false; + } + match self.status { + AccountStatus::Valid => true, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown => { + self.exhausted_until.is_some_and(|until| now_ms >= until) + } + AccountStatus::Unverified + | AccountStatus::InvalidCredentials + | AccountStatus::MissingCredential + | AccountStatus::Expired + | AccountStatus::Error => false, + } + } + pub fn is_expired(&self, now: u64) -> bool { match self.valid_until { Some(expiry) => now > expiry, @@ -219,13 +344,19 @@ impl Account { /// Mark this account as quota-exhausted until `until_ms` (Unix epoch /// ms). Transient — never persisted in SQLite. pub fn mark_exhausted(&mut self, until_ms: u64) { - self.exhausted_until = Some(until_ms); + self.mark_unavailable(AccountStatus::QuotaExhausted, until_ms); } /// Drop any pending quota-exhaustion marker, regardless of the /// remaining cooldown. pub fn clear_exhausted(&mut self) { self.exhausted_until = None; + if matches!( + self.status, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown + ) { + self.status = AccountStatus::Valid; + } } /// Active quota-exhaustion deadline (Unix epoch ms) when set, else diff --git a/src-tauri/src/domain/model/download.rs b/src-tauri/src/domain/model/download.rs index a5ebadbe..16581947 100644 --- a/src-tauri/src/domain/model/download.rs +++ b/src-tauri/src/domain/model/download.rs @@ -1,5 +1,6 @@ use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; +use crate::domain::model::account::AccountId; use crate::domain::model::checksum::ChecksumAlgorithm; use crate::domain::model::mirror::{Mirror, sort_by_priority as sort_mirrors_by_priority}; use crate::domain::model::queue::Priority; @@ -171,7 +172,7 @@ pub struct Download { protocol: String, resume_supported: bool, module_name: Option, - account_id: Option, + account_id: Option, destination_path: String, /// Empty when the download has a single source — in that case /// [`Download::active_url`] returns the canonical `url` field instead. @@ -238,7 +239,7 @@ impl Download { protocol: String, resume_supported: bool, module_name: Option, - account_id: Option, + account_id: Option, destination_path: String, mirrors: Vec, current_mirror_index: u32, @@ -314,7 +315,7 @@ impl Download { self } - pub fn with_account_id(mut self, id: u64) -> Self { + pub fn with_account_id(mut self, id: AccountId) -> Self { self.account_id = Some(id); self } @@ -506,8 +507,8 @@ impl Download { self.module_name.as_deref() } - pub fn account_id(&self) -> Option { - self.account_id + pub fn account_id(&self) -> Option<&AccountId> { + self.account_id.as_ref() } pub fn destination_path(&self) -> &str { diff --git a/src-tauri/src/domain/ports/driven/account_validator.rs b/src-tauri/src/domain/ports/driven/account_validator.rs index cee192a4..1fc5d669 100644 --- a/src-tauri/src/domain/ports/driven/account_validator.rs +++ b/src-tauri/src/domain/ports/driven/account_validator.rs @@ -8,6 +8,7 @@ //! error to the user. use crate::domain::error::DomainError; +use crate::domain::model::account::AccountStatus; /// Result of an account validation attempt. /// @@ -17,6 +18,7 @@ use crate::domain::error::DomainError; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ValidationOutcome { pub valid: bool, + pub status: AccountStatus, pub latency_ms: Option, pub traffic_left: Option, pub traffic_total: Option, @@ -28,13 +30,15 @@ impl ValidationOutcome { pub fn ok() -> Self { Self { valid: true, + status: AccountStatus::Valid, ..Self::default() } } - pub fn rejected(error_message: impl Into) -> Self { + pub fn rejected(status: AccountStatus, error_message: impl Into) -> Self { Self { valid: false, + status, error_message: Some(error_message.into()), ..Self::default() } From 10ad93e3a7db51043dccfc12f00f071813ca6273 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:10:33 +0200 Subject: [PATCH 04/33] test: define scoped plugin credential lifecycle --- CHANGELOG.md | 3 ++- .../adapters/driven/plugin/capabilities.rs | 15 ++++++++++++ .../src/adapters/driven/plugin/registry.rs | 23 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93217494..dd6a55d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,7 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 premium downloads with their account without exposing secrets to SQLite, logs, or frontend events. SQLite stores only typed status, cooldown deadline, and the opaque account UUID reference; legacy downloads keep their unused - numeric account column while new associations use `account_ref` text. + numeric account column while new associations use `account_ref` text. Each + plugin call receives an isolated, automatically cleared credential slot. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driven/plugin/capabilities.rs b/src-tauri/src/adapters/driven/plugin/capabilities.rs index 289a488e..68c46629 100644 --- a/src-tauri/src/adapters/driven/plugin/capabilities.rs +++ b/src-tauri/src/adapters/driven/plugin/capabilities.rs @@ -427,6 +427,21 @@ mod tests { assert!(missing.is_none()); } + #[test] + fn test_scoped_credential_slots_are_isolated_by_plugin() { + let shared = SharedHostResources::new(); + let first = shared.credential_slot("vortex-mod-1fichier"); + let second = shared.credential_slot("vortex-mod-other"); + + assert!(first.lock().unwrap().is_none()); + assert!(second.lock().unwrap().is_none()); + assert!(!Arc::ptr_eq(&first, &second)); + assert!(Arc::ptr_eq( + &first, + &shared.credential_slot("vortex-mod-1fichier") + )); + } + #[test] fn test_subprocess_denied_unauthorized_binary() { // Verify capability check logic: only declared binaries are allowed diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs index a5e872c0..06414a96 100644 --- a/src-tauri/src/adapters/driven/plugin/registry.rs +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -219,6 +219,29 @@ mod tests { assert!(registry.contains("present")); } + #[test] + fn test_scoped_credential_is_cleared_when_plugin_call_fails() { + use crate::adapters::driven::plugin::capabilities::SharedHostResources; + use crate::domain::model::credential::Credential; + + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + let resources = SharedHostResources::new(); + let slot = resources.credential_slot("plug-a"); + + let error = registry + .call_plugin_with_credential( + "plug-a", + "missing", + "", + Arc::clone(&slot), + Credential::new("alice", "secret"), + ) + .expect_err("missing export"); + assert!(matches!(error, DomainError::PluginError(_))); + assert!(slot.lock().unwrap().is_none()); + } + #[test] fn test_set_enabled() { let registry = PluginRegistry::new(); From 427f2b4b55f6dfdb92d0720c33e999ac9e20d7c1 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:12:05 +0200 Subject: [PATCH 05/33] feat: scope account credentials to plugin calls --- CHANGELOG.md | 3 +- .../adapters/driven/plugin/capabilities.rs | 16 +++++- .../adapters/driven/plugin/host_functions.rs | 30 +++++++---- .../src/adapters/driven/plugin/registry.rs | 52 ++++++++++++++++++- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6a55d0..a8edd17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,7 +76,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 logs, or frontend events. SQLite stores only typed status, cooldown deadline, and the opaque account UUID reference; legacy downloads keep their unused numeric account column while new associations use `account_ref` text. Each - plugin call receives an isolated, automatically cleared credential slot. + plugin call receives an isolated, automatically cleared credential slot; + existing service-level credentials remain a fallback for legacy plugins. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driven/plugin/capabilities.rs b/src-tauri/src/adapters/driven/plugin/capabilities.rs index 68c46629..b4905212 100644 --- a/src-tauri/src/adapters/driven/plugin/capabilities.rs +++ b/src-tauri/src/adapters/driven/plugin/capabilities.rs @@ -1,19 +1,23 @@ //! Capability-based host function registration for WASM plugins. use std::net::SocketAddr; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use dashmap::DashMap; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::PluginManifest; use crate::domain::ports::driven::CredentialStore; +pub(crate) type CredentialSlot = Arc>>; + /// Shared resources across all plugins (singleton). pub struct SharedHostResources { pub(crate) http_client: reqwest::blocking::Client, http_timeout: Duration, pub(crate) credential_store: Option>, + credential_slots: DashMap, pub(crate) plugin_configs: DashMap>, pub(crate) plugin_states: DashMap>, } @@ -43,6 +47,7 @@ impl SharedHostResources { http_client, http_timeout, credential_store: None, + credential_slots: DashMap::new(), plugin_configs: DashMap::new(), plugin_states: DashMap::new(), } @@ -72,6 +77,13 @@ impl SharedHostResources { self.credential_store.as_ref() } + pub(crate) fn credential_slot(&self, plugin_name: &str) -> CredentialSlot { + self.credential_slots + .entry(plugin_name.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(None))) + .clone() + } + pub fn plugin_configs(&self) -> &DashMap> { &self.plugin_configs } @@ -92,6 +104,7 @@ pub struct PluginHostContext { pub(crate) plugin_name: String, pub(crate) capabilities: Vec, pub(crate) shared: Arc, + pub(crate) credential_slot: CredentialSlot, } /// Host-owned privileges established outside the plugin manifest. @@ -163,6 +176,7 @@ pub(super) fn build_host_functions_with_grants( plugin_name: name, capabilities: manifest.capabilities().to_vec(), shared: Arc::clone(shared), + credential_slot: shared.credential_slot(manifest.info().name()), }; let user_data = extism::UserData::new(ctx); diff --git a/src-tauri/src/adapters/driven/plugin/host_functions.rs b/src-tauri/src/adapters/driven/plugin/host_functions.rs index e68eea95..a2599896 100644 --- a/src-tauri/src/adapters/driven/plugin/host_functions.rs +++ b/src-tauri/src/adapters/driven/plugin/host_functions.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::io::Read; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -427,7 +428,7 @@ pub fn make_get_credential_function( let service = read_input_string(plugin, inputs)?; // F3: Scope credential access — plugins can only read credentials // matching their own name to prevent cross-plugin credential theft. - let (store, plugin_name) = { + let (slot, store, plugin_name) = { let guard = ud.get()?; let ctx = guard .lock() @@ -440,16 +441,27 @@ pub fn make_get_credential_function( )); } - let store = ctx.shared.credential_store().cloned().ok_or_else(|| { - anyhow::anyhow!("get_credential: no credential store configured") - })?; - (store, ctx.plugin_name.clone()) + ( + Arc::clone(&ctx.credential_slot), + ctx.shared.credential_store().cloned(), + ctx.plugin_name.clone(), + ) }; - let cred = store - .get(&plugin_name) - .map_err(|e| anyhow::anyhow!("get_credential: store error: {e}"))? - .ok_or_else(|| anyhow::anyhow!("get_credential: no credential found"))?; + let scoped = slot + .lock() + .map_err(|_| anyhow::anyhow!("get_credential: credential slot poisoned"))? + .clone(); + let cred = match scoped { + Some(credential) => credential, + None => store + .ok_or_else(|| { + anyhow::anyhow!("get_credential: no credential store configured") + })? + .get(&plugin_name) + .map_err(|e| anyhow::anyhow!("get_credential: store error: {e}"))? + .ok_or_else(|| anyhow::anyhow!("get_credential: no credential found"))?, + }; let resp = CredentialResponse { username: cred.username().to_string(), diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs index 06414a96..1a5b2be2 100644 --- a/src-tauri/src/adapters/driven/plugin/registry.rs +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -5,9 +5,42 @@ use std::sync::{Arc, Mutex}; use dashmap::DashMap; use crate::domain::error::DomainError; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::PluginReadRepository; +use super::capabilities::CredentialSlot; + +struct CredentialScope { + slot: CredentialSlot, +} + +impl CredentialScope { + fn new(slot: CredentialSlot, credential: Credential) -> Result { + { + let mut current = slot.lock().map_err(|_| { + DomainError::PluginError("plugin credential slot mutex poisoned".into()) + })?; + if current.is_some() { + return Err(DomainError::PluginError( + "plugin credential slot already active".into(), + )); + } + *current = Some(credential); + } + Ok(Self { slot }) + } +} + +impl Drop for CredentialScope { + fn drop(&mut self) { + match self.slot.lock() { + Ok(mut current) => *current = None, + Err(poisoned) => *poisoned.into_inner() = None, + } + } +} + pub struct LoadedPlugin { pub manifest: PluginManifest, pub plugin: Arc>, @@ -96,7 +129,18 @@ impl PluginRegistry { } pub fn call_plugin(&self, name: &str, func: &str, input: &str) -> Result { - self.call_plugin_inner(name, func, input) + self.call_plugin_inner(name, func, input, None) + } + + pub fn call_plugin_with_credential( + &self, + name: &str, + func: &str, + input: &str, + slot: CredentialSlot, + credential: Credential, + ) -> Result { + self.call_plugin_inner(name, func, input, Some((slot, credential))) } /// Container plugins decode binary blobs (DLC / CCF / RSDF / Metalink); @@ -107,7 +151,7 @@ impl PluginRegistry { func: &str, input: &[u8], ) -> Result { - self.call_plugin_inner(name, func, input) + self.call_plugin_inner(name, func, input, None) } fn call_plugin_inner<'a, I>( @@ -115,6 +159,7 @@ impl PluginRegistry { name: &str, func: &str, input: I, + scoped_credential: Option<(CredentialSlot, Credential)>, ) -> Result where I: extism::convert::ToBytes<'a>, @@ -132,6 +177,9 @@ impl PluginRegistry { let mut plugin = plugin_handle .lock() .map_err(|_| DomainError::PluginError(format!("plugin '{name}' mutex poisoned")))?; + let _credential_scope = scoped_credential + .map(|(slot, credential)| CredentialScope::new(slot, credential)) + .transpose()?; let fn_exists = plugin.function_exists(func); tracing::debug!(plugin = name, func, fn_exists, "plugin call pre-call"); let result = plugin.call::(func, input).map_err(|e| { From a6da4816fb1f0cb785f7a053454c622fb65741e6 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:16:17 +0200 Subject: [PATCH 06/33] test: define plugin account validation bridge --- CHANGELOG.md | 2 + .../driven/plugin/account_validator.rs | 93 +++++++++++++++++++ .../adapters/driven/plugin/extism_loader.rs | 27 ++++++ src-tauri/src/adapters/driven/plugin/mod.rs | 1 + .../src/domain/ports/driven/plugin_loader.rs | 44 +++++++++ 5 files changed, 167 insertions(+) create mode 100644 src-tauri/src/adapters/driven/plugin/account_validator.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a8edd17a..19b6f9f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 numeric account column while new associations use `account_ref` text. Each plugin call receives an isolated, automatically cleared credential slot; existing service-level credentials remain a fallback for legacy plugins. + Contract tests pin the exact service/credential handoff and typed plugin + authentication failures before runtime validation is enabled. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driven/plugin/account_validator.rs b/src-tauri/src/adapters/driven/plugin/account_validator.rs new file mode 100644 index 00000000..0d0c92b3 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/account_validator.rs @@ -0,0 +1,93 @@ +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use crate::domain::error::DomainError; + use crate::domain::model::account::AccountStatus; + use crate::domain::model::credential::Credential; + use crate::domain::model::plugin::{PluginInfo, PluginManifest}; + use crate::domain::ports::driven::{AccountValidator, PluginLoader, ValidationOutcome}; + + use super::PluginAccountValidator; + + struct CapturingLoader { + captured: Mutex>, + outcome: ValidationOutcome, + } + + impl PluginLoader for CapturingLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(None) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(Vec::new()) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn validate_account( + &self, + service_name: &str, + credential: &Credential, + ) -> Result { + *self.captured.lock().expect("capture mutex") = + Some((service_name.to_string(), credential.clone())); + Ok(self.outcome.clone()) + } + } + + #[test] + fn validate_scopes_exact_credentials_and_records_host_latency() { + let loader = Arc::new(CapturingLoader { + captured: Mutex::new(None), + outcome: ValidationOutcome::ok(), + }); + let validator = PluginAccountValidator::new(loader.clone()); + + let outcome = validator + .validate("vortex-mod-1fichier", "alice", "api-key") + .expect("validation succeeds"); + + assert_eq!(outcome.status, AccountStatus::Valid); + assert!(outcome.latency_ms.is_some()); + let captured = loader.captured.lock().expect("capture mutex"); + let (service, credential) = captured.as_ref().expect("credential captured"); + assert_eq!(service, "vortex-mod-1fichier"); + assert_eq!(credential.username(), "alice"); + assert_eq!(credential.password(), "api-key"); + } + + #[test] + fn validate_preserves_plugin_supplied_latency_and_typed_status() { + let loader = Arc::new(CapturingLoader { + captured: Mutex::new(None), + outcome: ValidationOutcome { + latency_ms: Some(42), + ..ValidationOutcome::rejected( + AccountStatus::InvalidCredentials, + "credential rejected", + ) + }, + }); + let validator = PluginAccountValidator::new(loader); + + let outcome = validator + .validate("vortex-mod-1fichier", "alice", "bad-key") + .expect("typed rejection is an outcome"); + + assert!(!outcome.valid); + assert_eq!(outcome.status, AccountStatus::InvalidCredentials); + assert_eq!(outcome.latency_ms, Some(42)); + } +} diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index a59b1850..7743b421 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -704,6 +704,33 @@ mod tests { PluginManifest::new(info) } + #[test] + fn account_plugin_errors_map_to_typed_domain_errors() { + assert_eq!( + classify_account_plugin_error( + "ACCOUNT_INVALID_CREDENTIALS: configured key was rejected" + ), + DomainError::AccountInvalidCredentials + ); + assert_eq!( + classify_account_plugin_error("ACCOUNT_EXPIRED: subscription ended"), + DomainError::AccountExpired + ); + assert_eq!( + classify_account_plugin_error("ACCOUNT_COOLDOWN: flood detected"), + DomainError::AccountCooldown + ); + } + + #[test] + fn unknown_account_plugin_error_stays_a_plugin_error() { + let error = classify_account_plugin_error("PLUGIN_ERROR: malformed response"); + assert!(matches!( + error, + DomainError::PluginError(message) if message.contains("malformed response") + )); + } + fn setup_plugin_dir(plugins_dir: &Path, name: &str) { let plugin_dir = plugins_dir.join(name); std::fs::create_dir_all(&plugin_dir).unwrap(); diff --git a/src-tauri/src/adapters/driven/plugin/mod.rs b/src-tauri/src/adapters/driven/plugin/mod.rs index dc5d7a02..17211aa0 100644 --- a/src-tauri/src/adapters/driven/plugin/mod.rs +++ b/src-tauri/src/adapters/driven/plugin/mod.rs @@ -1,3 +1,4 @@ +pub mod account_validator; pub mod builtin; pub mod capabilities; pub mod extism_loader; diff --git a/src-tauri/src/domain/ports/driven/plugin_loader.rs b/src-tauri/src/domain/ports/driven/plugin_loader.rs index f3be5d16..62687220 100644 --- a/src-tauri/src/domain/ports/driven/plugin_loader.rs +++ b/src-tauri/src/domain/ports/driven/plugin_loader.rs @@ -4,7 +4,9 @@ //! to determine which plugin can handle a given URL. use crate::domain::error::DomainError; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::account_validator::ValidationOutcome; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; /// Result of a `download_to_file` plugin call. @@ -82,6 +84,26 @@ pub trait PluginLoader: Send + Sync { )) } + /// Extract links while exposing one account credential only for this call. + fn extract_links_with_credential( + &self, + url: &str, + _credential: &Credential, + ) -> Result { + self.extract_links(url) + } + + /// Validate an account through the plugin matching `service_name`. + fn validate_account( + &self, + service_name: &str, + _credential: &Credential, + ) -> Result { + Err(DomainError::NotFound(format!( + "account validation not supported for service '{service_name}'" + ))) + } + /// Fetch selectable media variants from the plugin that claims the URL. fn get_media_variants(&self, _url: &str) -> Result { Err(DomainError::NotFound( @@ -213,6 +235,28 @@ mod tests { assert!(matches!(result, Err(DomainError::NotFound(_)))); } + #[test] + fn test_extract_links_with_credential_default_delegates_without_support() { + let loader = MinimalLoader; + let credential = Credential::new("alice", "secret"); + let result = + loader.extract_links_with_credential("https://1fichier.com/?abc123", &credential); + assert!(matches!(result, Err(DomainError::NotFound(_)))); + } + + #[test] + fn test_validate_account_default_names_unsupported_service() { + let loader = MinimalLoader; + let credential = Credential::new("alice", "secret"); + let error = loader + .validate_account("vortex-mod-1fichier", &credential) + .expect_err("minimal loader must reject account validation"); + assert!(matches!( + error, + DomainError::NotFound(message) if message.contains("vortex-mod-1fichier") + )); + } + #[test] fn test_get_media_variants_default_returns_not_found() { let loader = MinimalLoader; From d06cdb792c5e04148e7fce54697d6476312549c8 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:19:22 +0200 Subject: [PATCH 07/33] feat: bridge premium validation through plugins --- CHANGELOG.md | 3 +- .../driven/plugin/account_validator.rs | 36 +++++ .../adapters/driven/plugin/extism_loader.rs | 147 +++++++++++++++++- src-tauri/src/adapters/driven/plugin/mod.rs | 1 + src-tauri/src/lib.rs | 2 +- 5 files changed, 186 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b6f9f4..fc057043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 plugin call receives an isolated, automatically cleared credential slot; existing service-level credentials remain a fallback for legacy plugins. Contract tests pin the exact service/credential handoff and typed plugin - authentication failures before runtime validation is enabled. + authentication failures; the Extism bridge now maps stable account error + codes and validation metrics without placing secrets in the plugin ABI. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driven/plugin/account_validator.rs b/src-tauri/src/adapters/driven/plugin/account_validator.rs index 0d0c92b3..11a8419b 100644 --- a/src-tauri/src/adapters/driven/plugin/account_validator.rs +++ b/src-tauri/src/adapters/driven/plugin/account_validator.rs @@ -1,3 +1,39 @@ +use std::sync::Arc; +use std::time::Instant; + +use crate::domain::error::DomainError; +use crate::domain::model::credential::Credential; +use crate::domain::ports::driven::{AccountValidator, PluginLoader, ValidationOutcome}; + +/// Validates account credentials through the plugin that owns the service. +pub struct PluginAccountValidator { + loader: Arc, +} + +impl PluginAccountValidator { + pub fn new(loader: Arc) -> Self { + Self { loader } + } +} + +impl AccountValidator for PluginAccountValidator { + fn validate( + &self, + service_name: &str, + username: &str, + password: &str, + ) -> Result { + let started_at = Instant::now(); + let credential = Credential::new(username, password); + let mut outcome = self.loader.validate_account(service_name, &credential)?; + if outcome.latency_ms.is_none() { + outcome.latency_ms = + Some(u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX)); + } + Ok(outcome) + } +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index 7743b421..6a7b8989 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -6,10 +6,12 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use crate::domain::error::DomainError; +use crate::domain::model::account::AccountStatus; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; -use crate::domain::ports::driven::PluginLoader; use crate::domain::ports::driven::plugin_loader::DownloadedFileInfo; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; +use crate::domain::ports::driven::{PluginLoader, ValidationOutcome}; use super::builtin::HttpModule; use super::capabilities::{SharedHostResources, build_host_functions_with_grants}; @@ -487,6 +489,72 @@ impl PluginLoader for ExtismPluginLoader { self.call_url_plugin_function(url, "extract_links") } + fn extract_links_with_credential( + &self, + url: &str, + credential: &Credential, + ) -> Result { + let info = self.resolve_wasm_plugin(url)?; + if !self + .registry + .function_exists(info.name(), "extract_links")? + { + return Err(DomainError::NotFound(format!( + "plugin '{}' does not export 'extract_links'", + info.name() + ))); + } + let slot = self.shared_resources.credential_slot(info.name()); + self.registry + .call_plugin_with_credential( + info.name(), + "extract_links", + url, + slot, + credential.clone(), + ) + .map_err(|error| classify_account_plugin_error(&error.to_string())) + } + + fn validate_account( + &self, + service_name: &str, + credential: &Credential, + ) -> Result { + let info = self + .registry + .list_info() + .into_iter() + .find(|info| info.name() == service_name) + .ok_or_else(|| DomainError::NotFound(service_name.to_string()))?; + if !info.is_enabled() { + return Err(DomainError::NotFound(format!( + "plugin '{service_name}' is disabled" + ))); + } + if !self + .registry + .function_exists(service_name, "validate_account")? + { + return Err(DomainError::NotFound(format!( + "plugin '{service_name}' does not export 'validate_account'" + ))); + } + + let slot = self.shared_resources.credential_slot(service_name); + let output = self + .registry + .call_plugin_with_credential( + service_name, + "validate_account", + "", + slot, + credential.clone(), + ) + .map_err(|error| classify_account_plugin_error(&error.to_string()))?; + parse_validation_outcome(&output) + } + fn get_media_variants(&self, url: &str) -> Result { self.call_url_plugin_function(url, "get_media_variants") } @@ -686,6 +754,65 @@ fn is_adaptive_stream_error(msg: &str) -> bool { msg.contains("adaptive stream (HLS/DASH)") } +fn classify_account_plugin_error(message: &str) -> DomainError { + if message.contains("ACCOUNT_INVALID_CREDENTIALS") { + DomainError::AccountInvalidCredentials + } else if message.contains("ACCOUNT_EXPIRED") { + DomainError::AccountExpired + } else if message.contains("ACCOUNT_COOLDOWN") { + DomainError::AccountCooldown + } else if message.contains("ACCOUNT_QUOTA_EXCEEDED") { + DomainError::AccountQuotaExceeded + } else { + DomainError::PluginError(message.to_string()) + } +} + +#[derive(serde::Deserialize)] +struct PluginValidationOutcome { + valid: bool, + #[serde(default)] + status: Option, + #[serde(default, alias = "latencyMs")] + latency_ms: Option, + #[serde(default, alias = "trafficLeft")] + traffic_left: Option, + #[serde(default, alias = "trafficTotal")] + traffic_total: Option, + #[serde(default, alias = "validUntil")] + valid_until: Option, + #[serde(default, alias = "errorMessage")] + error_message: Option, +} + +fn parse_validation_outcome(output: &str) -> Result { + use std::str::FromStr; + + let parsed: PluginValidationOutcome = serde_json::from_str(output).map_err(|error| { + DomainError::PluginError(format!( + "plugin account validation returned invalid JSON: {error}" + )) + })?; + let status = match parsed.status { + Some(status) => AccountStatus::from_str(&status).map_err(|_| { + DomainError::PluginError(format!( + "plugin account validation returned unknown status '{status}'" + )) + })?, + None if parsed.valid => AccountStatus::Valid, + None => AccountStatus::Error, + }; + Ok(ValidationOutcome { + valid: parsed.valid, + status, + latency_ms: parsed.latency_ms, + traffic_left: parsed.traffic_left, + traffic_total: parsed.traffic_total, + valid_until: parsed.valid_until, + error_message: parsed.error_message, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -731,6 +858,24 @@ mod tests { )); } + #[test] + fn validation_response_defaults_success_to_valid_status() { + let outcome = parse_validation_outcome(r#"{"valid":true}"#).expect("valid outcome"); + assert!(outcome.valid); + assert_eq!(outcome.status, AccountStatus::Valid); + } + + #[test] + fn validation_response_accepts_typed_metrics() { + let outcome = parse_validation_outcome( + r#"{"valid":false,"status":"quota_exhausted","trafficLeft":0,"trafficTotal":100,"errorMessage":"quota used"}"#, + ) + .expect("typed outcome"); + assert_eq!(outcome.status, AccountStatus::QuotaExhausted); + assert_eq!(outcome.traffic_left, Some(0)); + assert_eq!(outcome.traffic_total, Some(100)); + } + fn setup_plugin_dir(plugins_dir: &Path, name: &str) { let plugin_dir = plugins_dir.join(name); std::fs::create_dir_all(&plugin_dir).unwrap(); diff --git a/src-tauri/src/adapters/driven/plugin/mod.rs b/src-tauri/src/adapters/driven/plugin/mod.rs index 17211aa0..62d819fa 100644 --- a/src-tauri/src/adapters/driven/plugin/mod.rs +++ b/src-tauri/src/adapters/driven/plugin/mod.rs @@ -10,6 +10,7 @@ pub mod registry; pub mod watcher; pub(crate) mod ytdlp_broker; +pub use account_validator::PluginAccountValidator; pub use extism_loader::ExtismPluginLoader; pub use github_store_client::GithubStoreClient; pub use registry::PluginRegistry; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81d515b4..5aaf1afa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,7 +39,7 @@ pub use adapters::driven::notification::spawn_notification_bridge; pub use adapters::driven::plugin::builtin::HttpModule; pub use adapters::driven::plugin::capabilities::SharedHostResources; pub use adapters::driven::plugin::{ - ExtismPluginLoader, GithubStoreClient, PluginRegistry, PluginWatcher, + ExtismPluginLoader, GithubStoreClient, PluginAccountValidator, PluginRegistry, PluginWatcher, }; pub use adapters::driven::scheduler::{HISTORY_PURGE_STATE_FILE, HistoryPurgeWorker, SystemClock}; pub use adapters::driven::sqlite::account_repo::SqliteAccountRepo; From ca6c548a0eecfec142dcd15c70a9ed21fbad2f28 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:23:29 +0200 Subject: [PATCH 08/33] test: require automatic account validation --- CHANGELOG.md | 2 + src-tauri/src/adapters/driving/tauri_ipc.rs | 1 + .../src/application/commands/add_account.rs | 54 +++++++++++++++++-- src-tauri/src/application/commands/mod.rs | 1 + .../src/application/commands/tests_support.rs | 17 +++++- .../application/commands/update_account.rs | 52 ++++++++++++++++-- .../application/commands/validate_account.rs | 36 ++++++++++++- 7 files changed, 154 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc057043..75d1b887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Contract tests pin the exact service/credential handoff and typed plugin authentication failures; the Extism bridge now maps stable account error codes and validation metrics without placing secrets in the plugin ABI. + Command-level tests require add/update/explicit validation to persist the + resulting typed state, including after password rotation. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index e48d38b0..d2819183 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -3098,6 +3098,7 @@ pub async fn account_update( .handle_update_account(UpdateAccountCommand { id: AccountId::new(id), patch, + now_ms: now_unix_ms(), }) .await .map_err(|e| e.to_string()) diff --git a/src-tauri/src/application/commands/add_account.rs b/src-tauri/src/application/commands/add_account.rs index 3633bce5..ea5d4dbf 100644 --- a/src-tauri/src/application/commands/add_account.rs +++ b/src-tauri/src/application/commands/add_account.rs @@ -97,13 +97,16 @@ mod tests { use super::super::AddAccountCommand; use crate::application::commands::tests_support::{ - CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, build_account_bus, + CapturingEventBus, FakeAccountCredentialStore, FakeAccountValidator, InMemoryAccountRepo, + ValidatorBehavior, build_account_bus, }; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; - use crate::domain::model::account::AccountType; - use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository}; + use crate::domain::model::account::{AccountStatus, AccountType}; + use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, ValidationOutcome, + }; fn add_command(service: &str, user: &str, password: &str) -> AddAccountCommand { AddAccountCommand { @@ -153,6 +156,51 @@ mod tests { } } + #[tokio::test] + async fn test_add_account_validates_and_persists_valid_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::ok()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator.clone()), None); + + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "api-key")) + .await + .expect("invalid credentials do not discard account metadata"); + + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::Valid); + assert_eq!(stored.last_validated(), Some(1_700_000_000_000)); + assert_eq!(validator.calls().len(), 1); + } + + #[tokio::test] + async fn test_add_account_keeps_rejected_account_with_typed_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Reject("wrong key".into()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator), None); + + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "bad-key")) + .await + .expect("rejected account remains configured"); + + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::InvalidCredentials); + assert_eq!(stored.last_validated(), Some(1_700_000_000_000)); + } + #[tokio::test] async fn test_add_account_blank_service_returns_validation() { let repo = Arc::new(InMemoryAccountRepo::new()); diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 180b5438..84ac10b3 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -440,6 +440,7 @@ pub struct AccountPatch { pub struct UpdateAccountCommand { pub id: AccountId, pub patch: AccountPatch, + pub now_ms: u64, } impl Command for UpdateAccountCommand {} diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 5ed29435..ca4b2ae1 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -196,6 +196,7 @@ impl AccountCredentialStore for FakeAccountCredentialStore { pub(crate) struct FakeAccountValidator { behavior: Mutex>, + calls: Mutex>, } #[derive(Clone)] @@ -204,12 +205,14 @@ pub(crate) enum ValidatorBehavior { Reject(String), Missing, Storage(String), + Domain(DomainError), } impl FakeAccountValidator { pub(crate) fn new() -> Self { Self { behavior: Mutex::new(HashMap::new()), + calls: Mutex::new(Vec::new()), } } @@ -219,15 +222,24 @@ impl FakeAccountValidator { .unwrap() .insert(service_name.to_string(), behavior); } + + pub(crate) fn calls(&self) -> Vec<(String, String, String)> { + self.calls.lock().unwrap().clone() + } } impl AccountValidator for FakeAccountValidator { fn validate( &self, service_name: &str, - _username: &str, - _password: &str, + username: &str, + password: &str, ) -> Result { + self.calls.lock().unwrap().push(( + service_name.to_string(), + username.to_string(), + password.to_string(), + )); let behavior = self .behavior .lock() @@ -245,6 +257,7 @@ impl AccountValidator for FakeAccountValidator { "no plugin for service {service_name}" ))), ValidatorBehavior::Storage(msg) => Err(DomainError::StorageError(msg)), + ValidatorBehavior::Domain(error) => Err(error), } } } diff --git a/src-tauri/src/application/commands/update_account.rs b/src-tauri/src/application/commands/update_account.rs index e31ba1c8..444fae70 100644 --- a/src-tauri/src/application/commands/update_account.rs +++ b/src-tauri/src/application/commands/update_account.rs @@ -120,13 +120,16 @@ mod tests { use super::super::{AccountPatch, AddAccountCommand, UpdateAccountCommand}; use crate::application::commands::tests_support::{ - CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, build_account_bus, + CapturingEventBus, FakeAccountCredentialStore, FakeAccountValidator, InMemoryAccountRepo, + ValidatorBehavior, build_account_bus, }; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; - use crate::domain::model::account::{AccountId, AccountType}; - use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository}; + use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; + use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, ValidationOutcome, + }; fn add_command(service: &str, user: &str, pw: &str) -> AddAccountCommand { AddAccountCommand { @@ -151,6 +154,7 @@ mod tests { bus.handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { enabled: Some(false), ..AccountPatch::default() @@ -179,6 +183,7 @@ mod tests { bus.handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { password: Some("new-pw".into()), ..AccountPatch::default() @@ -190,6 +195,41 @@ mod tests { assert_eq!(creds.get_password(&id).unwrap().as_deref(), Some("new-pw")); } + #[tokio::test] + async fn test_update_account_validates_rotated_secret_and_persists_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::ok()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator.clone()), None); + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "old-key")) + .await + .expect("add succeeds"); + + bus.handle_update_account(UpdateAccountCommand { + id: id.clone(), + now_ms: 1_800_000_000_000, + patch: AccountPatch { + password: Some("new-key".into()), + ..AccountPatch::default() + }, + }) + .await + .expect("update succeeds"); + + let calls = validator.calls(); + assert_eq!(calls.len(), 2, "add and update must both validate"); + assert_eq!(calls[1].2, "new-key"); + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::Valid); + assert_eq!(stored.last_validated(), Some(1_800_000_000_000)); + } + #[tokio::test] async fn test_update_account_unknown_id_returns_not_found() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -200,6 +240,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: AccountId::new("missing"), + now_ms: 1_800_000_000_000, patch: AccountPatch::default(), }) .await @@ -225,6 +266,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { password: Some("new-pw".into()), enabled: Some(false), @@ -273,6 +315,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { username: Some(" ".into()), ..AccountPatch::default() @@ -299,6 +342,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { password: Some("".into()), ..AccountPatch::default() @@ -324,6 +368,7 @@ mod tests { bus.handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { account_type: Some(AccountType::Debrid), ..AccountPatch::default() @@ -369,6 +414,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id1, + now_ms: 1_800_000_000_000, patch: AccountPatch { username: Some("bob".into()), ..AccountPatch::default() diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index 64543b51..edfd0fb5 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -130,8 +130,9 @@ mod tests { ValidatorBehavior, build_account_bus, }; use crate::application::error::AppError; + use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; - use crate::domain::model::account::{AccountId, AccountType}; + use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, ValidationOutcome, }; @@ -255,6 +256,7 @@ mod tests { let after = repo.find_by_id(&id).unwrap().unwrap(); assert_eq!(after.last_validated(), Some(1_900_000_000_000)); + assert_eq!(after.status(), AccountStatus::InvalidCredentials); assert!(after.traffic_left().is_none(), "no traffic on reject"); assert!( @@ -265,6 +267,36 @@ mod tests { ); } + #[tokio::test] + async fn test_validate_account_maps_typed_plugin_error_to_persisted_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Domain(DomainError::AccountExpired), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator), None); + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier")) + .await + .expect("account remains configured"); + + let outcome = bus + .handle_validate_account(ValidateAccountCommand { + id: id.clone(), + now_ms: 1_900_000_000_000, + }) + .await + .expect("typed account failure is a validation outcome"); + + assert!(!outcome.valid); + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::Expired); + assert_eq!(stored.last_validated(), Some(1_900_000_000_000)); + } + #[tokio::test] async fn test_validate_account_storage_error_emits_validation_failed_event() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -341,5 +373,7 @@ mod tests { .await .expect_err("missing pw"); assert!(matches!(err, AppError::NotFound(_))); + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::MissingCredential); } } From d3e066dead900257f52a2dcadf60524aa91ee0d0 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:26:42 +0200 Subject: [PATCH 09/33] feat: validate accounts on credential changes --- CHANGELOG.md | 3 +- .../src/application/commands/add_account.rs | 31 +++ .../src/application/commands/tests_support.rs | 4 +- .../application/commands/update_account.rs | 33 +++- .../application/commands/validate_account.rs | 186 +++++++++++------- 5 files changed, 184 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d1b887..ec5c12b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 authentication failures; the Extism bridge now maps stable account error codes and validation metrics without placing secrets in the plugin ABI. Command-level tests require add/update/explicit validation to persist the - resulting typed state, including after password rotation. + resulting typed state, including after password rotation; rejected accounts + stay configured but are excluded from premium selection by that state. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/application/commands/add_account.rs b/src-tauri/src/application/commands/add_account.rs index ea5d4dbf..e81a0c9c 100644 --- a/src-tauri/src/application/commands/add_account.rs +++ b/src-tauri/src/application/commands/add_account.rs @@ -15,6 +15,8 @@ use crate::application::error::AppError; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountId}; +use super::validate_account::{apply_validation, publish_validation, validate_credentials}; + impl CommandBus { pub async fn handle_add_account( &self, @@ -74,10 +76,39 @@ impl CommandBus { return Err(e.into()); } + let validation = self + .account_validator() + .map(|validator| validate_credentials(validator, &account, &cmd.password)); + if let Some(attempt) = &validation { + let validated = apply_validation(&account, &attempt.outcome, cmd.created_at_ms); + if let Err(error) = repo.save(&validated) { + if let Err(rollback_error) = repo.delete(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + rollback_error = %rollback_error, + "account validation state failed to persist and row rollback also failed" + ); + } + if let Err(cleanup_error) = store.delete_password(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + cleanup_error = %cleanup_error, + "account validation state failed to persist and credential cleanup also failed" + ); + } + return Err(error.into()); + } + } + self.event_bus().publish(DomainEvent::AccountAdded { id: id.clone(), service_name: account.service_name().to_string(), }); + if let Some(attempt) = validation { + publish_validation(self, id.clone(), &attempt.outcome); + } Ok(id) } diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index ca4b2ae1..0a488988 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -72,7 +72,7 @@ impl AccountRepository for InMemoryAccountRepo { } } let stored = match guard.get(account.id()) { - Some(existing) => Account::reconstruct( + Some(existing) => Account::reconstruct_with_status( account.id().clone(), account.service_name().to_string(), account.username().to_string(), @@ -83,6 +83,8 @@ impl AccountRepository for InMemoryAccountRepo { account.valid_until(), account.last_validated(), existing.created_at(), + account.status(), + account.exhausted_until(), ), None => account.clone(), }; diff --git a/src-tauri/src/application/commands/update_account.rs b/src-tauri/src/application/commands/update_account.rs index 444fae70..515429a7 100644 --- a/src-tauri/src/application/commands/update_account.rs +++ b/src-tauri/src/application/commands/update_account.rs @@ -13,6 +13,9 @@ use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::domain::event::DomainEvent; use crate::domain::model::account::Account; +use crate::domain::ports::driven::ValidationOutcome; + +use super::validate_account::{apply_validation, publish_validation, validate_credentials}; impl CommandBus { pub async fn handle_update_account( @@ -49,7 +52,7 @@ impl CommandBus { return Err(AppError::Validation("password must not be empty".into())); } - let next = Account::reconstruct( + let mut next = Account::reconstruct_with_status( account.id().clone(), account.service_name().to_string(), username, @@ -60,6 +63,8 @@ impl CommandBus { account.valid_until(), account.last_validated(), account.created_at(), + account.status(), + account.exhausted_until(), ); // Capture the previous password BEFORE persisting the new row // so a keyring-rotation failure can restore it. The @@ -73,6 +78,27 @@ impl CommandBus { None }; + let validation = if let Some(validator) = self.account_validator() { + let password = match cmd.patch.password.as_deref() { + Some(password) => Some(password.to_string()), + None => store.get_password(&cmd.id)?, + }; + let attempt = match password { + Some(password) => validate_credentials(validator, &next, &password), + None => super::validate_account::AccountValidationAttempt { + outcome: ValidationOutcome::rejected( + crate::domain::model::account::AccountStatus::MissingCredential, + format!("no stored password for account {}", cmd.id.as_str()), + ), + error: None, + }, + }; + next = apply_validation(&next, &attempt.outcome, cmd.now_ms); + Some(attempt) + } else { + None + }; + repo.save(&next)?; // Apply password rotation after the row is persisted. If the @@ -109,7 +135,10 @@ impl CommandBus { } self.event_bus() - .publish(DomainEvent::AccountUpdated { id: cmd.id }); + .publish(DomainEvent::AccountUpdated { id: cmd.id.clone() }); + if let Some(attempt) = validation { + publish_validation(self, cmd.id, &attempt.outcome); + } Ok(()) } } diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index edfd0fb5..e92fe6db 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -12,7 +12,98 @@ use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; -use crate::domain::model::account::Account; +use crate::domain::model::account::{Account, AccountStatus}; +use crate::domain::ports::driven::{AccountValidator, ValidationOutcome}; + +pub(super) struct AccountValidationAttempt { + pub outcome: ValidationOutcome, + pub error: Option, +} + +pub(super) fn validate_credentials( + validator: &dyn AccountValidator, + account: &Account, + password: &str, +) -> AccountValidationAttempt { + match validator.validate(account.service_name(), account.username(), password) { + Ok(outcome) => AccountValidationAttempt { + outcome, + error: None, + }, + Err(DomainError::AccountInvalidCredentials) => typed_rejection( + AccountStatus::InvalidCredentials, + DomainError::AccountInvalidCredentials, + ), + Err(DomainError::AccountExpired) => { + typed_rejection(AccountStatus::Expired, DomainError::AccountExpired) + } + Err(DomainError::AccountCooldown) => { + typed_rejection(AccountStatus::Cooldown, DomainError::AccountCooldown) + } + Err(DomainError::AccountQuotaExceeded) => typed_rejection( + AccountStatus::QuotaExhausted, + DomainError::AccountQuotaExceeded, + ), + Err(error) => AccountValidationAttempt { + outcome: ValidationOutcome::rejected(AccountStatus::Error, error.to_string()), + error: Some(error), + }, + } +} + +fn typed_rejection(status: AccountStatus, error: DomainError) -> AccountValidationAttempt { + AccountValidationAttempt { + outcome: ValidationOutcome::rejected(status, error.to_string()), + error: None, + } +} + +pub(super) fn apply_validation( + account: &Account, + outcome: &ValidationOutcome, + now_ms: u64, +) -> Account { + let mut next = clone_account(account); + next.set_last_validated(now_ms); + next.set_status(outcome.status); + if outcome.valid { + if let Some(traffic_left) = outcome.traffic_left { + next.set_traffic_left(traffic_left); + } + if let Some(traffic_total) = outcome.traffic_total { + next.set_traffic_total(traffic_total); + } + if let Some(valid_until) = outcome.valid_until { + next.set_valid_until(valid_until); + } + } + next +} + +pub(super) fn publish_validation( + bus: &CommandBus, + id: crate::domain::model::account::AccountId, + outcome: &ValidationOutcome, +) { + if outcome.valid { + bus.event_bus().publish(DomainEvent::AccountValidated { + id, + latency_ms: outcome.latency_ms, + traffic_left: outcome.traffic_left, + traffic_total: outcome.traffic_total, + valid_until: outcome.valid_until, + }); + } else { + bus.event_bus() + .publish(DomainEvent::AccountValidationFailed { + id, + error: outcome + .error_message + .clone() + .unwrap_or_else(|| "validation rejected".into()), + }); + } +} impl CommandBus { pub async fn handle_validate_account( @@ -33,80 +124,35 @@ impl CommandBus { .find_by_id(&cmd.id)? .ok_or_else(|| AppError::NotFound(format!("account {} not found", cmd.id.as_str())))?; - let password = store.get_password(&cmd.id)?.ok_or_else(|| { - AppError::NotFound(format!( - "no stored password for account {}", - cmd.id.as_str() - )) - })?; - - let outcome = - match validator.validate(account.service_name(), account.username(), &password) { - Ok(o) => o, - Err(DomainError::NotFound(msg)) => { - self.event_bus() - .publish(DomainEvent::AccountValidationFailed { - id: cmd.id.clone(), - error: msg.clone(), - }); - return Err(AppError::NotFound(msg)); - } - Err(other) => { - // Network failures, keyring read errors, and any - // other domain error coming back from the validator - // are surfaced as `AccountValidationFailed` so the - // UI can react identically whether the upstream - // service rejected the credentials or was simply - // unreachable. - self.event_bus() - .publish(DomainEvent::AccountValidationFailed { - id: cmd.id.clone(), - error: other.to_string(), - }); - return Err(other.into()); - } - }; - - let mut next = clone_account(&account); - next.set_last_validated(cmd.now_ms); - if outcome.valid { - if let Some(t) = outcome.traffic_left { - next.set_traffic_left(t); + let password = match store.get_password(&cmd.id)? { + Some(password) => password, + None => { + let outcome = ValidationOutcome::rejected( + AccountStatus::MissingCredential, + format!("no stored password for account {}", cmd.id.as_str()), + ); + repo.save(&apply_validation(&account, &outcome, cmd.now_ms))?; + publish_validation(self, cmd.id.clone(), &outcome); + return Err(AppError::NotFound( + outcome.error_message.unwrap_or_default(), + )); } - if let Some(t) = outcome.traffic_total { - next.set_traffic_total(t); - } - if let Some(v) = outcome.valid_until { - next.set_valid_until(v); - } - } - repo.save(&next)?; - - if outcome.valid { - self.event_bus().publish(DomainEvent::AccountValidated { - id: cmd.id, - latency_ms: outcome.latency_ms, - traffic_left: outcome.traffic_left, - traffic_total: outcome.traffic_total, - valid_until: outcome.valid_until, - }); - } else { - self.event_bus() - .publish(DomainEvent::AccountValidationFailed { - id: cmd.id, - error: outcome - .error_message - .clone() - .unwrap_or_else(|| "validation rejected".into()), - }); - } + }; + + let attempt = validate_credentials(validator, &account, &password); + repo.save(&apply_validation(&account, &attempt.outcome, cmd.now_ms))?; + publish_validation(self, cmd.id, &attempt.outcome); - Ok(outcome.into()) + match attempt.error { + Some(DomainError::NotFound(message)) => Err(AppError::NotFound(message)), + Some(error) => Err(error.into()), + None => Ok(attempt.outcome.into()), + } } } fn clone_account(account: &Account) -> Account { - Account::reconstruct( + Account::reconstruct_with_status( account.id().clone(), account.service_name().to_string(), account.username().to_string(), @@ -117,6 +163,8 @@ fn clone_account(account: &Account) -> Account { account.valid_until(), account.last_validated(), account.created_at(), + account.status(), + account.exhausted_until(), ) } From 07ec064c9de6a524e101636259e9cde1478a34f2 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:29:52 +0200 Subject: [PATCH 10/33] test: require state-aware account rotation --- CHANGELOG.md | 2 + .../application/services/account_rotator.rs | 66 ++++++++++++++++++- .../application/services/account_selector.rs | 56 +++++++++++++++- 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5c12b7..7ac087df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Command-level tests require add/update/explicit validation to persist the resulting typed state, including after password rotation; rejected accounts stay configured but are excluded from premium selection by that state. + Selector/rotator tests require persisted cooldowns to survive a restart and + distinguish unavailable credentials from temporarily exhausted accounts. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index f46911c6..1d9ea377 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -318,7 +318,7 @@ mod tests { use super::*; use crate::application::services::AccountSelector; use crate::domain::error::DomainError; - use crate::domain::model::account::{Account, AccountType}; + use crate::domain::model::account::{Account, AccountStatus, AccountType}; use crate::domain::ports::driven::AccountRepository; use std::sync::Mutex as StdMutex; @@ -437,7 +437,7 @@ mod tests { } fn account(id: &str, service: &str, traffic_left: Option, enabled: bool) -> Account { - Account::reconstruct( + Account::reconstruct_with_status( AccountId::new(id), service.to_string(), format!("user-{id}"), @@ -450,6 +450,8 @@ mod tests { Some(u64::MAX), Some(0), 0, + AccountStatus::Valid, + None, ) } @@ -501,6 +503,59 @@ mod tests { ))); } + #[test] + fn test_mark_exhausted_persists_status_and_deadline() { + let a = account("a", "Uploaded", Some(50), true); + let (rotator, _bus, _clock) = build_rotator(vec![a], 1_700_000_000); + + rotator + .mark_exhausted(&AccountId::new("a"), "Uploaded", 600) + .expect("mark succeeds"); + + let stored = rotator + .repo + .find_by_id(&AccountId::new("a")) + .unwrap() + .expect("account remains persisted"); + assert_eq!(stored.status(), AccountStatus::QuotaExhausted); + assert_eq!(stored.exhausted_until(), Some(1_700_000_600_000)); + } + + #[test] + fn test_persisted_cooldown_survives_rotator_recreation() { + let a = account("a", "Uploaded", Some(50), true); + let (rotator, bus, clock) = build_rotator(vec![a], 1_700_000_000); + rotator + .mark_exhausted(&AccountId::new("a"), "Uploaded", 600) + .expect("mark succeeds"); + + let selector = AccountSelector::new(rotator.repo.clone(), bus.clone(), clock.clone()); + let restarted = AccountRotator::new(selector, rotator.repo.clone(), bus, clock); + let outcome = restarted + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("rotation succeeds"); + + assert_eq!( + outcome, + NextAccountOutcome::AllExhausted { + next_eligible_at_ms: 1_700_000_600_000, + } + ); + } + + #[test] + fn test_invalid_account_is_none_available_not_exhausted() { + let mut invalid = account("a", "Uploaded", Some(50), true); + invalid.set_status(AccountStatus::InvalidCredentials); + let (rotator, _bus, _clock) = build_rotator(vec![invalid], 1_700_000_000); + + let outcome = rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("rotation succeeds"); + + assert_eq!(outcome, NextAccountOutcome::NoneAvailable); + } + // --- AC #2: tous accounts 429 → AllExhausted --- #[test] fn test_next_account_returns_all_exhausted_when_every_candidate_is_marked() { @@ -613,6 +668,13 @@ mod tests { .unwrap(); rotator.clear_exhausted(&AccountId::new("a")).unwrap(); assert!(!rotator.is_exhausted(&AccountId::new("a")).unwrap()); + let stored = rotator + .repo + .find_by_id(&AccountId::new("a")) + .unwrap() + .expect("account exists"); + assert_eq!(stored.status(), AccountStatus::Valid); + assert!(stored.exhausted_until().is_none()); } #[test] diff --git a/src-tauri/src/application/services/account_selector.rs b/src-tauri/src/application/services/account_selector.rs index bc957179..d220bb19 100644 --- a/src-tauri/src/application/services/account_selector.rs +++ b/src-tauri/src/application/services/account_selector.rs @@ -224,7 +224,7 @@ enum TrafficRank { mod tests { use super::*; use crate::domain::error::DomainError; - use crate::domain::model::account::{AccountId, AccountType}; + use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; use std::sync::Mutex as StdMutex; // --- Inline mocks --- @@ -332,7 +332,7 @@ mod tests { last_validated_ms: Option, enabled: bool, ) -> Account { - Account::reconstruct( + Account::reconstruct_with_status( AccountId::new(id), service.to_string(), format!("user-{id}"), @@ -343,6 +343,8 @@ mod tests { valid_until_ms, last_validated_ms, 0, + AccountStatus::Valid, + None, ) } @@ -532,6 +534,56 @@ mod tests { assert_eq!(chosen.id().as_str(), "enabled"); } + #[test] + fn test_select_best_skips_unverified_and_invalid_accounts() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let mut unverified = account( + "unverified", + "S", + Some(u64::MAX), + Some(now_ms + 1), + None, + true, + ); + unverified.set_status(AccountStatus::Unverified); + let mut invalid = account("invalid", "S", Some(u64::MAX), Some(now_ms + 1), None, true); + invalid.set_status(AccountStatus::InvalidCredentials); + let valid = account("valid", "S", Some(1), Some(now_ms + 1), None, true); + + let (selector, _bus) = build_selector(vec![unverified, invalid, valid], now_secs); + + let chosen = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .expect("one valid account remains"); + assert_eq!(chosen.id().as_str(), "valid"); + } + + #[test] + fn test_select_best_skips_active_persisted_cooldown() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let mut cooling = account( + "cooling", + "S", + Some(u64::MAX), + Some(now_ms + 60_000), + Some(now_ms), + true, + ); + cooling.mark_unavailable(AccountStatus::Cooldown, now_ms + 30_000); + + let (selector, _bus) = build_selector(vec![cooling], now_secs); + + assert!( + selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .is_none() + ); + } + // --- Acceptance criterion 4: RoundRobin alternance --- #[test] fn test_round_robin_alternates_across_eligible_accounts() { From c770be2091d8f98ebabc030d3f9b3a057f2ea998 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:32:11 +0200 Subject: [PATCH 11/33] feat: persist account rotation state --- CHANGELOG.md | 3 +- .../application/services/account_rotator.rs | 79 ++++++++++++++----- .../application/services/account_selector.rs | 23 ++++-- src-tauri/src/domain/model/account.rs | 2 +- 4 files changed, 78 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac087df..ea88a92f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,7 +85,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resulting typed state, including after password rotation; rejected accounts stay configured but are excluded from premium selection by that state. Selector/rotator tests require persisted cooldowns to survive a restart and - distinguish unavailable credentials from temporarily exhausted accounts. + distinguish unavailable credentials from temporarily exhausted accounts; + selection and rotation now enforce those persisted states. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index 1d9ea377..fb4522b0 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -7,14 +7,9 @@ //! candidate, and emits a `DomainEvent::AccountExhausted` so the UI //! can warn the user. //! -//! The exhaustion state is held entirely in memory: the SQLite-backed -//! [`Account`] aggregate intentionally does not persist -//! `exhausted_until` (a fresh `Account::reconstruct` always returns -//! `exhausted_until == None`). Storing it in a process-local map means -//! a restart wipes the cooldown — that is the desired behaviour for a -//! 5-to-15 minute window. Persisting it would need a new SQLite column -//! plus a purge job, neither of which buys correctness when the -//! upstream hoster will simply re-send the same 429. +//! Cooldown status and deadline are persisted on the [`Account`] so the UI +//! and a restarted process observe the same availability. The in-memory map +//! remains a concurrency guard for selection probes racing with mark/clear. //! //! Concurrency: the map sits behind a `std::sync::Mutex`. Every public //! method that takes the lock surfaces a poisoned mutex as @@ -28,7 +23,7 @@ use std::sync::{Arc, Mutex}; use crate::application::error::AppError; use crate::application::services::AccountSelector; use crate::domain::event::DomainEvent; -use crate::domain::model::account::{Account, AccountId, AccountSelectionStrategy}; +use crate::domain::model::account::{Account, AccountId, AccountSelectionStrategy, AccountStatus}; use crate::domain::ports::driven::AccountRepository; use crate::domain::ports::driven::clock::Clock; use crate::domain::ports::driven::event_bus::EventBus; @@ -160,14 +155,24 @@ impl AccountRotator { } exhausted_ids = fresh; } - // No pick after stable re-snapshot. Decide between NoneAvailable - // and AllExhausted by looking at the repo directly: if there's - // at least one enabled, non-expired account for this service, - // the rotation is the blocker, not the absence of credentials. + // No pick after stable re-snapshot. Only validated accounts with a + // temporary quota/cooldown state count as exhausted. Invalid, + // missing, expired, and unverified accounts are unavailable. let candidates = self.repo.list_by_service(service_name)?; let live: Vec<&Account> = candidates .iter() - .filter(|a| a.is_enabled() && !a.is_expired(now_ms)) + .filter(|account| account.is_enabled() && !account.is_expired(now_ms)) + .filter(|account| match account.status() { + AccountStatus::Valid => true, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown => { + account.is_exhausted(now_ms) + } + AccountStatus::Unverified + | AccountStatus::InvalidCredentials + | AccountStatus::MissingCredential + | AccountStatus::Expired + | AccountStatus::Error => false, + }) .collect(); if live.is_empty() { return Ok(NextAccountOutcome::NoneAvailable); @@ -198,10 +203,25 @@ impl AccountRotator { let proposed = now_ms.saturating_add(ttl_secs.saturating_mul(1_000)); let committed = { let mut guard = self.lock_exhausted()?; - let final_deadline = match guard.get(account_id) { - Some(existing) if *existing > proposed => *existing, - _ => proposed, - }; + let mut account = self.repo.find_by_id(account_id)?.ok_or_else(|| { + AppError::NotFound(format!("account {} not found", account_id.as_str())) + })?; + if account.service_name() != service_name { + return Err(AppError::Validation(format!( + "account {} does not belong to service {service_name}", + account_id.as_str() + ))); + } + let final_deadline = guard + .get(account_id) + .copied() + .into_iter() + .chain(account.exhausted_until()) + .chain(std::iter::once(proposed)) + .max() + .unwrap_or(proposed); + account.mark_unavailable(AccountStatus::QuotaExhausted, final_deadline); + self.repo.save(&account)?; guard.insert(account_id.clone(), final_deadline); final_deadline }; @@ -218,6 +238,10 @@ impl AccountRotator { /// no-op. pub fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError> { let mut guard = self.lock_exhausted()?; + if let Some(mut account) = self.repo.find_by_id(account_id)? { + account.clear_exhausted(); + self.repo.save(&account)?; + } guard.remove(account_id); Ok(()) } @@ -230,9 +254,14 @@ impl AccountRotator { pub fn is_exhausted(&self, account_id: &AccountId) -> Result { let now_ms = self.now_ms(); let guard = self.lock_exhausted()?; - Ok(guard + let in_memory = guard .get(account_id) - .is_some_and(|deadline| now_ms < *deadline)) + .is_some_and(|deadline| now_ms < *deadline); + let persisted = self + .repo + .find_by_id(account_id)? + .is_some_and(|account| account.is_exhausted(now_ms)); + Ok(in_memory || persisted) } /// Hoster-agnostic quota signal. Returns `true` when an HTTP @@ -293,7 +322,15 @@ impl AccountRotator { let guard = self.lock_exhausted()?; let next = live_candidates .iter() - .filter_map(|acc| guard.get(acc.id()).copied()) + .filter_map(|account| { + guard + .get(account.id()) + .copied() + .into_iter() + .chain(account.exhausted_until()) + .filter(|deadline| now_ms < *deadline) + .max() + }) .filter(|deadline| now_ms < *deadline) .min() .unwrap_or(now_ms); diff --git a/src-tauri/src/application/services/account_selector.rs b/src-tauri/src/application/services/account_selector.rs index d220bb19..0148c5b0 100644 --- a/src-tauri/src/application/services/account_selector.rs +++ b/src-tauri/src/application/services/account_selector.rs @@ -4,7 +4,7 @@ //! service, the engine asks the selector for the one to use *now*. The //! selector applies the strategy currently set in `AppConfig`: //! -//! - `BestTraffic` (default): rank candidates by *enabled* → *not expired* +//! - `BestTraffic` (default): rank validated, currently selectable candidates //! → most `traffic_left` (unlimited > finite) → most recent //! `last_validated`. //! - `RoundRobin`: alternate over enabled, non-expired candidates ordered @@ -79,8 +79,7 @@ impl AccountSelector { /// Same contract as `select_best` but skips any account whose id is /// listed in `exclude`. Used by `AccountRotator` to filter out - /// quota-exhausted accounts without persisting transient state in - /// the repository. + /// quota-exhausted accounts that raced with a selection probe. /// /// Emits `NoAccountAvailable` only when the *post-exclude* eligible /// set is empty — that mirrors the caller-facing semantics: from @@ -124,7 +123,7 @@ impl AccountSelector { let now_ms = self.now_ms(); let base_eligible: Vec<&Account> = candidates .iter() - .filter(|a| a.is_enabled() && !a.is_expired(now_ms)) + .filter(|account| account.is_selectable(now_ms)) .collect(); let eligible: Vec<&Account> = base_eligible .iter() @@ -137,9 +136,21 @@ impl AccountSelector { // is reported as AllExhausted upstream and must not be // collapsed into "no account configured". if base_eligible.is_empty() { - self.event_bus.publish(DomainEvent::NoAccountAvailable { - service_name: service_name.to_string(), + let temporarily_unavailable = candidates.iter().any(|account| { + account.is_enabled() + && !account.is_expired(now_ms) + && matches!( + account.status(), + crate::domain::model::account::AccountStatus::QuotaExhausted + | crate::domain::model::account::AccountStatus::Cooldown + ) + && account.is_exhausted(now_ms) }); + if !temporarily_unavailable { + self.event_bus.publish(DomainEvent::NoAccountAvailable { + service_name: service_name.to_string(), + }); + } } return Ok(None); } diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index 8b452bdc..44c61953 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -342,7 +342,7 @@ impl Account { } /// Mark this account as quota-exhausted until `until_ms` (Unix epoch - /// ms). Transient — never persisted in SQLite. + /// ms). Adapters persist the status and deadline with the aggregate. pub fn mark_exhausted(&mut self, until_ms: u64) { self.mark_unavailable(AccountStatus::QuotaExhausted, until_ms); } From 8df2028ebd54d2d3f603d0aef7cb4fa1604d9e73 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:38:26 +0200 Subject: [PATCH 12/33] test(accounts): define premium resolution contract --- CHANGELOG.md | 2 + src-tauri/src/adapters/driving/tauri_ipc.rs | 6 + src-tauri/src/application/commands/mod.rs | 4 + .../src/application/commands/resolve_links.rs | 153 ++++++++++++++++++ .../application/commands/start_download.rs | 102 ++++++++++++ .../src/application/commands/tests_support.rs | 20 ++- 6 files changed, 286 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea88a92f..5f413af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Selector/rotator tests require persisted cooldowns to survive a restart and distinguish unavailable credentials from temporarily exhausted accounts; selection and rotation now enforce those persisted states. + Resolution/start contract tests carry only the selected account UUID and + plugin name alongside direct links, and require rotation to a second account. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index d2819183..28c816e5 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -94,12 +94,16 @@ pub async fn download_start( state: State<'_, AppState>, url: String, destination: Option, + module_name: Option, + account_id: Option, ) -> Result { let cmd = StartDownloadCommand { url, destination: destination.map(PathBuf::from), filename: None, source_hostname_override: None, + module_name, + account_id: account_id.map(AccountId::new), }; state .command_bus @@ -1750,6 +1754,8 @@ async fn start_media_download_for_url( destination: None, filename, source_hostname_override, + module_name: None, + account_id: None, }; command_bus .handle_start_download(cmd) diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 84ac10b3..1580ec97 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -76,6 +76,10 @@ pub struct StartDownloadCommand { /// `url`. Used when `url` is a CDN URL but we want to display the origin /// host (e.g. "youtube.com" instead of "rr1---sn-n4g-cvq6.googlevideo.com"). pub source_hostname_override: Option, + /// Plugin that resolved the direct URL, when applicable. + pub module_name: Option, + /// Opaque account UUID selected during resolution. Never a credential. + pub account_id: Option, } impl Command for StartDownloadCommand {} diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 42a16cd5..0610d550 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -25,6 +25,7 @@ pub struct ResolvedLinkDto { pub status: String, pub error_message: Option, pub module_name: String, + pub account_id: Option, pub is_media: bool, pub media_type: Option, } @@ -61,6 +62,7 @@ impl CommandBus { status: "error".to_string(), error_message: Some("URL scheme not allowed".to_string()), module_name: "core-http".to_string(), + account_id: None, is_media: false, media_type: None, }); @@ -77,6 +79,7 @@ impl CommandBus { status: "online".to_string(), error_message: None, module_name: "magnet".to_string(), + account_id: None, is_media: false, media_type: None, }); @@ -115,6 +118,7 @@ impl CommandBus { status: "online".to_string(), error_message: None, module_name, + account_id: None, is_media, media_type, }); @@ -129,6 +133,7 @@ impl CommandBus { status: "offline".to_string(), error_message: None, module_name, + account_id: None, is_media, media_type, }); @@ -144,6 +149,7 @@ impl CommandBus { status: "error".to_string(), error_message: Some(sanitize_resolve_error(&e)), module_name, + account_id: None, is_media, media_type, }); @@ -242,8 +248,155 @@ fn detect_media_type(url: &str) -> Option { #[cfg(test)] mod tests { use std::collections::HashMap; + use std::sync::{Arc, Mutex}; use super::*; + use crate::application::commands::tests_support::{ + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, + build_account_bus_with_plugin_loader, + }; + use crate::application::services::AccountSelector; + use crate::domain::error::DomainError; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; + use crate::domain::model::credential::Credential; + use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; + use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, Clock, PluginLoader, + }; + + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } + } + + struct PremiumPluginLoader { + credentials: Mutex>, + } + + impl PremiumPluginLoader { + fn new() -> Self { + Self { + credentials: Mutex::new(Vec::new()), + } + } + + fn plugin_info() -> PluginInfo { + PluginInfo::new( + "vortex-mod-1fichier".into(), + "1.1.0".into(), + "1fichier".into(), + "vortex".into(), + PluginCategory::Hoster, + ) + } + } + + impl PluginLoader for PremiumPluginLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(Some(Self::plugin_info())) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![Self::plugin_info()]) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn extract_links_with_credential( + &self, + _: &str, + credential: &Credential, + ) -> Result { + self.credentials + .lock() + .unwrap() + .push(credential.password().to_string()); + if credential.password() == "expired-key" { + return Err(DomainError::AccountExpired); + } + Ok(r#"{"kind":"file","mode":"premium","files":[{"id":"abc","url":"https://1fichier.com/?abc123","filename":"file.zip","size_bytes":42,"direct_url":"https://download.1fichier.com/token/file.zip","resumable":true,"wait_seconds":null,"requires_captcha":false,"traffic_used_bytes":1,"traffic_total_bytes":100}]}"#.into()) + } + } + + fn premium_account(id: &str, traffic_left: u64) -> Account { + Account::reconstruct_with_status( + AccountId::new(id), + "vortex-mod-1fichier".into(), + format!("user-{id}"), + AccountType::Premium, + true, + Some(traffic_left), + Some(100), + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Valid, + None, + ) + } + + #[tokio::test] + async fn resolve_hoster_rotates_expired_account_and_returns_opaque_account_id() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let plugin = Arc::new(PremiumPluginLoader::new()); + let primary = premium_account("primary", 100); + let backup = premium_account("backup", 50); + repo.save(&primary).unwrap(); + repo.save(&backup).unwrap(); + credentials + .store_password(primary.id(), "expired-key") + .unwrap(); + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + let selector = AccountSelector::new(repo.clone(), events.clone(), Arc::new(FixedClock)); + let bus = build_account_bus_with_plugin_loader( + repo.clone(), + credentials, + events, + None, + None, + plugin.clone(), + ) + .with_account_selector(selector); + + let result = bus + .handle_resolve_links(ResolveLinksCommand { + urls: vec!["https://1fichier.com/?abc123".into()], + }) + .await + .expect("resolve succeeds"); + + assert_eq!( + result[0].resolved_url.as_deref(), + Some("https://download.1fichier.com/token/file.zip") + ); + assert_eq!(result[0].account_id.as_deref(), Some("backup")); + assert_eq!(result[0].module_name, "vortex-mod-1fichier"); + assert_eq!( + repo.find_by_id(primary.id()).unwrap().unwrap().status(), + AccountStatus::Expired + ); + assert_eq!( + plugin.credentials.lock().unwrap().as_slice(), + ["expired-key", "working-key"] + ); + } #[test] fn test_extract_filename_from_url_returns_last_path_segment() { diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index cfb44d16..e13de72f 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -145,6 +145,7 @@ mod tests { use crate::application::command_bus::CommandBus; use crate::application::commands::StartDownloadCommand; + use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::config::{AppConfig, ConfigPatch}; @@ -159,6 +160,12 @@ mod tests { }; use std::sync::Arc; + use crate::application::commands::tests_support::{ + FakeAccountCredentialStore, InMemoryAccountRepo, + }; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; + use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository}; + struct MockDownloadRepo { store: Mutex>, } @@ -433,6 +440,8 @@ mod tests { destination: Some(PathBuf::from("/tmp/downloads")), filename: None, source_hostname_override: None, + module_name: None, + account_id: None, }; let id = bus.handle_start_download(cmd).await.unwrap(); @@ -448,6 +457,89 @@ mod tests { assert_eq!(events[0], DomainEvent::DownloadCreated { id }); } + #[tokio::test] + async fn test_start_download_persists_validated_account_association() { + let (bus, repo, _) = make_command_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account_id = AccountId::new("account-1"); + let account = Account::reconstruct_with_status( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Valid, + None, + ); + account_repo.save(&account).unwrap(); + credentials.store_password(&account_id, "api-key").unwrap(); + let bus = bus + .with_account_repo(account_repo) + .with_account_credential_store(credentials); + + let id = bus + .handle_start_download(StartDownloadCommand { + url: "https://download.1fichier.com/token/file.zip".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + source_hostname_override: Some("1fichier.com".into()), + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(account_id.clone()), + }) + .await + .expect("valid account association"); + + let stored = repo.store.lock().unwrap().get(&id.0).cloned().unwrap(); + assert_eq!(stored.module_name(), Some("vortex-mod-1fichier")); + assert_eq!(stored.account_id(), Some(&account_id)); + } + + #[tokio::test] + async fn test_start_download_rejects_account_with_missing_credential() { + let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account_id = AccountId::new("account-1"); + let account = Account::reconstruct_with_status( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Valid, + None, + ); + account_repo.save(&account).unwrap(); + let bus = bus + .with_account_repo(account_repo) + .with_account_credential_store(credentials); + + let error = bus + .handle_start_download(StartDownloadCommand { + url: "https://download.1fichier.com/token/file.zip".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + source_hostname_override: Some("1fichier.com".into()), + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(account_id), + }) + .await + .expect_err("missing credential must reject association"); + + assert!(matches!(error, AppError::NotFound(_))); + } + #[tokio::test] async fn test_start_download_invalid_url_returns_error() { let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); @@ -457,6 +549,8 @@ mod tests { destination: None, filename: None, source_hostname_override: None, + module_name: None, + account_id: None, }; let result = bus.handle_start_download(cmd).await; @@ -472,6 +566,8 @@ mod tests { destination: Some(PathBuf::from("/tmp")), filename: None, source_hostname_override: None, + module_name: None, + account_id: None, }; let id = bus.handle_start_download(cmd).await.unwrap(); @@ -521,6 +617,8 @@ mod tests { destination: Some(PathBuf::from("/tmp")), filename: Some("Rick Astley - Never Gonna Give You Up.mp4".to_string()), source_hostname_override: None, + module_name: None, + account_id: None, }; let id = bus.handle_start_download(cmd).await.unwrap(); @@ -554,6 +652,8 @@ mod tests { destination: Some(PathBuf::from("/tmp")), filename: Some("b.zip".to_string()), source_hostname_override: None, + module_name: None, + account_id: None, }; let id = bus.handle_start_download(cmd).await.unwrap(); @@ -576,6 +676,8 @@ mod tests { destination: Some(PathBuf::from("/tmp")), filename: Some("video.mp4".to_string()), source_hostname_override: Some("www.youtube.com".to_string()), + module_name: None, + account_id: None, }; let id = bus.handle_start_download(cmd).await.unwrap(); diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 0a488988..db526c4d 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -768,6 +768,24 @@ pub(crate) fn build_account_bus( event_bus: Arc, validator: Option>, codec: Option>, +) -> CommandBus { + build_account_bus_with_plugin_loader( + account_repo, + credential_store, + event_bus, + validator, + codec, + Arc::new(StubPluginLoader), + ) +} + +pub(crate) fn build_account_bus_with_plugin_loader( + account_repo: Arc, + credential_store: Arc, + event_bus: Arc, + validator: Option>, + codec: Option>, + plugin_loader: Arc, ) -> CommandBus { let mut bus = CommandBus::new( Arc::new(StubDownloadRepo), @@ -775,7 +793,7 @@ pub(crate) fn build_account_bus( event_bus, Arc::new(StubFileStorage), Arc::new(StubHttpClient), - Arc::new(StubPluginLoader), + plugin_loader, Arc::new(StubConfigStore), Arc::new(StubCredentialStore), Arc::new(StubClipboardObserver), From 2099d20211a49702fc477e14d51cc553f172f876 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:42:50 +0200 Subject: [PATCH 13/33] feat(accounts): resolve hoster links with rotation --- CHANGELOG.md | 4 + .../src/application/commands/resolve_links.rs | 217 +++++++++++++++++- .../application/commands/start_download.rs | 60 +++++ 3 files changed, 274 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f413af1..a3daf48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 selection and rotation now enforce those persisted states. Resolution/start contract tests carry only the selected account UUID and plugin name alongside direct links, and require rotation to a second account. + Hoster resolution now uses the configured selector/rotator, scopes each + keyring secret to its plugin call, persists typed account failures, retries + an eligible fallback account, and validates the opaque association before + persisting a download. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 0610d550..f2a66c9c 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -3,12 +3,17 @@ //! Checks each URL via plugin loader and HTTP HEAD, returning //! resolution metadata for the frontend link grabber view. -use serde::Serialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; +use crate::application::services::account_rotator::NextAccountOutcome; +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountStatus}; +use crate::domain::model::credential::Credential; use crate::domain::model::http::HttpResponse; +use crate::domain::model::plugin::PluginCategory; use super::ResolveLinksCommand; @@ -30,6 +35,28 @@ pub struct ResolvedLinkDto { pub media_type: Option, } +#[derive(Debug, Deserialize)] +struct HosterExtractResponse { + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct HosterFile { + url: String, + filename: Option, + size_bytes: Option, + direct_url: Option, + traffic_used_bytes: Option, + traffic_total_bytes: Option, +} + +struct HosterResolution { + resolved_url: String, + filename: Option, + size_bytes: Option, + account_id: Option, +} + impl CommandBus { pub async fn handle_resolve_links( &self, @@ -91,12 +118,46 @@ impl CommandBus { Ok(Some(info)) => info.name().to_string(), _ => "core-http".to_string(), }; - // Account dispatcher hook (task 24): plugins that need - // credentials must request them via - // `CommandBus::resolve_account_for(service_name)` so the - // selector strategy from `AppConfig` is honoured. Resolve - // is best-effort metadata only — no account is fetched here - // to avoid emitting `AccountSelected` once per probed URL. + + let is_hoster = matches!( + plugin_info.as_ref().ok().and_then(Option::as_ref), + Some(info) + if matches!(info.category(), PluginCategory::Hoster | PluginCategory::Debrid) + ); + if is_hoster { + match self.resolve_hoster_link(url, &module_name) { + Ok(resolved) => results.push(ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: Some(resolved.resolved_url), + filename: resolved.filename, + size_bytes: resolved.size_bytes, + status: "online".to_string(), + error_message: None, + module_name, + account_id: resolved.account_id, + is_media: false, + media_type: None, + }), + Err(error) => { + tracing::debug!(module_name, "hoster link resolution failed"); + results.push(ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: None, + filename: None, + size_bytes: None, + status: "error".to_string(), + error_message: Some(sanitize_hoster_error(&error)), + module_name, + account_id: None, + is_media: false, + media_type: None, + }); + } + } + continue; + } let is_media = is_media_url(url); let media_type = if is_media { @@ -159,6 +220,148 @@ impl CommandBus { Ok(results) } + + fn resolve_hoster_link( + &self, + url: &str, + service_name: &str, + ) -> Result { + if let (Some(repo), Some(store)) = (self.account_repo(), self.account_credential_store()) { + let max_attempts = repo.list_by_service(service_name)?.len(); + for _ in 0..max_attempts { + let Some(mut account) = self.next_hoster_account(service_name)? else { + break; + }; + let Some(password) = store.get_password(account.id())? else { + account.set_status(AccountStatus::MissingCredential); + repo.save(&account)?; + continue; + }; + let credential = Credential::new(account.username(), password); + + match self + .plugin_loader() + .extract_links_with_credential(url, &credential) + { + Ok(payload) => { + let resolved = + parse_hoster_response(&payload, Some(account.id().as_str()))?; + if let Some(total) = resolved.traffic_total_bytes { + account.set_traffic_total(total); + if let Some(used) = resolved.traffic_used_bytes { + account.set_traffic_left(total.saturating_sub(used)); + } + } + account.set_status(AccountStatus::Valid); + repo.save(&account)?; + return Ok(resolved.into_resolution()); + } + Err(error) => { + let status = match error { + DomainError::AccountInvalidCredentials => { + AccountStatus::InvalidCredentials + } + DomainError::AccountExpired => AccountStatus::Expired, + DomainError::AccountCooldown => AccountStatus::Cooldown, + DomainError::AccountQuotaExceeded => AccountStatus::QuotaExhausted, + other => return Err(other.into()), + }; + if status == AccountStatus::QuotaExhausted + && let Some(rotator) = self.account_rotator() + { + rotator.mark_exhausted(account.id(), service_name, 60)?; + } else if matches!( + status, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown + ) { + account + .mark_unavailable(status, current_time_ms().saturating_add(60_000)); + repo.save(&account)?; + } else { + account.set_status(status); + repo.save(&account)?; + } + } + } + } + } + + let payload = self.plugin_loader().extract_links(url)?; + Ok(parse_hoster_response(&payload, None)?.into_resolution()) + } + + fn next_hoster_account(&self, service_name: &str) -> Result, AppError> { + let Some(rotator) = self.account_rotator() else { + return self.resolve_account_for(service_name); + }; + let strategy = self.config_store().get_config()?.account_selection_strategy; + match rotator.next_account(service_name, strategy)? { + NextAccountOutcome::Picked(account) => Ok(Some(account)), + NextAccountOutcome::NoneAvailable | NextAccountOutcome::AllExhausted { .. } => Ok(None), + } + } +} + +struct ParsedHosterResponse { + file: HosterFile, + account_id: Option, + traffic_used_bytes: Option, + traffic_total_bytes: Option, +} + +impl ParsedHosterResponse { + fn into_resolution(self) -> HosterResolution { + let direct_url = self.file.direct_url; + HosterResolution { + resolved_url: direct_url.unwrap_or(self.file.url), + filename: self.file.filename, + size_bytes: self.file.size_bytes, + account_id: self.account_id, + } + } +} + +fn parse_hoster_response( + payload: &str, + account_id: Option<&str>, +) -> Result { + let response: HosterExtractResponse = serde_json::from_str(payload) + .map_err(|_| AppError::Plugin("hoster returned an invalid response".into()))?; + let file = response + .files + .into_iter() + .next() + .ok_or_else(|| AppError::Plugin("hoster returned no file".into()))?; + let selected_account = file.direct_url.as_ref().and(account_id).map(str::to_string); + Ok(ParsedHosterResponse { + traffic_used_bytes: file.traffic_used_bytes, + traffic_total_bytes: file.traffic_total_bytes, + file, + account_id: selected_account, + }) +} + +fn current_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn sanitize_hoster_error(error: &AppError) -> String { + match error { + AppError::Domain(DomainError::AccountInvalidCredentials) => { + "Account credentials were rejected".to_string() + } + AppError::Domain(DomainError::AccountExpired) => "Account is expired".to_string(), + AppError::Domain(DomainError::AccountCooldown) => { + "Account is temporarily rate-limited".to_string() + } + AppError::Domain(DomainError::AccountQuotaExceeded) => { + "Account quota is exhausted".to_string() + } + _ => "Could not resolve hoster link".to_string(), + } } fn is_allowed_scheme(url: &str) -> bool { diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index e13de72f..2b983107 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::domain::event::DomainEvent; +use crate::domain::model::account::{AccountId, AccountStatus}; use crate::domain::model::download::{Download, DownloadId, Url}; use crate::domain::model::http::HttpResponse; @@ -24,6 +25,7 @@ impl CommandBus { cmd: super::StartDownloadCommand, ) -> Result { let url = Url::new(&cmd.url)?; + self.validate_download_account(cmd.module_name.as_deref(), cmd.account_id.as_ref())?; // Use the pre-computed filename when available (e.g. set by media plugins // that already know the video title). Otherwise probe via HEAD or fall back @@ -78,6 +80,12 @@ impl CommandBus { if let Some(hostname) = cmd.source_hostname_override { download = download.with_source_hostname(hostname); } + if let Some(module_name) = cmd.module_name { + download = download.with_module_name(module_name); + } + if let Some(account_id) = cmd.account_id { + download = download.with_account_id(account_id); + } self.download_repo().save(&download)?; self.event_bus() @@ -85,6 +93,58 @@ impl CommandBus { Ok(id) } + + fn validate_download_account( + &self, + module_name: Option<&str>, + account_id: Option<&AccountId>, + ) -> Result<(), AppError> { + let (Some(module_name), Some(account_id)) = (module_name, account_id) else { + if account_id.is_some() { + return Err(AppError::Validation( + "account association requires a plugin name".into(), + )); + } + return Ok(()); + }; + let repo = self + .account_repo() + .ok_or_else(|| AppError::Validation("account repository not configured".into()))?; + let store = self.account_credential_store().ok_or_else(|| { + AppError::Validation("account credential store not configured".into()) + })?; + let mut account = repo.find_by_id(account_id)?.ok_or_else(|| { + AppError::NotFound(format!("account {} not found", account_id.as_str())) + })?; + if account.service_name() != module_name { + return Err(AppError::Validation(format!( + "account {} is not compatible with plugin {module_name}", + account_id.as_str() + ))); + } + if store.get_password(account_id)?.is_none() { + account.set_status(AccountStatus::MissingCredential); + repo.save(&account)?; + return Err(AppError::NotFound(format!( + "credential for account {} not found", + account_id.as_str() + ))); + } + if !account.is_selectable(current_time_ms()) { + return Err(AppError::Validation(format!( + "account {} is not available", + account_id.as_str() + ))); + } + Ok(()) + } +} + +fn current_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 } /// Generate a restart-safe, collision-resistant download ID that fits From c7a07fd7b16ec0285108703605de7c31599bab47 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:45:47 +0200 Subject: [PATCH 14/33] test(accounts): define premium UI contract --- CHANGELOG.md | 2 + .../application/read_models/account_view.rs | 5 ++ .../__tests__/statusUtils.test.ts | 20 +++++-- .../__tests__/LinkGrabberView.test.tsx | 53 +++++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3daf48c..8b7497f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 keyring secret to its plugin call, persists typed account failures, retries an eligible fallback account, and validates the opaque association before persisting a download. + Read-model and Link Grabber contract tests require typed account states, + cooldown deadlines, and opaque premium associations across IPC. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/application/read_models/account_view.rs b/src-tauri/src/application/read_models/account_view.rs index 7d58289f..9d8b6373 100644 --- a/src-tauri/src/application/read_models/account_view.rs +++ b/src-tauri/src/application/read_models/account_view.rs @@ -116,6 +116,9 @@ mod tests { assert_eq!(dto.last_validated, Some(1_900_000_000_000)); assert_eq!(dto.created_at, 1_700_000_000_000); assert_eq!(dto.credential_ref, "keyring://real-debrid/alice"); + let value = serde_json::to_value(&dto).unwrap(); + assert_eq!(value["status"], "valid"); + assert!(value["exhaustedUntil"].is_null()); } #[test] @@ -151,6 +154,8 @@ mod tests { "validUntil", "lastValidated", "createdAt", + "status", + "exhaustedUntil", "credentialRef", ] { assert!( diff --git a/src/views/AccountsView/__tests__/statusUtils.test.ts b/src/views/AccountsView/__tests__/statusUtils.test.ts index 3554614a..8c71bb95 100644 --- a/src/views/AccountsView/__tests__/statusUtils.test.ts +++ b/src/views/AccountsView/__tests__/statusUtils.test.ts @@ -14,6 +14,8 @@ function base(overrides: Partial = {}): AccountView { validUntil: null, lastValidated: null, createdAt: 0, + status: "unverified", + exhaustedUntil: null, credentialRef: "keyring://real-debrid/alice", ...overrides, }; @@ -23,6 +25,7 @@ describe("deriveAccountStatus", () => { it("returns 'disabled' when the account is disabled even if otherwise valid", () => { const account = base({ enabled: false, + status: "valid", lastValidated: 1_000, validUntil: 2_000_000_000_000, }); @@ -30,7 +33,7 @@ describe("deriveAccountStatus", () => { }); it("returns 'expired' when valid_until is in the past", () => { - const account = base({ validUntil: 1, lastValidated: 0 }); + const account = base({ status: "valid", validUntil: 1, lastValidated: 0 }); expect(deriveAccountStatus(account, 100)).toBe("expired"); }); @@ -40,12 +43,23 @@ describe("deriveAccountStatus", () => { }); it("returns 'active' when enabled, validated, not expired", () => { - const account = base({ lastValidated: 1, validUntil: 100_000 }); + const account = base({ status: "valid", lastValidated: 1, validUntil: 100_000 }); expect(deriveAccountStatus(account, 1)).toBe("active"); }); it("returns 'active' when validUntil is null but lastValidated set", () => { - const account = base({ lastValidated: 1, validUntil: null }); + const account = base({ status: "valid", lastValidated: 1, validUntil: null }); expect(deriveAccountStatus(account, 1)).toBe("active"); }); + + it.each([ + ["invalid_credentials", "invalidCredentials"], + ["missing_credential", "missingCredential"], + ["expired", "expired"], + ["quota_exhausted", "quotaExhausted"], + ["cooldown", "cooldown"], + ["error", "error"], + ] as const)("maps persisted '%s' to '%s'", (persisted, expected) => { + expect(deriveAccountStatus(base({ status: persisted }), 1)).toBe(expected); + }); }); diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index 375683cd..6080b959 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -182,6 +182,59 @@ describe("LinkGrabberView", () => { }); }); + it("passes the selected premium account association to download_start", async () => { + const directUrl = "https://download.1fichier.com/token/file.zip"; + mockInvoke.mockImplementation((command) => { + if (command === "link_resolve") { + return Promise.resolve([ + { + id: "premium-link", + originalUrl: "ftp://1fichier.com/file.zip", + resolvedUrl: directUrl, + filename: "file.zip", + sizeBytes: 42, + status: "online", + moduleName: "vortex-mod-1fichier", + accountId: "account-uuid", + isMedia: false, + }, + ]); + } + if (command === "link_detect_duplicates") { + return Promise.resolve([ + { + url: directUrl, + isDuplicate: false, + source: null, + existingId: null, + existingFilename: null, + }, + ]); + } + return Promise.resolve(null); + }); + + const user = userEvent.setup(); + renderWithProviders(); + await user.type(screen.getByRole("textbox"), "ftp://1fichier.com/file.zip"); + await user.click(screen.getByRole("button", { name: "Analyze Links" })); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("link_detect_duplicates", { + urls: [directUrl], + }); + }); + + await user.click(screen.getByRole("button", { name: "Start All Online" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("download_start", { + url: directUrl, + moduleName: "vortex-mod-1fichier", + accountId: "account-uuid", + }); + }); + }); + it("should surface error toast on failure and success toast on retry", async () => { mockInvoke.mockRejectedValueOnce(new Error("AppState not registered")).mockResolvedValueOnce([ { From 23029080277b6b492acb71923c37f5ad542eb626 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:48:59 +0200 Subject: [PATCH 15/33] feat(accounts): surface premium state in UI --- CHANGELOG.md | 3 ++ .../application/read_models/account_view.rs | 4 +++ src/i18n/locales/en.json | 7 ++++- src/i18n/locales/fr.json | 7 ++++- src/types/account.ts | 11 +++++++ src/views/AccountsView/AccountRow.tsx | 5 +++ .../__tests__/AccountsView.test.tsx | 20 ++++++++++++ src/views/AccountsView/statusUtils.ts | 31 +++++++++++++++++-- src/views/LinkGrabberView/LinkGrabberView.tsx | 11 +++++-- .../__tests__/LinkRow.test.tsx | 1 + .../__tests__/MediaGrabberDialog.test.tsx | 1 + .../__tests__/ResolvedLinksSection.test.tsx | 7 +++++ src/views/LinkGrabberView/types.ts | 1 + 13 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7497f7..a700fe65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 persisting a download. Read-model and Link Grabber contract tests require typed account states, cooldown deadlines, and opaque premium associations across IPC. + Account read models now expose non-secret validation state and cooldown + deadlines; Accounts renders typed badges, while Link Grabber forwards the + selected plugin/account pair when starting the resolved direct URL. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/application/read_models/account_view.rs b/src-tauri/src/application/read_models/account_view.rs index 9d8b6373..e9986f9f 100644 --- a/src-tauri/src/application/read_models/account_view.rs +++ b/src-tauri/src/application/read_models/account_view.rs @@ -31,6 +31,8 @@ pub struct AccountViewDto { pub valid_until: Option, pub last_validated: Option, pub created_at: u64, + pub status: String, + pub exhausted_until: Option, /// Opaque keyring URI (`keyring://service/user`) — never the /// password itself. Lets the frontend correlate two `AccountView` /// rows that share the same stored credential. @@ -50,6 +52,8 @@ impl From for AccountViewDto { valid_until: account.valid_until(), last_validated: account.last_validated(), created_at: account.created_at(), + status: account.status().to_string(), + exhausted_until: account.exhausted_until(), credential_ref: account.credential_ref(), } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e302c1db..29fa665a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -661,7 +661,12 @@ "active": "Active", "expired": "Expired", "disabled": "Disabled", - "unverified": "Unverified" + "unverified": "Unverified", + "invalidCredentials": "Invalid credentials", + "missingCredential": "Credential missing", + "quotaExhausted": "Quota exhausted", + "cooldown": "Rate limited", + "error": "Validation error" }, "traffic": { "ariaLabel": "Traffic remaining", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ad925e67..bfc3b51a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -661,7 +661,12 @@ "active": "Actif", "expired": "Expiré", "disabled": "Désactivé", - "unverified": "Non vérifié" + "unverified": "Non vérifié", + "invalidCredentials": "Identifiants invalides", + "missingCredential": "Identifiant manquant", + "quotaExhausted": "Quota épuisé", + "cooldown": "Limité temporairement", + "error": "Erreur de validation" }, "traffic": { "ariaLabel": "Trafic restant", diff --git a/src/types/account.ts b/src/types/account.ts index 7a8c4e4a..12ae9423 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -1,4 +1,13 @@ export type AccountType = "free" | "premium" | "debrid"; +export type PersistedAccountStatus = + | "unverified" + | "valid" + | "invalid_credentials" + | "missing_credential" + | "expired" + | "quota_exhausted" + | "cooldown" + | "error"; export interface AccountView { id: string; @@ -11,6 +20,8 @@ export interface AccountView { validUntil: number | null; lastValidated: number | null; createdAt: number; + status: PersistedAccountStatus; + exhaustedUntil: number | null; credentialRef: string; } diff --git a/src/views/AccountsView/AccountRow.tsx b/src/views/AccountsView/AccountRow.tsx index d7243620..fe64f5a2 100644 --- a/src/views/AccountsView/AccountRow.tsx +++ b/src/views/AccountsView/AccountRow.tsx @@ -33,6 +33,11 @@ const STATUS_VARIANT: Record { expect(mockInvoke).toHaveBeenCalledWith("account_list", expect.objectContaining({})); }); + it("renders a typed quota status returned by the account read model", async () => { + const exhausted = { + ...sampleAccounts()[0], + status: "quota_exhausted" as const, + exhaustedUntil: Date.now() + 60_000, + }; + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return [exhausted]; + return null; + }); + + renderView(); + + expect(await screen.findByText("Quota exhausted")).toBeInTheDocument(); + }); + it("renders the empty state when no accounts exist", async () => { mockInvoke.mockResolvedValue([]); renderView(); diff --git a/src/views/AccountsView/statusUtils.ts b/src/views/AccountsView/statusUtils.ts index f3d4cfd3..3f79dd0b 100644 --- a/src/views/AccountsView/statusUtils.ts +++ b/src/views/AccountsView/statusUtils.ts @@ -1,6 +1,15 @@ import type { AccountView } from "@/types/account"; -export type AccountStatus = "active" | "expired" | "disabled" | "unverified"; +export type AccountStatus = + | "active" + | "expired" + | "disabled" + | "unverified" + | "invalidCredentials" + | "missingCredential" + | "quotaExhausted" + | "cooldown" + | "error"; /** * Derives a UI status badge for an account row from its persisted state. @@ -13,7 +22,23 @@ export function deriveAccountStatus( nowMs: number = Date.now(), ): AccountStatus { if (!account.enabled) return "disabled"; + if (account.status === "expired") return "expired"; if (account.validUntil !== null && account.validUntil < nowMs) return "expired"; - if (account.lastValidated === null) return "unverified"; - return "active"; + + switch (account.status) { + case "valid": + return "active"; + case "invalid_credentials": + return "invalidCredentials"; + case "missing_credential": + return "missingCredential"; + case "quota_exhausted": + return "quotaExhausted"; + case "cooldown": + return "cooldown"; + case "error": + return "error"; + case "unverified": + return "unverified"; + } } diff --git a/src/views/LinkGrabberView/LinkGrabberView.tsx b/src/views/LinkGrabberView/LinkGrabberView.tsx index 0ed43d82..5359a5ed 100644 --- a/src/views/LinkGrabberView/LinkGrabberView.tsx +++ b/src/views/LinkGrabberView/LinkGrabberView.tsx @@ -198,7 +198,10 @@ export function LinkGrabberView() { onSuccess: applyResolvedBatch, }); - const { mutate: startDownload } = useTauriMutation("download_start"); + const { mutate: startDownload } = useTauriMutation< + unknown, + { url: string; moduleName: string; accountId: string | null } + >("download_start"); const { mutateAsync: startMediaDownloadAsync } = useTauriMutation< MediaDownloadResult, @@ -341,7 +344,11 @@ export function LinkGrabberView() { if (!url) continue; if (skipDuplicates && started.has(url)) continue; started.add(url); - startDownload({ url }); + startDownload({ + url, + moduleName: link.moduleName, + accountId: link.accountId, + }); } }; diff --git a/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx b/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx index 1ffb6e39..9a4b1209 100644 --- a/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx @@ -17,6 +17,7 @@ const baseLink: ResolvedLink = { sizeBytes: 1024, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }; diff --git a/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx b/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx index c3d6274d..83f6a251 100644 --- a/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx +++ b/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx @@ -26,6 +26,7 @@ const mockMediaLink: ResolvedLink = { sizeBytes: null, status: "online", moduleName: "youtube", + accountId: null, isMedia: true, mediaType: "video", }; diff --git a/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx b/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx index 6367f590..5ba6627e 100644 --- a/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx +++ b/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx @@ -19,6 +19,7 @@ const MOCK_LINKS: ResolvedLink[] = [ sizeBytes: 1048576, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }, { @@ -29,6 +30,7 @@ const MOCK_LINKS: ResolvedLink[] = [ sizeBytes: null, status: "online", moduleName: "youtube", + accountId: null, isMedia: true, mediaType: "video", }, @@ -40,6 +42,7 @@ const MOCK_LINKS: ResolvedLink[] = [ sizeBytes: null, status: "offline", moduleName: "core-http", + accountId: null, isMedia: false, }, ]; @@ -148,6 +151,7 @@ describe("ResolvedLinksSection", () => { sizeBytes: null, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }, { @@ -158,6 +162,7 @@ describe("ResolvedLinksSection", () => { sizeBytes: null, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }, ]; @@ -189,6 +194,7 @@ describe("ResolvedLinksSection", () => { // Static state says "checking" but the live event has flipped to "offline". status: "checking", moduleName: "core-http", + accountId: null, isMedia: false, }, ]; @@ -217,6 +223,7 @@ describe("applyFilter", () => { sizeBytes: null, status, moduleName: "core-http", + accountId: null, isMedia: false, }); diff --git a/src/views/LinkGrabberView/types.ts b/src/views/LinkGrabberView/types.ts index 21bd44e5..7e3a0c4e 100644 --- a/src/views/LinkGrabberView/types.ts +++ b/src/views/LinkGrabberView/types.ts @@ -38,6 +38,7 @@ export interface ResolvedLink { status: LinkStatus; errorMessage?: string; moduleName: string; + accountId: string | null; isMedia: boolean; mediaType?: "video" | "audio"; /** From 3068d369a77dedc863f1682f6a86e07ee45589ba Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:52:37 +0200 Subject: [PATCH 16/33] feat(accounts): wire premium services at runtime --- CHANGELOG.md | 3 + src-tauri/src/lib.rs | 19 +++++- src-tauri/tests/app_state_wiring.rs | 91 ++++++++++++++++++++--------- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a700fe65..377e131d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Account read models now expose non-secret validation state and cooldown deadlines; Accounts renders typed badges, while Link Grabber forwards the selected plugin/account pair when starting the resolved direct URL. + Production wiring now composes the Extism-backed account validator with the + SQLite selector and persistent quota rotator using one system clock; the + AppState integration test verifies both command and query account paths. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5aaf1afa..6b3bd4aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -65,8 +65,8 @@ pub use application::read_models::{ plugin_view::PluginViewDto, stats_view::{DailyVolumeDto, HostStatsDto, ModuleStatsDto, StatsViewDto}, }; -pub use application::services::QueueManager; pub use application::services::backfill_history_for_completed_downloads; +pub use application::services::{AccountRotator, AccountSelector, QueueManager}; pub use domain::model::ExtractionConfig; pub use adapters::driving::tauri_ipc::{ @@ -241,6 +241,20 @@ pub fn run() { let plugin_read_repo: Arc = plugin_loader_impl.registry().clone(); let plugin_loader: Arc = plugin_loader_impl.clone(); + let account_clock: Arc = Arc::new(SystemClock); + let account_selector = AccountSelector::new( + account_repo.clone(), + event_bus.clone(), + account_clock.clone(), + ); + let account_rotator = AccountRotator::new( + account_selector.clone(), + account_repo.clone(), + event_bus.clone(), + account_clock, + ); + let account_validator = + Arc::new(PluginAccountValidator::new(plugin_loader.clone())); // ── Download engine ───────────────────────────────────── let initial_engine_config = config_store @@ -383,6 +397,9 @@ pub fn run() { .with_plugin_config_store(plugin_config_store.clone()) .with_account_repo(account_repo.clone()) .with_account_credential_store(account_credential_store) + .with_account_validator(account_validator) + .with_account_selector(account_selector) + .with_account_rotator(account_rotator) .with_package_repo(package_repo.clone()) .with_passphrase_codec(passphrase_codec), ); diff --git a/src-tauri/tests/app_state_wiring.rs b/src-tauri/tests/app_state_wiring.rs index 9bc489df..403fd948 100644 --- a/src-tauri/tests/app_state_wiring.rs +++ b/src-tauri/tests/app_state_wiring.rs @@ -4,15 +4,17 @@ use std::sync::Arc; use vortex_lib::domain::ports::driven::{ - ArchiveExtractor, ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, - DownloadReadRepository, DownloadRepository, EventBus, FileStorage, HistoryRepository, - HttpClient, PluginLoader, PluginReadRepository, StatsRepository, + AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, + ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, DownloadRepository, + EventBus, FileStorage, HistoryRepository, HttpClient, PluginLoader, PluginReadRepository, + StatsRepository, }; use vortex_lib::{ - CommandBus, ExtismPluginLoader, ExtractionConfig, FsFileStorage, NoopCredentialStore, QueryBus, - ReqwestHttpClient, SegmentedDownloadEngine, SharedHostResources, SqliteDownloadReadRepo, - SqliteDownloadRepo, SqliteHistoryRepo, SqliteStatsRepo, TokioEventBus, TomlConfigStore, - VortexArchiveExtractor, connection, + AccountRotator, AccountSelector, CommandBus, ExtismPluginLoader, ExtractionConfig, + FsFileStorage, KeyringAccountStore, NoopCredentialStore, PluginAccountValidator, QueryBus, + ReqwestHttpClient, SegmentedDownloadEngine, SharedHostResources, SqliteAccountRepo, + SqliteDownloadReadRepo, SqliteDownloadRepo, SqliteHistoryRepo, SqliteStatsRepo, SystemClock, + TokioEventBus, TomlConfigStore, VortexArchiveExtractor, connection, }; /// Verifies that all driven adapters satisfy their port traits and that @@ -55,7 +57,9 @@ fn test_appstate_wiring_with_in_memory_db() { let download_read_repo: Arc = Arc::new(SqliteDownloadReadRepo::new(db.clone())); let history_repo: Arc = Arc::new(SqliteHistoryRepo::new(db.clone())); - let stats_repo: Arc = Arc::new(SqliteStatsRepo::new(db)); + let stats_repo: Arc = Arc::new(SqliteStatsRepo::new(db.clone())); + let account_repo: Arc = Arc::new(SqliteAccountRepo::new(db.clone())); + let account_credential_store: Arc = Arc::new(KeyringAccountStore); // Plugin system let plugins_dir = tempfile::tempdir().expect("plugins dir"); @@ -66,6 +70,19 @@ fn test_appstate_wiring_with_in_memory_db() { ); let plugin_read_repo: Arc = plugin_loader_impl.registry().clone(); let plugin_loader: Arc = plugin_loader_impl; + let account_clock: Arc = Arc::new(SystemClock); + let account_selector = AccountSelector::new( + account_repo.clone(), + event_bus.clone(), + account_clock.clone(), + ); + let account_rotator = AccountRotator::new( + account_selector.clone(), + account_repo.clone(), + event_bus.clone(), + account_clock, + ); + let account_validator = Arc::new(PluginAccountValidator::new(plugin_loader.clone())); // Download engine let download_engine: Arc = Arc::new(SegmentedDownloadEngine::new( @@ -79,34 +96,50 @@ fn test_appstate_wiring_with_in_memory_db() { let clipboard_observer: Arc = Arc::new(StubClipboardObserver); // CQRS buses - let command_bus = Arc::new(CommandBus::new( - download_repo, - download_engine, - event_bus, - file_storage, - http_client, - plugin_loader, - config_store, - credential_store, - clipboard_observer, - archive_extractor.clone(), - history_repo.clone(), - None, - )); + let command_bus = Arc::new( + CommandBus::new( + download_repo, + download_engine, + event_bus, + file_storage, + http_client, + plugin_loader, + config_store, + credential_store, + clipboard_observer, + archive_extractor.clone(), + history_repo.clone(), + None, + ) + .with_account_repo(account_repo.clone()) + .with_account_credential_store(account_credential_store) + .with_account_validator(account_validator) + .with_account_selector(account_selector) + .with_account_rotator(account_rotator), + ); - let query_bus = Arc::new(QueryBus::new( - download_read_repo, - history_repo, - stats_repo, - plugin_read_repo, - archive_extractor, - )); + let query_bus = Arc::new( + QueryBus::new( + download_read_repo, + history_repo, + stats_repo, + plugin_read_repo, + archive_extractor, + ) + .with_account_repo(account_repo), + ); // Verify command bus is wired (exercise a read through it) let _config = command_bus .config_store() .get_config() .expect("config load"); + assert!(command_bus.account_repo().is_some()); + assert!(command_bus.account_credential_store().is_some()); + assert!(command_bus.account_validator().is_some()); + assert!(command_bus.account_selector().is_some()); + assert!(command_bus.account_rotator().is_some()); + assert!(query_bus.account_repo().is_some()); // Verify query bus can execute a read query (empty DB → empty results) let downloads = query_bus From 8977c00af0a3fb7db459d1e6b17b57f8e8ab884c Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:56:39 +0200 Subject: [PATCH 17/33] test(accounts): cover premium fallback states --- CHANGELOG.md | 2 + .../src/application/commands/resolve_links.rs | 81 ++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 377e131d..53460af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Production wiring now composes the Extism-backed account validator with the SQLite selector and persistent quota rotator using one system clock; the AppState integration test verifies both command and query account paths. + Premium resolution integration tests now cover missing keyring entries plus + invalid, quota, and cooldown failures before rotation to a second account. - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index f2a66c9c..5f4f477a 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -458,7 +458,7 @@ mod tests { CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, build_account_bus_with_plugin_loader, }; - use crate::application::services::AccountSelector; + use crate::application::services::{AccountRotator, AccountSelector}; use crate::domain::error::DomainError; use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::domain::model::credential::Credential; @@ -527,8 +527,12 @@ mod tests { .lock() .unwrap() .push(credential.password().to_string()); - if credential.password() == "expired-key" { - return Err(DomainError::AccountExpired); + match credential.password() { + "expired-key" => return Err(DomainError::AccountExpired), + "invalid-key" => return Err(DomainError::AccountInvalidCredentials), + "quota-key" => return Err(DomainError::AccountQuotaExceeded), + "cooldown-key" => return Err(DomainError::AccountCooldown), + _ => {} } Ok(r#"{"kind":"file","mode":"premium","files":[{"id":"abc","url":"https://1fichier.com/?abc123","filename":"file.zip","size_bytes":42,"direct_url":"https://download.1fichier.com/token/file.zip","resumable":true,"wait_seconds":null,"requires_captcha":false,"traffic_used_bytes":1,"traffic_total_bytes":100}]}"#.into()) } @@ -551,8 +555,14 @@ mod tests { ) } - #[tokio::test] - async fn resolve_hoster_rotates_expired_account_and_returns_opaque_account_id() { + async fn resolve_with_primary_credential( + primary_password: Option<&str>, + ) -> ( + Vec, + Arc, + Arc, + Account, + ) { let repo = Arc::new(InMemoryAccountRepo::new()); let credentials = Arc::new(FakeAccountCredentialStore::new()); let events = Arc::new(CapturingEventBus::new()); @@ -561,13 +571,15 @@ mod tests { let backup = premium_account("backup", 50); repo.save(&primary).unwrap(); repo.save(&backup).unwrap(); - credentials - .store_password(primary.id(), "expired-key") - .unwrap(); + if let Some(password) = primary_password { + credentials.store_password(primary.id(), password).unwrap(); + } credentials .store_password(backup.id(), "working-key") .unwrap(); - let selector = AccountSelector::new(repo.clone(), events.clone(), Arc::new(FixedClock)); + let clock: Arc = Arc::new(FixedClock); + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector.clone(), repo.clone(), events.clone(), clock); let bus = build_account_bus_with_plugin_loader( repo.clone(), credentials, @@ -576,7 +588,8 @@ mod tests { None, plugin.clone(), ) - .with_account_selector(selector); + .with_account_selector(selector) + .with_account_rotator(rotator); let result = bus .handle_resolve_links(ResolveLinksCommand { @@ -584,6 +597,13 @@ mod tests { }) .await .expect("resolve succeeds"); + (result, repo, plugin, primary) + } + + #[tokio::test] + async fn resolve_hoster_rotates_expired_account_and_returns_opaque_account_id() { + let (result, repo, plugin, primary) = + resolve_with_primary_credential(Some("expired-key")).await; assert_eq!( result[0].resolved_url.as_deref(), @@ -601,6 +621,47 @@ mod tests { ); } + #[tokio::test] + async fn resolve_hoster_persists_typed_failures_before_rotating() { + for (password, expected_status) in [ + ("invalid-key", AccountStatus::InvalidCredentials), + ("quota-key", AccountStatus::QuotaExhausted), + ("cooldown-key", AccountStatus::Cooldown), + ] { + let (result, repo, plugin, primary) = + resolve_with_primary_credential(Some(password)).await; + + assert_eq!(result[0].account_id.as_deref(), Some("backup")); + let stored = repo.find_by_id(primary.id()).unwrap().unwrap(); + assert_eq!(stored.status(), expected_status); + if matches!( + expected_status, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown + ) { + assert!(stored.exhausted_until().is_some()); + } + assert_eq!( + plugin.credentials.lock().unwrap().as_slice(), + [password, "working-key"] + ); + } + } + + #[tokio::test] + async fn resolve_hoster_marks_missing_credential_and_rotates_without_exposing_it() { + let (result, repo, plugin, primary) = resolve_with_primary_credential(None).await; + + assert_eq!(result[0].account_id.as_deref(), Some("backup")); + assert_eq!( + repo.find_by_id(primary.id()).unwrap().unwrap().status(), + AccountStatus::MissingCredential + ); + assert_eq!( + plugin.credentials.lock().unwrap().as_slice(), + ["working-key"] + ); + } + #[test] fn test_extract_filename_from_url_returns_last_path_segment() { assert_eq!( From 1e1248f4736420e25d0f8d5a166d00c1d7f396c7 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:14:19 +0200 Subject: [PATCH 18/33] test(plugin): expose credential boundary regressions --- CHANGELOG.md | 3 +++ .../adapters/driven/plugin/extism_loader.rs | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53460af3..d5cc0d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **MAT-132 adversarial account tests**: added fail-closed coverage for + contradictory plugin validation states and plugin diagnostics that contain + credential-like material. - Updated compatible Rust and frontend dependencies, including the patched `quinn-proto` release. Disabled unused SeaORM migration defaults so the MySQL/PostgreSQL drivers and vulnerable `rsa` crate are no longer included. diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index 6a7b8989..5d66843d 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -858,6 +858,18 @@ mod tests { )); } + #[test] + fn unknown_account_plugin_error_does_not_expose_plugin_diagnostics() { + let error = classify_account_plugin_error( + "PLUGIN_ERROR: upstream echoed Authorization: Bearer super-secret-key", + ); + assert_eq!( + error, + DomainError::PluginError("plugin account operation failed".into()) + ); + assert!(!error.to_string().contains("super-secret-key")); + } + #[test] fn validation_response_defaults_success_to_valid_status() { let outcome = parse_validation_outcome(r#"{"valid":true}"#).expect("valid outcome"); @@ -876,6 +888,20 @@ mod tests { assert_eq!(outcome.traffic_total, Some(100)); } + #[test] + fn validation_response_rejects_invalid_flag_with_valid_status() { + let error = parse_validation_outcome(r#"{"valid":false,"status":"valid"}"#) + .expect_err("contradictory validation response must fail closed"); + assert!(matches!(error, DomainError::PluginError(_))); + } + + #[test] + fn validation_response_rejects_valid_flag_with_non_valid_status() { + let error = parse_validation_outcome(r#"{"valid":true,"status":"expired"}"#) + .expect_err("contradictory validation response must fail closed"); + assert!(matches!(error, DomainError::PluginError(_))); + } + fn setup_plugin_dir(plugins_dir: &Path, name: &str) { let plugin_dir = plugins_dir.join(name); std::fs::create_dir_all(&plugin_dir).unwrap(); From 5ee30956dcc15b2958b9b50557271886560fe8ab Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:27:58 +0200 Subject: [PATCH 19/33] fix(plugin): bind credentials to selected plugin --- CHANGELOG.md | 8 +- .../adapters/driven/plugin/capabilities.rs | 47 +++++--- .../adapters/driven/plugin/extism_loader.rs | 114 ++++++++++-------- .../adapters/driven/plugin/hoster_contract.rs | 39 ++++++ .../driven/plugin/hoster_contract_tests.rs | 109 +++++++++++++++++ src-tauri/src/adapters/driven/plugin/mod.rs | 3 + .../src/adapters/driven/plugin/registry.rs | 27 +++-- .../src/application/commands/resolve_links.rs | 105 ++++++---------- .../src/domain/ports/driven/hoster_link.rs | 16 +++ src-tauri/src/domain/ports/driven/mod.rs | 2 + .../src/domain/ports/driven/plugin_loader.rs | 28 +++-- 11 files changed, 346 insertions(+), 152 deletions(-) create mode 100644 src-tauri/src/adapters/driven/plugin/hoster_contract.rs create mode 100644 src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs create mode 100644 src-tauri/src/domain/ports/driven/hoster_link.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cc0d74..46d5dc59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **MAT-132 adversarial account tests**: added fail-closed coverage for - contradictory plugin validation states and plugin diagnostics that contain - credential-like material. +- **MAT-132 credential boundary hardening**: account extraction now calls the + exact selected plugin instead of resolving the URL a second time, credential + slots are isolated per loaded plugin generation across hot reloads, hoster + JSON is parsed at the adapter boundary, contradictory validation states fail + closed, and untrusted plugin diagnostics cannot reach events or IPC. - Updated compatible Rust and frontend dependencies, including the patched `quinn-proto` release. Disabled unused SeaORM migration defaults so the MySQL/PostgreSQL drivers and vulnerable `rsa` crate are no longer included. diff --git a/src-tauri/src/adapters/driven/plugin/capabilities.rs b/src-tauri/src/adapters/driven/plugin/capabilities.rs index b4905212..0ebca8af 100644 --- a/src-tauri/src/adapters/driven/plugin/capabilities.rs +++ b/src-tauri/src/adapters/driven/plugin/capabilities.rs @@ -17,7 +17,6 @@ pub struct SharedHostResources { pub(crate) http_client: reqwest::blocking::Client, http_timeout: Duration, pub(crate) credential_store: Option>, - credential_slots: DashMap, pub(crate) plugin_configs: DashMap>, pub(crate) plugin_states: DashMap>, } @@ -47,7 +46,6 @@ impl SharedHostResources { http_client, http_timeout, credential_store: None, - credential_slots: DashMap::new(), plugin_configs: DashMap::new(), plugin_states: DashMap::new(), } @@ -77,13 +75,6 @@ impl SharedHostResources { self.credential_store.as_ref() } - pub(crate) fn credential_slot(&self, plugin_name: &str) -> CredentialSlot { - self.credential_slots - .entry(plugin_name.to_string()) - .or_insert_with(|| Arc::new(Mutex::new(None))) - .clone() - } - pub fn plugin_configs(&self) -> &DashMap> { &self.plugin_configs } @@ -129,10 +120,31 @@ pub fn build_host_functions( build_host_functions_with_grants(manifest, shared, HostFunctionGrants::default()) } +#[cfg(test)] pub(super) fn build_host_functions_with_grants( manifest: &PluginManifest, shared: &Arc, grants: HostFunctionGrants, +) -> Vec { + build_host_functions_for_instance(manifest, shared, grants).0 +} + +pub(super) fn build_host_functions_for_instance( + manifest: &PluginManifest, + shared: &Arc, + grants: HostFunctionGrants, +) -> (Vec, CredentialSlot) { + let credential_slot = Arc::new(Mutex::new(None)); + let functions = + build_host_functions_with_slot(manifest, shared, grants, Arc::clone(&credential_slot)); + (functions, credential_slot) +} + +fn build_host_functions_with_slot( + manifest: &PluginManifest, + shared: &Arc, + grants: HostFunctionGrants, + credential_slot: CredentialSlot, ) -> Vec { let name = manifest.info().name().to_string(); @@ -176,7 +188,7 @@ pub(super) fn build_host_functions_with_grants( plugin_name: name, capabilities: manifest.capabilities().to_vec(), shared: Arc::clone(shared), - credential_slot: shared.credential_slot(manifest.info().name()), + credential_slot, }; let user_data = extism::UserData::new(ctx); @@ -442,18 +454,17 @@ mod tests { } #[test] - fn test_scoped_credential_slots_are_isolated_by_plugin() { - let shared = SharedHostResources::new(); - let first = shared.credential_slot("vortex-mod-1fichier"); - let second = shared.credential_slot("vortex-mod-other"); + fn test_scoped_credential_slots_are_isolated_by_plugin_instance() { + let shared = Arc::new(SharedHostResources::new()); + let manifest = make_named_manifest_with_caps("vortex-mod-1fichier", vec![]); + let (_, first) = + build_host_functions_for_instance(&manifest, &shared, HostFunctionGrants::default()); + let (_, second) = + build_host_functions_for_instance(&manifest, &shared, HostFunctionGrants::default()); assert!(first.lock().unwrap().is_none()); assert!(second.lock().unwrap().is_none()); assert!(!Arc::ptr_eq(&first, &second)); - assert!(Arc::ptr_eq( - &first, - &shared.credential_slot("vortex-mod-1fichier") - )); } #[test] diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index 5d66843d..7c9d7a85 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -11,10 +11,11 @@ use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::plugin_loader::DownloadedFileInfo; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; -use crate::domain::ports::driven::{PluginLoader, ValidationOutcome}; +use crate::domain::ports::driven::{ExtractedHosterLink, PluginLoader, ValidationOutcome}; use super::builtin::HttpModule; -use super::capabilities::{SharedHostResources, build_host_functions_with_grants}; +use super::capabilities::{SharedHostResources, build_host_functions_for_instance}; +use super::hoster_contract::parse_hoster_link; use super::manifest::{ find_wasm_file, parse_manifest, parse_manifest_metadata, parse_manifest_metadata_bytes, }; @@ -364,14 +365,15 @@ impl PluginLoader for ExtismPluginLoader { &manifest_bytes, ); let extism_manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)]); - let host_functions = - build_host_functions_with_grants(&disk_manifest, &self.shared_resources, grants); + let (host_functions, credential_slot) = + build_host_functions_for_instance(&disk_manifest, &self.shared_resources, grants); let plugin = extism::Plugin::new(&extism_manifest, host_functions, true) .map_err(|e| DomainError::PluginError(format!("failed to load plugin: {e}")))?; let loaded = LoadedPlugin { manifest: disk_manifest, plugin: std::sync::Arc::new(std::sync::Mutex::new(plugin)), + credential_slot, enabled: true, }; @@ -489,31 +491,41 @@ impl PluginLoader for ExtismPluginLoader { self.call_url_plugin_function(url, "extract_links") } - fn extract_links_with_credential( + fn extract_hoster_link( &self, + service_name: &str, url: &str, - credential: &Credential, - ) -> Result { - let info = self.resolve_wasm_plugin(url)?; + credential: Option<&Credential>, + ) -> Result { + let info = self + .registry + .list_info() + .into_iter() + .find(|info| info.name() == service_name) + .ok_or_else(|| DomainError::NotFound(service_name.to_string()))?; + if !info.is_enabled() { + return Err(DomainError::NotFound(format!( + "plugin '{service_name}' is disabled" + ))); + } if !self .registry - .function_exists(info.name(), "extract_links")? + .function_exists(service_name, "extract_links")? { return Err(DomainError::NotFound(format!( - "plugin '{}' does not export 'extract_links'", - info.name() + "plugin '{service_name}' does not export 'extract_links'" ))); } - let slot = self.shared_resources.credential_slot(info.name()); - self.registry - .call_plugin_with_credential( - info.name(), - "extract_links", - url, - slot, - credential.clone(), - ) - .map_err(|error| classify_account_plugin_error(&error.to_string())) + let output = match credential { + Some(credential) => self + .registry + .call_plugin_with_credential(service_name, "extract_links", url, credential.clone()) + .map_err(|error| classify_account_plugin_error(&error.to_string()))?, + None => self + .registry + .call_plugin(service_name, "extract_links", url)?, + }; + parse_hoster_link(&output) } fn validate_account( @@ -541,16 +553,9 @@ impl PluginLoader for ExtismPluginLoader { ))); } - let slot = self.shared_resources.credential_slot(service_name); let output = self .registry - .call_plugin_with_credential( - service_name, - "validate_account", - "", - slot, - credential.clone(), - ) + .call_plugin_with_credential(service_name, "validate_account", "", credential.clone()) .map_err(|error| classify_account_plugin_error(&error.to_string()))?; parse_validation_outcome(&output) } @@ -755,16 +760,21 @@ fn is_adaptive_stream_error(msg: &str) -> bool { } fn classify_account_plugin_error(message: &str) -> DomainError { - if message.contains("ACCOUNT_INVALID_CREDENTIALS") { + let has_code = |expected: &str| { + message + .split(|character: char| !(character.is_ascii_uppercase() || character == '_')) + .any(|token| token == expected) + }; + if has_code("ACCOUNT_INVALID_CREDENTIALS") { DomainError::AccountInvalidCredentials - } else if message.contains("ACCOUNT_EXPIRED") { + } else if has_code("ACCOUNT_EXPIRED") { DomainError::AccountExpired - } else if message.contains("ACCOUNT_COOLDOWN") { + } else if has_code("ACCOUNT_COOLDOWN") { DomainError::AccountCooldown - } else if message.contains("ACCOUNT_QUOTA_EXCEEDED") { + } else if has_code("ACCOUNT_QUOTA_EXCEEDED") { DomainError::AccountQuotaExceeded } else { - DomainError::PluginError(message.to_string()) + DomainError::PluginError("plugin account operation failed".into()) } } @@ -781,27 +791,37 @@ struct PluginValidationOutcome { traffic_total: Option, #[serde(default, alias = "validUntil")] valid_until: Option, - #[serde(default, alias = "errorMessage")] - error_message: Option, } fn parse_validation_outcome(output: &str) -> Result { use std::str::FromStr; - let parsed: PluginValidationOutcome = serde_json::from_str(output).map_err(|error| { - DomainError::PluginError(format!( - "plugin account validation returned invalid JSON: {error}" - )) + let parsed: PluginValidationOutcome = serde_json::from_str(output).map_err(|_| { + DomainError::PluginError("plugin account validation returned invalid JSON".into()) })?; let status = match parsed.status { Some(status) => AccountStatus::from_str(&status).map_err(|_| { - DomainError::PluginError(format!( - "plugin account validation returned unknown status '{status}'" - )) + DomainError::PluginError("plugin account validation returned unknown status".into()) })?, None if parsed.valid => AccountStatus::Valid, None => AccountStatus::Error, }; + if parsed.valid != (status == AccountStatus::Valid) { + return Err(DomainError::PluginError( + "plugin account validation returned contradictory state".into(), + )); + } + let error_message = match status { + AccountStatus::Valid => None, + AccountStatus::InvalidCredentials => Some("Account credentials were rejected".into()), + AccountStatus::MissingCredential => Some("Account credential is missing".into()), + AccountStatus::Expired => Some("Account is expired".into()), + AccountStatus::QuotaExhausted => Some("Account quota is exhausted".into()), + AccountStatus::Cooldown => Some("Account is temporarily rate-limited".into()), + AccountStatus::Unverified | AccountStatus::Error => { + Some("Account validation failed".into()) + } + }; Ok(ValidationOutcome { valid: parsed.valid, status, @@ -809,7 +829,7 @@ fn parse_validation_outcome(output: &str) -> Result, +} + +#[derive(Deserialize)] +struct HosterFile { + url: String, + filename: Option, + size_bytes: Option, + direct_url: Option, + traffic_used_bytes: Option, + traffic_total_bytes: Option, +} + +pub(super) fn parse_hoster_link(payload: &str) -> Result { + let response: HosterResponse = serde_json::from_str(payload) + .map_err(|_| DomainError::PluginError("hoster returned an invalid response".into()))?; + let file = response + .files + .into_iter() + .next() + .ok_or_else(|| DomainError::PluginError("hoster returned no file".into()))?; + Ok(ExtractedHosterLink { + source_url: file.url, + filename: file.filename, + size_bytes: file.size_bytes, + direct_url: file.direct_url, + traffic_used_bytes: file.traffic_used_bytes, + traffic_total_bytes: file.traffic_total_bytes, + }) +} diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs new file mode 100644 index 00000000..2be412d8 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; + +use super::ExtismPluginLoader; +use super::capabilities::SharedHostResources; +use super::hoster_contract::parse_hoster_link; +use crate::domain::model::credential::Credential; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; +use crate::domain::ports::driven::PluginLoader; + +#[test] +fn test_parse_hoster_link_maps_wire_fields() { + let parsed = parse_hoster_link( + r#"{"files":[{"url":"https://1fichier.com/?a","filename":"a.zip","size_bytes":42,"direct_url":"https://cdn.example/a","traffic_used_bytes":1,"traffic_total_bytes":100}]}"#, + ) + .expect("valid hoster response"); + + assert_eq!(parsed.source_url, "https://1fichier.com/?a"); + assert_eq!(parsed.filename.as_deref(), Some("a.zip")); + assert_eq!(parsed.direct_url.as_deref(), Some("https://cdn.example/a")); + assert_eq!(parsed.traffic_total_bytes, Some(100)); +} + +#[test] +fn test_parse_hoster_link_rejects_empty_file_list() { + assert!(parse_hoster_link(r#"{"files":[]}"#).is_err()); +} + +#[test] +fn test_extract_hoster_link_calls_exact_named_plugin_without_reresolving_url() { + let temp = tempfile::tempdir().unwrap(); + let name = "hoster-a"; + let plugin_dir = temp.path().join(name); + std::fs::create_dir(&plugin_dir).unwrap(); + std::fs::write( + plugin_dir.join("plugin.toml"), + format!( + "[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ncategory = \"hoster\"\nauthor = \"tester\"\ndescription = \"test hoster\"\n" + ), + ) + .unwrap(); + let payload = r#"{"files":[{"url":"https://source.example/file","filename":"from-a.zip","size_bytes":1,"direct_url":"https://cdn.example/file","traffic_used_bytes":0,"traffic_total_bytes":10}]}"#; + std::fs::write( + plugin_dir.join(format!("{name}.wasm")), + output_plugin_wat("false", payload), + ) + .unwrap(); + + let loader = ExtismPluginLoader::new( + temp.path().to_path_buf(), + Arc::new(SharedHostResources::new()), + ) + .unwrap(); + let manifest = PluginManifest::new(PluginInfo::new( + name.into(), + "1.0.0".into(), + "test hoster".into(), + "tester".into(), + PluginCategory::Hoster, + )); + loader.load(&manifest).unwrap(); + + let link = loader + .extract_hoster_link( + name, + "https://url-not-claimed-by-a.example/file", + Some(&Credential::new("alice", "secret")), + ) + .expect("the named plugin must be called directly"); + + assert_eq!(link.filename.as_deref(), Some("from-a.zip")); +} + +fn output_plugin_wat(can_handle: &str, payload: &str) -> String { + format!( + r#"(module + (import "extism:host/env" "alloc" (func $alloc (param i64) (result i64))) + (import "extism:host/env" "store_u8" (func $store_u8 (param i64 i32))) + (import "extism:host/env" "output_set" (func $output_set (param i64 i64))) + {} + {} +)"#, + output_function("can_handle", can_handle), + output_function("extract_links", payload), + ) +} + +fn output_function(name: &str, value: &str) -> String { + let stores = value + .bytes() + .enumerate() + .map(|(index, byte)| { + format!( + "(call $store_u8 (i64.add (local.get $output) (i64.const {index})) (i32.const {byte}))" + ) + }) + .collect::>() + .join("\n"); + format!( + r#"(func (export "{name}") (result i32) + (local $output i64) + (local.set $output (call $alloc (i64.const {}))) + {stores} + (call $output_set (local.get $output) (i64.const {})) + (i32.const 0) +)"#, + value.len(), + value.len(), + ) +} diff --git a/src-tauri/src/adapters/driven/plugin/mod.rs b/src-tauri/src/adapters/driven/plugin/mod.rs index 62d819fa..5fde9504 100644 --- a/src-tauri/src/adapters/driven/plugin/mod.rs +++ b/src-tauri/src/adapters/driven/plugin/mod.rs @@ -4,6 +4,9 @@ pub mod capabilities; pub mod extism_loader; pub mod github_store_client; pub mod host_functions; +mod hoster_contract; +#[cfg(test)] +mod hoster_contract_tests; pub mod manifest; mod provenance; pub mod registry; diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs index 1a5b2be2..3a83c9fc 100644 --- a/src-tauri/src/adapters/driven/plugin/registry.rs +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -44,6 +44,7 @@ impl Drop for CredentialScope { pub struct LoadedPlugin { pub manifest: PluginManifest, pub plugin: Arc>, + pub credential_slot: CredentialSlot, pub enabled: bool, } @@ -137,10 +138,9 @@ impl PluginRegistry { name: &str, func: &str, input: &str, - slot: CredentialSlot, credential: Credential, ) -> Result { - self.call_plugin_inner(name, func, input, Some((slot, credential))) + self.call_plugin_inner(name, func, input, Some(credential)) } /// Container plugins decode binary blobs (DLC / CCF / RSDF / Metalink); @@ -159,7 +159,7 @@ impl PluginRegistry { name: &str, func: &str, input: I, - scoped_credential: Option<(CredentialSlot, Credential)>, + scoped_credential: Option, ) -> Result where I: extism::convert::ToBytes<'a>, @@ -167,18 +167,21 @@ impl PluginRegistry { // Clone the Arc> and drop the DashMap shard guard // before locking — holding the shard across a slow WASM call would // block every other plugin lookup behind the same shard. - let plugin_handle = { + let (plugin_handle, credential_slot) = { let entry = self .plugins .get(name) .ok_or_else(|| DomainError::NotFound(name.to_string()))?; - Arc::clone(&entry.plugin) + ( + Arc::clone(&entry.plugin), + Arc::clone(&entry.credential_slot), + ) }; let mut plugin = plugin_handle .lock() .map_err(|_| DomainError::PluginError(format!("plugin '{name}' mutex poisoned")))?; let _credential_scope = scoped_credential - .map(|(slot, credential)| CredentialScope::new(slot, credential)) + .map(|credential| CredentialScope::new(credential_slot, credential)) .transpose()?; let fn_exists = plugin.function_exists(func); tracing::debug!(plugin = name, func, fn_exists, "plugin call pre-call"); @@ -231,6 +234,7 @@ mod tests { LoadedPlugin { manifest: make_manifest(name), plugin: Arc::new(Mutex::new(make_extism_plugin())), + credential_slot: Arc::new(Mutex::new(None)), enabled: true, } } @@ -269,20 +273,23 @@ mod tests { #[test] fn test_scoped_credential_is_cleared_when_plugin_call_fails() { - use crate::adapters::driven::plugin::capabilities::SharedHostResources; use crate::domain::model::credential::Credential; let registry = PluginRegistry::new(); registry.insert("plug-a".to_string(), make_loaded("plug-a")); - let resources = SharedHostResources::new(); - let slot = resources.credential_slot("plug-a"); + let slot = Arc::clone( + ®istry + .plugins + .get("plug-a") + .expect("loaded plugin") + .credential_slot, + ); let error = registry .call_plugin_with_credential( "plug-a", "missing", "", - Arc::clone(&slot), Credential::new("alice", "secret"), ) .expect_err("missing export"); diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 5f4f477a..0c38493d 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -3,7 +3,7 @@ //! Checks each URL via plugin loader and HTTP HEAD, returning //! resolution metadata for the frontend link grabber view. -use serde::{Deserialize, Serialize}; +use serde::Serialize; use uuid::Uuid; use crate::application::command_bus::CommandBus; @@ -14,6 +14,7 @@ use crate::domain::model::account::{Account, AccountStatus}; use crate::domain::model::credential::Credential; use crate::domain::model::http::HttpResponse; use crate::domain::model::plugin::PluginCategory; +use crate::domain::ports::driven::ExtractedHosterLink; use super::ResolveLinksCommand; @@ -35,21 +36,6 @@ pub struct ResolvedLinkDto { pub media_type: Option, } -#[derive(Debug, Deserialize)] -struct HosterExtractResponse { - files: Vec, -} - -#[derive(Debug, Deserialize)] -struct HosterFile { - url: String, - filename: Option, - size_bytes: Option, - direct_url: Option, - traffic_used_bytes: Option, - traffic_total_bytes: Option, -} - struct HosterResolution { resolved_url: String, filename: Option, @@ -241,20 +227,18 @@ impl CommandBus { match self .plugin_loader() - .extract_links_with_credential(url, &credential) + .extract_hoster_link(service_name, url, Some(&credential)) { - Ok(payload) => { - let resolved = - parse_hoster_response(&payload, Some(account.id().as_str()))?; - if let Some(total) = resolved.traffic_total_bytes { + Ok(link) => { + if let Some(total) = link.traffic_total_bytes { account.set_traffic_total(total); - if let Some(used) = resolved.traffic_used_bytes { + if let Some(used) = link.traffic_used_bytes { account.set_traffic_left(total.saturating_sub(used)); } } account.set_status(AccountStatus::Valid); repo.save(&account)?; - return Ok(resolved.into_resolution()); + return Ok(into_hoster_resolution(link, Some(account.id().as_str()))); } Err(error) => { let status = match error { @@ -286,8 +270,10 @@ impl CommandBus { } } - let payload = self.plugin_loader().extract_links(url)?; - Ok(parse_hoster_response(&payload, None)?.into_resolution()) + let link = self + .plugin_loader() + .extract_hoster_link(service_name, url, None)?; + Ok(into_hoster_resolution(link, None)) } fn next_hoster_account(&self, service_name: &str) -> Result, AppError> { @@ -302,43 +288,14 @@ impl CommandBus { } } -struct ParsedHosterResponse { - file: HosterFile, - account_id: Option, - traffic_used_bytes: Option, - traffic_total_bytes: Option, -} - -impl ParsedHosterResponse { - fn into_resolution(self) -> HosterResolution { - let direct_url = self.file.direct_url; - HosterResolution { - resolved_url: direct_url.unwrap_or(self.file.url), - filename: self.file.filename, - size_bytes: self.file.size_bytes, - account_id: self.account_id, - } - } -} - -fn parse_hoster_response( - payload: &str, - account_id: Option<&str>, -) -> Result { - let response: HosterExtractResponse = serde_json::from_str(payload) - .map_err(|_| AppError::Plugin("hoster returned an invalid response".into()))?; - let file = response - .files - .into_iter() - .next() - .ok_or_else(|| AppError::Plugin("hoster returned no file".into()))?; - let selected_account = file.direct_url.as_ref().and(account_id).map(str::to_string); - Ok(ParsedHosterResponse { - traffic_used_bytes: file.traffic_used_bytes, - traffic_total_bytes: file.traffic_total_bytes, - file, +fn into_hoster_resolution(link: ExtractedHosterLink, account_id: Option<&str>) -> HosterResolution { + let selected_account = link.direct_url.as_ref().and(account_id).map(str::to_string); + HosterResolution { + resolved_url: link.direct_url.unwrap_or(link.source_url), + filename: link.filename, + size_bytes: link.size_bytes, account_id: selected_account, - }) + } } fn current_time_ms() -> u64 { @@ -477,12 +434,14 @@ mod tests { struct PremiumPluginLoader { credentials: Mutex>, + services: Mutex>, } impl PremiumPluginLoader { fn new() -> Self { Self { credentials: Mutex::new(Vec::new()), + services: Mutex::new(Vec::new()), } } @@ -518,11 +477,16 @@ mod tests { Ok(()) } - fn extract_links_with_credential( + fn extract_hoster_link( &self, + service_name: &str, _: &str, - credential: &Credential, - ) -> Result { + credential: Option<&Credential>, + ) -> Result { + self.services.lock().unwrap().push(service_name.to_string()); + let credential = credential.ok_or_else(|| { + DomainError::NotFound("free hoster extraction is not configured".into()) + })?; self.credentials .lock() .unwrap() @@ -534,7 +498,14 @@ mod tests { "cooldown-key" => return Err(DomainError::AccountCooldown), _ => {} } - Ok(r#"{"kind":"file","mode":"premium","files":[{"id":"abc","url":"https://1fichier.com/?abc123","filename":"file.zip","size_bytes":42,"direct_url":"https://download.1fichier.com/token/file.zip","resumable":true,"wait_seconds":null,"requires_captcha":false,"traffic_used_bytes":1,"traffic_total_bytes":100}]}"#.into()) + Ok(ExtractedHosterLink { + source_url: "https://1fichier.com/?abc123".into(), + filename: Some("file.zip".into()), + size_bytes: Some(42), + direct_url: Some("https://download.1fichier.com/token/file.zip".into()), + traffic_used_bytes: Some(1), + traffic_total_bytes: Some(100), + }) } } @@ -619,6 +590,10 @@ mod tests { plugin.credentials.lock().unwrap().as_slice(), ["expired-key", "working-key"] ); + assert_eq!( + plugin.services.lock().unwrap().as_slice(), + ["vortex-mod-1fichier", "vortex-mod-1fichier"] + ); } #[tokio::test] diff --git a/src-tauri/src/domain/ports/driven/hoster_link.rs b/src-tauri/src/domain/ports/driven/hoster_link.rs new file mode 100644 index 00000000..eeaf58ba --- /dev/null +++ b/src-tauri/src/domain/ports/driven/hoster_link.rs @@ -0,0 +1,16 @@ +//! Typed output of a hoster plugin extraction. + +/// One hoster file resolved by a plugin adapter. +/// +/// The adapter owns deserialisation of the plugin wire format. Application +/// services receive this std-only type and never depend on JSON field names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractedHosterLink { + pub source_url: String, + pub filename: Option, + pub size_bytes: Option, + /// Ephemeral bearer URL produced for the selected account. + pub direct_url: Option, + pub traffic_used_bytes: Option, + pub traffic_total_bytes: Option, +} diff --git a/src-tauri/src/domain/ports/driven/mod.rs b/src-tauri/src/domain/ports/driven/mod.rs index 8a2e0a1f..99e09f06 100644 --- a/src-tauri/src/domain/ports/driven/mod.rs +++ b/src-tauri/src/domain/ports/driven/mod.rs @@ -17,6 +17,7 @@ pub mod event_bus; pub mod file_opener; pub mod file_storage; pub mod history_repository; +pub mod hoster_link; pub mod http_client; pub mod package_read_repository; pub mod package_repository; @@ -44,6 +45,7 @@ pub use event_bus::EventBus; pub use file_opener::FileOpener; pub use file_storage::FileStorage; pub use history_repository::HistoryRepository; +pub use hoster_link::ExtractedHosterLink; pub use http_client::HttpClient; pub use package_read_repository::PackageReadRepository; pub use package_repository::PackageRepository; diff --git a/src-tauri/src/domain/ports/driven/plugin_loader.rs b/src-tauri/src/domain/ports/driven/plugin_loader.rs index 62687220..696cf2b1 100644 --- a/src-tauri/src/domain/ports/driven/plugin_loader.rs +++ b/src-tauri/src/domain/ports/driven/plugin_loader.rs @@ -7,6 +7,7 @@ use crate::domain::error::DomainError; use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::account_validator::ValidationOutcome; +use crate::domain::ports::driven::hoster_link::ExtractedHosterLink; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; /// Result of a `download_to_file` plugin call. @@ -84,13 +85,19 @@ pub trait PluginLoader: Send + Sync { )) } - /// Extract links while exposing one account credential only for this call. - fn extract_links_with_credential( + /// Extract one hoster link from the exact named plugin. + /// + /// `service_name` binds account selection to the called plugin. Adapters + /// must not resolve the URL again after the application selects an account. + fn extract_hoster_link( &self, - url: &str, - _credential: &Credential, - ) -> Result { - self.extract_links(url) + service_name: &str, + _url: &str, + _credential: Option<&Credential>, + ) -> Result { + Err(DomainError::NotFound(format!( + "hoster extraction not supported for service '{service_name}'" + ))) } /// Validate an account through the plugin matching `service_name`. @@ -236,11 +243,14 @@ mod tests { } #[test] - fn test_extract_links_with_credential_default_delegates_without_support() { + fn test_extract_hoster_link_default_fails_closed() { let loader = MinimalLoader; let credential = Credential::new("alice", "secret"); - let result = - loader.extract_links_with_credential("https://1fichier.com/?abc123", &credential); + let result = loader.extract_hoster_link( + "vortex-mod-1fichier", + "https://1fichier.com/?abc123", + Some(&credential), + ); assert!(matches!(result, Err(DomainError::NotFound(_)))); } From c2053cfe1752ca03104e599120999069cbdfa035 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:17 +0200 Subject: [PATCH 20/33] test(accounts): expose state transition regressions --- CHANGELOG.md | 3 ++ .../src/application/commands/resolve_links.rs | 32 +++++++++++---- .../application/commands/validate_account.rs | 39 ++++++++++++++++++- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d5dc59..c8b3dcbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-132 account-state regression coverage**: added failing-first tests for + stale subscription expiry removal, bounded temporary validation states, and + exhausted premium accounts never silently falling back to anonymous mode. - **Lot 1 install race fixes (MAT-131)**: adaptive downloads atomically reserve unique destination files before copying, and failed Store installs clean their staging directory before propagating loader or task errors. diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 0c38493d..dc3181af 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -528,6 +528,7 @@ mod tests { async fn resolve_with_primary_credential( primary_password: Option<&str>, + include_backup: bool, ) -> ( Vec, Arc, @@ -541,13 +542,17 @@ mod tests { let primary = premium_account("primary", 100); let backup = premium_account("backup", 50); repo.save(&primary).unwrap(); - repo.save(&backup).unwrap(); + if include_backup { + repo.save(&backup).unwrap(); + } if let Some(password) = primary_password { credentials.store_password(primary.id(), password).unwrap(); } - credentials - .store_password(backup.id(), "working-key") - .unwrap(); + if include_backup { + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + } let clock: Arc = Arc::new(FixedClock); let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); let rotator = AccountRotator::new(selector.clone(), repo.clone(), events.clone(), clock); @@ -574,7 +579,7 @@ mod tests { #[tokio::test] async fn resolve_hoster_rotates_expired_account_and_returns_opaque_account_id() { let (result, repo, plugin, primary) = - resolve_with_primary_credential(Some("expired-key")).await; + resolve_with_primary_credential(Some("expired-key"), true).await; assert_eq!( result[0].resolved_url.as_deref(), @@ -604,7 +609,7 @@ mod tests { ("cooldown-key", AccountStatus::Cooldown), ] { let (result, repo, plugin, primary) = - resolve_with_primary_credential(Some(password)).await; + resolve_with_primary_credential(Some(password), true).await; assert_eq!(result[0].account_id.as_deref(), Some("backup")); let stored = repo.find_by_id(primary.id()).unwrap().unwrap(); @@ -624,7 +629,7 @@ mod tests { #[tokio::test] async fn resolve_hoster_marks_missing_credential_and_rotates_without_exposing_it() { - let (result, repo, plugin, primary) = resolve_with_primary_credential(None).await; + let (result, repo, plugin, primary) = resolve_with_primary_credential(None, true).await; assert_eq!(result[0].account_id.as_deref(), Some("backup")); assert_eq!( @@ -637,6 +642,19 @@ mod tests { ); } + #[tokio::test] + async fn resolve_hoster_does_not_fall_back_to_free_when_all_accounts_are_exhausted() { + let (result, _, plugin, _) = + resolve_with_primary_credential(Some("quota-key"), false).await; + + assert_eq!(result[0].status, "error"); + assert_eq!( + result[0].error_message.as_deref(), + Some("Account quota is exhausted") + ); + assert_eq!(plugin.credentials.lock().unwrap().as_slice(), ["quota-key"]); + } + #[test] fn test_extract_filename_from_url_returns_last_path_segment() { assert_eq!( diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index e92fe6db..a734015e 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -180,7 +180,7 @@ mod tests { use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; - use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, ValidationOutcome, }; @@ -195,6 +195,43 @@ mod tests { } } + #[test] + fn successful_validation_clears_a_stale_expiry() { + let mut account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + account.set_valid_until(100); + + let validated = super::apply_validation(&account, &ValidationOutcome::ok(), 200); + + assert_eq!(validated.valid_until(), None); + } + + #[test] + fn temporary_validation_failure_records_a_retry_deadline() { + let account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + let outcome = ValidationOutcome::rejected( + AccountStatus::Cooldown, + DomainError::AccountCooldown.to_string(), + ); + + let validated = super::apply_validation(&account, &outcome, 200); + + assert_eq!(validated.status(), AccountStatus::Cooldown); + assert_eq!(validated.exhausted_until(), Some(60_200)); + } + #[tokio::test] async fn test_validate_account_unknown_service_returns_not_found() { let repo = Arc::new(InMemoryAccountRepo::new()); From da088ee1168176f9a8c78a2ffa96c5b53bd7ac72 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:43:30 +0200 Subject: [PATCH 21/33] fix(accounts): harden state transitions and rotation --- CHANGELOG.md | 10 +- .../driven/plugin/account_validator.rs | 2 +- .../adapters/driven/plugin/extism_loader.rs | 3 +- src-tauri/src/application/command_bus.rs | 31 ++- .../src/application/commands/add_account.rs | 2 + .../application/commands/delete_account.rs | 2 + src-tauri/src/application/commands/mod.rs | 2 +- .../src/application/commands/resolve_links.rs | 60 +++-- .../application/commands/start_download.rs | 71 +++++- .../src/application/commands/tests_support.rs | 16 +- .../application/commands/update_account.rs | 107 ++++++--- .../application/commands/validate_account.rs | 220 +++++++++++++++--- .../services/account_operation_locks.rs | 42 ++++ .../application/services/account_rotator.rs | 2 +- .../application/services/account_selector.rs | 2 +- src-tauri/src/application/services/mod.rs | 1 + src-tauri/src/domain/model/account.rs | 37 +-- .../domain/ports/driven/account_validator.rs | 13 +- src-tauri/src/lib.rs | 3 +- 19 files changed, 502 insertions(+), 124 deletions(-) create mode 100644 src-tauri/src/application/services/account_operation_locks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b3dcbd..98068242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,9 +47,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **MAT-132 account-state regression coverage**: added failing-first tests for - stale subscription expiry removal, bounded temporary validation states, and - exhausted premium accounts never silently falling back to anonymous mode. +- **MAT-132 account-state hardening**: validation now derives validity from its + typed status, clears stale subscription expiries, bounds temporary failures, + and invalidates the rotator cache on recovery. Per-account async locks prevent + slow plugin calls from undoing deletes or edits; password rotation writes the + keyring first and restores the prior secret on SQLite failure. Cooldown checks + use an injected clock, and exhausted premium accounts no longer fall back to + anonymous extraction. - **Lot 1 install race fixes (MAT-131)**: adaptive downloads atomically reserve unique destination files before copying, and failed Store installs clean their staging directory before propagating loader or task errors. diff --git a/src-tauri/src/adapters/driven/plugin/account_validator.rs b/src-tauri/src/adapters/driven/plugin/account_validator.rs index 11a8419b..fbef3cc6 100644 --- a/src-tauri/src/adapters/driven/plugin/account_validator.rs +++ b/src-tauri/src/adapters/driven/plugin/account_validator.rs @@ -122,7 +122,7 @@ mod tests { .validate("vortex-mod-1fichier", "alice", "bad-key") .expect("typed rejection is an outcome"); - assert!(!outcome.valid); + assert!(!outcome.is_valid()); assert_eq!(outcome.status, AccountStatus::InvalidCredentials); assert_eq!(outcome.latency_ms, Some(42)); } diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index 7c9d7a85..b913d7f2 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -823,7 +823,6 @@ fn parse_validation_outcome(output: &str) -> Result>, account_selector: Option>, account_rotator: Option>, + account_operation_locks: Arc, + account_clock: Option>, passphrase_codec: Option>, /// Serializes queue-position allocation across handlers. Without this, /// two concurrent move-to-top/move-to-bottom/start-download calls can @@ -167,6 +171,8 @@ impl CommandBus { account_validator: None, account_selector: None, account_rotator: None, + account_operation_locks: Arc::new(AccountOperationLocks::default()), + account_clock: None, passphrase_codec: None, queue_position_lock: tokio::sync::Mutex::new(()), link_check_limiter, @@ -225,6 +231,11 @@ impl CommandBus { self } + pub fn with_account_clock(mut self, clock: Arc) -> Self { + self.account_clock = Some(clock); + self + } + /// Builder-style setter for the passphrase codec used by the /// import / export commands. pub fn with_passphrase_codec(mut self, codec: Arc) -> Self { @@ -252,6 +263,24 @@ impl CommandBus { self.account_rotator.as_deref() } + pub(crate) fn account_operation_lock( + &self, + id: &AccountId, + ) -> Result>, crate::application::error::AppError> { + self.account_operation_locks.lock_for(id) + } + + pub(crate) fn account_now_ms(&self) -> Result { + self.account_clock + .as_deref() + .map(Clock::now_unix_ms) + .ok_or_else(|| { + crate::application::error::AppError::Validation( + "account clock not configured".into(), + ) + }) + } + pub fn passphrase_codec(&self) -> Option<&dyn PassphraseCodec> { self.passphrase_codec.as_deref() } diff --git a/src-tauri/src/application/commands/add_account.rs b/src-tauri/src/application/commands/add_account.rs index e81a0c9c..abcf22b1 100644 --- a/src-tauri/src/application/commands/add_account.rs +++ b/src-tauri/src/application/commands/add_account.rs @@ -36,6 +36,8 @@ impl CommandBus { })?; let id = AccountId::new(Uuid::new_v4().to_string()); + let operation_lock = self.account_operation_lock(&id)?; + let _operation_guard = operation_lock.lock().await; let account = Account::new( id.clone(), service_name, diff --git a/src-tauri/src/application/commands/delete_account.rs b/src-tauri/src/application/commands/delete_account.rs index 7230bea4..48902998 100644 --- a/src-tauri/src/application/commands/delete_account.rs +++ b/src-tauri/src/application/commands/delete_account.rs @@ -27,6 +27,8 @@ impl CommandBus { let store = self.account_credential_store().ok_or_else(|| { AppError::Validation("account credential store not configured".into()) })?; + let operation_lock = self.account_operation_lock(&cmd.id)?; + let _operation_guard = operation_lock.lock().await; repo.delete(&cmd.id)?; if let Err(e) = store.delete_password(&cmd.id) { diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 1580ec97..270d6cd3 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -484,7 +484,7 @@ pub struct ValidationOutcomeDto { impl From for ValidationOutcomeDto { fn from(o: crate::domain::ports::driven::ValidationOutcome) -> Self { Self { - valid: o.valid, + valid: o.is_valid(), latency_ms: o.latency_ms, traffic_left: o.traffic_left, traffic_total: o.traffic_total, diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index dc3181af..95e13d98 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -10,7 +10,7 @@ use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::application::services::account_rotator::NextAccountOutcome; use crate::domain::error::DomainError; -use crate::domain::model::account::{Account, AccountStatus}; +use crate::domain::model::account::AccountStatus; use crate::domain::model::credential::Credential; use crate::domain::model::http::HttpResponse; use crate::domain::model::plugin::PluginCategory; @@ -111,7 +111,7 @@ impl CommandBus { if matches!(info.category(), PluginCategory::Hoster | PluginCategory::Debrid) ); if is_hoster { - match self.resolve_hoster_link(url, &module_name) { + match self.resolve_hoster_link(url, &module_name).await { Ok(resolved) => results.push(ResolvedLinkDto { id, original_url: url.clone(), @@ -207,17 +207,34 @@ impl CommandBus { Ok(results) } - fn resolve_hoster_link( + async fn resolve_hoster_link( &self, url: &str, service_name: &str, ) -> Result { if let (Some(repo), Some(store)) = (self.account_repo(), self.account_credential_store()) { let max_attempts = repo.list_by_service(service_name)?.len(); + let mut last_temporary_error = None; for _ in 0..max_attempts { - let Some(mut account) = self.next_hoster_account(service_name)? else { - break; + let selected = match self.next_hoster_account(service_name)? { + NextAccountOutcome::Picked(account) => account, + NextAccountOutcome::NoneAvailable => break, + NextAccountOutcome::AllExhausted { .. } => { + return Err(last_temporary_error + .unwrap_or(DomainError::AccountQuotaExceeded) + .into()); + } + }; + let operation_lock = self.account_operation_lock(selected.id())?; + let _operation_guard = operation_lock.lock().await; + let Some(mut account) = repo.find_by_id(selected.id())? else { + continue; }; + if account.service_name() != service_name + || !account.is_selectable(self.account_now_ms()?) + { + continue; + } let Some(password) = store.get_password(account.id())? else { account.set_status(AccountStatus::MissingCredential); repo.save(&account)?; @@ -250,6 +267,9 @@ impl CommandBus { DomainError::AccountQuotaExceeded => AccountStatus::QuotaExhausted, other => return Err(other.into()), }; + if status.is_temporary() { + last_temporary_error = Some(error.clone()); + } if status == AccountStatus::QuotaExhausted && let Some(rotator) = self.account_rotator() { @@ -258,8 +278,12 @@ impl CommandBus { status, AccountStatus::QuotaExhausted | AccountStatus::Cooldown ) { - account - .mark_unavailable(status, current_time_ms().saturating_add(60_000)); + let until_ms = self.account_now_ms()?.saturating_add(60_000); + if status == AccountStatus::Cooldown { + account.mark_cooldown(until_ms); + } else { + account.mark_exhausted(until_ms); + } repo.save(&account)?; } else { account.set_status(status); @@ -268,6 +292,9 @@ impl CommandBus { } } } + if let Some(error) = last_temporary_error { + return Err(error.into()); + } } let link = self @@ -276,15 +303,15 @@ impl CommandBus { Ok(into_hoster_resolution(link, None)) } - fn next_hoster_account(&self, service_name: &str) -> Result, AppError> { + fn next_hoster_account(&self, service_name: &str) -> Result { let Some(rotator) = self.account_rotator() else { - return self.resolve_account_for(service_name); + return Ok(match self.resolve_account_for(service_name)? { + Some(account) => NextAccountOutcome::Picked(account), + None => NextAccountOutcome::NoneAvailable, + }); }; let strategy = self.config_store().get_config()?.account_selection_strategy; - match rotator.next_account(service_name, strategy)? { - NextAccountOutcome::Picked(account) => Ok(Some(account)), - NextAccountOutcome::NoneAvailable | NextAccountOutcome::AllExhausted { .. } => Ok(None), - } + rotator.next_account(service_name, strategy) } } @@ -298,13 +325,6 @@ fn into_hoster_resolution(link: ExtractedHosterLink, account_id: Option<&str>) - } } -fn current_time_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - fn sanitize_hoster_error(error: &AppError) -> String { match error { AppError::Domain(DomainError::AccountInvalidCredentials) => { diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index 2b983107..09ea144d 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -25,7 +25,8 @@ impl CommandBus { cmd: super::StartDownloadCommand, ) -> Result { let url = Url::new(&cmd.url)?; - self.validate_download_account(cmd.module_name.as_deref(), cmd.account_id.as_ref())?; + self.validate_download_account(cmd.module_name.as_deref(), cmd.account_id.as_ref()) + .await?; // Use the pre-computed filename when available (e.g. set by media plugins // that already know the video title). Otherwise probe via HEAD or fall back @@ -94,7 +95,7 @@ impl CommandBus { Ok(id) } - fn validate_download_account( + async fn validate_download_account( &self, module_name: Option<&str>, account_id: Option<&AccountId>, @@ -113,6 +114,8 @@ impl CommandBus { let store = self.account_credential_store().ok_or_else(|| { AppError::Validation("account credential store not configured".into()) })?; + let operation_lock = self.account_operation_lock(account_id)?; + let _operation_guard = operation_lock.lock().await; let mut account = repo.find_by_id(account_id)?.ok_or_else(|| { AppError::NotFound(format!("account {} not found", account_id.as_str())) })?; @@ -130,7 +133,7 @@ impl CommandBus { account_id.as_str() ))); } - if !account.is_selectable(current_time_ms()) { + if !account.is_selectable(self.account_now_ms()?) { return Err(AppError::Validation(format!( "account {} is not available", account_id.as_str() @@ -140,13 +143,6 @@ impl CommandBus { } } -fn current_time_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - /// Generate a restart-safe, collision-resistant download ID that fits /// within JavaScript's `Number.MAX_SAFE_INTEGER` (2^53). /// @@ -224,7 +220,15 @@ mod tests { FakeAccountCredentialStore, InMemoryAccountRepo, }; use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; - use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository}; + use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository, Clock}; + + struct FixedAccountClock; + + impl Clock for FixedAccountClock { + fn now_unix_secs(&self) -> u64 { + 1 + } + } struct MockDownloadRepo { store: Mutex>, @@ -541,7 +545,8 @@ mod tests { credentials.store_password(&account_id, "api-key").unwrap(); let bus = bus .with_account_repo(account_repo) - .with_account_credential_store(credentials); + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedAccountClock)); let id = bus .handle_start_download(StartDownloadCommand { @@ -600,6 +605,48 @@ mod tests { assert!(matches!(error, AppError::NotFound(_))); } + #[tokio::test] + async fn test_start_download_uses_injected_clock_for_account_cooldown() { + let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account_id = AccountId::new("account-1"); + let account = Account::reconstruct_with_status( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Cooldown, + Some(2_000), + ); + account_repo.save(&account).unwrap(); + credentials.store_password(&account_id, "api-key").unwrap(); + let bus = bus + .with_account_repo(account_repo) + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedAccountClock)); + + let error = bus + .handle_start_download(StartDownloadCommand { + url: "https://download.1fichier.com/token/file.zip".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + source_hostname_override: Some("1fichier.com".into()), + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(account_id), + }) + .await + .expect_err("injected clock keeps cooldown active"); + + assert!(matches!(error, AppError::Validation(_))); + } + #[tokio::test] async fn test_start_download_invalid_url_returns_error() { let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index db526c4d..c774b050 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -24,8 +24,9 @@ use crate::domain::model::package::{Package, PackageId}; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, AccountValidator, ArchiveExtractor, - ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, EventBus, - FileStorage, HttpClient, PackageRepository, PassphraseCodec, PluginLoader, ValidationOutcome, + ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, + EventBus, FileStorage, HttpClient, PackageRepository, PassphraseCodec, PluginLoader, + ValidationOutcome, }; // ── In-memory account repository ───────────────────────────────────── @@ -201,6 +202,14 @@ pub(crate) struct FakeAccountValidator { calls: Mutex>, } +struct FixedAccountClock; + +impl Clock for FixedAccountClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } +} + #[derive(Clone)] pub(crate) enum ValidatorBehavior { Ok(ValidationOutcome), @@ -802,7 +811,8 @@ pub(crate) fn build_account_bus_with_plugin_loader( None, ) .with_account_repo(account_repo) - .with_account_credential_store(credential_store); + .with_account_credential_store(credential_store) + .with_account_clock(Arc::new(FixedAccountClock)); if let Some(v) = validator { bus = bus.with_account_validator(v); diff --git a/src-tauri/src/application/commands/update_account.rs b/src-tauri/src/application/commands/update_account.rs index 515429a7..00d9f261 100644 --- a/src-tauri/src/application/commands/update_account.rs +++ b/src-tauri/src/application/commands/update_account.rs @@ -15,7 +15,9 @@ use crate::domain::event::DomainEvent; use crate::domain::model::account::Account; use crate::domain::ports::driven::ValidationOutcome; -use super::validate_account::{apply_validation, publish_validation, validate_credentials}; +use super::validate_account::{ + apply_validation, publish_validation, sync_validation_availability, validate_credentials, +}; impl CommandBus { pub async fn handle_update_account( @@ -28,6 +30,8 @@ impl CommandBus { let store = self.account_credential_store().ok_or_else(|| { AppError::Validation("account credential store not configured".into()) })?; + let operation_lock = self.account_operation_lock(&cmd.id)?; + let _operation_guard = operation_lock.lock().await; let account = repo .find_by_id(&cmd.id)? @@ -99,39 +103,29 @@ impl CommandBus { None }; - repo.save(&next)?; - - // Apply password rotation after the row is persisted. If the - // keyring write fails we roll the row back to the original so - // callers never observe a row that says "password rotated" while - // the keyring still holds the previous secret. - if let Some(pw) = cmd.patch.password - && let Err(e) = store.store_password(&cmd.id, &pw) + if let Some(password) = cmd.patch.password.as_deref() + && let Err(error) = store.store_password(&cmd.id, password) { - if let Err(rollback_err) = repo.save(&account) { - tracing::warn!( - account_id = %cmd.id.as_str(), - keyring_error = %e, - rollback_error = %rollback_err, - "keyring rotation failed and row rollback also failed; row metadata diverges from keyring" - ); + restore_password(store, &cmd.id, previous_password.as_deref(), &error); + return Err(error.into()); + } + + if let Err(error) = repo.save(&next) { + if cmd.patch.password.is_some() { + restore_password(store, &cmd.id, previous_password.as_deref(), &error); } - // Restore the previous password (or wipe the entry if the - // account had none) so a partially-completed write doesn't - // leave a half-rotated credential in the keyring. - let restore_result = match previous_password { - Some(prev) => store.store_password(&cmd.id, &prev), - None => store.delete_password(&cmd.id), - }; - if let Err(restore_err) = restore_result { + if let Err(rollback_error) = repo.save(&account) { tracing::warn!( account_id = %cmd.id.as_str(), - keyring_error = %e, - restore_error = %restore_err, - "keyring rotation failed and the password-restore step also failed; keyring may hold a partially rotated secret" + save_error = %error, + rollback_error = %rollback_error, + "account row save failed and rollback also failed" ); } - return Err(e.into()); + return Err(error.into()); + } + if let Some(attempt) = &validation { + sync_validation_availability(self, &cmd.id, &attempt.outcome)?; } self.event_bus() @@ -143,6 +137,26 @@ impl CommandBus { } } +fn restore_password( + store: &dyn crate::domain::ports::driven::AccountCredentialStore, + id: &crate::domain::model::account::AccountId, + previous_password: Option<&str>, + cause: &crate::domain::error::DomainError, +) { + let restored = match previous_password { + Some(password) => store.store_password(id, password), + None => store.delete_password(id), + }; + if let Err(restore_error) = restored { + tracing::warn!( + account_id = %id.as_str(), + error = %cause, + restore_error = %restore_error, + "account password restore failed; keyring may be inconsistent" + ); + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -456,4 +470,41 @@ mod tests { AppError::Domain(DomainError::AlreadyExists(_)) )); } + + #[tokio::test] + async fn repo_failure_after_password_rotation_restores_the_previous_secret() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo, creds.clone(), events, None, None); + let id = bus + .handle_add_account(add_command("real-debrid", "alice", "old-pw")) + .await + .expect("first account"); + bus.handle_add_account(add_command("real-debrid", "bob", "other-pw")) + .await + .expect("conflicting account"); + + let error = bus + .handle_update_account(UpdateAccountCommand { + id: id.clone(), + now_ms: 1_800_000_000_000, + patch: AccountPatch { + username: Some("bob".into()), + password: Some("new-pw".into()), + ..AccountPatch::default() + }, + }) + .await + .expect_err("duplicate username must reject the row"); + + assert!(matches!( + error, + AppError::Domain(DomainError::AlreadyExists(_)) + )); + assert_eq!(creds.get_password(&id).unwrap().as_deref(), Some("old-pw")); + let attempts = creds.write_attempts(); + assert_eq!(attempts[attempts.len() - 2].1, "new-pw"); + assert_eq!(attempts[attempts.len() - 1].1, "old-pw"); + } } diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index a734015e..8f344bec 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -63,19 +63,27 @@ pub(super) fn apply_validation( outcome: &ValidationOutcome, now_ms: u64, ) -> Account { - let mut next = clone_account(account); + const TEMPORARY_FAILURE_TTL_MS: u64 = 60_000; + + let mut next = account.clone(); next.set_last_validated(now_ms); - next.set_status(outcome.status); - if outcome.valid { + match outcome.status { + AccountStatus::QuotaExhausted => { + next.mark_exhausted(now_ms.saturating_add(TEMPORARY_FAILURE_TTL_MS)); + } + AccountStatus::Cooldown => { + next.mark_cooldown(now_ms.saturating_add(TEMPORARY_FAILURE_TTL_MS)); + } + status => next.set_status(status), + } + if outcome.is_valid() { if let Some(traffic_left) = outcome.traffic_left { next.set_traffic_left(traffic_left); } if let Some(traffic_total) = outcome.traffic_total { next.set_traffic_total(traffic_total); } - if let Some(valid_until) = outcome.valid_until { - next.set_valid_until(valid_until); - } + next.replace_valid_until(outcome.valid_until); } next } @@ -85,7 +93,7 @@ pub(super) fn publish_validation( id: crate::domain::model::account::AccountId, outcome: &ValidationOutcome, ) { - if outcome.valid { + if outcome.is_valid() { bus.event_bus().publish(DomainEvent::AccountValidated { id, latency_ms: outcome.latency_ms, @@ -105,6 +113,19 @@ pub(super) fn publish_validation( } } +pub(super) fn sync_validation_availability( + bus: &CommandBus, + id: &crate::domain::model::account::AccountId, + outcome: &ValidationOutcome, +) -> Result<(), AppError> { + if outcome.is_valid() + && let Some(rotator) = bus.account_rotator() + { + rotator.clear_exhausted(id)?; + } + Ok(()) +} + impl CommandBus { pub async fn handle_validate_account( &self, @@ -119,6 +140,8 @@ impl CommandBus { let validator = self .account_validator() .ok_or_else(|| AppError::Validation("account validator not configured".into()))?; + let operation_lock = self.account_operation_lock(&cmd.id)?; + let _operation_guard = operation_lock.lock().await; let account = repo .find_by_id(&cmd.id)? @@ -141,6 +164,7 @@ impl CommandBus { let attempt = validate_credentials(validator, &account, &password); repo.save(&apply_validation(&account, &attempt.outcome, cmd.now_ms))?; + sync_validation_availability(self, &cmd.id, &attempt.outcome)?; publish_validation(self, cmd.id, &attempt.outcome); match attempt.error { @@ -151,40 +175,52 @@ impl CommandBus { } } -fn clone_account(account: &Account) -> Account { - Account::reconstruct_with_status( - account.id().clone(), - account.service_name().to_string(), - account.username().to_string(), - account.account_type(), - account.is_enabled(), - account.traffic_left(), - account.traffic_total(), - account.valid_until(), - account.last_validated(), - account.created_at(), - account.status(), - account.exhausted_until(), - ) -} - #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Condvar, Mutex}; + use std::time::Duration; - use super::super::{AddAccountCommand, ValidateAccountCommand}; + use super::super::{AddAccountCommand, DeleteAccountCommand, ValidateAccountCommand}; use crate::application::commands::tests_support::{ CapturingEventBus, FakeAccountCredentialStore, FakeAccountValidator, InMemoryAccountRepo, ValidatorBehavior, build_account_bus, }; use crate::application::error::AppError; + use crate::application::services::{AccountRotator, AccountSelector}; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, ValidationOutcome, + AccountCredentialStore, AccountRepository, AccountValidator, Clock, ValidationOutcome, }; + struct ValidationClock; + + impl Clock for ValidationClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } + } + + struct BlockingValidator { + entered: Mutex>>, + release: Arc<(Mutex, Condvar)>, + } + + impl AccountValidator for BlockingValidator { + fn validate(&self, _: &str, _: &str, _: &str) -> Result { + if let Some(sender) = self.entered.lock().expect("entered mutex").take() { + sender.send(()).expect("test receiver remains alive"); + } + let (mutex, condition) = &*self.release; + let mut released = mutex.lock().expect("release mutex"); + while !*released { + released = condition.wait(released).expect("release wait"); + } + Ok(ValidationOutcome::ok()) + } + } + fn add_command(service: &str) -> AddAccountCommand { AddAccountCommand { service_name: service.into(), @@ -232,6 +268,135 @@ mod tests { assert_eq!(validated.exhausted_until(), Some(60_200)); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_delete_cannot_be_undone_by_a_slow_validation() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let validator = Arc::new(BlockingValidator { + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + }); + let events = Arc::new(CapturingEventBus::new()); + let bus = Arc::new(build_account_bus( + repo.clone(), + credentials, + events, + Some(validator), + None, + )); + + let validating_bus = bus.clone(); + let validating = tokio::spawn(async move { + validating_bus + .handle_validate_account(ValidateAccountCommand { + id: AccountId::new("account-1"), + now_ms: 2, + }) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) + .await + .expect("join entered wait") + .expect("validator entered"); + + let deleting_bus = bus.clone(); + let mut deleting = tokio::spawn(async move { + deleting_bus + .handle_delete_account(DeleteAccountCommand { + id: AccountId::new("account-1"), + }) + .await + }); + let deleted_while_validation_blocked = + tokio::time::timeout(Duration::from_millis(100), &mut deleting) + .await + .ok(); + + let (mutex, condition) = &*release; + *mutex.lock().expect("release mutex") = true; + condition.notify_all(); + validating + .await + .expect("validation join") + .expect("validation"); + match deleted_while_validation_blocked { + Some(result) => result.expect("delete join").expect("delete"), + None => deleting.await.expect("delete join").expect("delete"), + } + + assert!( + repo.find_by_id(account.id()) + .expect("read account") + .is_none(), + "a validation that began before deletion must not recreate the row" + ); + } + + #[tokio::test] + async fn successful_validation_clears_the_rotator_cooldown_cache() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::ok()), + ); + let account = Account::reconstruct_with_status( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 1, + AccountStatus::Valid, + None, + ); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + let clock: Arc = Arc::new(ValidationClock); + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock); + rotator + .mark_exhausted(account.id(), account.service_name(), 60) + .expect("mark exhausted"); + let bus = build_account_bus(repo.clone(), credentials, events, Some(validator), None) + .with_account_rotator(rotator.clone()); + + bus.handle_validate_account(ValidateAccountCommand { + id: account.id().clone(), + now_ms: 1_700_000_000_000, + }) + .await + .expect("validation succeeds"); + + assert!(!rotator.is_exhausted(account.id()).expect("rotator state")); + assert_eq!( + repo.find_by_id(account.id()).unwrap().unwrap().status(), + AccountStatus::Valid + ); + } + #[tokio::test] async fn test_validate_account_unknown_service_returns_not_found() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -271,7 +436,6 @@ mod tests { validator.set( "real-debrid", ValidatorBehavior::Ok(ValidationOutcome { - valid: true, status: crate::domain::model::account::AccountStatus::Valid, latency_ms: Some(120), traffic_left: Some(50_000), diff --git a/src-tauri/src/application/services/account_operation_locks.rs b/src-tauri/src/application/services/account_operation_locks.rs new file mode 100644 index 00000000..807dd96e --- /dev/null +++ b/src-tauri/src/application/services/account_operation_locks.rs @@ -0,0 +1,42 @@ +//! Per-account serialization for metadata, keyring, and plugin operations. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::application::error::AppError; +use crate::domain::model::account::AccountId; + +#[derive(Default)] +pub struct AccountOperationLocks { + entries: Mutex>>>, +} + +impl AccountOperationLocks { + pub fn lock_for(&self, id: &AccountId) -> Result>, AppError> { + let mut entries = self + .entries + .lock() + .map_err(|_| AppError::Validation("account operation locks mutex poisoned".into()))?; + Ok(entries + .entry(id.clone()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_account_reuses_one_lock_while_distinct_accounts_do_not() { + let locks = AccountOperationLocks::default(); + let first = locks.lock_for(&AccountId::new("first")).unwrap(); + let same = locks.lock_for(&AccountId::new("first")).unwrap(); + let other = locks.lock_for(&AccountId::new("other")).unwrap(); + + assert!(Arc::ptr_eq(&first, &same)); + assert!(!Arc::ptr_eq(&first, &other)); + } +} diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index fb4522b0..d9500f68 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -220,7 +220,7 @@ impl AccountRotator { .chain(std::iter::once(proposed)) .max() .unwrap_or(proposed); - account.mark_unavailable(AccountStatus::QuotaExhausted, final_deadline); + account.mark_exhausted(final_deadline); self.repo.save(&account)?; guard.insert(account_id.clone(), final_deadline); final_deadline diff --git a/src-tauri/src/application/services/account_selector.rs b/src-tauri/src/application/services/account_selector.rs index 0148c5b0..614f72b3 100644 --- a/src-tauri/src/application/services/account_selector.rs +++ b/src-tauri/src/application/services/account_selector.rs @@ -583,7 +583,7 @@ mod tests { Some(now_ms), true, ); - cooling.mark_unavailable(AccountStatus::Cooldown, now_ms + 30_000); + cooling.mark_cooldown(now_ms + 30_000); let (selector, _bus) = build_selector(vec![cooling], now_secs); diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index 4b68ac78..b0537b9e 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod account_operation_locks; pub mod account_rotator; pub mod account_selector; pub mod checksum_validator; diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index 44c61953..9567a0db 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -68,6 +68,12 @@ pub enum AccountStatus { Error, } +impl AccountStatus { + pub fn is_temporary(self) -> bool { + matches!(self, Self::QuotaExhausted | Self::Cooldown) + } +} + impl fmt::Display for AccountStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let value = match self { @@ -290,16 +296,17 @@ impl Account { self.valid_until = Some(timestamp); } + pub fn replace_valid_until(&mut self, timestamp: Option) { + self.valid_until = timestamp; + } + pub fn set_last_validated(&mut self, timestamp: u64) { self.last_validated = Some(timestamp); } pub fn set_status(&mut self, status: AccountStatus) { self.status = status; - if !matches!( - status, - AccountStatus::QuotaExhausted | AccountStatus::Cooldown - ) { + if !status.is_temporary() { self.exhausted_until = None; } } @@ -308,11 +315,7 @@ impl Account { self.status } - pub fn mark_unavailable(&mut self, status: AccountStatus, until_ms: u64) { - debug_assert!(matches!( - status, - AccountStatus::QuotaExhausted | AccountStatus::Cooldown - )); + fn mark_temporarily_unavailable(&mut self, status: AccountStatus, until_ms: u64) { self.status = status; self.exhausted_until = Some(until_ms); } @@ -344,17 +347,19 @@ impl Account { /// Mark this account as quota-exhausted until `until_ms` (Unix epoch /// ms). Adapters persist the status and deadline with the aggregate. pub fn mark_exhausted(&mut self, until_ms: u64) { - self.mark_unavailable(AccountStatus::QuotaExhausted, until_ms); + self.mark_temporarily_unavailable(AccountStatus::QuotaExhausted, until_ms); + } + + /// Mark this account as rate-limited until `until_ms` (Unix epoch ms). + pub fn mark_cooldown(&mut self, until_ms: u64) { + self.mark_temporarily_unavailable(AccountStatus::Cooldown, until_ms); } /// Drop any pending quota-exhaustion marker, regardless of the /// remaining cooldown. pub fn clear_exhausted(&mut self) { self.exhausted_until = None; - if matches!( - self.status, - AccountStatus::QuotaExhausted | AccountStatus::Cooldown - ) { + if self.status.is_temporary() { self.status = AccountStatus::Valid; } } @@ -666,11 +671,11 @@ mod tests { account.set_status(AccountStatus::InvalidCredentials); assert!(!account.is_selectable(1_000)); - account.mark_unavailable(AccountStatus::Cooldown, 2_000); + account.mark_cooldown(2_000); assert!(!account.is_selectable(1_999)); assert!(account.is_selectable(2_000)); - account.mark_unavailable(AccountStatus::QuotaExhausted, 3_000); + account.mark_exhausted(3_000); assert!(!account.is_selectable(2_999)); assert!(account.is_selectable(3_000)); } diff --git a/src-tauri/src/domain/ports/driven/account_validator.rs b/src-tauri/src/domain/ports/driven/account_validator.rs index 1fc5d669..fea16b86 100644 --- a/src-tauri/src/domain/ports/driven/account_validator.rs +++ b/src-tauri/src/domain/ports/driven/account_validator.rs @@ -17,7 +17,6 @@ use crate::domain::model::account::AccountStatus; /// explain *why* (wrong password, expired, rate-limited, ...). #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ValidationOutcome { - pub valid: bool, pub status: AccountStatus, pub latency_ms: Option, pub traffic_left: Option, @@ -29,7 +28,6 @@ pub struct ValidationOutcome { impl ValidationOutcome { pub fn ok() -> Self { Self { - valid: true, status: AccountStatus::Valid, ..Self::default() } @@ -37,12 +35,15 @@ impl ValidationOutcome { pub fn rejected(status: AccountStatus, error_message: impl Into) -> Self { Self { - valid: false, status, error_message: Some(error_message.into()), ..Self::default() } } + + pub fn is_valid(&self) -> bool { + self.status == AccountStatus::Valid + } } /// Validates an account's credentials by attempting to connect to the @@ -68,7 +69,7 @@ mod tests { #[test] fn test_validation_outcome_ok_marks_valid_with_no_error() { let out = ValidationOutcome::ok(); - assert!(out.valid); + assert!(out.is_valid()); assert_eq!(out.status, AccountStatus::Valid); assert!(out.error_message.is_none()); assert!(out.latency_ms.is_none()); @@ -78,7 +79,7 @@ mod tests { #[test] fn test_validation_outcome_rejected_records_message_and_invalid_flag() { let out = ValidationOutcome::rejected(AccountStatus::InvalidCredentials, "wrong password"); - assert!(!out.valid); + assert!(!out.is_valid()); assert_eq!(out.status, AccountStatus::InvalidCredentials); assert_eq!(out.error_message.as_deref(), Some("wrong password")); } @@ -86,7 +87,7 @@ mod tests { #[test] fn test_validation_outcome_default_is_invalid_and_empty() { let out = ValidationOutcome::default(); - assert!(!out.valid); + assert!(!out.is_valid()); assert!(out.latency_ms.is_none()); assert!(out.error_message.is_none()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b3bd4aa..89fa6a4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -251,7 +251,7 @@ pub fn run() { account_selector.clone(), account_repo.clone(), event_bus.clone(), - account_clock, + account_clock.clone(), ); let account_validator = Arc::new(PluginAccountValidator::new(plugin_loader.clone())); @@ -400,6 +400,7 @@ pub fn run() { .with_account_validator(account_validator) .with_account_selector(account_selector) .with_account_rotator(account_rotator) + .with_account_clock(account_clock) .with_package_repo(package_repo.clone()) .with_passphrase_codec(passphrase_codec), ); From 91f07716a1dcf2ae432786da5eb15735b52de37d Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:47:00 +0200 Subject: [PATCH 22/33] test(accounts): keep direct URLs inside native runtime --- CHANGELOG.md | 4 ++++ .../src/application/commands/resolve_links.rs | 8 +++++++- .../__tests__/LinkGrabberView.test.tsx | 16 +++++++++------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98068242..ec02847b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **MAT-132 transient premium URLs**: failing-first backend and frontend + contracts require short-lived direct download capabilities to remain inside + the native runtime; IPC and queued download persistence carry the stable + source URL plus opaque account id instead. - **MAT-132 credential boundary hardening**: account extraction now calls the exact selected plugin instead of resolving the URL a second time, credential slots are isolated per loaded plugin generation across hot reloads, hoster diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 95e13d98..413cfb30 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -603,7 +603,13 @@ mod tests { assert_eq!( result[0].resolved_url.as_deref(), - Some("https://download.1fichier.com/token/file.zip") + Some("https://1fichier.com/?abc123") + ); + assert!( + !serde_json::to_string(&result) + .expect("serialize resolved links") + .contains("download.1fichier.com/token"), + "short-lived direct capabilities must not cross IPC" ); assert_eq!(result[0].account_id.as_deref(), Some("backup")); assert_eq!(result[0].module_name, "vortex-mod-1fichier"); diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index 6080b959..5f329378 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -182,15 +182,16 @@ describe("LinkGrabberView", () => { }); }); - it("passes the selected premium account association to download_start", async () => { + it("passes the source URL and premium account association to download_start", async () => { + const sourceUrl = "https://1fichier.com/?abc123"; const directUrl = "https://download.1fichier.com/token/file.zip"; mockInvoke.mockImplementation((command) => { if (command === "link_resolve") { return Promise.resolve([ { id: "premium-link", - originalUrl: "ftp://1fichier.com/file.zip", - resolvedUrl: directUrl, + originalUrl: sourceUrl, + resolvedUrl: sourceUrl, filename: "file.zip", sizeBytes: 42, status: "online", @@ -203,7 +204,7 @@ describe("LinkGrabberView", () => { if (command === "link_detect_duplicates") { return Promise.resolve([ { - url: directUrl, + url: sourceUrl, isDuplicate: false, source: null, existingId: null, @@ -216,11 +217,11 @@ describe("LinkGrabberView", () => { const user = userEvent.setup(); renderWithProviders(); - await user.type(screen.getByRole("textbox"), "ftp://1fichier.com/file.zip"); + await user.type(screen.getByRole("textbox"), sourceUrl); await user.click(screen.getByRole("button", { name: "Analyze Links" })); await waitFor(() => { expect(mockInvoke).toHaveBeenCalledWith("link_detect_duplicates", { - urls: [directUrl], + urls: [sourceUrl], }); }); @@ -228,11 +229,12 @@ describe("LinkGrabberView", () => { await waitFor(() => { expect(mockInvoke).toHaveBeenCalledWith("download_start", { - url: directUrl, + url: sourceUrl, moduleName: "vortex-mod-1fichier", accountId: "account-uuid", }); }); + expect(JSON.stringify(mockInvoke.mock.calls)).not.toContain(directUrl); }); it("should surface error toast on failure and success toast on retry", async () => { From dbefb288b09b56fa457eff85173d1cbf0fd75d57 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:59:28 +0200 Subject: [PATCH 23/33] feat(accounts): resolve premium URLs just in time --- CHANGELOG.md | 10 +- .../driven/network/download_engine.rs | 249 ++++++++++++++---- src-tauri/src/adapters/driven/network/mod.rs | 2 + .../src/adapters/driven/network/safe_url.rs | 124 +++++++++ .../adapters/driven/network/segment_worker.rs | 23 +- .../adapters/driven/plugin/host_functions.rs | 154 +++-------- src-tauri/src/application/command_bus.rs | 8 + .../src/application/commands/resolve_links.rs | 40 ++- .../application/commands/validate_account.rs | 36 +-- .../src/application/services/account_state.rs | 25 ++ src-tauri/src/application/services/mod.rs | 2 + .../services/premium_source_resolver.rs | 117 ++++++++ .../services/premium_source_resolver_tests.rs | 120 +++++++++ .../ports/driven/download_source_resolver.rs | 31 +++ src-tauri/src/domain/ports/driven/mod.rs | 2 + src-tauri/src/lib.rs | 18 +- .../__tests__/LinkGrabberView.test.tsx | 2 +- 17 files changed, 740 insertions(+), 223 deletions(-) create mode 100644 src-tauri/src/adapters/driven/network/safe_url.rs create mode 100644 src-tauri/src/application/services/account_state.rs create mode 100644 src-tauri/src/application/services/premium_source_resolver.rs create mode 100644 src-tauri/src/application/services/premium_source_resolver_tests.rs create mode 100644 src-tauri/src/domain/ports/driven/download_source_resolver.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ec02847b..54204167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **MAT-132 transient premium URLs**: failing-first backend and frontend - contracts require short-lived direct download capabilities to remain inside - the native runtime; IPC and queued download persistence carry the stable - source URL plus opaque account id instead. +- **MAT-132 transient premium URLs**: link analysis, IPC, queued downloads, and + resume sidecars now retain only the stable source URL plus opaque account id. + The engine resolves the direct capability immediately before connecting, + pins its public DNS answers, requires credential-free HTTPS, and rejects + redirects/private networks. Sensitive network diagnostics and all plugin + logs emitted while a credential is scoped are redacted. - **MAT-132 credential boundary hardening**: account extraction now calls the exact selected plugin instead of resolving the URL a second time, credential slots are isolated per loaded plugin generation across hot reloads, hoster diff --git a/src-tauri/src/adapters/driven/network/download_engine.rs b/src-tauri/src/adapters/driven/network/download_engine.rs index ac65e4d3..0d78adb3 100644 --- a/src-tauri/src/adapters/driven/network/download_engine.rs +++ b/src-tauri/src/adapters/driven/network/download_engine.rs @@ -11,10 +11,10 @@ use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::download::{Download, DownloadId}; use crate::domain::model::meta::{DownloadMeta, SegmentMeta}; -use crate::domain::ports::driven::{DownloadEngine, EventBus, FileStorage}; +use crate::domain::ports::driven::{DownloadEngine, DownloadSourceResolver, EventBus, FileStorage}; -use super::format_error_chain; use super::segment_worker::{SegmentError, SegmentParams, download_segment}; +use super::{format_error_chain, restricted_download_client}; struct ActiveDownload { cancel_token: CancellationToken, @@ -171,6 +171,7 @@ pub struct SegmentedDownloadEngine { dynamic_split_enabled: Arc, dynamic_split_min_remaining_bytes: Arc, active_downloads: Arc>>, + source_resolver: Option>, } impl SegmentedDownloadEngine { @@ -189,6 +190,7 @@ impl SegmentedDownloadEngine { dynamic_split_enabled: Arc::new(AtomicBool::new(true)), dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(4 * 1024 * 1024)), active_downloads: Arc::new(Mutex::new(HashMap::new())), + source_resolver: None, } } @@ -197,6 +199,11 @@ impl SegmentedDownloadEngine { self } + pub fn with_source_resolver(mut self, resolver: Arc) -> Self { + self.source_resolver = Some(resolver); + self + } + /// Configure runtime re-splitting of slow segments. PRD §7.1. /// `min_remaining_mb == 0` disables the size gate entirely; the engine /// then only refuses to split if the candidate has 0 bytes left. @@ -235,18 +242,13 @@ impl SegmentedDownloadEngine { Ok(response) if response.status().is_success() => response, Ok(response) => { tracing::warn!( - url, status = %response.status(), "HEAD probe returned non-success status, falling back to GET metadata probe" ); client.get(url).send().await? } - Err(err) => { - tracing::warn!( - url, - error = %format_error_chain(&err), - "HEAD probe failed, falling back to GET metadata probe" - ); + Err(_) => { + tracing::warn!("HEAD probe failed, falling back to GET metadata probe"); client.get(url).send().await? } }; @@ -281,6 +283,75 @@ enum AttemptOutcome { Failed(String), } +struct PreparedSources { + urls: Vec, + initial_index: usize, + client: reqwest::Client, + resume_url: String, + sensitive: bool, +} + +async fn prepare_sources( + download: Download, + resolver: Option>, + client: reqwest::Client, +) -> Result { + let resume_url = download.url().as_str().to_string(); + if download.account_id().is_some() { + let resolver = resolver.ok_or_else(|| { + DomainError::PluginError("premium source resolver is not configured".into()) + })?; + let source = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .map_err(|_| DomainError::PluginError("premium source resolver stopped".into()))??; + let request_url = source.request_url().to_string(); + let (request_url, client) = tokio::task::spawn_blocking(move || { + let parsed = reqwest::Url::parse(&request_url) + .map_err(|_| DomainError::NetworkError("plugin returned an invalid URL".into()))?; + let client = restricted_download_client(&parsed)?; + Ok::<_, DomainError>((request_url, client)) + }) + .await + .map_err(|_| DomainError::NetworkError("URL safety check stopped".into()))??; + return Ok(PreparedSources { + urls: vec![request_url], + initial_index: 0, + client, + resume_url, + sensitive: true, + }); + } + let urls = if download.mirrors().is_empty() { + vec![resume_url.clone()] + } else { + download + .mirrors() + .iter() + .map(|mirror| mirror.url().as_str().to_string()) + .collect::>() + }; + let initial_index = + (download.current_mirror_index() as usize).min(urls.len().saturating_sub(1)); + Ok(PreparedSources { + urls, + initial_index, + client, + resume_url, + sensitive: false, + }) +} + +fn source_failure_message(error: &DomainError) -> String { + match error { + DomainError::AccountInvalidCredentials => "Account credentials were rejected", + DomainError::AccountExpired => "Account is expired", + DomainError::AccountCooldown => "Account is temporarily rate-limited", + DomainError::AccountQuotaExceeded => "Account quota is exhausted", + _ => "Premium download source could not be resolved", + } + .to_string() +} + impl DownloadEngine for SegmentedDownloadEngine { fn start(&self, download: &Download) -> Result<(), DomainError> { let download_id = download.id(); @@ -293,22 +364,6 @@ impl DownloadEngine for SegmentedDownloadEngine { download.segments_count() }; - // Snapshot the candidate URLs ahead of `tokio::spawn` so the - // failover loop owns them. An empty mirror list collapses to the - // canonical URL — single-source downloads keep their pre-mirror - // behaviour and never observe `MirrorSwitched`. - let mirror_urls: Vec = if download.mirrors().is_empty() { - vec![download.url().as_str().to_string()] - } else { - download - .mirrors() - .iter() - .map(|m| m.url().as_str().to_string()) - .collect() - }; - let initial_mirror_idx = - (download.current_mirror_index() as usize).min(mirror_urls.len().saturating_sub(1)); - let cancel_token = CancellationToken::new(); let (pause_tx, pause_rx) = watch::channel(false); @@ -339,9 +394,42 @@ impl DownloadEngine for SegmentedDownloadEngine { let min_segment_bytes = self.min_segment_bytes; let dynamic_split_enabled = self.dynamic_split_enabled.clone(); let dynamic_split_min_remaining_bytes = self.dynamic_split_min_remaining_bytes.clone(); + let source_resolver = self.source_resolver.clone(); + let download = download.clone(); tokio::spawn(async move { - let mut mirror_idx = initial_mirror_idx; + let prepared = match prepare_sources(download, source_resolver, client).await { + Ok(prepared) => prepared, + Err(error) => { + let event = if cancel_token.is_cancelled() { + DomainEvent::DownloadCancelled { id: download_id } + } else { + DomainEvent::DownloadFailed { + id: download_id, + error: source_failure_message(&error), + } + }; + event_bus.publish(event); + active_downloads + .lock() + .expect("active_downloads lock poisoned") + .remove(&download_id); + return; + } + }; + let client = prepared.client; + let mirror_urls = prepared.urls; + let resume_url = prepared.resume_url; + let sensitive_url = prepared.sensitive; + if cancel_token.is_cancelled() { + event_bus.publish(DomainEvent::DownloadCancelled { id: download_id }); + active_downloads + .lock() + .expect("active_downloads lock poisoned") + .remove(&download_id); + return; + } + let mut mirror_idx = prepared.initial_index; loop { let url = mirror_urls[mirror_idx].clone(); // Each attempt gets a fresh attempt-scoped child token so @@ -364,6 +452,8 @@ impl DownloadEngine for SegmentedDownloadEngine { min_segment_bytes, dynamic_split_enabled: dynamic_split_enabled.clone(), dynamic_split_min_remaining_bytes: dynamic_split_min_remaining_bytes.clone(), + resume_url: resume_url.clone(), + sensitive_url, }) .await; @@ -392,13 +482,21 @@ impl DownloadEngine for SegmentedDownloadEngine { break; } mirror_idx = next; - tracing::info!( - download_id = download_id.0, - new_mirror_index = mirror_idx, - new_url = %mirror_urls[mirror_idx], - previous_error = %err, - "switching to next mirror after failure" - ); + if sensitive_url { + tracing::info!( + download_id = download_id.0, + new_mirror_index = mirror_idx, + "switching sensitive source after failure" + ); + } else { + tracing::info!( + download_id = download_id.0, + new_mirror_index = mirror_idx, + new_url = %mirror_urls[mirror_idx], + previous_error = %err, + "switching to next mirror after failure" + ); + } // Wipe the previous mirror's partial file + meta so // the next attempt starts clean. The pre-allocation // step uses `create_new(true)` and would otherwise @@ -438,7 +536,11 @@ impl DownloadEngine for SegmentedDownloadEngine { event_bus.publish(DomainEvent::MirrorSwitched { id: download_id, new_mirror_index: mirror_idx as u32, - new_url: mirror_urls[mirror_idx].clone(), + new_url: if sensitive_url { + resume_url.clone() + } else { + mirror_urls[mirror_idx].clone() + }, }); continue; } @@ -531,6 +633,8 @@ struct MirrorAttemptParams { min_segment_bytes: u64, dynamic_split_enabled: Arc, dynamic_split_min_remaining_bytes: Arc, + resume_url: String, + sensitive_url: bool, } async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { @@ -548,24 +652,31 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { min_segment_bytes, dynamic_split_enabled, dynamic_split_min_remaining_bytes, + resume_url, + sensitive_url, } = params; let (total_size, supports_range) = match SegmentedDownloadEngine::probe_remote_metadata(&client, &url).await { Ok(metadata) => metadata, Err(e) => { - tracing::warn!( - download_id = download_id.0, - url = %url, - error = %format_error_chain(&e), - "metadata probe failed (mirror attempt)" - ); + if sensitive_url { + tracing::warn!(download_id = download_id.0, "metadata probe failed"); + } else { + tracing::warn!( + download_id = download_id.0, + url = %url, + error = %format_error_chain(&e), + "metadata probe failed (mirror attempt)" + ); + } if user_cancel_token.is_cancelled() { return AttemptOutcome::Cancelled; } - return AttemptOutcome::Failed(format!( - "metadata probe failed: {}", - format_error_chain(&e) - )); + return AttemptOutcome::Failed(if sensitive_url { + "metadata probe failed".into() + } else { + format!("metadata probe failed: {}", format_error_chain(&e)) + }); } }; @@ -676,6 +787,7 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { cancel_token: attempt_token.clone(), shared_downloaded: shared_downloaded.clone(), segment_progress: progress, + sensitive_url, }; let slot_idx = index; join_set.spawn(async move { (slot_idx, download_segment(params).await) }); @@ -739,6 +851,7 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { cancel_token: attempt_token.clone(), shared_downloaded: shared_downloaded.clone(), segment_progress: new_progress.clone(), + sensitive_url, }; join_set.spawn(async move { (new_slot_idx, download_segment(params).await) }); active_segments.push(SegmentRuntimeState { @@ -754,7 +867,7 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { &file_storage, &dest_path, download_id, - &url, + &resume_url, total_size, &active_segments, ) @@ -807,14 +920,18 @@ mod tests { use std::path::Path; use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::time::Duration; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; + use crate::domain::model::account::AccountId; use crate::domain::model::download::{Download, DownloadId, Url}; use crate::domain::model::meta::DownloadMeta; - use crate::domain::ports::driven::{EventBus, FileStorage}; + use crate::domain::ports::driven::{ + DownloadSourceResolver, EventBus, FileStorage, ResolvedDownloadSource, + }; // --- Mock types --- @@ -918,6 +1035,46 @@ mod tests { SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus, 4) } + struct LoopbackSourceResolver { + calls: AtomicUsize, + } + + impl DownloadSourceResolver for LoopbackSourceResolver { + fn resolve(&self, _: &Download) -> Result { + self.calls.fetch_add(1, AtomicOrdering::SeqCst); + Ok(ResolvedDownloadSource::sensitive( + "https://127.0.0.1/secret-token".into(), + )) + } + } + + #[tokio::test] + async fn premium_source_is_resolved_jit_and_blocked_before_private_network_access() { + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let resolver = Arc::new(LoopbackSourceResolver { + calls: AtomicUsize::new(0), + }); + let engine = make_engine(storage, bus.clone()).with_source_resolver(resolver.clone()); + let download = make_download(99, "https://1fichier.com/?abc123") + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(AccountId::new("account-1")); + + engine.start(&download).expect("spawn download"); + + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadFailed { id, .. } if id.0 == 99), + Duration::from_secs(2), + ) + .await + ); + assert_eq!(resolver.calls.load(AtomicOrdering::SeqCst), 1); + let serialized = format!("{:?}", bus.collected()); + assert!(!serialized.contains("secret-token")); + assert_eq!(download.url().as_str(), "https://1fichier.com/?abc123"); + } + // --- Tests --- #[tokio::test] diff --git a/src-tauri/src/adapters/driven/network/mod.rs b/src-tauri/src/adapters/driven/network/mod.rs index 11782275..438b941c 100644 --- a/src-tauri/src/adapters/driven/network/mod.rs +++ b/src-tauri/src/adapters/driven/network/mod.rs @@ -1,12 +1,14 @@ mod checksum; mod download_engine; mod reqwest_client; +mod safe_url; mod segment_worker; mod wait_manager; pub use checksum::StreamingChecksumComputer; pub use download_engine::SegmentedDownloadEngine; pub use reqwest_client::ReqwestHttpClient; +pub(crate) use safe_url::{restricted_download_client, validate_public_url}; pub use wait_manager::WaitManager; pub(super) fn format_error_chain(err: &(dyn std::error::Error + 'static)) -> String { diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs new file mode 100644 index 00000000..4a8fa748 --- /dev/null +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -0,0 +1,124 @@ +//! Network policy for URLs supplied by plugins rather than users. + +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::time::Duration; + +use crate::domain::error::DomainError; + +pub(crate) fn validate_public_url( + url: &reqwest::Url, +) -> Result>, DomainError> { + let host = url + .host_str() + .ok_or_else(|| DomainError::NetworkError("URL has no host".into()))?; + if host == "localhost" || host.ends_with(".localhost") { + return Err(blocked()); + } + if let Ok(ip) = host.parse::() { + return if is_forbidden_ip(&ip) { + Err(blocked()) + } else { + Ok(None) + }; + } + let port = url + .port_or_known_default() + .ok_or_else(|| DomainError::NetworkError("URL has no known port".into()))?; + let addresses = (host, port) + .to_socket_addrs() + .map_err(|_| DomainError::NetworkError("host resolution failed".into()))? + .collect::>(); + if addresses.is_empty() || addresses.iter().any(|addr| is_forbidden_ip(&addr.ip())) { + return Err(blocked()); + } + Ok(Some(addresses)) +} + +pub(crate) fn restricted_download_client( + url: &reqwest::Url, +) -> Result { + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return Err(DomainError::NetworkError( + "plugin download URL must be credential-free HTTPS".into(), + )); + } + let addresses = validate_public_url(url)?; + let mut builder = reqwest::Client::builder() + .user_agent("Vortex/0.1") + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(3600)); + if let (Some(host), Some(addresses)) = (url.host_str(), addresses.as_deref()) { + builder = builder.resolve_to_addrs(host, addresses); + } + builder + .build() + .map_err(|_| DomainError::NetworkError("restricted HTTP client creation failed".into())) +} + +fn blocked() -> DomainError { + DomainError::NetworkError("plugin URL targets a non-public network".into()) +} + +pub(crate) fn is_forbidden_ip(ip: &IpAddr) -> bool { + let normalized = match ip { + IpAddr::V6(ip) if ip.to_ipv4_mapped().is_some() => IpAddr::V4( + ip.to_ipv4_mapped() + .unwrap_or(std::net::Ipv4Addr::UNSPECIFIED), + ), + other => *other, + }; + match normalized { + IpAddr::V4(ip) => { + let [a, b, c, d] = ip.octets(); + a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224 + || (a == 255 && b == 255 && c == 255 && d == 255) + } + IpAddr::V6(ip) => { + let first = ip.segments()[0]; + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || ip.segments()[..2] == [0x2001, 0x0db8] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_loopback_private_link_local_and_mapped_addresses() { + for raw in [ + "127.0.0.1", + "10.0.0.1", + "169.254.169.254", + "::ffff:127.0.0.1", + ] { + assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}"); + } + } + + #[test] + fn restricted_client_requires_https_and_public_destination() { + let http = reqwest::Url::parse("http://1.1.1.1/file").unwrap(); + let local = reqwest::Url::parse("https://127.0.0.1/file").unwrap(); + assert!(restricted_download_client(&http).is_err()); + assert!(restricted_download_client(&local).is_err()); + } +} diff --git a/src-tauri/src/adapters/driven/network/segment_worker.rs b/src-tauri/src/adapters/driven/network/segment_worker.rs index 77a66c21..d58f9879 100644 --- a/src-tauri/src/adapters/driven/network/segment_worker.rs +++ b/src-tauri/src/adapters/driven/network/segment_worker.rs @@ -53,6 +53,8 @@ pub(crate) struct SegmentParams { /// Per-segment downloaded counter, observable by the engine to estimate /// throughput when picking a split target. pub segment_progress: Arc, + /// Suppress request diagnostics that may contain a short-lived capability. + pub sensitive_url: bool, } /// Downloads a single byte range and writes it to disk. @@ -76,6 +78,7 @@ pub(crate) async fn download_segment(params: SegmentParams) -> Result Result Result Result>, extism::Error> { - if let Some(host) = url.host_str() { - // Reject localhost variants - if host == "localhost" || host.ends_with(".localhost") { - return Err(anyhow::anyhow!( - "http_request: requests to localhost are forbidden" - )); - } - - if let Ok(ip) = host.parse::() { - if is_forbidden_ip(&ip) { - return Err(anyhow::anyhow!( - "http_request: requests to internal networks are forbidden" - )); - } - return Ok(None); - } - - let port = url - .port_or_known_default() - .ok_or_else(|| anyhow::anyhow!("http_request: URL is missing a known port"))?; - - let resolved_addrs = (host, port) - .to_socket_addrs() - .map_err(|e| anyhow::anyhow!("http_request: failed to resolve host '{host}': {e}"))? - .collect::>(); - - if resolved_addrs.is_empty() { - return Err(anyhow::anyhow!( - "http_request: host '{host}' did not resolve to any addresses" - )); - } - - if resolved_addrs - .iter() - .any(|addr| is_forbidden_ip(&addr.ip())) - { - return Err(anyhow::anyhow!( - "http_request: requests to internal networks are forbidden" - )); - } - - return Ok(Some(resolved_addrs)); - } - Ok(None) -} - -fn normalize_ip(ip: &IpAddr) -> IpAddr { - match ip { - IpAddr::V4(v4) => IpAddr::V4(*v4), - IpAddr::V6(v6) => v6 - .to_ipv4_mapped() - .map(IpAddr::V4) - .unwrap_or(IpAddr::V6(*v6)), - } -} - -fn is_forbidden_ip(ip: &IpAddr) -> bool { - let normalized = normalize_ip(ip); - normalized.is_loopback() - || normalized.is_unspecified() - || is_private_ip(&normalized) - || is_link_local(&normalized) -} - -fn is_private_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - let octets = v4.octets(); - octets[0] == 10 - || (octets[0] == 172 && (16..=31).contains(&octets[1])) - || (octets[0] == 192 && octets[1] == 168) - } - IpAddr::V6(v6) => { - if let Some(v4) = v6.to_ipv4_mapped() { - return is_private_ip(&IpAddr::V4(v4)); - } - let segments = v6.segments(); - // fc00::/7 (unique local) - (segments[0] & 0xfe00) == 0xfc00 - } - } -} - -fn is_link_local(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - let octets = v4.octets(); - // 169.254.0.0/16 (includes AWS metadata 169.254.169.254) - octets[0] == 169 && octets[1] == 254 - } - IpAddr::V6(v6) => { - if let Some(v4) = v6.to_ipv4_mapped() { - return is_link_local(&IpAddr::V4(v4)); - } - let segments = v6.segments(); - // fe80::/10 - (segments[0] & 0xffc0) == 0xfe80 - } - } +fn safe_plugin_log_message( + message: &str, + credential_slot: &CredentialSlot, +) -> Result { + let carries_credential = credential_slot + .lock() + .map_err(|_| anyhow::anyhow!("log: credential slot poisoned"))? + .is_some(); + Ok(if carries_credential { + "plugin log redacted during credential operation".into() + } else { + message.to_string() + }) } fn read_http_body_capped( @@ -207,11 +119,12 @@ pub fn make_log_function(user_data: extism::UserData) -> exti .lock() .map_err(|_| anyhow::anyhow!("log: mutex poisoned"))?; let plugin_name = ctx.plugin_name.as_str(); + let message = safe_plugin_log_message(&req.message, &ctx.credential_slot)?; match req.level.as_str() { - "error" => tracing::error!(plugin = plugin_name, "{}", req.message), - "warn" => tracing::warn!(plugin = plugin_name, "{}", req.message), - "debug" => tracing::debug!(plugin = plugin_name, "{}", req.message), - _ => tracing::info!(plugin = plugin_name, "{}", req.message), + "error" => tracing::error!(plugin = plugin_name, "{}", message), + "warn" => tracing::warn!(plugin = plugin_name, "{}", message), + "debug" => tracing::debug!(plugin = plugin_name, "{}", message), + _ => tracing::info!(plugin = plugin_name, "{}", message), } Ok(()) }, @@ -241,7 +154,8 @@ pub fn make_http_request_function( .map_err(|e| anyhow::anyhow!("http_request: invalid URL '{}': {e}", req.url))?; // F1: SSRF protection — reject internal/loopback destinations - let resolved_addrs = validate_url_not_internal(&url)?; + let resolved_addrs = + validate_public_url(&url).map_err(|e| anyhow::anyhow!("http_request: {e}"))?; // F6: Minimize mutex scope — prepare the client, then release the lock let client = { @@ -556,6 +470,7 @@ pub fn make_legacy_run_subprocess_function( mod tests { use super::*; use crate::adapters::driven::plugin::capabilities::SharedHostResources; + use crate::domain::model::credential::Credential; #[test] fn test_log_request_deserialization() { @@ -619,13 +534,20 @@ mod tests { } #[test] - fn test_ipv4_mapped_ipv6_is_forbidden() { - let loopback = "::ffff:127.0.0.1".parse::().unwrap(); - let private = "::ffff:10.0.0.1".parse::().unwrap(); - let link_local = "::ffff:169.254.169.254".parse::().unwrap(); - - assert!(is_forbidden_ip(&loopback)); - assert!(is_forbidden_ip(&private)); - assert!(is_forbidden_ip(&link_local)); + fn plugin_logs_are_fully_redacted_while_a_credential_is_scoped() { + let slot = Arc::new(std::sync::Mutex::new(Some(Credential::new( + "alice", + "super-secret", + )))); + + let message = safe_plugin_log_message( + "Authorization: Bearer super-secret; direct=https://cdn/token", + &slot, + ) + .unwrap(); + + assert_eq!(message, "plugin log redacted during credential operation"); + assert!(!message.contains("super-secret")); + assert!(!message.contains("cdn/token")); } } diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index b5b7a3d2..d89e514d 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -236,6 +236,14 @@ impl CommandBus { self } + pub(crate) fn with_account_operation_locks( + mut self, + locks: Arc, + ) -> Self { + self.account_operation_locks = locks; + self + } + /// Builder-style setter for the passphrase codec used by the /// import / export commands. pub fn with_passphrase_codec(mut self, codec: Arc) -> Self { diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 413cfb30..c67f786a 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -9,6 +9,7 @@ use uuid::Uuid; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::application::services::account_rotator::NextAccountOutcome; +use crate::application::services::account_state::{apply_status, status_for_plugin_error}; use crate::domain::error::DomainError; use crate::domain::model::account::AccountStatus; use crate::domain::model::credential::Credential; @@ -255,17 +256,15 @@ impl CommandBus { } account.set_status(AccountStatus::Valid); repo.save(&account)?; - return Ok(into_hoster_resolution(link, Some(account.id().as_str()))); + return Ok(into_hoster_resolution( + link, + Some(account.id().as_str()), + url, + )); } Err(error) => { - let status = match error { - DomainError::AccountInvalidCredentials => { - AccountStatus::InvalidCredentials - } - DomainError::AccountExpired => AccountStatus::Expired, - DomainError::AccountCooldown => AccountStatus::Cooldown, - DomainError::AccountQuotaExceeded => AccountStatus::QuotaExhausted, - other => return Err(other.into()), + let Some(status) = status_for_plugin_error(&error) else { + return Err(error.into()); }; if status.is_temporary() { last_temporary_error = Some(error.clone()); @@ -274,19 +273,8 @@ impl CommandBus { && let Some(rotator) = self.account_rotator() { rotator.mark_exhausted(account.id(), service_name, 60)?; - } else if matches!( - status, - AccountStatus::QuotaExhausted | AccountStatus::Cooldown - ) { - let until_ms = self.account_now_ms()?.saturating_add(60_000); - if status == AccountStatus::Cooldown { - account.mark_cooldown(until_ms); - } else { - account.mark_exhausted(until_ms); - } - repo.save(&account)?; } else { - account.set_status(status); + apply_status(&mut account, status, self.account_now_ms()?); repo.save(&account)?; } } @@ -300,7 +288,7 @@ impl CommandBus { let link = self .plugin_loader() .extract_hoster_link(service_name, url, None)?; - Ok(into_hoster_resolution(link, None)) + Ok(into_hoster_resolution(link, None, url)) } fn next_hoster_account(&self, service_name: &str) -> Result { @@ -315,10 +303,14 @@ impl CommandBus { } } -fn into_hoster_resolution(link: ExtractedHosterLink, account_id: Option<&str>) -> HosterResolution { +fn into_hoster_resolution( + link: ExtractedHosterLink, + account_id: Option<&str>, + stable_url: &str, +) -> HosterResolution { let selected_account = link.direct_url.as_ref().and(account_id).map(str::to_string); HosterResolution { - resolved_url: link.direct_url.unwrap_or(link.source_url), + resolved_url: stable_url.to_string(), filename: link.filename, size_bytes: link.size_bytes, account_id: selected_account, diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index 8f344bec..d933afa4 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -10,6 +10,7 @@ use super::ValidationOutcomeDto; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; +use crate::application::services::account_state::{apply_status, status_for_plugin_error}; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountStatus}; @@ -30,23 +31,12 @@ pub(super) fn validate_credentials( outcome, error: None, }, - Err(DomainError::AccountInvalidCredentials) => typed_rejection( - AccountStatus::InvalidCredentials, - DomainError::AccountInvalidCredentials, - ), - Err(DomainError::AccountExpired) => { - typed_rejection(AccountStatus::Expired, DomainError::AccountExpired) - } - Err(DomainError::AccountCooldown) => { - typed_rejection(AccountStatus::Cooldown, DomainError::AccountCooldown) - } - Err(DomainError::AccountQuotaExceeded) => typed_rejection( - AccountStatus::QuotaExhausted, - DomainError::AccountQuotaExceeded, - ), - Err(error) => AccountValidationAttempt { - outcome: ValidationOutcome::rejected(AccountStatus::Error, error.to_string()), - error: Some(error), + Err(error) => match status_for_plugin_error(&error) { + Some(status) => typed_rejection(status, error), + None => AccountValidationAttempt { + outcome: ValidationOutcome::rejected(AccountStatus::Error, error.to_string()), + error: Some(error), + }, }, } } @@ -63,19 +53,9 @@ pub(super) fn apply_validation( outcome: &ValidationOutcome, now_ms: u64, ) -> Account { - const TEMPORARY_FAILURE_TTL_MS: u64 = 60_000; - let mut next = account.clone(); next.set_last_validated(now_ms); - match outcome.status { - AccountStatus::QuotaExhausted => { - next.mark_exhausted(now_ms.saturating_add(TEMPORARY_FAILURE_TTL_MS)); - } - AccountStatus::Cooldown => { - next.mark_cooldown(now_ms.saturating_add(TEMPORARY_FAILURE_TTL_MS)); - } - status => next.set_status(status), - } + apply_status(&mut next, outcome.status, now_ms); if outcome.is_valid() { if let Some(traffic_left) = outcome.traffic_left { next.set_traffic_left(traffic_left); diff --git a/src-tauri/src/application/services/account_state.rs b/src-tauri/src/application/services/account_state.rs new file mode 100644 index 00000000..6d6b619b --- /dev/null +++ b/src-tauri/src/application/services/account_state.rs @@ -0,0 +1,25 @@ +//! Shared account transitions for typed plugin failures. + +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountStatus}; + +pub const TEMPORARY_ACCOUNT_FAILURE_MS: u64 = 60_000; + +pub fn status_for_plugin_error(error: &DomainError) -> Option { + match error { + DomainError::AccountInvalidCredentials => Some(AccountStatus::InvalidCredentials), + DomainError::AccountExpired => Some(AccountStatus::Expired), + DomainError::AccountCooldown => Some(AccountStatus::Cooldown), + DomainError::AccountQuotaExceeded => Some(AccountStatus::QuotaExhausted), + _ => None, + } +} + +pub fn apply_status(account: &mut Account, status: AccountStatus, now_ms: u64) { + let until_ms = now_ms.saturating_add(TEMPORARY_ACCOUNT_FAILURE_MS); + match status { + AccountStatus::QuotaExhausted => account.mark_exhausted(until_ms), + AccountStatus::Cooldown => account.mark_cooldown(until_ms), + other => account.set_status(other), + } +} diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index b0537b9e..bee81841 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -1,12 +1,14 @@ pub(crate) mod account_operation_locks; pub mod account_rotator; pub mod account_selector; +pub(crate) mod account_state; pub mod checksum_validator; pub mod engine_config_bridge; pub(crate) mod group_lock; pub mod history_backfill; pub mod history_paginate; pub mod playlist_grouper; +pub(crate) mod premium_source_resolver; pub mod queue_config_bridge; pub mod queue_manager; pub mod split_archive_grouper; diff --git a/src-tauri/src/application/services/premium_source_resolver.rs b/src-tauri/src/application/services/premium_source_resolver.rs new file mode 100644 index 00000000..ab053902 --- /dev/null +++ b/src-tauri/src/application/services/premium_source_resolver.rs @@ -0,0 +1,117 @@ +//! Just-in-time premium URL resolution for queued downloads. + +use std::sync::Arc; + +use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::application::services::account_state::{apply_status, status_for_plugin_error}; +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountStatus}; +use crate::domain::model::credential::Credential; +use crate::domain::model::download::Download; +use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, Clock, DownloadSourceResolver, PluginLoader, + ResolvedDownloadSource, +}; + +pub struct PremiumSourceResolver { + repo: Arc, + credentials: Arc, + plugins: Arc, + clock: Arc, + locks: Arc, +} + +impl PremiumSourceResolver { + pub fn new( + repo: Arc, + credentials: Arc, + plugins: Arc, + clock: Arc, + locks: Arc, + ) -> Self { + Self { + repo, + credentials, + plugins, + clock, + locks, + } + } + + fn persist_failure(&self, account: &mut Account, error: &DomainError) { + if let Some(status) = status_for_plugin_error(error) { + apply_status(account, status, self.clock.now_unix_ms()); + if let Err(save_error) = self.repo.save(account) { + tracing::warn!( + account_id = %account.id().as_str(), + error = %save_error, + "failed to persist premium account failure" + ); + } + } + } +} + +impl DownloadSourceResolver for PremiumSourceResolver { + fn resolve(&self, download: &Download) -> Result { + let account_id = download.account_id().ok_or_else(|| { + DomainError::ValidationError("premium download has no account association".into()) + })?; + let service_name = download.module_name().ok_or_else(|| { + DomainError::ValidationError("premium download has no plugin association".into()) + })?; + let lock = self + .locks + .lock_for(account_id) + .map_err(|_| DomainError::StorageError("account lock unavailable".into()))?; + let _guard = lock.blocking_lock(); + let mut account = self + .repo + .find_by_id(account_id)? + .ok_or_else(|| DomainError::NotFound(format!("account {}", account_id.as_str())))?; + if account.service_name() != service_name + || !account.is_selectable(self.clock.now_unix_ms()) + { + return Err(DomainError::ValidationError( + "premium account is unavailable".into(), + )); + } + let password = self.credentials.get_password(account_id)?.ok_or_else(|| { + account.set_status(AccountStatus::MissingCredential); + let _ = self.repo.save(&account); + DomainError::NotFound(format!("credential for account {}", account_id.as_str())) + })?; + let credential = Credential::new(account.username(), password); + let link = match self.plugins.extract_hoster_link( + service_name, + download.url().as_str(), + Some(&credential), + ) { + Ok(link) => link, + Err(error) => { + self.persist_failure(&mut account, &error); + return Err(match error { + DomainError::AccountInvalidCredentials + | DomainError::AccountExpired + | DomainError::AccountCooldown + | DomainError::AccountQuotaExceeded => error, + _ => DomainError::PluginError("premium source resolution failed".into()), + }); + } + }; + let direct_url = link.direct_url.ok_or_else(|| { + DomainError::PluginError("premium plugin returned no direct URL".into()) + })?; + if let Some(total) = link.traffic_total_bytes { + account.set_traffic_total(total); + account.set_traffic_left(total.saturating_sub(link.traffic_used_bytes.unwrap_or(0))); + } + account.set_status(AccountStatus::Valid); + self.repo.save(&account)?; + Ok(ResolvedDownloadSource::sensitive(direct_url)) + } +} + +#[cfg(test)] +#[path = "premium_source_resolver_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/services/premium_source_resolver_tests.rs b/src-tauri/src/application/services/premium_source_resolver_tests.rs new file mode 100644 index 00000000..824a1413 --- /dev/null +++ b/src-tauri/src/application/services/premium_source_resolver_tests.rs @@ -0,0 +1,120 @@ +use std::sync::{Arc, Mutex}; + +use super::*; +use crate::application::commands::tests_support::{ + FakeAccountCredentialStore, InMemoryAccountRepo, +}; +use crate::domain::model::account::{AccountId, AccountType}; +use crate::domain::model::download::{DownloadId, Url}; +use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, ExtractedHosterLink, +}; + +struct FixedClock; + +impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } +} + +struct DirectUrlPlugin { + calls: Mutex>, +} + +impl PluginLoader for DirectUrlPlugin { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(None) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(Vec::new()) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn extract_hoster_link( + &self, + service: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result { + let credential = credential.expect("premium credential"); + self.calls.lock().unwrap().push(( + service.to_string(), + url.to_string(), + credential.password().to_string(), + )); + Ok(ExtractedHosterLink { + source_url: url.to_string(), + filename: Some("file.zip".into()), + size_bytes: Some(42), + direct_url: Some("https://1.1.1.1/short-lived-token".into()), + traffic_used_bytes: Some(10), + traffic_total_bytes: Some(100), + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resolves_the_direct_url_only_when_the_engine_requests_it() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin { + calls: Mutex::new(Vec::new()), + }); + let account_id = AccountId::new("account-1"); + let mut account = Account::new( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + repo.save(&account).unwrap(); + credentials.store_password(&account_id, "api-key").unwrap(); + let resolver = Arc::new(PremiumSourceResolver::new( + repo.clone(), + credentials, + plugin.clone(), + Arc::new(FixedClock), + Arc::new(AccountOperationLocks::default()), + )); + let download = Download::new( + DownloadId(1), + Url::new("https://1fichier.com/?abc123").unwrap(), + "file.zip".into(), + "/tmp/file.zip".into(), + ) + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(account_id.clone()); + + let source = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .unwrap() + .unwrap(); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert_eq!( + plugin.calls.lock().unwrap().as_slice(), + [( + "vortex-mod-1fichier".into(), + "https://1fichier.com/?abc123".into(), + "api-key".into() + )] + ); + let stored = repo.find_by_id(&account_id).unwrap().unwrap(); + assert_eq!(stored.traffic_left(), Some(90)); +} diff --git a/src-tauri/src/domain/ports/driven/download_source_resolver.rs b/src-tauri/src/domain/ports/driven/download_source_resolver.rs new file mode 100644 index 00000000..d01e34ba --- /dev/null +++ b/src-tauri/src/domain/ports/driven/download_source_resolver.rs @@ -0,0 +1,31 @@ +//! Resolves a persisted source URL into an ephemeral download capability. + +use crate::domain::error::DomainError; +use crate::domain::model::download::Download; + +#[derive(Clone, PartialEq, Eq)] +pub struct ResolvedDownloadSource { + request_url: String, +} + +impl std::fmt::Debug for ResolvedDownloadSource { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ResolvedDownloadSource()") + } +} + +impl ResolvedDownloadSource { + pub fn sensitive(request_url: String) -> Self { + Self { request_url } + } + + pub fn request_url(&self) -> &str { + &self.request_url + } +} + +/// Called by the download engine immediately before opening the connection. +/// Implementations must never persist or log the returned URL. +pub trait DownloadSourceResolver: Send + Sync { + fn resolve(&self, download: &Download) -> Result; +} diff --git a/src-tauri/src/domain/ports/driven/mod.rs b/src-tauri/src/domain/ports/driven/mod.rs index 99e09f06..b3278014 100644 --- a/src-tauri/src/domain/ports/driven/mod.rs +++ b/src-tauri/src/domain/ports/driven/mod.rs @@ -13,6 +13,7 @@ pub mod credential_store; pub mod download_engine; pub mod download_read_repository; pub mod download_repository; +pub mod download_source_resolver; pub mod event_bus; pub mod file_opener; pub mod file_storage; @@ -41,6 +42,7 @@ pub use credential_store::CredentialStore; pub use download_engine::DownloadEngine; pub use download_read_repository::DownloadReadRepository; pub use download_repository::DownloadRepository; +pub use download_source_resolver::{DownloadSourceResolver, ResolvedDownloadSource}; pub use event_bus::EventBus; pub use file_opener::FileOpener; pub use file_storage::FileStorage; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89fa6a4b..a3fbd1fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,8 +9,8 @@ use tauri::Manager; use domain::ports::driven::{ AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, DownloadRepository, - EventBus, FileStorage, HistoryRepository, HttpClient, PassphraseCodec, PluginLoader, - PluginReadRepository, StatsRepository, + DownloadSourceResolver, EventBus, FileStorage, HistoryRepository, HttpClient, PassphraseCodec, + PluginLoader, PluginReadRepository, StatsRepository, }; // Public API — concrete types for app wiring (main.rs, Tauri setup, integration tests) @@ -255,6 +255,18 @@ pub fn run() { ); let account_validator = Arc::new(PluginAccountValidator::new(plugin_loader.clone())); + let account_operation_locks = Arc::new( + application::services::account_operation_locks::AccountOperationLocks::default(), + ); + let premium_source_resolver: Arc = Arc::new( + application::services::premium_source_resolver::PremiumSourceResolver::new( + account_repo.clone(), + account_credential_store.clone(), + plugin_loader.clone(), + account_clock.clone(), + account_operation_locks.clone(), + ), + ); // ── Download engine ───────────────────────────────────── let initial_engine_config = config_store @@ -267,6 +279,7 @@ pub fn run() { event_bus.clone(), 4, ) + .with_source_resolver(premium_source_resolver) .with_dynamic_split( initial_engine_config.dynamic_split_enabled, initial_engine_config.dynamic_split_min_remaining_mb, @@ -401,6 +414,7 @@ pub fn run() { .with_account_selector(account_selector) .with_account_rotator(account_rotator) .with_account_clock(account_clock) + .with_account_operation_locks(account_operation_locks) .with_package_repo(package_repo.clone()) .with_passphrase_codec(passphrase_codec), ); diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index 5f329378..b7b24ae1 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -183,7 +183,7 @@ describe("LinkGrabberView", () => { }); it("passes the source URL and premium account association to download_start", async () => { - const sourceUrl = "https://1fichier.com/?abc123"; + const sourceUrl = "ftp://1fichier.com/file.zip"; const directUrl = "https://download.1fichier.com/token/file.zip"; mockInvoke.mockImplementation((command) => { if (command === "link_resolve") { From ba81261328751662ced6ad53f1e190aa7eac6395 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:03:50 +0200 Subject: [PATCH 24/33] test(accounts): expose remaining UI contract gaps --- CHANGELOG.md | 3 +++ src-tauri/src/adapters/driving/tauri_ipc.rs | 27 ++++++++++++++----- .../application/read_models/account_view.rs | 6 +++-- .../__tests__/AccountsView.test.tsx | 2 -- .../__tests__/statusUtils.test.ts | 15 ++++++++++- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54204167..523edfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **MAT-132 account status contract**: account validation responses now expose + their typed status to the UI, elapsed quota/cooldown markers render as active, + and account read models no longer expose backend keyring references. - **Task 40 follow-up — `/simplify` cleanup** (scope `download`, sprint task 40): extracted `getHostname` + `getProtocol` to `src/lib/url.ts` and replaced the inlined copies in `MirrorsSection.tsx` + `SourceInfoSection.tsx` with the shared helpers. `run_mirror_attempt` swapped its 13 positional arguments for a `MirrorAttemptParams` struct mirroring the adjacent `SegmentParams` pattern; the `#[allow(clippy::too_many_arguments)]` suppression is gone. `entities/download.rs` + `download_read_repo.rs` use `safe_u32(model.current_mirror_index as i64)` instead of the ad-hoc `u32::try_from(...).unwrap_or(0)` so the cast goes through the SQLite adapter's standard helper. New `MAX_MIRRORS_PER_DOWNLOAD = 64` constant in `domain/model/download.rs`; `Download::set_mirrors` truncates after sorting so a malformed `.metalink` with thousands of entries cannot bloat `mirrors_json` or stall the failover loop. Doc comments on `Download::mirrors` / `current_mirror_index` and the entity's `mirrors_json` / `current_mirror_index` columns trimmed to the WHY clauses (cap rationale, future-extension hook); the persisted cursor's actual behaviour is now spelled out — the engine still drives failover with its in-task cursor, so a crash mid-failover restarts from slot 0, and the column is retained as a hook for the future call site that will mark a failing slot at the domain level. Frontend gates simplified: `DownloadDetailsPanel.tsx` drops the redundant `mirrors && …` (non-optional field in TS), `MirrorsSection.tsx` drops the redundant `!download.mirrors` short-circuit (dead code under the parent gate). - **Task 39 follow-up — PR #151 review fixes** (scope `download`): `WaitManager::schedule_wait` now aborts the previous `JoinHandle` returned by `guard.insert(id, handle)` instead of just dropping it (dropping a `JoinHandle` only detaches the task — `abort()` is what actually stops the timer), so calling `schedule_wait` twice for the same `DownloadId` no longer leaves a stale task that could fire `expire_wait` against an outdated deadline. `expire_wait` now short-circuits when the handle has already been removed by a peer `cancel_wait` / `skip_wait` (the abort is cooperative: once the sleep wakes and the task is past its last `.await`, it runs to completion regardless of `abort()`), preventing a spurious `resume_aggregate` race against the cancel flow. `download_cancel` IPC now runs the `CancelDownloadCommand` first and only invokes `wait_manager.cancel_wait` on success, so a fallible cancel command no longer strands the download in `Waiting` with no timer to resume it. `download_log_bridge` records `DownloadWaitingEnded { expired_naturally: false }` as `"ended early"` instead of `"skipped"` (the same branch fires for cancel-driven endings, where "skipped" was misleading). `useCountdown` short-circuits before scheduling `setInterval` when `untilUnixMs` is already in the past, dropping one no-op render + interval wakeup cycle for already-expired rows. New regression test `rescheduling_same_id_aborts_previous_timer` proves only the latest timer can fire after a reschedule + new `useCountdown` test asserts `setInterval` is not called for past deadlines. diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index 28c816e5..0f81ba34 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -3495,15 +3495,17 @@ pub async fn package_find_by_external_id( #[cfg(test)] mod tests { use super::{ - DEFAULT_DOWNLOAD_LOG_LIMIT, StreamResolution, configured_download_destination, - configured_status_bar_path, extract_hostname_from_url, load_plugin_media_metadata, - parse_plugin_video_metadata, parse_soundcloud_metadata, parse_soundcloud_playlist_targets, - parse_stats_period, read_available_space, resolve_download_log_limit, - resolve_existing_disk_path, resolve_media_stream, sanitize_extension, sanitize_filename, - soundcloud_track_download_title, unique_destination, + DEFAULT_DOWNLOAD_LOG_LIMIT, StreamResolution, ValidationOutcomeView, + configured_download_destination, configured_status_bar_path, extract_hostname_from_url, + load_plugin_media_metadata, parse_plugin_video_metadata, parse_soundcloud_metadata, + parse_soundcloud_playlist_targets, parse_stats_period, read_available_space, + resolve_download_log_limit, resolve_existing_disk_path, resolve_media_stream, + sanitize_extension, sanitize_filename, soundcloud_track_download_title, unique_destination, }; use crate::adapters::driven::logging::download_log_store::DownloadLogStore; + use crate::application::commands::ValidationOutcomeDto; use crate::domain::error::DomainError; + use crate::domain::model::account::AccountStatus; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::model::views::StatsPeriod; use crate::domain::ports::driven::PluginLoader; @@ -3513,6 +3515,19 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Barrier}; + #[test] + fn validation_outcome_view_serializes_typed_status() { + let view = ValidationOutcomeView::from(ValidationOutcomeDto { + status: AccountStatus::QuotaExhausted, + error_message: Some("quota exhausted".to_string()), + ..ValidationOutcomeDto::default() + }); + + let value = serde_json::to_value(view).expect("validation outcome serializes"); + assert_eq!(value["status"], "quota_exhausted"); + assert_eq!(value["valid"], false); + } + #[derive(Clone)] struct MetadataPluginLoader { resolved: Option, diff --git a/src-tauri/src/application/read_models/account_view.rs b/src-tauri/src/application/read_models/account_view.rs index e9986f9f..a5ebc9e5 100644 --- a/src-tauri/src/application/read_models/account_view.rs +++ b/src-tauri/src/application/read_models/account_view.rs @@ -119,7 +119,6 @@ mod tests { assert_eq!(dto.valid_until, Some(2_500_000_000_000)); assert_eq!(dto.last_validated, Some(1_900_000_000_000)); assert_eq!(dto.created_at, 1_700_000_000_000); - assert_eq!(dto.credential_ref, "keyring://real-debrid/alice"); let value = serde_json::to_value(&dto).unwrap(); assert_eq!(value["status"], "valid"); assert!(value["exhaustedUntil"].is_null()); @@ -140,6 +139,10 @@ mod tests { !object.contains_key("credential"), "AccountViewDto must never expose a raw credential field" ); + assert!( + !object.contains_key("credentialRef"), + "AccountViewDto must keep keyring references inside the backend" + ); } #[test] @@ -160,7 +163,6 @@ mod tests { "createdAt", "status", "exhaustedUntil", - "credentialRef", ] { assert!( object.contains_key(camel_field), diff --git a/src/views/AccountsView/__tests__/AccountsView.test.tsx b/src/views/AccountsView/__tests__/AccountsView.test.tsx index 8b1196f9..ff4825d1 100644 --- a/src/views/AccountsView/__tests__/AccountsView.test.tsx +++ b/src/views/AccountsView/__tests__/AccountsView.test.tsx @@ -38,7 +38,6 @@ function sampleAccounts(): AccountView[] { createdAt: Date.now() - 86_400_000, status: "valid", exhaustedUntil: null, - credentialRef: "keyring://real-debrid/alice", }, { id: "ad-1", @@ -53,7 +52,6 @@ function sampleAccounts(): AccountView[] { createdAt: Date.now() - 172_800_000, status: "unverified", exhaustedUntil: null, - credentialRef: "keyring://alldebrid/bob", }, ]; } diff --git a/src/views/AccountsView/__tests__/statusUtils.test.ts b/src/views/AccountsView/__tests__/statusUtils.test.ts index 8c71bb95..82db9f59 100644 --- a/src/views/AccountsView/__tests__/statusUtils.test.ts +++ b/src/views/AccountsView/__tests__/statusUtils.test.ts @@ -16,7 +16,6 @@ function base(overrides: Partial = {}): AccountView { createdAt: 0, status: "unverified", exhaustedUntil: null, - credentialRef: "keyring://real-debrid/alice", ...overrides, }; } @@ -52,6 +51,20 @@ describe("deriveAccountStatus", () => { expect(deriveAccountStatus(account, 1)).toBe("active"); }); + it.each(["quota_exhausted", "cooldown"] as const)( + "returns 'active' when temporary status '%s' has elapsed", + (status) => { + expect(deriveAccountStatus(base({ status, exhaustedUntil: 100 }), 100)).toBe("active"); + }, + ); + + it.each([ + ["quota_exhausted", "quotaExhausted"], + ["cooldown", "cooldown"], + ] as const)("keeps temporary status '%s' until its deadline", (status, expected) => { + expect(deriveAccountStatus(base({ status, exhaustedUntil: 101 }), 100)).toBe(expected); + }); + it.each([ ["invalid_credentials", "invalidCredentials"], ["missing_credential", "missingCredential"], From 8fff13507f715eddf1f7e43db6623743250f0f3c Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:07:05 +0200 Subject: [PATCH 25/33] feat(accounts): expose typed validation status --- CHANGELOG.md | 6 +- src-tauri/src/adapters/driving/tauri_ipc.rs | 2 + src-tauri/src/application/commands/mod.rs | 4 +- .../src/application/queries/list_accounts.rs | 5 +- .../application/read_models/account_view.rs | 14 +--- src-tauri/src/domain/model/account.rs | 79 ------------------- .../domain/ports/driven/account_repository.rs | 4 +- src/types/account.ts | 2 +- src/views/AccountsView/AccountsView.tsx | 3 +- .../__tests__/AccountsView.test.tsx | 26 ++++++ src/views/AccountsView/statusUtils.ts | 17 +++- 11 files changed, 60 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 523edfb4..3930a3a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,8 +78,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **MAT-132 account status contract**: account validation responses now expose - their typed status to the UI, elapsed quota/cooldown markers render as active, - and account read models no longer expose backend keyring references. + their exact typed status to the UI, rejected-validation toasts use its + localized label instead of plugin diagnostics, elapsed quota/cooldown markers + render as active, and account read models no longer expose backend keyring + references. - **Task 40 follow-up — `/simplify` cleanup** (scope `download`, sprint task 40): extracted `getHostname` + `getProtocol` to `src/lib/url.ts` and replaced the inlined copies in `MirrorsSection.tsx` + `SourceInfoSection.tsx` with the shared helpers. `run_mirror_attempt` swapped its 13 positional arguments for a `MirrorAttemptParams` struct mirroring the adjacent `SegmentParams` pattern; the `#[allow(clippy::too_many_arguments)]` suppression is gone. `entities/download.rs` + `download_read_repo.rs` use `safe_u32(model.current_mirror_index as i64)` instead of the ad-hoc `u32::try_from(...).unwrap_or(0)` so the cast goes through the SQLite adapter's standard helper. New `MAX_MIRRORS_PER_DOWNLOAD = 64` constant in `domain/model/download.rs`; `Download::set_mirrors` truncates after sorting so a malformed `.metalink` with thousands of entries cannot bloat `mirrors_json` or stall the failover loop. Doc comments on `Download::mirrors` / `current_mirror_index` and the entity's `mirrors_json` / `current_mirror_index` columns trimmed to the WHY clauses (cap rationale, future-extension hook); the persisted cursor's actual behaviour is now spelled out — the engine still drives failover with its in-task cursor, so a crash mid-failover restarts from slot 0, and the column is retained as a hook for the future call site that will mark a failing slot at the domain level. Frontend gates simplified: `DownloadDetailsPanel.tsx` drops the redundant `mirrors && …` (non-optional field in TS), `MirrorsSection.tsx` drops the redundant `!download.mirrors` short-circuit (dead code under the parent gate). - **Task 39 follow-up — PR #151 review fixes** (scope `download`): `WaitManager::schedule_wait` now aborts the previous `JoinHandle` returned by `guard.insert(id, handle)` instead of just dropping it (dropping a `JoinHandle` only detaches the task — `abort()` is what actually stops the timer), so calling `schedule_wait` twice for the same `DownloadId` no longer leaves a stale task that could fire `expire_wait` against an outdated deadline. `expire_wait` now short-circuits when the handle has already been removed by a peer `cancel_wait` / `skip_wait` (the abort is cooperative: once the sleep wakes and the task is past its last `.await`, it runs to completion regardless of `abort()`), preventing a spurious `resume_aggregate` race against the cancel flow. `download_cancel` IPC now runs the `CancelDownloadCommand` first and only invokes `wait_manager.cancel_wait` on success, so a fallible cancel command no longer strands the download in `Waiting` with no timer to resume it. `download_log_bridge` records `DownloadWaitingEnded { expired_naturally: false }` as `"ended early"` instead of `"skipped"` (the same branch fires for cancel-driven endings, where "skipped" was misleading). `useCountdown` short-circuits before scheduling `setInterval` when `untilUnixMs` is already in the past, dropping one no-op render + interval wakeup cycle for already-expired rows. New regression test `rescheduling_same_id_aborts_previous_timer` proves only the latest timer can fire after a reschedule + new `useCountdown` test asserts `setInterval` is not called for past deadlines. diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index 0f81ba34..c319f12b 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -3015,6 +3015,7 @@ impl AccountPatchDto { #[serde(rename_all = "camelCase")] pub struct ValidationOutcomeView { pub valid: bool, + pub status: String, pub latency_ms: Option, pub traffic_left: Option, pub traffic_total: Option, @@ -3026,6 +3027,7 @@ impl From for ValidationOutcomeView { fn from(o: ValidationOutcomeDto) -> Self { Self { valid: o.valid, + status: o.status.to_string(), latency_ms: o.latency_ms, traffic_left: o.traffic_left, traffic_total: o.traffic_total, diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 270d6cd3..7950a035 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -59,7 +59,7 @@ mod verify_checksum; use std::path::PathBuf; -use crate::domain::model::account::{AccountId, AccountType}; +use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; use crate::domain::model::config::ConfigPatch; use crate::domain::model::download::DownloadId; use crate::domain::model::package::{PackageId, PackageSourceType}; @@ -474,6 +474,7 @@ impl Command for ValidateAccountCommand {} #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ValidationOutcomeDto { pub valid: bool, + pub status: AccountStatus, pub latency_ms: Option, pub traffic_left: Option, pub traffic_total: Option, @@ -485,6 +486,7 @@ impl From for ValidationOutcome fn from(o: crate::domain::ports::driven::ValidationOutcome) -> Self { Self { valid: o.is_valid(), + status: o.status, latency_ms: o.latency_ms, traffic_left: o.traffic_left, traffic_total: o.traffic_total, diff --git a/src-tauri/src/application/queries/list_accounts.rs b/src-tauri/src/application/queries/list_accounts.rs index d9d374ec..b56312d8 100644 --- a/src-tauri/src/application/queries/list_accounts.rs +++ b/src-tauri/src/application/queries/list_accounts.rs @@ -3,9 +3,8 @@ //! Returns persisted accounts as [`AccountViewDto`] read models. //! Filters AND together — service + type + enabled all match. The DTO //! carries no password or raw secret material, so no plaintext secret -//! can leak through this read path. Non-secret identifiers (username, -//! opaque `credential_ref`) are present and intentional — only the -//! credential itself is fetched server-side via the keyring. +//! or keyring locator can leak through this read path. The credential +//! itself is fetched server-side by opaque account id. use crate::application::error::AppError; use crate::application::query_bus::QueryBus; diff --git a/src-tauri/src/application/read_models/account_view.rs b/src-tauri/src/application/read_models/account_view.rs index a5ebc9e5..03e3f4bf 100644 --- a/src-tauri/src/application/read_models/account_view.rs +++ b/src-tauri/src/application/read_models/account_view.rs @@ -12,12 +12,9 @@ use crate::domain::model::account::Account; /// Read model for the Accounts list and detail panels. /// -/// Mirrors the persisted columns of the `accounts` table, including the -/// non-secret [`Self::credential_ref`] (an opaque keyring URI such as -/// `keyring://service/user`). The reference itself is never a password -/// or token — the actual secret is fetched server-side from the OS -/// keyring when the "test connection" surface needs it. Passwords and -/// raw credential material never appear on this DTO. +/// Mirrors the non-secret persisted columns of the `accounts` table. +/// Passwords, tokens, and backend keyring identifiers never appear on +/// this DTO. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountViewDto { @@ -33,10 +30,6 @@ pub struct AccountViewDto { pub created_at: u64, pub status: String, pub exhausted_until: Option, - /// Opaque keyring URI (`keyring://service/user`) — never the - /// password itself. Lets the frontend correlate two `AccountView` - /// rows that share the same stored credential. - pub credential_ref: String, } impl From for AccountViewDto { @@ -54,7 +47,6 @@ impl From for AccountViewDto { created_at: account.created_at(), status: account.status().to_string(), exhausted_until: account.exhausted_until(), - credential_ref: account.credential_ref(), } } } diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index 9567a0db..bca1fa9a 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -381,18 +381,6 @@ impl Account { } } - /// Reference used to look up the credential in the system keyring. - /// Format: `keyring://{service_name}/{username}`. Both segments are - /// percent-encoded so reserved characters (`/`, `?`, `#`, `@`...) cannot - /// produce ambiguous refs that point at the wrong stored credential. - pub fn credential_ref(&self) -> String { - format!( - "keyring://{}/{}", - percent_encode_segment(&self.service_name), - percent_encode_segment(&self.username) - ) - } - pub fn id(&self) -> &AccountId { &self.id } @@ -430,24 +418,6 @@ impl Account { } } -/// Percent-encode a string so it can be safely embedded as a path segment in -/// `keyring://...` refs. Only RFC 3986 unreserved characters survive -/// untouched; everything else is rendered as `%XX` per UTF-8 byte. -fn percent_encode_segment(s: &str) -> String { - use std::fmt::Write; - - let mut out = String::with_capacity(s.len()); - for byte in s.bytes() { - let unreserved = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~'); - if unreserved { - out.push(byte as char); - } else { - let _ = write!(out, "%{byte:02X}"); - } - } - out -} - #[cfg(test)] mod tests { use super::*; @@ -544,55 +514,6 @@ mod tests { assert_eq!(acc.last_validated(), Some(1_700_000_500_000)); } - #[test] - fn test_account_credential_ref_uses_keyring_scheme() { - let acc = make_account(); - // `@` in `user@example.com` is reserved → percent-encoded as %40. - assert_eq!( - acc.credential_ref(), - "keyring://ExampleHost/user%40example.com" - ); - } - - #[test] - fn test_account_credential_ref_percent_encodes_reserved_chars() { - // A `/` in the service or username could otherwise collide with the - // path separator and point two distinct accounts at the same ref. - let acc = Account::new( - AccountId::new("acc-collision"), - "real-debrid/eu".to_string(), - "alice/admin".to_string(), - AccountType::Debrid, - 0, - ); - assert_eq!( - acc.credential_ref(), - "keyring://real-debrid%2Feu/alice%2Fadmin" - ); - - let other = Account::new( - AccountId::new("acc-other"), - "real-debrid".to_string(), - "eu/alice/admin".to_string(), - AccountType::Debrid, - 0, - ); - assert_ne!(acc.credential_ref(), other.credential_ref()); - } - - #[test] - fn test_account_credential_ref_handles_unicode_username() { - let acc = Account::new( - AccountId::new("acc-utf8"), - "host".to_string(), - "café".to_string(), - AccountType::Free, - 0, - ); - // `é` is `0xC3 0xA9` in UTF-8. - assert_eq!(acc.credential_ref(), "keyring://host/caf%C3%A9"); - } - #[test] fn test_account_type_round_trip_via_string() { for t in [AccountType::Free, AccountType::Premium, AccountType::Debrid] { diff --git a/src-tauri/src/domain/ports/driven/account_repository.rs b/src-tauri/src/domain/ports/driven/account_repository.rs index 346665a9..cd73bb42 100644 --- a/src-tauri/src/domain/ports/driven/account_repository.rs +++ b/src-tauri/src/domain/ports/driven/account_repository.rs @@ -1,8 +1,8 @@ //! Write repository for the `Account` aggregate (CQRS write side). //! //! Persists account metadata only. Credentials (passwords / tokens) live -//! in the OS keyring and are looked up via `Account::credential_ref()` — -//! never through this port. +//! in the OS keyring and are looked up by opaque `AccountId` — never +//! through this port. use crate::domain::error::DomainError; use crate::domain::model::account::{Account, AccountId}; diff --git a/src/types/account.ts b/src/types/account.ts index 12ae9423..2f042f52 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -22,7 +22,6 @@ export interface AccountView { createdAt: number; status: PersistedAccountStatus; exhaustedUntil: number | null; - credentialRef: string; } export interface AccountTraffic { @@ -49,6 +48,7 @@ export interface AddAccountInput { export interface ValidationOutcome { valid: boolean; + status: PersistedAccountStatus; latencyMs: number | null; trafficLeft: number | null; trafficTotal: number | null; diff --git a/src/views/AccountsView/AccountsView.tsx b/src/views/AccountsView/AccountsView.tsx index c2c396de..20ed7b30 100644 --- a/src/views/AccountsView/AccountsView.tsx +++ b/src/views/AccountsView/AccountsView.tsx @@ -25,6 +25,7 @@ import { AddAccountDialog } from "./AddAccountDialog"; import { DeleteAccountDialog } from "./DeleteAccountDialog"; import { EditAccountDialog } from "./EditAccountDialog"; import { ExportAccountsDialog, ImportAccountsDialog } from "./ImportExportDialog"; +import { persistedAccountStatusToUi } from "./statusUtils"; const FILTER_ORDER: ReadonlyArray<"all" | AccountType> = ["all", "debrid", "premium", "free"]; const INVALIDATE_KEYS = [accountQueries.all()] as const; @@ -167,7 +168,7 @@ export function AccountsView() { } else { toast.error( t("accounts.toast.validateInvalid", { - reason: outcome.errorMessage ?? "—", + reason: t(`accounts.status.${persistedAccountStatusToUi(outcome.status)}`), }), ); } diff --git a/src/views/AccountsView/__tests__/AccountsView.test.tsx b/src/views/AccountsView/__tests__/AccountsView.test.tsx index ff4825d1..c40315b3 100644 --- a/src/views/AccountsView/__tests__/AccountsView.test.tsx +++ b/src/views/AccountsView/__tests__/AccountsView.test.tsx @@ -110,6 +110,32 @@ describe("AccountsView", () => { expect(await screen.findByText("Quota exhausted")).toBeInTheDocument(); }); + it("surfaces the typed status returned by account validation", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return sampleAccounts(); + if (command === "account_validate") { + return { + valid: false, + status: "cooldown", + latencyMs: null, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + errorMessage: "untrusted plugin detail", + }; + } + return null; + }); + + renderView(); + const row = await screen.findByTestId("account-row-rd-1"); + await userEvent.click(within(row).getByText("Validate")); + + await waitFor(() => + expect(mockToastError).toHaveBeenCalledWith("Validation failed: Rate limited"), + ); + }); + it("renders the empty state when no accounts exist", async () => { mockInvoke.mockResolvedValue([]); renderView(); diff --git a/src/views/AccountsView/statusUtils.ts b/src/views/AccountsView/statusUtils.ts index 3f79dd0b..c7535203 100644 --- a/src/views/AccountsView/statusUtils.ts +++ b/src/views/AccountsView/statusUtils.ts @@ -1,4 +1,4 @@ -import type { AccountView } from "@/types/account"; +import type { AccountView, PersistedAccountStatus } from "@/types/account"; export type AccountStatus = | "active" @@ -24,10 +24,23 @@ export function deriveAccountStatus( if (!account.enabled) return "disabled"; if (account.status === "expired") return "expired"; if (account.validUntil !== null && account.validUntil < nowMs) return "expired"; + if ( + (account.status === "quota_exhausted" || account.status === "cooldown") && + account.exhaustedUntil !== null && + nowMs >= account.exhaustedUntil + ) { + return "active"; + } + + return persistedAccountStatusToUi(account.status); +} - switch (account.status) { +export function persistedAccountStatusToUi(status: PersistedAccountStatus): AccountStatus { + switch (status) { case "valid": return "active"; + case "expired": + return "expired"; case "invalid_credentials": return "invalidCredentials"; case "missing_credential": From e64058e4d2246009896d18b4cf5dae2ea74d1992 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:26:34 +0200 Subject: [PATCH 26/33] test(accounts): capture final review regressions --- CHANGELOG.md | 3 + .../src/adapters/driven/network/safe_url.rs | 7 ++ .../application/commands/start_download.rs | 81 ++++++++++++++++++- .../services/account_operation_locks.rs | 13 +++ .../services/premium_source_resolver_tests.rs | 80 ++++++++++++++++++ src/hooks/__tests__/useAccountEvents.test.ts | 49 +++++++++++ 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/hooks/__tests__/useAccountEvents.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3930a3a0..e423efbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-132 review regressions**: added coverage for fail-closed account state + persistence, premium account lifecycle races, account-event refreshes, lock + reclamation, and IPv6/proxy SSRF boundaries. - **MAT-132 account-state hardening**: validation now derives validity from its typed status, clears stale subscription expiries, bounds temporary failures, and invalidates the rotator cache on recovery. Per-account async locks prevent diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs index 4a8fa748..4a7aad59 100644 --- a/src-tauri/src/adapters/driven/network/safe_url.rs +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -109,11 +109,18 @@ mod tests { "10.0.0.1", "169.254.169.254", "::ffff:127.0.0.1", + "fec0::1", + "64:ff9b:1::c0a8:1", ] { assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}"); } } + #[test] + fn accepts_globally_routable_ipv6_address() { + assert!(!is_forbidden_ip(&"2606:4700:4700::1111".parse().unwrap())); + } + #[test] fn restricted_client_requires_https_and_public_destination() { let http = reqwest::Url::parse("http://1.1.1.1/file").unwrap(); diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index 09ea144d..49d5cc4f 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -200,7 +200,7 @@ mod tests { use std::sync::Mutex; use crate::application::command_bus::CommandBus; - use crate::application::commands::StartDownloadCommand; + use crate::application::commands::{DeleteAccountCommand, StartDownloadCommand}; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; @@ -230,6 +230,27 @@ mod tests { } } + struct SignallingCredentialStore { + password_read: Mutex>>, + } + + impl AccountCredentialStore for SignallingCredentialStore { + fn store_password(&self, _: &AccountId, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn get_password(&self, _: &AccountId) -> Result, DomainError> { + if let Some(sender) = self.password_read.lock().unwrap().take() { + let _ = sender.send(()); + } + Ok(Some("api-key".into())) + } + + fn delete_password(&self, _: &AccountId) -> Result<(), DomainError> { + Ok(()) + } + } + struct MockDownloadRepo { store: Mutex>, } @@ -565,6 +586,64 @@ mod tests { assert_eq!(stored.account_id(), Some(&account_id)); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn account_delete_waits_until_validated_download_is_persisted() { + let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let account_id = AccountId::new("account-1"); + let mut account = Account::new( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 0, + ); + account.set_status(AccountStatus::Valid); + account_repo.save(&account).unwrap(); + let (password_read_tx, password_read_rx) = tokio::sync::oneshot::channel(); + let credentials = Arc::new(SignallingCredentialStore { + password_read: Mutex::new(Some(password_read_tx)), + }); + let bus = Arc::new( + bus.with_account_repo(account_repo) + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedAccountClock)), + ); + let queue_guard = bus.lock_queue_positions().await; + let start_bus = Arc::clone(&bus); + let start_account = account_id.clone(); + let start = tokio::spawn(async move { + start_bus + .handle_start_download(StartDownloadCommand { + url: "https://1fichier.com/?abc123".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + source_hostname_override: None, + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(start_account), + }) + .await + }); + password_read_rx.await.expect("account validation reached"); + + let delete_bus = Arc::clone(&bus); + let mut deletion = tokio::spawn(async move { + delete_bus + .handle_delete_account(DeleteAccountCommand { id: account_id }) + .await + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut deletion) + .await + .is_err(), + "deletion must serialize with validation and persistence" + ); + + drop(queue_guard); + start.await.unwrap().expect("download persisted first"); + deletion.await.unwrap().expect("account deleted second"); + } + #[tokio::test] async fn test_start_download_rejects_account_with_missing_credential() { let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); diff --git a/src-tauri/src/application/services/account_operation_locks.rs b/src-tauri/src/application/services/account_operation_locks.rs index 807dd96e..b88acee9 100644 --- a/src-tauri/src/application/services/account_operation_locks.rs +++ b/src-tauri/src/application/services/account_operation_locks.rs @@ -39,4 +39,17 @@ mod tests { assert!(Arc::ptr_eq(&first, &same)); assert!(!Arc::ptr_eq(&first, &other)); } + + #[test] + fn abandoned_account_locks_are_pruned() { + let locks = AccountOperationLocks::default(); + let abandoned = locks.lock_for(&AccountId::new("abandoned")).unwrap(); + drop(abandoned); + + let live = locks.lock_for(&AccountId::new("live")).unwrap(); + let _new = locks.lock_for(&AccountId::new("new")).unwrap(); + + assert_eq!(locks.entries.lock().unwrap().len(), 2); + assert!(Arc::strong_count(&live) >= 1); + } } diff --git a/src-tauri/src/application/services/premium_source_resolver_tests.rs b/src-tauri/src/application/services/premium_source_resolver_tests.rs index 824a1413..22a671c8 100644 --- a/src-tauri/src/application/services/premium_source_resolver_tests.rs +++ b/src-tauri/src/application/services/premium_source_resolver_tests.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use super::*; @@ -19,6 +20,45 @@ impl Clock for FixedClock { } } +struct SaveFailingRepo { + inner: InMemoryAccountRepo, + fail_saves: AtomicBool, +} + +impl SaveFailingRepo { + fn new() -> Self { + Self { + inner: InMemoryAccountRepo::new(), + fail_saves: AtomicBool::new(false), + } + } +} + +impl AccountRepository for SaveFailingRepo { + fn find_by_id(&self, id: &AccountId) -> Result, DomainError> { + self.inner.find_by_id(id) + } + + fn save(&self, account: &Account) -> Result<(), DomainError> { + if self.fail_saves.load(Ordering::SeqCst) { + return Err(DomainError::StorageError("database unavailable".into())); + } + self.inner.save(account) + } + + fn list(&self) -> Result, DomainError> { + self.inner.list() + } + + fn list_by_service(&self, service_name: &str) -> Result, DomainError> { + self.inner.list_by_service(service_name) + } + + fn delete(&self, id: &AccountId) -> Result<(), DomainError> { + self.inner.delete(id) + } +} + struct DirectUrlPlugin { calls: Mutex>, } @@ -118,3 +158,43 @@ async fn resolves_the_direct_url_only_when_the_engine_requests_it() { let stored = repo.find_by_id(&account_id).unwrap().unwrap(); assert_eq!(stored.traffic_left(), Some(90)); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_credential_persistence_failure_is_not_suppressed() { + let repo = Arc::new(SaveFailingRepo::new()); + let account_id = AccountId::new("account-1"); + let mut account = Account::new( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + repo.save(&account).unwrap(); + repo.fail_saves.store(true, Ordering::SeqCst); + let resolver = Arc::new(PremiumSourceResolver::new( + repo, + Arc::new(FakeAccountCredentialStore::new()), + Arc::new(DirectUrlPlugin { + calls: Mutex::new(Vec::new()), + }), + Arc::new(FixedClock), + Arc::new(AccountOperationLocks::default()), + )); + let download = Download::new( + DownloadId(1), + Url::new("https://1fichier.com/?abc123").unwrap(), + "file.zip".into(), + "/tmp/file.zip".into(), + ) + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(account_id); + + let error = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .unwrap() + .expect_err("storage failure must win over missing credential"); + + assert!(matches!(error, DomainError::StorageError(_))); +} diff --git a/src/hooks/__tests__/useAccountEvents.test.ts b/src/hooks/__tests__/useAccountEvents.test.ts new file mode 100644 index 00000000..a34a8021 --- /dev/null +++ b/src/hooks/__tests__/useAccountEvents.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useAccountEvents } from "@/hooks/useAccountEvents"; + +vi.mock("@/hooks/useTauriEvent", () => ({ + useTauriEvent: vi.fn(), +})); + +vi.mock("@/api/client", () => ({ + queryClient: { + invalidateQueries: vi.fn(), + }, +})); + +import { queryClient } from "@/api/client"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; + +describe("useAccountEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("subscribes to every event that changes account availability", () => { + renderHook(() => useAccountEvents()); + + expect(vi.mocked(useTauriEvent).mock.calls.map(([event]) => event)).toEqual([ + "account-added", + "account-updated", + "account-deleted", + "account-validated", + "account-validation-failed", + "account-exhausted", + ]); + }); + + it("invalidates account queries when automatic validation fails", () => { + vi.mocked(useTauriEvent).mockImplementation((event, callback) => { + if (event === "account-validation-failed") { + callback({ id: "account-1", error: "expired" }); + } + }); + + renderHook(() => useAccountEvents()); + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["accounts"], + }); + }); +}); From fa680e214475ff4a8e06c2ced5c1ee45e249f64a Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:39:10 +0200 Subject: [PATCH 27/33] fix(accounts): close premium review gaps --- CHANGELOG.md | 8 + registry/registry.toml | 6 +- .../src/adapters/driven/network/safe_url.rs | 13 +- .../adapters/driven/plugin/capabilities.rs | 1 + src-tauri/src/application/command_bus.rs | 16 +- src-tauri/src/application/commands/mod.rs | 1 + .../commands/premium_account_resolution.rs | 127 ++++++++++++ .../src/application/commands/resolve_links.rs | 72 +++---- .../commands/resolve_premium_source.rs | 113 +++++++++++ .../resolve_premium_source_test_support.rs} | 190 +++++++----------- .../commands/resolve_premium_source_tests.rs | 105 ++++++++++ .../application/commands/start_download.rs | 16 +- .../src/application/commands/tests_support.rs | 25 ++- .../application/commands/update_account.rs | 2 +- .../application/commands/validate_account.rs | 9 +- .../services/account_operation_locks.rs | 15 +- .../application/services/account_rotator.rs | 17 ++ src-tauri/src/application/services/mod.rs | 4 +- .../services/premium_source_resolver.rs | 117 ----------- src-tauri/src/lib.rs | 25 ++- src-tauri/tests/app_state_wiring.rs | 76 +++++-- src/hooks/useAccountEvents.ts | 23 +++ src/layouts/AppLayout.tsx | 2 + src/layouts/__tests__/AppLayout.test.tsx | 6 + src/types/events.ts | 30 +++ 25 files changed, 678 insertions(+), 341 deletions(-) create mode 100644 src-tauri/src/application/commands/premium_account_resolution.rs create mode 100644 src-tauri/src/application/commands/resolve_premium_source.rs rename src-tauri/src/application/{services/premium_source_resolver_tests.rs => commands/resolve_premium_source_test_support.rs} (50%) create mode 100644 src-tauri/src/application/commands/resolve_premium_source_tests.rs delete mode 100644 src-tauri/src/application/services/premium_source_resolver.rs create mode 100644 src/hooks/useAccountEvents.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e423efbd..cb5c4401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-132 final review hardening**: premium extraction now runs through one + CQRS command handler shared by link analysis and just-in-time downloads. + Account failures persist before typed events, cooldown recovery uses one + atomic row write, per-account locks are reclaimed and cover download + association persistence, account events refresh the UI immediately, and + restricted HTTP clients ignore system proxies while rejecting additional + non-public IPv6 ranges. The official 1fichier registry entry now targets the + credential-validation-capable v1.1.0 artifact. - **MAT-132 review regressions**: added coverage for fail-closed account state persistence, premium account lifecycle races, account-event refreshes, lock reclamation, and IPv6/proxy SSRF boundaries. diff --git a/registry/registry.toml b/registry/registry.toml index 7ec90006..22319f54 100644 --- a/registry/registry.toml +++ b/registry/registry.toml @@ -112,11 +112,11 @@ min_vortex_version = "0.1.0" name = "vortex-mod-1fichier" description = "1fichier hoster — free (60s wait + captcha stub) and premium (API key)" author = "vortex-community" -version = "1.0.0" +version = "1.1.0" category = "hoster" repository = "https://github.com/mpiton/vortex-mod-1fichier" -checksum_sha256 = "02035a49da81566cf7e4ff64cd0b5ed858e05dfdca643dbd14e53b359d93232c" -checksum_sha256_toml = "c16cb7d418ee6d261114b0ed66701629f7aa7b92317dc97fb7593ede9487c091" +checksum_sha256 = "e29f5d4d69ac80f8c6d1bd4d9b98cb2ae3bddb0265544b84a75b929e8fa36ccb" +checksum_sha256_toml = "bcf3ba595c323d2d0a4ea680e4c6354285153020e07d1efe05c856d5e281dd6d" official = true min_vortex_version = "0.1.0" diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs index 4a7aad59..658b871d 100644 --- a/src-tauri/src/adapters/driven/network/safe_url.rs +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -44,6 +44,7 @@ pub(crate) fn restricted_download_client( } let addresses = validate_public_url(url)?; let mut builder = reqwest::Client::builder() + .no_proxy() .user_agent("Vortex/0.1") .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(30)) @@ -87,13 +88,21 @@ pub(crate) fn is_forbidden_ip(ip: &IpAddr) -> bool { || (a == 255 && b == 255 && c == 255 && d == 255) } IpAddr::V6(ip) => { - let first = ip.segments()[0]; + let segments = ip.segments(); + let first = segments[0]; ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || (first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80 - || ip.segments()[..2] == [0x2001, 0x0db8] + || (first & 0xffc0) == 0xfec0 + || matches!(segments, [0x0064, 0xff9b, 0x0001, ..]) + || matches!(segments, [0x0100, 0, 0, 0, ..]) + || matches!(segments, [0x2001, 0x0002, 0, ..]) + || matches!(segments, [0x2001, 0x0db8, ..]) + || matches!(segments, [0x2002, ..]) + || matches!(segments, [0x3ff0..=0x3fff, ..]) + || matches!(segments, [0x5f00, ..]) } } } diff --git a/src-tauri/src/adapters/driven/plugin/capabilities.rs b/src-tauri/src/adapters/driven/plugin/capabilities.rs index 0ebca8af..6e286464 100644 --- a/src-tauri/src/adapters/driven/plugin/capabilities.rs +++ b/src-tauri/src/adapters/driven/plugin/capabilities.rs @@ -31,6 +31,7 @@ impl SharedHostResources { // gives the remote side a way to identify traffic from Vortex // rather than generic scripted clients. reqwest::blocking::Client::builder() + .no_proxy() .user_agent("Vortex/0.1") .redirect(reqwest::redirect::Policy::none()) .timeout(timeout) diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index d89e514d..abd28e19 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::Semaphore; +use crate::application::commands::resolve_premium_source::ResolvePremiumSourceHandler; use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::services::{AccountRotator, AccountSelector}; use crate::domain::model::account::AccountId; @@ -102,6 +103,7 @@ pub struct CommandBus { account_rotator: Option>, account_operation_locks: Arc, account_clock: Option>, + premium_source_handler: Option>, passphrase_codec: Option>, /// Serializes queue-position allocation across handlers. Without this, /// two concurrent move-to-top/move-to-bottom/start-download calls can @@ -173,6 +175,7 @@ impl CommandBus { account_rotator: None, account_operation_locks: Arc::new(AccountOperationLocks::default()), account_clock: None, + premium_source_handler: None, passphrase_codec: None, queue_position_lock: tokio::sync::Mutex::new(()), link_check_limiter, @@ -236,10 +239,15 @@ impl CommandBus { self } - pub(crate) fn with_account_operation_locks( + pub fn with_premium_source_handler( mut self, - locks: Arc, + handler: Arc, ) -> Self { + self.premium_source_handler = Some(handler); + self + } + + pub fn with_account_operation_locks(mut self, locks: Arc) -> Self { self.account_operation_locks = locks; self } @@ -271,6 +279,10 @@ impl CommandBus { self.account_rotator.as_deref() } + pub fn premium_source_handler(&self) -> Option<&ResolvePremiumSourceHandler> { + self.premium_source_handler.as_deref() + } + pub(crate) fn account_operation_lock( &self, id: &AccountId, diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 7950a035..dd799bdb 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -37,6 +37,7 @@ mod remove_download; mod remove_download_from_package; mod report_broken_plugin; mod resolve_links; +pub mod resolve_premium_source; mod resume_all; mod resume_download; mod retry_download; diff --git a/src-tauri/src/application/commands/premium_account_resolution.rs b/src-tauri/src/application/commands/premium_account_resolution.rs new file mode 100644 index 00000000..7d762931 --- /dev/null +++ b/src-tauri/src/application/commands/premium_account_resolution.rs @@ -0,0 +1,127 @@ +use crate::application::services::account_state::{apply_status, status_for_plugin_error}; +use crate::domain::error::DomainError; +use crate::domain::event::DomainEvent; +use crate::domain::model::account::{Account, AccountStatus}; +use crate::domain::model::credential::Credential; +use crate::domain::ports::driven::ExtractedHosterLink; + +use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; + +impl ResolvePremiumSourceHandler { + pub(super) fn resolve_locked( + &self, + command: ResolvePremiumSourceCommand, + ) -> Result { + let mut account = self.load_available_account(&command)?; + let credential = self.load_credential(&mut account)?; + let link = match self.plugins.extract_hoster_link( + &command.service_name, + &command.source_url, + Some(&credential), + ) { + Ok(link) => link, + Err(error) => return self.reject_plugin_failure(&mut account, error), + }; + self.persist_success(&mut account, &link)?; + Ok(link) + } + + fn load_available_account( + &self, + command: &ResolvePremiumSourceCommand, + ) -> Result { + let account = self.repo.find_by_id(&command.account_id)?.ok_or_else(|| { + DomainError::NotFound(format!("account {}", command.account_id.as_str())) + })?; + if account.service_name() != command.service_name + || !account.is_selectable(self.clock.now_unix_ms()) + { + return Err(DomainError::ValidationError( + "premium account is unavailable".into(), + )); + } + Ok(account) + } + + fn load_credential(&self, account: &mut Account) -> Result { + let Some(password) = self.credentials.get_password(account.id())? else { + account.set_status(AccountStatus::MissingCredential); + self.repo.save(account)?; + self.publish_failure(account, "Account credential is unavailable"); + return Err(DomainError::NotFound(format!( + "credential for account {}", + account.id().as_str() + ))); + }; + Ok(Credential::new(account.username(), password)) + } + + fn reject_plugin_failure( + &self, + account: &mut Account, + error: DomainError, + ) -> Result { + let Some(status) = status_for_plugin_error(&error) else { + return Err(DomainError::PluginError( + "premium source resolution failed".into(), + )); + }; + apply_status(account, status, self.clock.now_unix_ms()); + self.repo.save(account)?; + self.publish_typed_failure(account, status); + Err(error) + } + + fn persist_success( + &self, + account: &mut Account, + link: &ExtractedHosterLink, + ) -> Result<(), DomainError> { + if link.direct_url.is_none() { + return Err(DomainError::PluginError( + "premium plugin returned no direct URL".into(), + )); + } + if let Some(total) = link.traffic_total_bytes { + account.set_traffic_total(total); + if let Some(used) = link.traffic_used_bytes { + account.set_traffic_left(total.saturating_sub(used)); + } + } + account.set_status(AccountStatus::Valid); + self.repo.save(account)?; + self.events.publish(DomainEvent::AccountUpdated { + id: account.id().clone(), + }); + Ok(()) + } + + fn publish_typed_failure(&self, account: &Account, status: AccountStatus) { + if status == AccountStatus::QuotaExhausted { + self.events.publish(DomainEvent::AccountExhausted { + id: account.id().clone(), + service_name: account.service_name().to_string(), + exhausted_until_ms: account.exhausted_until().unwrap_or_default(), + }); + } else { + self.publish_failure(account, account_failure_message(status)); + } + } + + fn publish_failure(&self, account: &Account, error: &str) { + self.events.publish(DomainEvent::AccountValidationFailed { + id: account.id().clone(), + error: error.to_string(), + }); + } +} + +fn account_failure_message(status: AccountStatus) -> &'static str { + match status { + AccountStatus::InvalidCredentials => "Account credentials were rejected", + AccountStatus::Expired => "Account is expired", + AccountStatus::Cooldown => "Account is temporarily rate-limited", + AccountStatus::QuotaExhausted => "Account quota is exhausted", + _ => "Account validation failed", + } +} diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index c67f786a..899445d2 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -7,12 +7,10 @@ use serde::Serialize; use uuid::Uuid; use crate::application::command_bus::CommandBus; +use crate::application::commands::resolve_premium_source::ResolvePremiumSourceCommand; use crate::application::error::AppError; use crate::application::services::account_rotator::NextAccountOutcome; -use crate::application::services::account_state::{apply_status, status_for_plugin_error}; use crate::domain::error::DomainError; -use crate::domain::model::account::AccountStatus; -use crate::domain::model::credential::Credential; use crate::domain::model::http::HttpResponse; use crate::domain::model::plugin::PluginCategory; use crate::domain::ports::driven::ExtractedHosterLink; @@ -213,7 +211,7 @@ impl CommandBus { url: &str, service_name: &str, ) -> Result { - if let (Some(repo), Some(store)) = (self.account_repo(), self.account_credential_store()) { + if let Some(repo) = self.account_repo() { let max_attempts = repo.list_by_service(service_name)?.len(); let mut last_temporary_error = None; for _ in 0..max_attempts { @@ -226,58 +224,34 @@ impl CommandBus { .into()); } }; - let operation_lock = self.account_operation_lock(selected.id())?; - let _operation_guard = operation_lock.lock().await; - let Some(mut account) = repo.find_by_id(selected.id())? else { - continue; - }; - if account.service_name() != service_name - || !account.is_selectable(self.account_now_ms()?) - { - continue; - } - let Some(password) = store.get_password(account.id())? else { - account.set_status(AccountStatus::MissingCredential); - repo.save(&account)?; - continue; - }; - let credential = Credential::new(account.username(), password); - - match self - .plugin_loader() - .extract_hoster_link(service_name, url, Some(&credential)) - { + let handler = self.premium_source_handler().ok_or_else(|| { + AppError::Validation("premium source handler not configured".into()) + })?; + let command = ResolvePremiumSourceCommand::new( + selected.id().clone(), + service_name.to_string(), + url.to_string(), + ); + match handler.handle(command).await { Ok(link) => { - if let Some(total) = link.traffic_total_bytes { - account.set_traffic_total(total); - if let Some(used) = link.traffic_used_bytes { - account.set_traffic_left(total.saturating_sub(used)); - } - } - account.set_status(AccountStatus::Valid); - repo.save(&account)?; return Ok(into_hoster_resolution( link, - Some(account.id().as_str()), + Some(selected.id().as_str()), url, )); } - Err(error) => { - let Some(status) = status_for_plugin_error(&error) else { - return Err(error.into()); - }; - if status.is_temporary() { - last_temporary_error = Some(error.clone()); - } - if status == AccountStatus::QuotaExhausted - && let Some(rotator) = self.account_rotator() - { - rotator.mark_exhausted(account.id(), service_name, 60)?; - } else { - apply_status(&mut account, status, self.account_now_ms()?); - repo.save(&account)?; - } + Err( + error @ (DomainError::AccountCooldown | DomainError::AccountQuotaExceeded), + ) => { + last_temporary_error = Some(error); } + Err( + DomainError::AccountInvalidCredentials + | DomainError::AccountExpired + | DomainError::NotFound(_) + | DomainError::ValidationError(_), + ) => {} + Err(error) => return Err(error.into()), } } if let Some(error) = last_temporary_error { diff --git a/src-tauri/src/application/commands/resolve_premium_source.rs b/src-tauri/src/application/commands/resolve_premium_source.rs new file mode 100644 index 00000000..4f77541b --- /dev/null +++ b/src-tauri/src/application/commands/resolve_premium_source.rs @@ -0,0 +1,113 @@ +//! Command handler for one credential-scoped premium hoster resolution. + +use std::sync::Arc; + +use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; +use crate::domain::model::download::Download; +use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, Clock, DownloadSourceResolver, EventBus, + ExtractedHosterLink, PluginLoader, ResolvedDownloadSource, +}; +use crate::domain::ports::driving::Command; + +#[path = "premium_account_resolution.rs"] +mod resolution; + +#[derive(Debug)] +pub struct ResolvePremiumSourceCommand { + account_id: AccountId, + service_name: String, + source_url: String, +} + +impl ResolvePremiumSourceCommand { + pub fn new(account_id: AccountId, service_name: String, source_url: String) -> Self { + Self { + account_id, + service_name, + source_url, + } + } +} + +impl Command for ResolvePremiumSourceCommand {} + +pub struct ResolvePremiumSourceHandler { + repo: Arc, + credentials: Arc, + plugins: Arc, + events: Arc, + clock: Arc, + locks: Arc, +} + +impl ResolvePremiumSourceHandler { + pub fn new( + repo: Arc, + credentials: Arc, + plugins: Arc, + events: Arc, + clock: Arc, + locks: Arc, + ) -> Self { + Self { + repo, + credentials, + plugins, + events, + clock, + locks, + } + } + + pub async fn handle( + &self, + command: ResolvePremiumSourceCommand, + ) -> Result { + let lock = self.account_lock(&command.account_id)?; + let _guard = lock.lock().await; + self.resolve_locked(command) + } + + fn handle_blocking( + &self, + command: ResolvePremiumSourceCommand, + ) -> Result { + let lock = self.account_lock(&command.account_id)?; + let _guard = lock.blocking_lock(); + self.resolve_locked(command) + } + + fn account_lock(&self, id: &AccountId) -> Result>, DomainError> { + self.locks + .lock_for(id) + .map_err(|_| DomainError::StorageError("account lock unavailable".into())) + } +} + +impl DownloadSourceResolver for ResolvePremiumSourceHandler { + fn resolve(&self, download: &Download) -> Result { + let account_id = download.account_id().cloned().ok_or_else(|| { + DomainError::ValidationError("premium download has no account association".into()) + })?; + let service_name = download.module_name().map(str::to_string).ok_or_else(|| { + DomainError::ValidationError("premium download has no plugin association".into()) + })?; + let command = ResolvePremiumSourceCommand::new( + account_id, + service_name, + download.url().as_str().to_string(), + ); + let link = self.handle_blocking(command)?; + let direct_url = link.direct_url.ok_or_else(|| { + DomainError::PluginError("premium plugin returned no direct URL".into()) + })?; + Ok(ResolvedDownloadSource::sensitive(direct_url)) + } +} + +#[cfg(test)] +#[path = "resolve_premium_source_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/services/premium_source_resolver_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs similarity index 50% rename from src-tauri/src/application/services/premium_source_resolver_tests.rs rename to src-tauri/src/application/commands/resolve_premium_source_test_support.rs index 22a671c8..1c8fd1de 100644 --- a/src-tauri/src/application/services/premium_source_resolver_tests.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs @@ -1,18 +1,20 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use super::*; use crate::application::commands::tests_support::{ - FakeAccountCredentialStore, InMemoryAccountRepo, + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, }; -use crate::domain::model::account::{AccountId, AccountType}; -use crate::domain::model::download::{DownloadId, Url}; +use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use crate::domain::model::credential::Credential; +use crate::domain::model::download::{Download, DownloadId, Url}; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; -use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, ExtractedHosterLink, -}; +use crate::domain::ports::driven::{AccountRepository, Clock, ExtractedHosterLink, PluginLoader}; + +use super::ResolvePremiumSourceHandler; -struct FixedClock; +pub(super) struct FixedClock; impl Clock for FixedClock { fn now_unix_secs(&self) -> u64 { @@ -20,70 +22,34 @@ impl Clock for FixedClock { } } -struct SaveFailingRepo { - inner: InMemoryAccountRepo, - fail_saves: AtomicBool, +pub(super) struct DirectUrlPlugin { + pub(super) calls: Mutex>, } -impl SaveFailingRepo { - fn new() -> Self { +impl DirectUrlPlugin { + pub(super) fn new() -> Self { Self { - inner: InMemoryAccountRepo::new(), - fail_saves: AtomicBool::new(false), - } - } -} - -impl AccountRepository for SaveFailingRepo { - fn find_by_id(&self, id: &AccountId) -> Result, DomainError> { - self.inner.find_by_id(id) - } - - fn save(&self, account: &Account) -> Result<(), DomainError> { - if self.fail_saves.load(Ordering::SeqCst) { - return Err(DomainError::StorageError("database unavailable".into())); + calls: Mutex::new(Vec::new()), } - self.inner.save(account) - } - - fn list(&self) -> Result, DomainError> { - self.inner.list() } - - fn list_by_service(&self, service_name: &str) -> Result, DomainError> { - self.inner.list_by_service(service_name) - } - - fn delete(&self, id: &AccountId) -> Result<(), DomainError> { - self.inner.delete(id) - } -} - -struct DirectUrlPlugin { - calls: Mutex>, } impl PluginLoader for DirectUrlPlugin { fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { Ok(()) } - fn unload(&self, _: &str) -> Result<(), DomainError> { Ok(()) } - fn resolve_url(&self, _: &str) -> Result, DomainError> { Ok(None) } - fn list_loaded(&self) -> Result, DomainError> { Ok(Vec::new()) } - fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { Ok(()) } - fn extract_hoster_link( &self, service: &str, @@ -107,94 +73,76 @@ impl PluginLoader for DirectUrlPlugin { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resolves_the_direct_url_only_when_the_engine_requests_it() { - let repo = Arc::new(InMemoryAccountRepo::new()); - let credentials = Arc::new(FakeAccountCredentialStore::new()); - let plugin = Arc::new(DirectUrlPlugin { - calls: Mutex::new(Vec::new()), - }); - let account_id = AccountId::new("account-1"); - let mut account = Account::new( - account_id.clone(), - "vortex-mod-1fichier".into(), - "alice".into(), - AccountType::Premium, - 1, - ); - account.set_status(AccountStatus::Valid); - repo.save(&account).unwrap(); - credentials.store_password(&account_id, "api-key").unwrap(); - let resolver = Arc::new(PremiumSourceResolver::new( - repo.clone(), - credentials, - plugin.clone(), - Arc::new(FixedClock), - Arc::new(AccountOperationLocks::default()), - )); - let download = Download::new( - DownloadId(1), - Url::new("https://1fichier.com/?abc123").unwrap(), - "file.zip".into(), - "/tmp/file.zip".into(), - ) - .with_module_name("vortex-mod-1fichier".into()) - .with_account_id(account_id.clone()); +pub(super) struct SaveFailingRepo { + inner: InMemoryAccountRepo, + pub(super) fail_saves: AtomicBool, +} - let source = tokio::task::spawn_blocking(move || resolver.resolve(&download)) - .await - .unwrap() - .unwrap(); +impl SaveFailingRepo { + pub(super) fn new() -> Self { + Self { + inner: InMemoryAccountRepo::new(), + fail_saves: AtomicBool::new(false), + } + } +} - assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); - assert_eq!( - plugin.calls.lock().unwrap().as_slice(), - [( - "vortex-mod-1fichier".into(), - "https://1fichier.com/?abc123".into(), - "api-key".into() - )] - ); - let stored = repo.find_by_id(&account_id).unwrap().unwrap(); - assert_eq!(stored.traffic_left(), Some(90)); +impl AccountRepository for SaveFailingRepo { + fn find_by_id(&self, id: &AccountId) -> Result, DomainError> { + self.inner.find_by_id(id) + } + fn save(&self, account: &Account) -> Result<(), DomainError> { + if self.fail_saves.load(Ordering::SeqCst) { + return Err(DomainError::StorageError("database unavailable".into())); + } + self.inner.save(account) + } + fn list(&self) -> Result, DomainError> { + self.inner.list() + } + fn list_by_service(&self, service_name: &str) -> Result, DomainError> { + self.inner.list_by_service(service_name) + } + fn delete(&self, id: &AccountId) -> Result<(), DomainError> { + self.inner.delete(id) + } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn missing_credential_persistence_failure_is_not_suppressed() { - let repo = Arc::new(SaveFailingRepo::new()); - let account_id = AccountId::new("account-1"); +pub(super) fn valid_account(id: &str) -> Account { let mut account = Account::new( - account_id.clone(), + AccountId::new(id), "vortex-mod-1fichier".into(), "alice".into(), AccountType::Premium, 1, ); account.set_status(AccountStatus::Valid); - repo.save(&account).unwrap(); - repo.fail_saves.store(true, Ordering::SeqCst); - let resolver = Arc::new(PremiumSourceResolver::new( - repo, - Arc::new(FakeAccountCredentialStore::new()), - Arc::new(DirectUrlPlugin { - calls: Mutex::new(Vec::new()), - }), - Arc::new(FixedClock), - Arc::new(AccountOperationLocks::default()), - )); - let download = Download::new( + account +} + +pub(super) fn download(account_id: AccountId) -> Download { + Download::new( DownloadId(1), Url::new("https://1fichier.com/?abc123").unwrap(), "file.zip".into(), "/tmp/file.zip".into(), ) .with_module_name("vortex-mod-1fichier".into()) - .with_account_id(account_id); - - let error = tokio::task::spawn_blocking(move || resolver.resolve(&download)) - .await - .unwrap() - .expect_err("storage failure must win over missing credential"); + .with_account_id(account_id) +} - assert!(matches!(error, DomainError::StorageError(_))); +pub(super) fn handler( + repo: Arc, + credentials: Arc, + plugin: Arc, + events: Arc, +) -> Arc { + Arc::new(ResolvePremiumSourceHandler::new( + repo, + credentials, + plugin, + events, + Arc::new(FixedClock), + Arc::new(AccountOperationLocks::default()), + )) } diff --git a/src-tauri/src/application/commands/resolve_premium_source_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_tests.rs new file mode 100644 index 00000000..e0dad489 --- /dev/null +++ b/src-tauri/src/application/commands/resolve_premium_source_tests.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use super::*; +use crate::application::commands::tests_support::{ + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, +}; +use crate::domain::event::DomainEvent; +use crate::domain::model::account::AccountStatus; +use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, DownloadSourceResolver, +}; + +#[path = "resolve_premium_source_test_support.rs"] +mod support; +use support::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resolves_the_direct_url_only_when_the_engine_requests_it() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let events = Arc::new(CapturingEventBus::new()); + let account = valid_account("account-1"); + repo.save(&account).unwrap(); + credentials.store_password(account.id(), "api-key").unwrap(); + let resolver = handler(repo.clone(), credentials, plugin.clone(), events.clone()); + let download = download(account.id().clone()); + + let source = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .unwrap() + .unwrap(); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert_eq!(plugin.calls.lock().unwrap()[0].2, "api-key"); + assert_eq!( + repo.find_by_id(account.id()) + .unwrap() + .unwrap() + .traffic_left(), + Some(90) + ); + assert!( + events + .snapshot() + .iter() + .any(|event| matches!(event, DomainEvent::AccountUpdated { id } if id == account.id())) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_credential_persistence_failure_is_not_suppressed() { + let repo = Arc::new(SaveFailingRepo::new()); + let account = valid_account("account-1"); + repo.save(&account).unwrap(); + repo.fail_saves.store(true, Ordering::SeqCst); + let resolver = handler( + repo, + Arc::new(FakeAccountCredentialStore::new()), + Arc::new(DirectUrlPlugin::new()), + Arc::new(CapturingEventBus::new()), + ); + let download = download(account.id().clone()); + + let error = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .unwrap() + .expect_err("storage failure must win over missing credential"); + + assert!(matches!(error, DomainError::StorageError(_))); +} + +#[tokio::test] +async fn missing_credential_state_is_persisted_before_frontend_event() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let account = valid_account("account-1"); + repo.save(&account).unwrap(); + let events = Arc::new(CapturingEventBus::new()); + let resolver = handler( + repo.clone(), + Arc::new(FakeAccountCredentialStore::new()), + Arc::new(DirectUrlPlugin::new()), + events.clone(), + ); + + let error = resolver + .handle(ResolvePremiumSourceCommand::new( + account.id().clone(), + account.service_name().to_string(), + "https://1fichier.com/?abc123".into(), + )) + .await + .expect_err("missing credential"); + + assert!(matches!(error, DomainError::NotFound(_))); + assert_eq!( + repo.find_by_id(account.id()).unwrap().unwrap().status(), + AccountStatus::MissingCredential + ); + assert!(events.snapshot().iter().any(|event| matches!( + event, + DomainEvent::AccountValidationFailed { id, .. } if id == account.id() + ))); +} diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index 49d5cc4f..c7f6589c 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -25,8 +25,6 @@ impl CommandBus { cmd: super::StartDownloadCommand, ) -> Result { let url = Url::new(&cmd.url)?; - self.validate_download_account(cmd.module_name.as_deref(), cmd.account_id.as_ref()) - .await?; // Use the pre-computed filename when available (e.g. set by media plugins // that already know the video title). Otherwise probe via HEAD or fall back @@ -67,6 +65,16 @@ impl CommandBus { let dest = dest_dir.join(&file_name); let id = next_download_id(); + let account_lock = cmd + .account_id + .as_ref() + .map(|id| self.account_operation_lock(id)) + .transpose()?; + let _account_guard = match account_lock { + Some(lock) => Some(lock.lock_owned().await), + None => None, + }; + self.validate_download_account(cmd.module_name.as_deref(), cmd.account_id.as_ref())?; // Append to the back of the queue so a freshly added download // does not jump in front of items the user has explicitly // reordered (default queue_position 0 would sort before 1..N). @@ -95,7 +103,7 @@ impl CommandBus { Ok(id) } - async fn validate_download_account( + fn validate_download_account( &self, module_name: Option<&str>, account_id: Option<&AccountId>, @@ -114,8 +122,6 @@ impl CommandBus { let store = self.account_credential_store().ok_or_else(|| { AppError::Validation("account credential store not configured".into()) })?; - let operation_lock = self.account_operation_lock(account_id)?; - let _operation_guard = operation_lock.lock().await; let mut account = repo.find_by_id(account_id)?.ok_or_else(|| { AppError::NotFound(format!("account {} not found", account_id.as_str())) })?; diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index c774b050..2b1bef80 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -6,10 +6,12 @@ use std::collections::HashMap; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use crate::application::command_bus::CommandBus; +use crate::application::commands::resolve_premium_source::ResolvePremiumSourceHandler; +use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::test_support::NoopHistoryRepo; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; @@ -33,12 +35,14 @@ use crate::domain::ports::driven::{ pub(crate) struct InMemoryAccountRepo { store: Mutex>, + save_count: AtomicUsize, } impl InMemoryAccountRepo { pub(crate) fn new() -> Self { Self { store: Mutex::new(HashMap::new()), + save_count: AtomicUsize::new(0), } } @@ -51,6 +55,10 @@ impl InMemoryAccountRepo { }); accounts } + + pub(crate) fn save_count(&self) -> usize { + self.save_count.load(Ordering::SeqCst) + } } impl AccountRepository for InMemoryAccountRepo { @@ -59,6 +67,7 @@ impl AccountRepository for InMemoryAccountRepo { } fn save(&self, account: &Account) -> Result<(), DomainError> { + self.save_count.fetch_add(1, Ordering::SeqCst); let mut guard = self.store.lock().unwrap(); for (id, existing) in guard.iter() { if id != account.id() @@ -796,6 +805,16 @@ pub(crate) fn build_account_bus_with_plugin_loader( codec: Option>, plugin_loader: Arc, ) -> CommandBus { + let clock: Arc = Arc::new(FixedAccountClock); + let locks = Arc::new(AccountOperationLocks::default()); + let premium_handler = Arc::new(ResolvePremiumSourceHandler::new( + account_repo.clone(), + credential_store.clone(), + plugin_loader.clone(), + event_bus.clone(), + clock.clone(), + locks.clone(), + )); let mut bus = CommandBus::new( Arc::new(StubDownloadRepo), Arc::new(StubDownloadEngine), @@ -812,7 +831,9 @@ pub(crate) fn build_account_bus_with_plugin_loader( ) .with_account_repo(account_repo) .with_account_credential_store(credential_store) - .with_account_clock(Arc::new(FixedAccountClock)); + .with_account_clock(clock) + .with_account_operation_locks(locks) + .with_premium_source_handler(premium_handler); if let Some(v) = validator { bus = bus.with_account_validator(v); diff --git a/src-tauri/src/application/commands/update_account.rs b/src-tauri/src/application/commands/update_account.rs index 00d9f261..89d242d8 100644 --- a/src-tauri/src/application/commands/update_account.rs +++ b/src-tauri/src/application/commands/update_account.rs @@ -125,7 +125,7 @@ impl CommandBus { return Err(error.into()); } if let Some(attempt) = &validation { - sync_validation_availability(self, &cmd.id, &attempt.outcome)?; + sync_validation_availability(self, &cmd.id, &attempt.outcome); } self.event_bus() diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index d933afa4..6114cfe8 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -97,13 +97,12 @@ pub(super) fn sync_validation_availability( bus: &CommandBus, id: &crate::domain::model::account::AccountId, outcome: &ValidationOutcome, -) -> Result<(), AppError> { +) { if outcome.is_valid() && let Some(rotator) = bus.account_rotator() { - rotator.clear_exhausted(id)?; + rotator.clear_cached_exhausted(id); } - Ok(()) } impl CommandBus { @@ -144,7 +143,7 @@ impl CommandBus { let attempt = validate_credentials(validator, &account, &password); repo.save(&apply_validation(&account, &attempt.outcome, cmd.now_ms))?; - sync_validation_availability(self, &cmd.id, &attempt.outcome)?; + sync_validation_availability(self, &cmd.id, &attempt.outcome); publish_validation(self, cmd.id, &attempt.outcome); match attempt.error { @@ -360,6 +359,7 @@ mod tests { rotator .mark_exhausted(account.id(), account.service_name(), 60) .expect("mark exhausted"); + let saves_before_validation = repo.save_count(); let bus = build_account_bus(repo.clone(), credentials, events, Some(validator), None) .with_account_rotator(rotator.clone()); @@ -371,6 +371,7 @@ mod tests { .expect("validation succeeds"); assert!(!rotator.is_exhausted(account.id()).expect("rotator state")); + assert_eq!(repo.save_count(), saves_before_validation + 1); assert_eq!( repo.find_by_id(account.id()).unwrap().unwrap().status(), AccountStatus::Valid diff --git a/src-tauri/src/application/services/account_operation_locks.rs b/src-tauri/src/application/services/account_operation_locks.rs index b88acee9..8c93f92e 100644 --- a/src-tauri/src/application/services/account_operation_locks.rs +++ b/src-tauri/src/application/services/account_operation_locks.rs @@ -1,7 +1,7 @@ //! Per-account serialization for metadata, keyring, and plugin operations. use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use tokio::sync::Mutex as AsyncMutex; use crate::application::error::AppError; @@ -9,7 +9,7 @@ use crate::domain::model::account::AccountId; #[derive(Default)] pub struct AccountOperationLocks { - entries: Mutex>>>, + entries: Mutex>>>, } impl AccountOperationLocks { @@ -18,10 +18,13 @@ impl AccountOperationLocks { .entries .lock() .map_err(|_| AppError::Validation("account operation locks mutex poisoned".into()))?; - Ok(entries - .entry(id.clone()) - .or_insert_with(|| Arc::new(AsyncMutex::new(()))) - .clone()) + entries.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = entries.get(id).and_then(Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(AsyncMutex::new(())); + entries.insert(id.clone(), Arc::downgrade(&lock)); + Ok(lock) } } diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index d9500f68..af582668 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -246,6 +246,23 @@ impl AccountRotator { Ok(()) } + /// Clear only the non-canonical in-memory cooldown cache. + /// + /// Command handlers use this after atomically saving an aggregate whose + /// validated status already cleared the persisted deadline. Keeping this + /// operation repository-free avoids a second save that could fail after + /// the validated state was committed. + pub(crate) fn clear_cached_exhausted(&self, account_id: &AccountId) { + let mut guard = match self.exhausted.lock() { + Ok(guard) => guard, + Err(poisoned) => { + tracing::warn!("recovering poisoned non-canonical account cooldown cache"); + poisoned.into_inner() + } + }; + guard.remove(account_id); + } + /// `true` when `account_id` has an active cooldown at the current /// clock reading. Expired entries are NOT pruned by this call — /// pruning happens lazily inside `next_account` / diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index bee81841..f2953eec 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -1,4 +1,4 @@ -pub(crate) mod account_operation_locks; +pub mod account_operation_locks; pub mod account_rotator; pub mod account_selector; pub(crate) mod account_state; @@ -8,13 +8,13 @@ pub(crate) mod group_lock; pub mod history_backfill; pub mod history_paginate; pub mod playlist_grouper; -pub(crate) mod premium_source_resolver; pub mod queue_config_bridge; pub mod queue_manager; pub mod split_archive_grouper; pub mod startup_recovery; pub mod url_normalizer; +pub use account_operation_locks::AccountOperationLocks; pub use account_rotator::AccountRotator; pub use account_selector::AccountSelector; pub use checksum_validator::{ChecksumOutcome, ChecksumValidatorService}; diff --git a/src-tauri/src/application/services/premium_source_resolver.rs b/src-tauri/src/application/services/premium_source_resolver.rs deleted file mode 100644 index ab053902..00000000 --- a/src-tauri/src/application/services/premium_source_resolver.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Just-in-time premium URL resolution for queued downloads. - -use std::sync::Arc; - -use crate::application::services::account_operation_locks::AccountOperationLocks; -use crate::application::services::account_state::{apply_status, status_for_plugin_error}; -use crate::domain::error::DomainError; -use crate::domain::model::account::{Account, AccountStatus}; -use crate::domain::model::credential::Credential; -use crate::domain::model::download::Download; -use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, Clock, DownloadSourceResolver, PluginLoader, - ResolvedDownloadSource, -}; - -pub struct PremiumSourceResolver { - repo: Arc, - credentials: Arc, - plugins: Arc, - clock: Arc, - locks: Arc, -} - -impl PremiumSourceResolver { - pub fn new( - repo: Arc, - credentials: Arc, - plugins: Arc, - clock: Arc, - locks: Arc, - ) -> Self { - Self { - repo, - credentials, - plugins, - clock, - locks, - } - } - - fn persist_failure(&self, account: &mut Account, error: &DomainError) { - if let Some(status) = status_for_plugin_error(error) { - apply_status(account, status, self.clock.now_unix_ms()); - if let Err(save_error) = self.repo.save(account) { - tracing::warn!( - account_id = %account.id().as_str(), - error = %save_error, - "failed to persist premium account failure" - ); - } - } - } -} - -impl DownloadSourceResolver for PremiumSourceResolver { - fn resolve(&self, download: &Download) -> Result { - let account_id = download.account_id().ok_or_else(|| { - DomainError::ValidationError("premium download has no account association".into()) - })?; - let service_name = download.module_name().ok_or_else(|| { - DomainError::ValidationError("premium download has no plugin association".into()) - })?; - let lock = self - .locks - .lock_for(account_id) - .map_err(|_| DomainError::StorageError("account lock unavailable".into()))?; - let _guard = lock.blocking_lock(); - let mut account = self - .repo - .find_by_id(account_id)? - .ok_or_else(|| DomainError::NotFound(format!("account {}", account_id.as_str())))?; - if account.service_name() != service_name - || !account.is_selectable(self.clock.now_unix_ms()) - { - return Err(DomainError::ValidationError( - "premium account is unavailable".into(), - )); - } - let password = self.credentials.get_password(account_id)?.ok_or_else(|| { - account.set_status(AccountStatus::MissingCredential); - let _ = self.repo.save(&account); - DomainError::NotFound(format!("credential for account {}", account_id.as_str())) - })?; - let credential = Credential::new(account.username(), password); - let link = match self.plugins.extract_hoster_link( - service_name, - download.url().as_str(), - Some(&credential), - ) { - Ok(link) => link, - Err(error) => { - self.persist_failure(&mut account, &error); - return Err(match error { - DomainError::AccountInvalidCredentials - | DomainError::AccountExpired - | DomainError::AccountCooldown - | DomainError::AccountQuotaExceeded => error, - _ => DomainError::PluginError("premium source resolution failed".into()), - }); - } - }; - let direct_url = link.direct_url.ok_or_else(|| { - DomainError::PluginError("premium plugin returned no direct URL".into()) - })?; - if let Some(total) = link.traffic_total_bytes { - account.set_traffic_total(total); - account.set_traffic_left(total.saturating_sub(link.traffic_used_bytes.unwrap_or(0))); - } - account.set_status(AccountStatus::Valid); - self.repo.save(&account)?; - Ok(ResolvedDownloadSource::sensitive(direct_url)) - } -} - -#[cfg(test)] -#[path = "premium_source_resolver_tests.rs"] -mod tests; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a3fbd1fc..2939fa49 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -55,6 +55,7 @@ pub use adapters::driven::tray::{ spawn_tray_animator, }; pub use application::command_bus::CommandBus; +pub use application::commands::resolve_premium_source::ResolvePremiumSourceHandler; pub use application::commands::store_refresh::{read_cache, write_cache}; pub use application::error::AppError; pub use application::query_bus::QueryBus; @@ -66,7 +67,9 @@ pub use application::read_models::{ stats_view::{DailyVolumeDto, HostStatsDto, ModuleStatsDto, StatsViewDto}, }; pub use application::services::backfill_history_for_completed_downloads; -pub use application::services::{AccountRotator, AccountSelector, QueueManager}; +pub use application::services::{ + AccountOperationLocks, AccountRotator, AccountSelector, QueueManager, +}; pub use domain::model::ExtractionConfig; pub use adapters::driving::tauri_ipc::{ @@ -258,15 +261,16 @@ pub fn run() { let account_operation_locks = Arc::new( application::services::account_operation_locks::AccountOperationLocks::default(), ); - let premium_source_resolver: Arc = Arc::new( - application::services::premium_source_resolver::PremiumSourceResolver::new( - account_repo.clone(), - account_credential_store.clone(), - plugin_loader.clone(), - account_clock.clone(), - account_operation_locks.clone(), - ), - ); + let premium_source_handler = Arc::new(ResolvePremiumSourceHandler::new( + account_repo.clone(), + account_credential_store.clone(), + plugin_loader.clone(), + event_bus.clone(), + account_clock.clone(), + account_operation_locks.clone(), + )); + let premium_source_resolver: Arc = + premium_source_handler.clone(); // ── Download engine ───────────────────────────────────── let initial_engine_config = config_store @@ -415,6 +419,7 @@ pub fn run() { .with_account_rotator(account_rotator) .with_account_clock(account_clock) .with_account_operation_locks(account_operation_locks) + .with_premium_source_handler(premium_source_handler) .with_package_repo(package_repo.clone()) .with_passphrase_codec(passphrase_codec), ); diff --git a/src-tauri/tests/app_state_wiring.rs b/src-tauri/tests/app_state_wiring.rs index 403fd948..01cf6f19 100644 --- a/src-tauri/tests/app_state_wiring.rs +++ b/src-tauri/tests/app_state_wiring.rs @@ -3,18 +3,21 @@ use std::sync::Arc; +use vortex_lib::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use vortex_lib::domain::model::download::{Download, DownloadId, Url}; use vortex_lib::domain::ports::driven::{ AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, DownloadRepository, - EventBus, FileStorage, HistoryRepository, HttpClient, PluginLoader, PluginReadRepository, - StatsRepository, + DownloadSourceResolver, EventBus, FileStorage, HistoryRepository, HttpClient, PluginLoader, + PluginReadRepository, StatsRepository, }; use vortex_lib::{ - AccountRotator, AccountSelector, CommandBus, ExtismPluginLoader, ExtractionConfig, - FsFileStorage, KeyringAccountStore, NoopCredentialStore, PluginAccountValidator, QueryBus, - ReqwestHttpClient, SegmentedDownloadEngine, SharedHostResources, SqliteAccountRepo, - SqliteDownloadReadRepo, SqliteDownloadRepo, SqliteHistoryRepo, SqliteStatsRepo, SystemClock, - TokioEventBus, TomlConfigStore, VortexArchiveExtractor, connection, + AccountOperationLocks, AccountRotator, AccountSelector, CommandBus, ExtismPluginLoader, + ExtractionConfig, FsFileStorage, KeyringAccountStore, NoopCredentialStore, + PluginAccountValidator, QueryBus, ReqwestHttpClient, ResolvePremiumSourceHandler, + SegmentedDownloadEngine, SharedHostResources, SqliteAccountRepo, SqliteDownloadReadRepo, + SqliteDownloadRepo, SqliteHistoryRepo, SqliteStatsRepo, SystemClock, TokioEventBus, + TomlConfigStore, VortexArchiveExtractor, connection, }; /// Verifies that all driven adapters satisfy their port traits and that @@ -80,17 +83,25 @@ fn test_appstate_wiring_with_in_memory_db() { account_selector.clone(), account_repo.clone(), event_bus.clone(), - account_clock, + account_clock.clone(), ); let account_validator = Arc::new(PluginAccountValidator::new(plugin_loader.clone())); - - // Download engine - let download_engine: Arc = Arc::new(SegmentedDownloadEngine::new( - reqwest_client, - file_storage.clone(), + let account_operation_locks = Arc::new(AccountOperationLocks::default()); + let premium_source_handler = Arc::new(ResolvePremiumSourceHandler::new( + account_repo.clone(), + account_credential_store.clone(), + plugin_loader.clone(), event_bus.clone(), - 4, + account_clock.clone(), + account_operation_locks.clone(), )); + let premium_source_resolver: Arc = premium_source_handler.clone(); + + // Download engine + let download_engine: Arc = Arc::new( + SegmentedDownloadEngine::new(reqwest_client, file_storage.clone(), event_bus.clone(), 4) + .with_source_resolver(premium_source_resolver), + ); // Clipboard stub (no Tauri AppHandle in tests) let clipboard_observer: Arc = Arc::new(StubClipboardObserver); @@ -98,7 +109,7 @@ fn test_appstate_wiring_with_in_memory_db() { // CQRS buses let command_bus = Arc::new( CommandBus::new( - download_repo, + download_repo.clone(), download_engine, event_bus, file_storage, @@ -115,7 +126,10 @@ fn test_appstate_wiring_with_in_memory_db() { .with_account_credential_store(account_credential_store) .with_account_validator(account_validator) .with_account_selector(account_selector) - .with_account_rotator(account_rotator), + .with_account_rotator(account_rotator) + .with_account_clock(account_clock) + .with_account_operation_locks(account_operation_locks) + .with_premium_source_handler(premium_source_handler), ); let query_bus = Arc::new( @@ -126,7 +140,7 @@ fn test_appstate_wiring_with_in_memory_db() { plugin_read_repo, archive_extractor, ) - .with_account_repo(account_repo), + .with_account_repo(account_repo.clone()), ); // Verify command bus is wired (exercise a read through it) @@ -139,6 +153,7 @@ fn test_appstate_wiring_with_in_memory_db() { assert!(command_bus.account_validator().is_some()); assert!(command_bus.account_selector().is_some()); assert!(command_bus.account_rotator().is_some()); + assert!(command_bus.premium_source_handler().is_some()); assert!(query_bus.account_repo().is_some()); // Verify query bus can execute a read query (empty DB → empty results) @@ -155,6 +170,33 @@ fn test_appstate_wiring_with_in_memory_db() { .expect("stats query"); assert_eq!(stats.total_files, 0); assert_eq!(stats.total_downloaded_bytes, 0); + + let account_id = AccountId::new("account-round-trip"); + let mut account = Account::new( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + account_repo.save(&account).expect("save account"); + let download = Download::new( + DownloadId(42), + Url::new("https://1fichier.com/?abc123").expect("source URL"), + "archive.zip".into(), + "/tmp/archive.zip".into(), + ) + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(account_id.clone()); + download_repo.save(&download).expect("save download"); + + let reloaded = download_repo + .find_by_id(download.id()) + .expect("read download") + .expect("stored download"); + assert_eq!(reloaded.account_id(), Some(&account_id)); + assert_eq!(reloaded.url().as_str(), "https://1fichier.com/?abc123"); } /// Minimal clipboard observer stub for tests without a Tauri runtime. diff --git a/src/hooks/useAccountEvents.ts b/src/hooks/useAccountEvents.ts new file mode 100644 index 00000000..b7bf3968 --- /dev/null +++ b/src/hooks/useAccountEvents.ts @@ -0,0 +1,23 @@ +import { queryClient } from "@/api/client"; +import { accountQueries } from "@/api/queries"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; +import type { + AccountAddedPayload, + AccountExhaustedPayload, + AccountIdPayload, + AccountValidatedPayload, + AccountValidationFailedPayload, +} from "@/types/events"; + +export function useAccountEvents(): void { + const invalidateAccounts = () => { + queryClient.invalidateQueries({ queryKey: accountQueries.all() }); + }; + + useTauriEvent("account-added", invalidateAccounts); + useTauriEvent("account-updated", invalidateAccounts); + useTauriEvent("account-deleted", invalidateAccounts); + useTauriEvent("account-validated", invalidateAccounts); + useTauriEvent("account-validation-failed", invalidateAccounts); + useTauriEvent("account-exhausted", invalidateAccounts); +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 30f9f031..91312456 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -7,6 +7,7 @@ import { SkipLink } from "@/components/a11y/SkipLink"; import { ROUTES } from "@/types/layout"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { useDownloadEvents } from "@/hooks/useDownloadEvents"; +import { useAccountEvents } from "@/hooks/useAccountEvents"; import { useAppEffects } from "@/hooks/useAppEffects"; import { tauriInvoke } from "@/api/client"; import { useTauriQuery } from "@/api/hooks"; @@ -27,6 +28,7 @@ export function AppLayout() { const updateCountByState = useDownloadStore((s) => s.updateCountByState); useDownloadProgress(); useDownloadEvents(); + useAccountEvents(); useAppEffects(); const { data: config } = useTauriQuery("settings_get", undefined, { diff --git a/src/layouts/__tests__/AppLayout.test.tsx b/src/layouts/__tests__/AppLayout.test.tsx index 37f1169d..68d88ca2 100644 --- a/src/layouts/__tests__/AppLayout.test.tsx +++ b/src/layouts/__tests__/AppLayout.test.tsx @@ -6,6 +6,7 @@ import { invoke } from "@tauri-apps/api/core"; import { AppLayout } from "../AppLayout"; import { useUiStore } from "@/stores/uiStore"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useAccountEvents } from "@/hooks/useAccountEvents"; import type { AppConfig } from "@/types/settings"; import enTranslations from "@/i18n/locales/en.json"; @@ -59,6 +60,10 @@ vi.mock("@/hooks/useDownloadEvents", () => ({ useDownloadEvents: vi.fn(), })); +vi.mock("@/hooks/useAccountEvents", () => ({ + useAccountEvents: vi.fn(), +})); + vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); @@ -126,6 +131,7 @@ describe("AppLayout", () => { it("should render Sidebar, main content, and StatusBar", () => { renderAppLayout(); + expect(useAccountEvents).toHaveBeenCalledOnce(); expect(screen.getByAltText("Vortex")).toBeInTheDocument(); expect(screen.getByText("Downloads Page")).toBeInTheDocument(); expect(screen.getByText(/vortex v0\.1\.0/)).toBeInTheDocument(); diff --git a/src/types/events.ts b/src/types/events.ts index dfb19e05..5087e63a 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -66,6 +66,30 @@ export interface ClipboardMonitoringChangedPayload { enabled: boolean; } +export interface AccountIdPayload { + id: string; +} + +export interface AccountAddedPayload extends AccountIdPayload { + serviceName: string; +} + +export interface AccountValidatedPayload extends AccountIdPayload { + latencyMs: number | null; + trafficLeft: number | null; + trafficTotal: number | null; + validUntil: number | null; +} + +export interface AccountValidationFailedPayload extends AccountIdPayload { + error: string; +} + +export interface AccountExhaustedPayload extends AccountIdPayload { + serviceName: string; + exhaustedUntilMs: number; +} + export type TauriEventMap = { "download-created": DownloadIdPayload; "download-started": DownloadIdPayload; @@ -91,6 +115,12 @@ export type TauriEventMap = { "package-created": PackageCreatedPayload; "clipboard-url-detected": ClipboardUrlDetectedPayload; "clipboard-monitoring-changed": ClipboardMonitoringChangedPayload; + "account-added": AccountAddedPayload; + "account-updated": AccountIdPayload; + "account-deleted": AccountIdPayload; + "account-validated": AccountValidatedPayload; + "account-validation-failed": AccountValidationFailedPayload; + "account-exhausted": AccountExhaustedPayload; }; export type TauriEventName = keyof TauriEventMap; From 3e0f273024d3940d3b292919c173937c69003ae3 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:56:35 +0200 Subject: [PATCH 28/33] test(accounts): pin final acceptance gaps --- CHANGELOG.md | 4 ++ .../src/adapters/driven/network/safe_url.rs | 11 +++ .../src/application/commands/resolve_links.rs | 72 ++----------------- .../resolve_premium_source_test_support.rs | 47 +++++++++++- .../commands/resolve_premium_source_tests.rs | 62 +++++++++++++++- .../application/commands/start_download.rs | 14 +++- .../__tests__/AccountRow.test.tsx | 50 +++++++++++++ 7 files changed, 186 insertions(+), 74 deletions(-) create mode 100644 src/views/AccountsView/__tests__/AccountRow.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5c4401..3031c652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-132 final acceptance regressions (RED)**: added contracts for strict + JIT-only credential use, runtime account rotation with association updates, + referenced-account deletion safety, observable missing credentials, cooldown + badge expiry, and operator-prefix NAT64 SSRF rejection. - **MAT-132 final review hardening**: premium extraction now runs through one CQRS command handler shared by link analysis and just-in-time downloads. Account failures persist before typed events, cooldown recovery uses one diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs index 658b871d..3509a225 100644 --- a/src-tauri/src/adapters/driven/network/safe_url.rs +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -119,12 +119,23 @@ mod tests { "169.254.169.254", "::ffff:127.0.0.1", "fec0::1", + "64:ff9b::c0a8:1", "64:ff9b:1::c0a8:1", + "2001:10::1", + "2001:20::1", ] { assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}"); } } + #[test] + fn rejects_private_ipv4_embedded_in_discovered_operator_nat64_prefix() { + let prefix = Nat64Prefix::new("2606:4700:64::".parse().unwrap(), 96).unwrap(); + let target = "2606:4700:64::c0a8:1".parse().unwrap(); + + assert!(is_forbidden_ipv6(&target, &[prefix])); + } + #[test] fn accepts_globally_routable_ipv6_address() { assert!(!is_forbidden_ip(&"2606:4700:4700::1111".parse().unwrap())); diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 899445d2..589871e2 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -563,9 +563,9 @@ mod tests { } #[tokio::test] - async fn resolve_hoster_rotates_expired_account_and_returns_opaque_account_id() { + async fn resolve_hoster_selects_account_without_reading_secret_or_issuing_token() { let (result, repo, plugin, primary) = - resolve_with_primary_credential(Some("expired-key"), true).await; + resolve_with_primary_credential(Some("working-key"), true).await; assert_eq!( result[0].resolved_url.as_deref(), @@ -577,74 +577,14 @@ mod tests { .contains("download.1fichier.com/token"), "short-lived direct capabilities must not cross IPC" ); - assert_eq!(result[0].account_id.as_deref(), Some("backup")); + assert_eq!(result[0].account_id.as_deref(), Some("primary")); assert_eq!(result[0].module_name, "vortex-mod-1fichier"); assert_eq!( repo.find_by_id(primary.id()).unwrap().unwrap().status(), - AccountStatus::Expired + AccountStatus::Valid ); - assert_eq!( - plugin.credentials.lock().unwrap().as_slice(), - ["expired-key", "working-key"] - ); - assert_eq!( - plugin.services.lock().unwrap().as_slice(), - ["vortex-mod-1fichier", "vortex-mod-1fichier"] - ); - } - - #[tokio::test] - async fn resolve_hoster_persists_typed_failures_before_rotating() { - for (password, expected_status) in [ - ("invalid-key", AccountStatus::InvalidCredentials), - ("quota-key", AccountStatus::QuotaExhausted), - ("cooldown-key", AccountStatus::Cooldown), - ] { - let (result, repo, plugin, primary) = - resolve_with_primary_credential(Some(password), true).await; - - assert_eq!(result[0].account_id.as_deref(), Some("backup")); - let stored = repo.find_by_id(primary.id()).unwrap().unwrap(); - assert_eq!(stored.status(), expected_status); - if matches!( - expected_status, - AccountStatus::QuotaExhausted | AccountStatus::Cooldown - ) { - assert!(stored.exhausted_until().is_some()); - } - assert_eq!( - plugin.credentials.lock().unwrap().as_slice(), - [password, "working-key"] - ); - } - } - - #[tokio::test] - async fn resolve_hoster_marks_missing_credential_and_rotates_without_exposing_it() { - let (result, repo, plugin, primary) = resolve_with_primary_credential(None, true).await; - - assert_eq!(result[0].account_id.as_deref(), Some("backup")); - assert_eq!( - repo.find_by_id(primary.id()).unwrap().unwrap().status(), - AccountStatus::MissingCredential - ); - assert_eq!( - plugin.credentials.lock().unwrap().as_slice(), - ["working-key"] - ); - } - - #[tokio::test] - async fn resolve_hoster_does_not_fall_back_to_free_when_all_accounts_are_exhausted() { - let (result, _, plugin, _) = - resolve_with_primary_credential(Some("quota-key"), false).await; - - assert_eq!(result[0].status, "error"); - assert_eq!( - result[0].error_message.as_deref(), - Some("Account quota is exhausted") - ); - assert_eq!(plugin.credentials.lock().unwrap().as_slice(), ["quota-key"]); + assert!(plugin.credentials.lock().unwrap().is_empty()); + assert!(plugin.services.lock().unwrap().is_empty()); } #[test] diff --git a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs index 1c8fd1de..0cdde1d1 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs @@ -2,15 +2,19 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use crate::application::commands::tests_support::{ - CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, InMemoryDownloadRepo, }; use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::application::services::{AccountRotator, AccountSelector}; use crate::domain::error::DomainError; use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, Url}; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; -use crate::domain::ports::driven::{AccountRepository, Clock, ExtractedHosterLink, PluginLoader}; +use crate::domain::ports::driven::{ + AccountRepository, Clock, ConfigStore, ExtractedHosterLink, PluginLoader, +}; use super::ResolvePremiumSourceHandler; @@ -62,6 +66,9 @@ impl PluginLoader for DirectUrlPlugin { url.to_string(), credential.password().to_string(), )); + if credential.password() == "quota-key" { + return Err(DomainError::AccountQuotaExceeded); + } Ok(ExtractedHosterLink { source_url: url.to_string(), filename: Some("file.zip".into()), @@ -73,6 +80,18 @@ impl PluginLoader for DirectUrlPlugin { } } +struct FixedConfigStore; + +impl ConfigStore for FixedConfigStore { + fn get_config(&self) -> Result { + Ok(AppConfig::default()) + } + + fn update_config(&self, _: ConfigPatch) -> Result { + Ok(AppConfig::default()) + } +} + pub(super) struct SaveFailingRepo { inner: InMemoryAccountRepo, pub(super) fail_saves: AtomicBool, @@ -137,12 +156,34 @@ pub(super) fn handler( plugin: Arc, events: Arc, ) -> Arc { + handler_with_downloads( + repo, + credentials, + plugin, + events, + Arc::new(InMemoryDownloadRepo::new()), + ) +} + +pub(super) fn handler_with_downloads( + repo: Arc, + credentials: Arc, + plugin: Arc, + events: Arc, + downloads: Arc, +) -> Arc { + let clock: Arc = Arc::new(FixedClock); + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock.clone()); Arc::new(ResolvePremiumSourceHandler::new( repo, credentials, plugin, events, - Arc::new(FixedClock), + clock, Arc::new(AccountOperationLocks::default()), + downloads, + Arc::new(FixedConfigStore), + rotator, )) } diff --git a/src-tauri/src/application/commands/resolve_premium_source_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_tests.rs index e0dad489..ed66f4d3 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_tests.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_tests.rs @@ -3,12 +3,12 @@ use std::sync::atomic::Ordering; use super::*; use crate::application::commands::tests_support::{ - CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, InMemoryDownloadRepo, }; use crate::domain::event::DomainEvent; use crate::domain::model::account::AccountStatus; use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, DownloadSourceResolver, + AccountCredentialStore, AccountRepository, DownloadRepository, DownloadSourceResolver, }; #[path = "resolve_premium_source_test_support.rs"] @@ -49,6 +49,64 @@ async fn resolves_the_direct_url_only_when_the_engine_requests_it() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rotates_at_jit_resolution_and_persists_the_selected_backup() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let events = Arc::new(CapturingEventBus::new()); + let primary = valid_account("primary"); + let backup = valid_account("backup"); + repo.save(&primary).unwrap(); + repo.save(&backup).unwrap(); + credentials + .store_password(primary.id(), "quota-key") + .unwrap(); + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let queued = download(primary.id().clone()); + let queued_id = queued.id(); + downloads.seed(queued.clone()); + let resolver = handler_with_downloads( + repo.clone(), + credentials, + plugin.clone(), + events, + downloads.clone(), + ); + + let source = tokio::task::spawn_blocking(move || resolver.resolve(&queued)) + .await + .unwrap() + .expect("backup account resolves the source"); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert_eq!( + plugin + .calls + .lock() + .unwrap() + .iter() + .map(|call| call.2.as_str()) + .collect::>(), + ["quota-key", "working-key"] + ); + assert_eq!( + repo.find_by_id(primary.id()).unwrap().unwrap().status(), + AccountStatus::QuotaExhausted + ); + assert_eq!( + downloads + .find_by_id(queued_id) + .unwrap() + .unwrap() + .account_id(), + Some(backup.id()) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn missing_credential_persistence_failure_is_not_suppressed() { let repo = Arc::new(SaveFailingRepo::new()); diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index c7f6589c..deb04e57 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -647,12 +647,16 @@ mod tests { drop(queue_guard); start.await.unwrap().expect("download persisted first"); - deletion.await.unwrap().expect("account deleted second"); + let error = deletion + .await + .unwrap() + .expect_err("a referenced account cannot be deleted"); + assert!(matches!(error, AppError::Validation(_))); } #[tokio::test] async fn test_start_download_rejects_account_with_missing_credential() { - let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); + let (bus, _, events) = make_command_bus(Arc::new(MockHttpClient::failing())); let account_repo = Arc::new(InMemoryAccountRepo::new()); let credentials = Arc::new(FakeAccountCredentialStore::new()); let account_id = AccountId::new("account-1"); @@ -682,12 +686,16 @@ mod tests { filename: Some("file.zip".into()), source_hostname_override: Some("1fichier.com".into()), module_name: Some("vortex-mod-1fichier".into()), - account_id: Some(account_id), + account_id: Some(account_id.clone()), }) .await .expect_err("missing credential must reject association"); assert!(matches!(error, AppError::NotFound(_))); + assert!(events.events.lock().unwrap().iter().any(|event| matches!( + event, + DomainEvent::AccountValidationFailed { id, .. } if id == &account_id + ))); } #[tokio::test] diff --git a/src/views/AccountsView/__tests__/AccountRow.test.tsx b/src/views/AccountsView/__tests__/AccountRow.test.tsx new file mode 100644 index 00000000..b3c4a2ea --- /dev/null +++ b/src/views/AccountsView/__tests__/AccountRow.test.tsx @@ -0,0 +1,50 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AccountView } from "@/types/account"; +import { AccountRow, type AccountRowActions } from "../AccountRow"; + +const actions: AccountRowActions = { + validate: vi.fn(), + edit: vi.fn(), + delete: vi.fn(), + toggleEnabled: vi.fn(), +}; + +function renderRow(account: AccountView) { + render( + + + + +
, + ); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("AccountRow", () => { + it("wakes at the cooldown deadline without requiring a backend event", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + renderRow({ + id: "account-1", + serviceName: "vortex-mod-1fichier", + username: "alice", + accountType: "premium", + enabled: true, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + lastValidated: null, + createdAt: 1_699_999_000_000, + status: "cooldown", + exhaustedUntil: 1_700_000_001_000, + }); + + expect(screen.getByText("Rate limited")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1_000)); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); +}); From b4330ca1c5ca8907a6591ff3917f695ba97cca68 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:13:44 +0200 Subject: [PATCH 29/33] fix(accounts): enforce final premium invariants --- CHANGELOG.md | 24 +- registry/registry.toml | 6 +- src-tauri/src/adapters/driven/network/mod.rs | 1 + .../src/adapters/driven/network/nat64.rs | 104 +++++++++ .../src/adapters/driven/network/safe_url.rs | 89 +++----- .../adapters/driven/network/safe_url_tests.rs | 39 ++++ .../adapters/driven/sqlite/download_repo.rs | 30 +++ src-tauri/src/application/command_bus.rs | 15 -- .../application/commands/delete_account.rs | 5 + .../commands/premium_account_resolution.rs | 11 +- .../commands/premium_account_rotation.rs | 100 +++++++++ .../src/application/commands/redownload.rs | 73 +++++- .../src/application/commands/resolve_links.rs | 91 +++----- .../commands/resolve_premium_source.rs | 48 ++-- .../resolve_premium_source_test_support.rs | 5 +- .../commands/resolve_premium_source_tests.rs | 46 ++-- .../application/commands/start_download.rs | 7 +- .../src/application/commands/tests_support.rs | 12 +- .../application/commands/validate_account.rs | 6 +- .../application/services/account_rotator.rs | 212 +++++++----------- src-tauri/src/domain/event.rs | 8 +- .../ports/driven/download_repository.rs | 26 +++ src-tauri/src/lib.rs | 4 +- src-tauri/tests/app_state_wiring.rs | 7 +- src/views/AccountsView/AccountRow.tsx | 4 +- src/views/AccountsView/useAccountStatusNow.ts | 31 +++ 26 files changed, 650 insertions(+), 354 deletions(-) create mode 100644 src-tauri/src/adapters/driven/network/nat64.rs create mode 100644 src-tauri/src/adapters/driven/network/safe_url_tests.rs create mode 100644 src-tauri/src/application/commands/premium_account_rotation.rs create mode 100644 src/views/AccountsView/useAccountStatusNow.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3031c652..8949cfcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,18 +53,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **MAT-132 final acceptance regressions (RED)**: added contracts for strict +- **MAT-132 final acceptance coverage**: added contracts for strict JIT-only credential use, runtime account rotation with association updates, referenced-account deletion safety, observable missing credentials, cooldown badge expiry, and operator-prefix NAT64 SSRF rejection. -- **MAT-132 final review hardening**: premium extraction now runs through one - CQRS command handler shared by link analysis and just-in-time downloads. - Account failures persist before typed events, cooldown recovery uses one - atomic row write, per-account locks are reclaimed and cover download - association persistence, account events refresh the UI immediately, and - restricted HTTP clients ignore system proxies while rejecting additional - non-public IPv6 ranges. The official 1fichier registry entry now targets the - credential-validation-capable v1.1.0 artifact. +- **MAT-132 final review hardening**: link analysis now selects only an opaque + account id without reading credentials or creating a one-shot token; the CQRS + resolver rotates accounts only when the engine requests the direct source. + Account failures persist before typed events, referenced accounts cannot be + deleted, cooldown badges wake at their deadline, and restricted HTTP clients + ignore system proxies while rejecting special IPv6 and discovered NAT64 + mappings to private IPv4. The registry stays on published 1fichier v1.0.0; + v1.1.0 registration is deliberately deferred until its release assets exist. - **MAT-132 review regressions**: added coverage for fail-closed account state persistence, premium account lifecycle races, account-event refreshes, lock reclamation, and IPv6/proxy SSRF boundaries. @@ -125,9 +125,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Selector/rotator tests require persisted cooldowns to survive a restart and distinguish unavailable credentials from temporarily exhausted accounts; selection and rotation now enforce those persisted states. - Resolution/start contract tests carry only the selected account UUID and - plugin name alongside direct links, and require rotation to a second account. - Hoster resolution now uses the configured selector/rotator, scopes each + Resolution/start contract tests carry only the stable source URL, selected + account UUID, and plugin name, and require JIT rotation to a second account. + JIT hoster resolution uses the configured selector/rotator, scopes each keyring secret to its plugin call, persists typed account failures, retries an eligible fallback account, and validates the opaque association before persisting a download. diff --git a/registry/registry.toml b/registry/registry.toml index 22319f54..7ec90006 100644 --- a/registry/registry.toml +++ b/registry/registry.toml @@ -112,11 +112,11 @@ min_vortex_version = "0.1.0" name = "vortex-mod-1fichier" description = "1fichier hoster — free (60s wait + captcha stub) and premium (API key)" author = "vortex-community" -version = "1.1.0" +version = "1.0.0" category = "hoster" repository = "https://github.com/mpiton/vortex-mod-1fichier" -checksum_sha256 = "e29f5d4d69ac80f8c6d1bd4d9b98cb2ae3bddb0265544b84a75b929e8fa36ccb" -checksum_sha256_toml = "bcf3ba595c323d2d0a4ea680e4c6354285153020e07d1efe05c856d5e281dd6d" +checksum_sha256 = "02035a49da81566cf7e4ff64cd0b5ed858e05dfdca643dbd14e53b359d93232c" +checksum_sha256_toml = "c16cb7d418ee6d261114b0ed66701629f7aa7b92317dc97fb7593ede9487c091" official = true min_vortex_version = "0.1.0" diff --git a/src-tauri/src/adapters/driven/network/mod.rs b/src-tauri/src/adapters/driven/network/mod.rs index 438b941c..ef99f002 100644 --- a/src-tauri/src/adapters/driven/network/mod.rs +++ b/src-tauri/src/adapters/driven/network/mod.rs @@ -1,5 +1,6 @@ mod checksum; mod download_engine; +mod nat64; mod reqwest_client; mod safe_url; mod segment_worker; diff --git a/src-tauri/src/adapters/driven/network/nat64.rs b/src-tauri/src/adapters/driven/network/nat64.rs new file mode 100644 index 00000000..acdb5923 --- /dev/null +++ b/src-tauri/src/adapters/driven/network/nat64.rs @@ -0,0 +1,104 @@ +use std::net::{Ipv4Addr, Ipv6Addr, ToSocketAddrs}; +use std::sync::OnceLock; + +const PREFIX_LENGTHS: [u8; 6] = [32, 40, 48, 56, 64, 96]; +const WELL_KNOWN_IPV4: [Ipv4Addr; 2] = + [Ipv4Addr::new(192, 0, 0, 170), Ipv4Addr::new(192, 0, 0, 171)]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Nat64Prefix { + network: u128, + length: u8, +} + +impl Nat64Prefix { + pub(super) fn new(address: Ipv6Addr, length: u8) -> Option { + if !PREFIX_LENGTHS.contains(&length) || (length == 96 && address.octets()[8] != 0) { + return None; + } + Some(Self { + network: u128::from(address) & prefix_mask(length), + length, + }) + } + + pub(super) fn embedded_ipv4(self, address: Ipv6Addr) -> Option { + if u128::from(address) & prefix_mask(self.length) != self.network { + return None; + } + let bytes = address.octets(); + if self.length < 96 && bytes[8] != 0 { + return None; + } + let octets = match self.length { + 32 => [bytes[4], bytes[5], bytes[6], bytes[7]], + 40 => [bytes[5], bytes[6], bytes[7], bytes[9]], + 48 => [bytes[6], bytes[7], bytes[9], bytes[10]], + 56 => [bytes[7], bytes[9], bytes[10], bytes[11]], + 64 => [bytes[9], bytes[10], bytes[11], bytes[12]], + 96 => [bytes[12], bytes[13], bytes[14], bytes[15]], + _ => return None, + }; + Some(Ipv4Addr::from(octets)) + } +} + +pub(super) fn discovered_prefixes() -> &'static [Nat64Prefix] { + static PREFIXES: OnceLock> = OnceLock::new(); + PREFIXES.get_or_init(|| { + let known = Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0); + let mut prefixes = Nat64Prefix::new(known, 96).into_iter().collect(); + if let Ok(addresses) = ("ipv4only.arpa", 80).to_socket_addrs() { + for address in addresses.filter_map(|address| match address.ip() { + std::net::IpAddr::V6(ip) => Some(ip), + std::net::IpAddr::V4(_) => None, + }) { + discover_from_address(address, &mut prefixes); + } + } + prefixes + }) +} + +fn discover_from_address(address: Ipv6Addr, prefixes: &mut Vec) { + for length in PREFIX_LENGTHS { + let Some(prefix) = Nat64Prefix::new(address, length) else { + continue; + }; + if prefix + .embedded_ipv4(address) + .is_some_and(|ipv4| WELL_KNOWN_IPV4.contains(&ipv4)) + && !prefixes.contains(&prefix) + { + prefixes.push(prefix); + } + } +} + +fn prefix_mask(length: u8) -> u128 { + u128::MAX << (128 - length) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_all_rfc_6052_layouts() { + for (address, length) in [ + ("2001:db8:c000:221::", 32), + ("2001:db8:1c0:2:21::", 40), + ("2001:db8:122:c000:2:2100::", 48), + ("2001:db8:122:3c0:0:221::", 56), + ("2001:db8:122:344:c0:2:2100::", 64), + ("2001:db8:122:344::c000:221", 96), + ] { + let address = address.parse().unwrap(); + let prefix = Nat64Prefix::new(address, length).unwrap(); + assert_eq!( + prefix.embedded_ipv4(address), + Some(Ipv4Addr::new(192, 0, 2, 33)) + ); + } + } +} diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs index 3509a225..6cb5e9cb 100644 --- a/src-tauri/src/adapters/driven/network/safe_url.rs +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::domain::error::DomainError; +use super::nat64::{Nat64Prefix, discovered_prefixes}; + pub(crate) fn validate_public_url( url: &reqwest::Url, ) -> Result>, DomainError> { @@ -87,65 +89,38 @@ pub(crate) fn is_forbidden_ip(ip: &IpAddr) -> bool { || a >= 224 || (a == 255 && b == 255 && c == 255 && d == 255) } - IpAddr::V6(ip) => { - let segments = ip.segments(); - let first = segments[0]; - ip.is_loopback() - || ip.is_unspecified() - || ip.is_multicast() - || (first & 0xfe00) == 0xfc00 - || (first & 0xffc0) == 0xfe80 - || (first & 0xffc0) == 0xfec0 - || matches!(segments, [0x0064, 0xff9b, 0x0001, ..]) - || matches!(segments, [0x0100, 0, 0, 0, ..]) - || matches!(segments, [0x2001, 0x0002, 0, ..]) - || matches!(segments, [0x2001, 0x0db8, ..]) - || matches!(segments, [0x2002, ..]) - || matches!(segments, [0x3ff0..=0x3fff, ..]) - || matches!(segments, [0x5f00, ..]) - } + IpAddr::V6(ip) => is_forbidden_ipv6(&ip, discovered_prefixes()), } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_loopback_private_link_local_and_mapped_addresses() { - for raw in [ - "127.0.0.1", - "10.0.0.1", - "169.254.169.254", - "::ffff:127.0.0.1", - "fec0::1", - "64:ff9b::c0a8:1", - "64:ff9b:1::c0a8:1", - "2001:10::1", - "2001:20::1", - ] { - assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}"); - } - } - - #[test] - fn rejects_private_ipv4_embedded_in_discovered_operator_nat64_prefix() { - let prefix = Nat64Prefix::new("2606:4700:64::".parse().unwrap(), 96).unwrap(); - let target = "2606:4700:64::c0a8:1".parse().unwrap(); - - assert!(is_forbidden_ipv6(&target, &[prefix])); - } - - #[test] - fn accepts_globally_routable_ipv6_address() { - assert!(!is_forbidden_ip(&"2606:4700:4700::1111".parse().unwrap())); - } +fn is_forbidden_ipv6(ip: &std::net::Ipv6Addr, nat64: &[Nat64Prefix]) -> bool { + let segments = ip.segments(); + let first = segments[0]; + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || (first & 0xffc0) == 0xfec0 + || matches!(segments, [0x0064, 0xff9b, 0x0001, ..]) + || matches!(segments, [0x0100, 0, 0, 0, ..]) + || is_orchid(&segments) + || matches!(segments, [0x2001, 0x0002, 0, ..]) + || matches!(segments, [0x2001, 0x0db8, ..]) + || matches!(segments, [0x2002, ..]) + || matches!(segments, [0x3ff0..=0x3fff, ..]) + || matches!(segments, [0x5f00, ..]) + || nat64.iter().any(|prefix| { + prefix + .embedded_ipv4(*ip) + .is_some_and(|ipv4| is_forbidden_ip(&IpAddr::V4(ipv4))) + }) +} - #[test] - fn restricted_client_requires_https_and_public_destination() { - let http = reqwest::Url::parse("http://1.1.1.1/file").unwrap(); - let local = reqwest::Url::parse("https://127.0.0.1/file").unwrap(); - assert!(restricted_download_client(&http).is_err()); - assert!(restricted_download_client(&local).is_err()); - } +fn is_orchid(segments: &[u16; 8]) -> bool { + segments[0] == 0x2001 && matches!(segments[1] & 0xfff0, 0x0010 | 0x0020 | 0x0030) } + +#[cfg(test)] +#[path = "safe_url_tests.rs"] +mod tests; diff --git a/src-tauri/src/adapters/driven/network/safe_url_tests.rs b/src-tauri/src/adapters/driven/network/safe_url_tests.rs new file mode 100644 index 00000000..1b79fc91 --- /dev/null +++ b/src-tauri/src/adapters/driven/network/safe_url_tests.rs @@ -0,0 +1,39 @@ +use super::*; + +#[test] +fn rejects_loopback_private_link_local_and_mapped_addresses() { + for raw in [ + "127.0.0.1", + "10.0.0.1", + "169.254.169.254", + "::ffff:127.0.0.1", + "fec0::1", + "64:ff9b::c0a8:1", + "64:ff9b:1::c0a8:1", + "2001:10::1", + "2001:20::1", + ] { + assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}"); + } +} + +#[test] +fn rejects_private_ipv4_embedded_in_discovered_operator_nat64_prefix() { + let prefix = Nat64Prefix::new("2606:4700:64::".parse().unwrap(), 96).unwrap(); + let target = "2606:4700:64::c0a8:1".parse().unwrap(); + + assert!(is_forbidden_ipv6(&target, &[prefix])); +} + +#[test] +fn accepts_globally_routable_ipv6_address() { + assert!(!is_forbidden_ip(&"2606:4700:4700::1111".parse().unwrap())); +} + +#[test] +fn restricted_client_requires_https_and_public_destination() { + let http = reqwest::Url::parse("http://1.1.1.1/file").unwrap(); + let local = reqwest::Url::parse("https://127.0.0.1/file").unwrap(); + assert!(restricted_download_client(&http).is_err()); + assert!(restricted_download_client(&local).is_err()); +} diff --git a/src-tauri/src/adapters/driven/sqlite/download_repo.rs b/src-tauri/src/adapters/driven/sqlite/download_repo.rs index 1b9ab090..a29ce14e 100644 --- a/src-tauri/src/adapters/driven/sqlite/download_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/download_repo.rs @@ -8,6 +8,7 @@ use sea_orm::{ }; use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; use crate::domain::model::download::{Download, DownloadId, DownloadState}; use crate::domain::ports::driven::download_repository::DownloadRepository; @@ -198,6 +199,17 @@ impl DownloadRepository for SqliteDownloadRepo { models.into_iter().map(|m| m.into_domain()).collect() }) } + + fn has_account_reference(&self, account_id: &AccountId) -> Result { + block_on(async { + download::Entity::find() + .filter(download::Column::AccountId.eq(account_id.as_str())) + .one(&self.db) + .await + .map(|row| row.is_some()) + .map_err(map_db_err) + }) + } } #[cfg(test)] @@ -499,6 +511,24 @@ mod tests { assert!(found.is_none()); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn account_reference_query_tracks_persisted_downloads() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteDownloadRepo::new(db); + let account_id = AccountId::new("account-1"); + let download = make_download(1).with_account_id(account_id.clone()); + repo.save(&download).expect("save"); + + assert!(repo.has_account_reference(&account_id).unwrap()); + assert!( + !repo + .has_account_reference(&AccountId::new("other")) + .unwrap() + ); + repo.delete(download.id()).expect("delete"); + assert!(!repo.has_account_reference(&account_id).unwrap()); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_find_by_state_filters_correctly() { let db = setup_test_db().await.expect("test db"); diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index abd28e19..6d532794 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -8,7 +8,6 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::Semaphore; -use crate::application::commands::resolve_premium_source::ResolvePremiumSourceHandler; use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::services::{AccountRotator, AccountSelector}; use crate::domain::model::account::AccountId; @@ -103,7 +102,6 @@ pub struct CommandBus { account_rotator: Option>, account_operation_locks: Arc, account_clock: Option>, - premium_source_handler: Option>, passphrase_codec: Option>, /// Serializes queue-position allocation across handlers. Without this, /// two concurrent move-to-top/move-to-bottom/start-download calls can @@ -175,7 +173,6 @@ impl CommandBus { account_rotator: None, account_operation_locks: Arc::new(AccountOperationLocks::default()), account_clock: None, - premium_source_handler: None, passphrase_codec: None, queue_position_lock: tokio::sync::Mutex::new(()), link_check_limiter, @@ -239,14 +236,6 @@ impl CommandBus { self } - pub fn with_premium_source_handler( - mut self, - handler: Arc, - ) -> Self { - self.premium_source_handler = Some(handler); - self - } - pub fn with_account_operation_locks(mut self, locks: Arc) -> Self { self.account_operation_locks = locks; self @@ -279,10 +268,6 @@ impl CommandBus { self.account_rotator.as_deref() } - pub fn premium_source_handler(&self) -> Option<&ResolvePremiumSourceHandler> { - self.premium_source_handler.as_deref() - } - pub(crate) fn account_operation_lock( &self, id: &AccountId, diff --git a/src-tauri/src/application/commands/delete_account.rs b/src-tauri/src/application/commands/delete_account.rs index 48902998..8ddfef8e 100644 --- a/src-tauri/src/application/commands/delete_account.rs +++ b/src-tauri/src/application/commands/delete_account.rs @@ -29,6 +29,11 @@ impl CommandBus { })?; let operation_lock = self.account_operation_lock(&cmd.id)?; let _operation_guard = operation_lock.lock().await; + if self.download_repo().has_account_reference(&cmd.id)? { + return Err(AppError::Validation( + "account is still referenced by a download".into(), + )); + } repo.delete(&cmd.id)?; if let Err(e) = store.delete_password(&cmd.id) { diff --git a/src-tauri/src/application/commands/premium_account_resolution.rs b/src-tauri/src/application/commands/premium_account_resolution.rs index 7d762931..adb2f2f9 100644 --- a/src-tauri/src/application/commands/premium_account_resolution.rs +++ b/src-tauri/src/application/commands/premium_account_resolution.rs @@ -68,6 +68,9 @@ impl ResolvePremiumSourceHandler { }; apply_status(account, status, self.clock.now_unix_ms()); self.repo.save(account)?; + if let Some(deadline) = account.exhausted_until() { + self.rotator.cache_exhausted(account.id(), deadline); + } self.publish_typed_failure(account, status); Err(error) } @@ -90,12 +93,14 @@ impl ResolvePremiumSourceHandler { } account.set_status(AccountStatus::Valid); self.repo.save(account)?; - self.events.publish(DomainEvent::AccountUpdated { - id: account.id().clone(), - }); Ok(()) } + pub(super) fn publish_success(&self, id: &crate::domain::model::account::AccountId) { + self.events + .publish(DomainEvent::AccountUpdated { id: id.clone() }); + } + fn publish_typed_failure(&self, account: &Account, status: AccountStatus) { if status == AccountStatus::QuotaExhausted { self.events.publish(DomainEvent::AccountExhausted { diff --git a/src-tauri/src/application/commands/premium_account_rotation.rs b/src-tauri/src/application/commands/premium_account_rotation.rs new file mode 100644 index 00000000..759b6641 --- /dev/null +++ b/src-tauri/src/application/commands/premium_account_rotation.rs @@ -0,0 +1,100 @@ +use crate::application::error::AppError; +use crate::application::services::account_rotator::NextAccountOutcome; +use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; +use crate::domain::model::download::Download; +use crate::domain::ports::driven::{ExtractedHosterLink, ResolvedDownloadSource}; + +use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; + +impl ResolvePremiumSourceHandler { + pub(super) fn resolve_download( + &self, + download: &Download, + ) -> Result { + let service = download.module_name().ok_or_else(|| { + DomainError::ValidationError("premium download has no plugin association".into()) + })?; + let mut account_id = download.account_id().cloned().ok_or_else(|| { + DomainError::ValidationError("premium download has no account association".into()) + })?; + let attempts = self.repo.list_by_service(service)?.len().saturating_add(1); + let mut last_error = None; + + for _ in 0..attempts { + match self.resolve_once(download, service, &account_id) { + Ok(link) => return sensitive_source(link), + Err(error) if is_rotatable(&error) => last_error = Some(error), + Err(error) => return Err(error), + } + account_id = match self.next_account(service)? { + NextAccountOutcome::Picked(account) => account.id().clone(), + NextAccountOutcome::AllExhausted { .. } => { + return Err(DomainError::AccountQuotaExceeded); + } + NextAccountOutcome::NoneAvailable => break, + }; + } + + Err(last_error.unwrap_or_else(|| { + DomainError::ValidationError("no premium account is available".into()) + })) + } + + fn resolve_once( + &self, + download: &Download, + service: &str, + account_id: &AccountId, + ) -> Result { + let lock = self.account_lock(account_id)?; + let _guard = lock.blocking_lock(); + let command = ResolvePremiumSourceCommand::new( + account_id.clone(), + service.to_string(), + download.url().as_str().to_string(), + ); + let link = self.resolve_locked(command)?; + if download.account_id() != Some(account_id) { + self.downloads + .save(&download.clone().with_account_id(account_id.clone()))?; + } + self.publish_success(account_id); + Ok(link) + } + + fn next_account(&self, service: &str) -> Result { + let strategy = self.config.get_config()?.account_selection_strategy; + self.rotator + .next_account(service, strategy) + .map_err(app_error_to_domain) + } +} + +fn sensitive_source(link: ExtractedHosterLink) -> Result { + let direct_url = link + .direct_url + .ok_or_else(|| DomainError::PluginError("premium plugin returned no direct URL".into()))?; + Ok(ResolvedDownloadSource::sensitive(direct_url)) +} + +fn is_rotatable(error: &DomainError) -> bool { + matches!( + error, + DomainError::AccountInvalidCredentials + | DomainError::AccountExpired + | DomainError::AccountCooldown + | DomainError::AccountQuotaExceeded + | DomainError::NotFound(_) + | DomainError::ValidationError(_) + ) +} + +fn app_error_to_domain(error: AppError) -> DomainError { + match error { + AppError::Domain(error) => error, + AppError::NotFound(message) => DomainError::NotFound(message), + AppError::Validation(message) => DomainError::ValidationError(message), + other => DomainError::StorageError(other.to_string()), + } +} diff --git a/src-tauri/src/application/commands/redownload.rs b/src-tauri/src/application/commands/redownload.rs index 89a641b7..fc103be1 100644 --- a/src-tauri/src/application/commands/redownload.rs +++ b/src-tauri/src/application/commands/redownload.rs @@ -19,6 +19,19 @@ impl CommandBus { cmd: super::RedownloadCommand, ) -> Result { let template = self.load_template(&cmd.source)?; + let account_lock = template + .account_id + .as_ref() + .map(|id| self.account_operation_lock(id)) + .transpose()?; + let _account_guard = match account_lock { + Some(lock) => Some(lock.lock_owned().await), + None => None, + }; + self.validate_download_account( + template.module_name.as_deref(), + template.account_id.as_ref(), + )?; let url = Url::new(&template.url)?; let dest = cmd @@ -121,13 +134,16 @@ mod tests { use std::sync::{Arc, Mutex}; use crate::application::command_bus::CommandBus; + use crate::application::commands::tests_support::{ + FakeAccountCredentialStore, InMemoryAccountRepo, + }; use crate::application::commands::{RedownloadCommand, RedownloadSource}; use crate::application::error::AppError; use crate::application::test_support::InMemoryHistoryRepo; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::Priority; - use crate::domain::model::account::AccountId; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; @@ -136,8 +152,9 @@ mod tests { use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::model::views::HistoryEntry; use crate::domain::ports::driven::{ - ArchiveExtractor, ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, - DownloadRepository, EventBus, FileStorage, HistoryRepository, HttpClient, PluginLoader, + AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, + ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, EventBus, FileStorage, + HistoryRepository, HttpClient, PluginLoader, }; struct MockDownloadRepo { @@ -341,6 +358,14 @@ mod tests { } } + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } + } + fn make_bus( repo: Arc, events: Arc, @@ -371,8 +396,7 @@ mod tests { ) .with_segments_count(4) .with_priority(Priority::new(9).unwrap()) - .with_module_name("vortex-mod-example".to_string()) - .with_account_id(AccountId::new("account-7")); + .with_module_name("vortex-mod-example".to_string()); d.start().unwrap(); d.complete().unwrap(); d @@ -383,9 +407,24 @@ mod tests { let repo = Arc::new(MockDownloadRepo::new()); let events = Arc::new(MockEventBus::new()); let history: Arc = Arc::new(InMemoryHistoryRepo::new()); - let original = completed_download(1); + let original = completed_download(1).with_account_id(AccountId::new("account-7")); repo.save(&original).unwrap(); - let bus = make_bus(repo.clone(), events.clone(), history); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let mut account = Account::new( + AccountId::new("account-7"), + "vortex-mod-example".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + account_repo.save(&account).unwrap(); + credentials.store_password(account.id(), "api-key").unwrap(); + let bus = make_bus(repo.clone(), events.clone(), history) + .with_account_repo(account_repo) + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedClock)); let new_id = bus .handle_redownload(RedownloadCommand { @@ -417,6 +456,26 @@ mod tests { ); } + #[tokio::test] + async fn redownload_rejects_an_orphaned_account_reference() { + let repo = Arc::new(MockDownloadRepo::new()); + let events = Arc::new(MockEventBus::new()); + let history: Arc = Arc::new(InMemoryHistoryRepo::new()); + let original = completed_download(10).with_account_id(AccountId::new("missing")); + repo.save(&original).unwrap(); + let bus = make_bus(repo, events, history); + + let error = bus + .handle_redownload(RedownloadCommand { + source: RedownloadSource::Download(DownloadId(10)), + destination_override: None, + }) + .await + .expect_err("orphaned account must not be copied"); + + assert!(matches!(error, AppError::Validation(_))); + } + #[tokio::test] async fn redownload_emits_download_created_event_with_new_id() { let repo = Arc::new(MockDownloadRepo::new()); diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 589871e2..a9957a8d 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -7,7 +7,6 @@ use serde::Serialize; use uuid::Uuid; use crate::application::command_bus::CommandBus; -use crate::application::commands::resolve_premium_source::ResolvePremiumSourceCommand; use crate::application::error::AppError; use crate::application::services::account_rotator::NextAccountOutcome; use crate::domain::error::DomainError; @@ -211,58 +210,27 @@ impl CommandBus { url: &str, service_name: &str, ) -> Result { - if let Some(repo) = self.account_repo() { - let max_attempts = repo.list_by_service(service_name)?.len(); - let mut last_temporary_error = None; - for _ in 0..max_attempts { - let selected = match self.next_hoster_account(service_name)? { - NextAccountOutcome::Picked(account) => account, - NextAccountOutcome::NoneAvailable => break, - NextAccountOutcome::AllExhausted { .. } => { - return Err(last_temporary_error - .unwrap_or(DomainError::AccountQuotaExceeded) - .into()); - } - }; - let handler = self.premium_source_handler().ok_or_else(|| { - AppError::Validation("premium source handler not configured".into()) - })?; - let command = ResolvePremiumSourceCommand::new( - selected.id().clone(), - service_name.to_string(), - url.to_string(), - ); - match handler.handle(command).await { - Ok(link) => { - return Ok(into_hoster_resolution( - link, - Some(selected.id().as_str()), - url, - )); - } - Err( - error @ (DomainError::AccountCooldown | DomainError::AccountQuotaExceeded), - ) => { - last_temporary_error = Some(error); - } - Err( - DomainError::AccountInvalidCredentials - | DomainError::AccountExpired - | DomainError::NotFound(_) - | DomainError::ValidationError(_), - ) => {} - Err(error) => return Err(error.into()), + if self.account_repo().is_some() { + match self.next_hoster_account(service_name)? { + NextAccountOutcome::Picked(account) => { + return Ok(HosterResolution { + resolved_url: url.to_string(), + filename: extract_filename_from_url(url), + size_bytes: None, + account_id: Some(account.id().as_str().to_string()), + }); } - } - if let Some(error) = last_temporary_error { - return Err(error.into()); + NextAccountOutcome::AllExhausted { .. } => { + return Err(DomainError::AccountQuotaExceeded.into()); + } + NextAccountOutcome::NoneAvailable => {} } } let link = self .plugin_loader() .extract_hoster_link(service_name, url, None)?; - Ok(into_hoster_resolution(link, None, url)) + Ok(into_hoster_resolution(link, url)) } fn next_hoster_account(&self, service_name: &str) -> Result { @@ -277,17 +245,12 @@ impl CommandBus { } } -fn into_hoster_resolution( - link: ExtractedHosterLink, - account_id: Option<&str>, - stable_url: &str, -) -> HosterResolution { - let selected_account = link.direct_url.as_ref().and(account_id).map(str::to_string); +fn into_hoster_resolution(link: ExtractedHosterLink, stable_url: &str) -> HosterResolution { HosterResolution { resolved_url: stable_url.to_string(), filename: link.filename, size_bytes: link.size_bytes, - account_id: selected_account, + account_id: None, } } @@ -515,6 +478,7 @@ mod tests { async fn resolve_with_primary_credential( primary_password: Option<&str>, include_backup: bool, + primary_exhausted: bool, ) -> ( Vec, Arc, @@ -525,8 +489,11 @@ mod tests { let credentials = Arc::new(FakeAccountCredentialStore::new()); let events = Arc::new(CapturingEventBus::new()); let plugin = Arc::new(PremiumPluginLoader::new()); - let primary = premium_account("primary", 100); + let mut primary = premium_account("primary", 100); let backup = premium_account("backup", 50); + if primary_exhausted { + primary.mark_exhausted(1_700_000_060_000); + } repo.save(&primary).unwrap(); if include_backup { repo.save(&backup).unwrap(); @@ -565,7 +532,7 @@ mod tests { #[tokio::test] async fn resolve_hoster_selects_account_without_reading_secret_or_issuing_token() { let (result, repo, plugin, primary) = - resolve_with_primary_credential(Some("working-key"), true).await; + resolve_with_primary_credential(Some("working-key"), true, false).await; assert_eq!( result[0].resolved_url.as_deref(), @@ -587,6 +554,20 @@ mod tests { assert!(plugin.services.lock().unwrap().is_empty()); } + #[tokio::test] + async fn resolve_hoster_surfaces_persisted_exhaustion_without_free_fallback() { + let (result, _, plugin, _) = + resolve_with_primary_credential(Some("quota-key"), false, true).await; + + assert_eq!(result[0].status, "error"); + assert_eq!( + result[0].error_message.as_deref(), + Some("Account quota is exhausted") + ); + assert!(plugin.credentials.lock().unwrap().is_empty()); + assert!(plugin.services.lock().unwrap().is_empty()); + } + #[test] fn test_extract_filename_from_url_returns_last_path_segment() { assert_eq!( diff --git a/src-tauri/src/application/commands/resolve_premium_source.rs b/src-tauri/src/application/commands/resolve_premium_source.rs index 4f77541b..412be684 100644 --- a/src-tauri/src/application/commands/resolve_premium_source.rs +++ b/src-tauri/src/application/commands/resolve_premium_source.rs @@ -2,18 +2,21 @@ use std::sync::Arc; +use crate::application::services::AccountRotator; use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::domain::error::DomainError; use crate::domain::model::account::AccountId; use crate::domain::model::download::Download; use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, Clock, DownloadSourceResolver, EventBus, - ExtractedHosterLink, PluginLoader, ResolvedDownloadSource, + AccountCredentialStore, AccountRepository, Clock, ConfigStore, DownloadRepository, + DownloadSourceResolver, EventBus, ExtractedHosterLink, PluginLoader, ResolvedDownloadSource, }; use crate::domain::ports::driving::Command; #[path = "premium_account_resolution.rs"] mod resolution; +#[path = "premium_account_rotation.rs"] +mod rotation; #[derive(Debug)] pub struct ResolvePremiumSourceCommand { @@ -41,9 +44,13 @@ pub struct ResolvePremiumSourceHandler { events: Arc, clock: Arc, locks: Arc, + downloads: Arc, + config: Arc, + rotator: Arc, } impl ResolvePremiumSourceHandler { + #[allow(clippy::too_many_arguments)] pub fn new( repo: Arc, credentials: Arc, @@ -51,6 +58,9 @@ impl ResolvePremiumSourceHandler { events: Arc, clock: Arc, locks: Arc, + downloads: Arc, + config: Arc, + rotator: Arc, ) -> Self { Self { repo, @@ -59,6 +69,9 @@ impl ResolvePremiumSourceHandler { events, clock, locks, + downloads, + config, + rotator, } } @@ -66,18 +79,12 @@ impl ResolvePremiumSourceHandler { &self, command: ResolvePremiumSourceCommand, ) -> Result { + let account_id = command.account_id.clone(); let lock = self.account_lock(&command.account_id)?; let _guard = lock.lock().await; - self.resolve_locked(command) - } - - fn handle_blocking( - &self, - command: ResolvePremiumSourceCommand, - ) -> Result { - let lock = self.account_lock(&command.account_id)?; - let _guard = lock.blocking_lock(); - self.resolve_locked(command) + let link = self.resolve_locked(command)?; + self.publish_success(&account_id); + Ok(link) } fn account_lock(&self, id: &AccountId) -> Result>, DomainError> { @@ -89,22 +96,7 @@ impl ResolvePremiumSourceHandler { impl DownloadSourceResolver for ResolvePremiumSourceHandler { fn resolve(&self, download: &Download) -> Result { - let account_id = download.account_id().cloned().ok_or_else(|| { - DomainError::ValidationError("premium download has no account association".into()) - })?; - let service_name = download.module_name().map(str::to_string).ok_or_else(|| { - DomainError::ValidationError("premium download has no plugin association".into()) - })?; - let command = ResolvePremiumSourceCommand::new( - account_id, - service_name, - download.url().as_str().to_string(), - ); - let link = self.handle_blocking(command)?; - let direct_url = link.direct_url.ok_or_else(|| { - DomainError::PluginError("premium plugin returned no direct URL".into()) - })?; - Ok(ResolvedDownloadSource::sensitive(direct_url)) + self.resolve_download(download) } } diff --git a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs index 0cdde1d1..8872d212 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs @@ -69,6 +69,9 @@ impl PluginLoader for DirectUrlPlugin { if credential.password() == "quota-key" { return Err(DomainError::AccountQuotaExceeded); } + if credential.password() == "expired-key" { + return Err(DomainError::AccountExpired); + } Ok(ExtractedHosterLink { source_url: url.to_string(), filename: Some("file.zip".into()), @@ -131,7 +134,7 @@ pub(super) fn valid_account(id: &str) -> Account { let mut account = Account::new( AccountId::new(id), "vortex-mod-1fichier".into(), - "alice".into(), + format!("user-{id}"), AccountType::Premium, 1, ); diff --git a/src-tauri/src/application/commands/resolve_premium_source_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_tests.rs index ed66f4d3..629604ea 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_tests.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_tests.rs @@ -50,18 +50,39 @@ async fn resolves_the_direct_url_only_when_the_engine_requests_it() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rotates_at_jit_resolution_and_persists_the_selected_backup() { +async fn rotates_jit_failures_and_persists_the_selected_backup() { + for (password, expected_status, expected_calls) in [ + ( + Some("quota-key"), + AccountStatus::QuotaExhausted, + vec!["quota-key", "working-key"], + ), + ( + Some("expired-key"), + AccountStatus::Expired, + vec!["expired-key", "working-key"], + ), + (None, AccountStatus::MissingCredential, vec!["working-key"]), + ] { + assert_jit_rotation(password, expected_status, &expected_calls).await; + } +} + +async fn assert_jit_rotation( + primary_password: Option<&str>, + expected_status: AccountStatus, + expected_calls: &[&str], +) { let repo = Arc::new(InMemoryAccountRepo::new()); let credentials = Arc::new(FakeAccountCredentialStore::new()); let plugin = Arc::new(DirectUrlPlugin::new()); - let events = Arc::new(CapturingEventBus::new()); let primary = valid_account("primary"); let backup = valid_account("backup"); repo.save(&primary).unwrap(); repo.save(&backup).unwrap(); - credentials - .store_password(primary.id(), "quota-key") - .unwrap(); + if let Some(password) = primary_password { + credentials.store_password(primary.id(), password).unwrap(); + } credentials .store_password(backup.id(), "working-key") .unwrap(); @@ -73,7 +94,7 @@ async fn rotates_at_jit_resolution_and_persists_the_selected_backup() { repo.clone(), credentials, plugin.clone(), - events, + Arc::new(CapturingEventBus::new()), downloads.clone(), ); @@ -83,19 +104,14 @@ async fn rotates_at_jit_resolution_and_persists_the_selected_backup() { .expect("backup account resolves the source"); assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + let calls = plugin.calls.lock().unwrap(); assert_eq!( - plugin - .calls - .lock() - .unwrap() - .iter() - .map(|call| call.2.as_str()) - .collect::>(), - ["quota-key", "working-key"] + calls.iter().map(|call| call.2.as_str()).collect::>(), + expected_calls ); assert_eq!( repo.find_by_id(primary.id()).unwrap().unwrap().status(), - AccountStatus::QuotaExhausted + expected_status ); assert_eq!( downloads diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index deb04e57..279b8924 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -103,7 +103,7 @@ impl CommandBus { Ok(id) } - fn validate_download_account( + pub(super) fn validate_download_account( &self, module_name: Option<&str>, account_id: Option<&AccountId>, @@ -134,6 +134,11 @@ impl CommandBus { if store.get_password(account_id)?.is_none() { account.set_status(AccountStatus::MissingCredential); repo.save(&account)?; + self.event_bus() + .publish(DomainEvent::AccountValidationFailed { + id: account_id.clone(), + error: "Account credential is unavailable".into(), + }); return Err(AppError::NotFound(format!( "credential for account {} not found", account_id.as_str() diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 2b1bef80..31a9d95e 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -10,7 +10,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use crate::application::command_bus::CommandBus; -use crate::application::commands::resolve_premium_source::ResolvePremiumSourceHandler; use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::test_support::NoopHistoryRepo; use crate::domain::error::DomainError; @@ -807,14 +806,6 @@ pub(crate) fn build_account_bus_with_plugin_loader( ) -> CommandBus { let clock: Arc = Arc::new(FixedAccountClock); let locks = Arc::new(AccountOperationLocks::default()); - let premium_handler = Arc::new(ResolvePremiumSourceHandler::new( - account_repo.clone(), - credential_store.clone(), - plugin_loader.clone(), - event_bus.clone(), - clock.clone(), - locks.clone(), - )); let mut bus = CommandBus::new( Arc::new(StubDownloadRepo), Arc::new(StubDownloadEngine), @@ -832,8 +823,7 @@ pub(crate) fn build_account_bus_with_plugin_loader( .with_account_repo(account_repo) .with_account_credential_store(credential_store) .with_account_clock(clock) - .with_account_operation_locks(locks) - .with_premium_source_handler(premium_handler); + .with_account_operation_locks(locks); if let Some(v) = validator { bus = bus.with_account_validator(v); diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index 6114cfe8..76057086 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -335,7 +335,7 @@ mod tests { "vortex-mod-1fichier", ValidatorBehavior::Ok(ValidationOutcome::ok()), ); - let account = Account::reconstruct_with_status( + let mut account = Account::reconstruct_with_status( AccountId::new("account-1"), "vortex-mod-1fichier".into(), "alice".into(), @@ -349,6 +349,7 @@ mod tests { AccountStatus::Valid, None, ); + account.mark_exhausted(1_700_000_060_000); repo.save(&account).expect("seed account"); credentials .store_password(account.id(), "api-key") @@ -356,9 +357,6 @@ mod tests { let clock: Arc = Arc::new(ValidationClock); let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock); - rotator - .mark_exhausted(account.id(), account.service_name(), 60) - .expect("mark exhausted"); let saves_before_validation = repo.save_count(); let bus = build_account_bus(repo.clone(), credentials, events, Some(validator), None) .with_account_rotator(rotator.clone()); diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index af582668..c2dcfa81 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -2,14 +2,12 @@ //! //! PRD §6.4 ("Rotation si quota atteint") — when a hoster signals //! quota exhaustion (HTTP 429, `traffic_left` below threshold, …) the -//! rotator pulls the offending account out of the rotation for a -//! cooldown window, asks the [`AccountSelector`] for the next best -//! candidate, and emits a `DomainEvent::AccountExhausted` so the UI -//! can warn the user. +//! rotator pulls the offending account out of the rotation and asks the +//! [`AccountSelector`] for the next best candidate. //! //! Cooldown status and deadline are persisted on the [`Account`] so the UI -//! and a restarted process observe the same availability. The in-memory map -//! remains a concurrency guard for selection probes racing with mark/clear. +//! and a restarted process observe the same availability. Command handlers +//! own aggregate writes; the in-memory map is only a selection cache. //! //! Concurrency: the map sits behind a `std::sync::Mutex`. Every public //! method that takes the lock surfaces a poisoned mutex as @@ -24,9 +22,8 @@ use crate::application::error::AppError; use crate::application::services::AccountSelector; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountId, AccountSelectionStrategy, AccountStatus}; -use crate::domain::ports::driven::AccountRepository; use crate::domain::ports::driven::clock::Clock; -use crate::domain::ports::driven::event_bus::EventBus; +use crate::domain::ports::driven::{AccountRepository, EventBus}; /// Outcome of [`AccountRotator::next_account`]. /// @@ -107,15 +104,15 @@ impl AccountRotator { ) -> Result { let now_ms = self.now_ms(); let mut exhausted_ids = self.snapshot_exhausted(now_ms)?; - // Linearise with concurrent `mark_exhausted` / `clear_exhausted`: + // Linearise with command-handler cache updates: // - On every pick, re-check the chosen id under the lock and // retry with that id added to the exclude list when a - // parallel `mark_exhausted` landed in the gap. + // parallel committed cooldown landed in the gap. // - When the selector exhausts options, re-snapshot the // cooldown map and retry once more if any id from the full // current exclude list (initial snapshot ∪ race-pushed ids) - // has since been cleared via `clear_exhausted` or - // `record_traffic_refresh`. Otherwise we'd return + // has since been cleared after successful validation. Otherwise + // we'd return // `AllExhausted` while a live account is in fact selectable. loop { let picked = self.selector.select_best_excluding_quiet( @@ -133,7 +130,7 @@ impl AccountRotator { if still_available { // Emit AccountSelected only on the committed pick, not // on probes that lose the race to a parallel - // `mark_exhausted`. Otherwise UI/telemetry would see + // a committed cooldown. Otherwise UI/telemetry would see // "selected" for an account never returned to the caller. self.event_bus.publish(DomainEvent::AccountSelected { id: account.id().clone(), @@ -183,67 +180,16 @@ impl AccountRotator { }) } - /// Mark `account_id` as quota-exhausted for `ttl_secs` seconds. - /// Callers pass a hoster-specific cooldown (typical range: a few - /// hundred seconds for free plans, longer for daily caps). Emits - /// [`DomainEvent::AccountExhausted`] carrying the committed deadline. - /// - /// If a cooldown entry already exists and its deadline is further - /// in the future than the proposed one, the existing deadline - /// wins. This prevents a short retry-driven TTL from accidentally - /// shortening a longer daily-cap cooldown set by a previous - /// signal. - pub fn mark_exhausted( - &self, - account_id: &AccountId, - service_name: &str, - ttl_secs: u64, - ) -> Result<(), AppError> { - let now_ms = self.now_ms(); - let proposed = now_ms.saturating_add(ttl_secs.saturating_mul(1_000)); - let committed = { - let mut guard = self.lock_exhausted()?; - let mut account = self.repo.find_by_id(account_id)?.ok_or_else(|| { - AppError::NotFound(format!("account {} not found", account_id.as_str())) - })?; - if account.service_name() != service_name { - return Err(AppError::Validation(format!( - "account {} does not belong to service {service_name}", - account_id.as_str() - ))); + /// Cache a cooldown only after a command handler committed it. + pub(crate) fn cache_exhausted(&self, account_id: &AccountId, deadline_ms: u64) { + let mut guard = match self.exhausted.lock() { + Ok(guard) => guard, + Err(poisoned) => { + tracing::warn!("recovering poisoned non-canonical account cooldown cache"); + poisoned.into_inner() } - let final_deadline = guard - .get(account_id) - .copied() - .into_iter() - .chain(account.exhausted_until()) - .chain(std::iter::once(proposed)) - .max() - .unwrap_or(proposed); - account.mark_exhausted(final_deadline); - self.repo.save(&account)?; - guard.insert(account_id.clone(), final_deadline); - final_deadline }; - self.event_bus.publish(DomainEvent::AccountExhausted { - id: account_id.clone(), - service_name: service_name.to_string(), - exhausted_until_ms: committed, - }); - Ok(()) - } - - /// Drop any cooldown entry for `account_id` regardless of its - /// remaining TTL. Idempotent — calling on an unknown id is a - /// no-op. - pub fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError> { - let mut guard = self.lock_exhausted()?; - if let Some(mut account) = self.repo.find_by_id(account_id)? { - account.clear_exhausted(); - self.repo.save(&account)?; - } - guard.remove(account_id); - Ok(()) + guard.insert(account_id.clone(), deadline_ms); } /// Clear only the non-canonical in-memory cooldown cache. @@ -302,26 +248,6 @@ impl AccountRotator { matches!(traffic_left, Some(left) if left < threshold_bytes) } - /// Reconcile a freshly observed `traffic_left` against the - /// exhaustion map. When the upstream confirms `traffic_left` is at - /// or above `threshold_bytes`, drop the cooldown so the next - /// `next_account` call can pick the account again. When the - /// observation is below the threshold OR `None` (unknown), the - /// cooldown is left untouched — `mark_exhausted` is the canonical - /// way to extend it. - pub fn record_traffic_refresh( - &self, - account_id: &AccountId, - traffic_left: Option, - threshold_bytes: u64, - ) -> Result<(), AppError> { - let confirms_available = matches!(traffic_left, Some(left) if left >= threshold_bytes); - if !confirms_available { - return Ok(()); - } - self.clear_exhausted(account_id) - } - fn snapshot_exhausted(&self, now_ms: u64) -> Result, AppError> { let mut guard = self.lock_exhausted()?; guard.retain(|_, deadline| now_ms < *deadline); @@ -509,6 +435,65 @@ mod tests { ) } + trait PersistCooldownForTest { + fn mark_exhausted( + &self, + account_id: &AccountId, + service_name: &str, + ttl_secs: u64, + ) -> Result<(), AppError>; + fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError>; + fn record_traffic_refresh( + &self, + account_id: &AccountId, + traffic_left: Option, + threshold_bytes: u64, + ) -> Result<(), AppError>; + } + + impl PersistCooldownForTest for AccountRotator { + fn mark_exhausted( + &self, + account_id: &AccountId, + service_name: &str, + ttl_secs: u64, + ) -> Result<(), AppError> { + let mut account = self.repo.find_by_id(account_id)?.ok_or_else(|| { + AppError::NotFound(format!("account {} not found", account_id.as_str())) + })?; + if account.service_name() != service_name { + return Err(AppError::Validation("account service mismatch".into())); + } + let proposed = self.now_ms().saturating_add(ttl_secs.saturating_mul(1_000)); + let deadline = account.exhausted_until().unwrap_or_default().max(proposed); + account.mark_exhausted(deadline); + self.repo.save(&account)?; + self.cache_exhausted(account_id, deadline); + Ok(()) + } + + fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError> { + if let Some(mut account) = self.repo.find_by_id(account_id)? { + account.clear_exhausted(); + self.repo.save(&account)?; + } + self.clear_cached_exhausted(account_id); + Ok(()) + } + + fn record_traffic_refresh( + &self, + account_id: &AccountId, + traffic_left: Option, + threshold_bytes: u64, + ) -> Result<(), AppError> { + if matches!(traffic_left, Some(left) if left >= threshold_bytes) { + self.clear_exhausted(account_id)?; + } + Ok(()) + } + } + fn build_rotator( accounts: Vec, clock_secs: u64, @@ -526,7 +511,7 @@ mod tests { fn test_mark_exhausted_routes_next_account_to_remaining_candidate() { let a = account("a", "Uploaded", Some(50_000_000_000), true); let b = account("b", "Uploaded", Some(40_000_000_000), true); - let (rotator, bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); + let (rotator, _bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); let first = rotator .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) @@ -548,13 +533,6 @@ mod tests { NextAccountOutcome::Picked(acc) => assert_eq!(acc.id().as_str(), "b"), other => panic!("expected Picked(b), got {other:?}"), } - - let events = bus.events(); - assert!(events.iter().any(|e| matches!( - e, - DomainEvent::AccountExhausted { id, service_name, exhausted_until_ms: _ } - if id.as_str() == "a" && service_name == "Uploaded" - ))); } #[test] @@ -798,7 +776,7 @@ mod tests { fn test_quota_detection_to_rotation_full_flow() { let a = account("primary", "Uploaded", Some(50_000_000), true); let b = account("backup", "Uploaded", Some(500), true); - let (rotator, bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); + let (rotator, _bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); // Step 1: caller picks the primary (more traffic wins). let first = rotator @@ -830,16 +808,6 @@ mod tests { NextAccountOutcome::Picked(acc) => assert_eq!(acc.id().as_str(), "backup"), other => panic!("expected Picked(backup), got {other:?}"), } - - let event_count = bus - .events() - .iter() - .filter(|e| matches!(e, DomainEvent::AccountExhausted { .. })) - .count(); - assert_eq!( - event_count, 1, - "exactly one AccountExhausted should have been emitted" - ); } #[test] @@ -866,14 +834,10 @@ mod tests { // wins, and the AccountExhausted event publishes it verbatim // so subscribers don't see a phantom shorter window. let a = account("a", "S", Some(50), true); - let (rotator, bus, clock) = build_rotator(vec![a], 1_700_000_000); - let now_ms = 1_700_000_000_u64 * 1_000; - + let (rotator, _bus, clock) = build_rotator(vec![a], 1_700_000_000); rotator .mark_exhausted(&AccountId::new("a"), "S", 600) .unwrap(); - let long_deadline = now_ms + 600 * 1_000; - rotator .mark_exhausted(&AccountId::new("a"), "S", 60) .unwrap(); @@ -887,22 +851,6 @@ mod tests { // Advance past the long deadline; cooldown finally clears. clock.advance_secs(600); assert!(!rotator.is_exhausted(&AccountId::new("a")).unwrap()); - - let payloads: Vec = bus - .events() - .iter() - .filter_map(|e| match e { - DomainEvent::AccountExhausted { - exhausted_until_ms, .. - } => Some(*exhausted_until_ms), - _ => None, - }) - .collect(); - assert_eq!( - payloads, - vec![long_deadline, long_deadline], - "second AccountExhausted must republish the still-active longer deadline, not the shorter proposed one" - ); } /// PRD §6.4 freezes the human-facing message format. Callers that diff --git a/src-tauri/src/domain/event.rs b/src-tauri/src/domain/event.rs index 8a9f3fb3..f06b0120 100644 --- a/src-tauri/src/domain/event.rs +++ b/src-tauri/src/domain/event.rs @@ -361,11 +361,9 @@ pub enum DomainEvent { /// One of `"best_traffic"`, `"round_robin"`, `"manual"`. strategy: String, }, - /// Emitted by `AccountRotator::mark_exhausted` when a hoster signals - /// quota exhaustion (HTTP 429, low `traffic_left`, …) so the account - /// is taken out of the rotation until the cooldown expires or the - /// next traffic refresh confirms availability. Carries `service_name` - /// so the UI can group notifications per hoster. + /// Emitted after a command handler persists quota exhaustion so the + /// account is excluded until its cooldown expires. Carries + /// `service_name` so the UI can group notifications per hoster. AccountExhausted { id: AccountId, service_name: String, diff --git a/src-tauri/src/domain/ports/driven/download_repository.rs b/src-tauri/src/domain/ports/driven/download_repository.rs index 7f0a8464..e75be8d8 100644 --- a/src-tauri/src/domain/ports/driven/download_repository.rs +++ b/src-tauri/src/domain/ports/driven/download_repository.rs @@ -4,6 +4,7 @@ //! to load, persist, and delete downloads. use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; use crate::domain::model::download::{Download, DownloadId, DownloadState}; /// Persists and retrieves `Download` aggregates. @@ -40,4 +41,29 @@ pub trait DownloadRepository: Send + Sync { /// Find all downloads in a given state. fn find_by_state(&self, state: DownloadState) -> Result, DomainError>; + + /// Whether any persisted download still depends on an account. + fn has_account_reference(&self, account_id: &AccountId) -> Result { + const STATES: [DownloadState; 9] = [ + DownloadState::Queued, + DownloadState::Downloading, + DownloadState::Paused, + DownloadState::Waiting, + DownloadState::Retry, + DownloadState::Error, + DownloadState::Extracting, + DownloadState::Completed, + DownloadState::Checking, + ]; + for state in STATES { + if self + .find_by_state(state)? + .iter() + .any(|download| download.account_id() == Some(account_id)) + { + return Ok(true); + } + } + Ok(false) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2939fa49..b148dfcf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -268,6 +268,9 @@ pub fn run() { event_bus.clone(), account_clock.clone(), account_operation_locks.clone(), + download_repo.clone(), + config_store.clone(), + account_rotator.clone(), )); let premium_source_resolver: Arc = premium_source_handler.clone(); @@ -419,7 +422,6 @@ pub fn run() { .with_account_rotator(account_rotator) .with_account_clock(account_clock) .with_account_operation_locks(account_operation_locks) - .with_premium_source_handler(premium_source_handler) .with_package_repo(package_repo.clone()) .with_passphrase_codec(passphrase_codec), ); diff --git a/src-tauri/tests/app_state_wiring.rs b/src-tauri/tests/app_state_wiring.rs index 01cf6f19..4b628b00 100644 --- a/src-tauri/tests/app_state_wiring.rs +++ b/src-tauri/tests/app_state_wiring.rs @@ -94,6 +94,9 @@ fn test_appstate_wiring_with_in_memory_db() { event_bus.clone(), account_clock.clone(), account_operation_locks.clone(), + download_repo.clone(), + config_store.clone(), + account_rotator.clone(), )); let premium_source_resolver: Arc = premium_source_handler.clone(); @@ -128,8 +131,7 @@ fn test_appstate_wiring_with_in_memory_db() { .with_account_selector(account_selector) .with_account_rotator(account_rotator) .with_account_clock(account_clock) - .with_account_operation_locks(account_operation_locks) - .with_premium_source_handler(premium_source_handler), + .with_account_operation_locks(account_operation_locks), ); let query_bus = Arc::new( @@ -153,7 +155,6 @@ fn test_appstate_wiring_with_in_memory_db() { assert!(command_bus.account_validator().is_some()); assert!(command_bus.account_selector().is_some()); assert!(command_bus.account_rotator().is_some()); - assert!(command_bus.premium_source_handler().is_some()); assert!(query_bus.account_repo().is_some()); // Verify query bus can execute a read query (empty DB → empty results) diff --git a/src/views/AccountsView/AccountRow.tsx b/src/views/AccountsView/AccountRow.tsx index fe64f5a2..fe466c48 100644 --- a/src/views/AccountsView/AccountRow.tsx +++ b/src/views/AccountsView/AccountRow.tsx @@ -14,6 +14,7 @@ import { useLanguage } from "@/hooks/useLanguage"; import { formatBytes, formatDate } from "@/lib/format"; import type { AccountView } from "@/types/account"; import { deriveAccountStatus, type AccountStatus } from "./statusUtils"; +import { useAccountStatusNow } from "./useAccountStatusNow"; export interface AccountRowActions { validate: (account: AccountView) => void; @@ -43,7 +44,8 @@ const STATUS_VARIANT: Record { + if (!isTemporary(status) || exhaustedUntil === null) return; + const remainingMs = exhaustedUntil - Date.now(); + if (remainingMs <= 0) { + setNowMs((current) => (current >= exhaustedUntil ? current : Date.now())); + return; + } + const timeout = window.setTimeout( + () => setNowMs(Date.now()), + Math.min(remainingMs, MAX_TIMEOUT_MS), + ); + return () => window.clearTimeout(timeout); + }, [status, exhaustedUntil, nowMs]); + + return nowMs; +} + +function isTemporary(status: PersistedAccountStatus): boolean { + return status === "quota_exhausted" || status === "cooldown"; +} From 0e65fb2ee9f1f30e65b06319cbafc902493636ec Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:40:02 +0200 Subject: [PATCH 30/33] fix(account): close final premium acceptance gaps --- CHANGELOG.md | 5 + .../adapters/driven/sqlite/download_repo.rs | 75 +++++++++++- .../commands/premium_account_resolution.rs | 16 ++- .../commands/premium_account_rotation.rs | 33 +++++- .../src/application/commands/resolve_links.rs | 40 +++++-- .../resolve_premium_source_test_support.rs | 10 +- .../commands/resolve_premium_source_tests.rs | 107 ++++++++++++++++++ .../src/application/commands/tests_support.rs | 17 +++ .../application/services/account_rotator.rs | 71 ++++++++++-- .../ports/driven/download_repository.rs | 16 +++ src/views/AccountsView/AccountRow.tsx | 2 +- .../__tests__/AccountRow.test.tsx | 23 ++++ src/views/AccountsView/useAccountStatusNow.ts | 27 +++-- 13 files changed, 408 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8949cfcd..3ec01efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-132 final concurrency and quota fixes**: JIT rotation now updates only + the existing download's account reference, preserving concurrent state and + never recreating a removed row. Cooldown and quota exhaustion remain distinct + when no backup exists, zero remaining traffic triggers typed rotation, and + account badges wake at subscription expiry without a backend event. - **MAT-132 final acceptance coverage**: added contracts for strict JIT-only credential use, runtime account rotation with association updates, referenced-account deletion safety, observable missing credentials, cooldown diff --git a/src-tauri/src/adapters/driven/sqlite/download_repo.rs b/src-tauri/src/adapters/driven/sqlite/download_repo.rs index a29ce14e..a8e35be0 100644 --- a/src-tauri/src/adapters/driven/sqlite/download_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/download_repo.rs @@ -75,6 +75,9 @@ async fn persist_download( // race with the event-driven write and overwrite a fresh // failover position with the cursor at the time the // aggregate was loaded. + // AccountId excluded: JIT premium rotation owns this column through + // compare_and_set_account_reference(), which is UPDATE-only and must not be + // reverted by a concurrent aggregate save carrying a stale account. .update_columns([ download::Column::Url, download::Column::FileName, @@ -92,7 +95,6 @@ async fn persist_download( download::Column::Protocol, download::Column::ResumeSupported, download::Column::ModuleName, - download::Column::AccountId, download::Column::DestinationPath, download::Column::MirrorsJson, ]); @@ -182,6 +184,27 @@ impl DownloadRepository for SqliteDownloadRepo { }) } + fn compare_and_set_account_reference( + &self, + id: DownloadId, + expected: &AccountId, + replacement: &AccountId, + ) -> Result { + block_on(async { + let result = download::Entity::update_many() + .col_expr( + download::Column::AccountId, + Expr::value(replacement.as_str()), + ) + .filter(download::Column::Id.eq(id.0 as i64)) + .filter(download::Column::AccountId.eq(expected.as_str())) + .exec(&self.db) + .await + .map_err(map_db_err)?; + Ok(result.rows_affected == 1) + }) + } + fn find_by_state(&self, state: DownloadState) -> Result, DomainError> { block_on(async { // Deterministic order so callers (e.g. queue_position @@ -529,6 +552,56 @@ mod tests { assert!(!repo.has_account_reference(&account_id).unwrap()); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_update_account_reference_only_updates_an_existing_row() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteDownloadRepo::new(db); + let primary = AccountId::new("primary"); + let backup = AccountId::new("backup"); + let mut stale_snapshot = make_download(2).with_account_id(primary); + stale_snapshot.start().unwrap(); + repo.save(&stale_snapshot).expect("save active download"); + + assert!( + repo.compare_and_set_account_reference( + stale_snapshot.id(), + &AccountId::new("primary"), + &backup + ) + .unwrap() + ); + assert!( + !repo + .compare_and_set_account_reference( + stale_snapshot.id(), + &AccountId::new("primary"), + &AccountId::new("other"), + ) + .unwrap() + ); + stale_snapshot.pause().unwrap(); + repo.save(&stale_snapshot).expect("save stale snapshot"); + + let persisted = repo + .find_by_id(stale_snapshot.id()) + .unwrap() + .expect("download remains persisted"); + assert_eq!(persisted.state(), DownloadState::Paused); + assert_eq!(persisted.account_id(), Some(&backup)); + + repo.delete(stale_snapshot.id()).unwrap(); + assert!( + !repo + .compare_and_set_account_reference( + stale_snapshot.id(), + &AccountId::new("primary"), + &backup, + ) + .unwrap() + ); + assert!(repo.find_by_id(stale_snapshot.id()).unwrap().is_none()); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_find_by_state_filters_correctly() { let db = setup_test_db().await.expect("test db"); diff --git a/src-tauri/src/application/commands/premium_account_resolution.rs b/src-tauri/src/application/commands/premium_account_resolution.rs index adb2f2f9..d08824ce 100644 --- a/src-tauri/src/application/commands/premium_account_resolution.rs +++ b/src-tauri/src/application/commands/premium_account_resolution.rs @@ -88,7 +88,21 @@ impl ResolvePremiumSourceHandler { if let Some(total) = link.traffic_total_bytes { account.set_traffic_total(total); if let Some(used) = link.traffic_used_bytes { - account.set_traffic_left(total.saturating_sub(used)); + let remaining = total.saturating_sub(used); + account.set_traffic_left(remaining); + if total > 0 && remaining == 0 { + apply_status( + account, + AccountStatus::QuotaExhausted, + self.clock.now_unix_ms(), + ); + self.repo.save(account)?; + if let Some(deadline) = account.exhausted_until() { + self.rotator.cache_exhausted(account.id(), deadline); + } + self.publish_typed_failure(account, AccountStatus::QuotaExhausted); + return Err(DomainError::AccountQuotaExceeded); + } } } account.set_status(AccountStatus::Valid); diff --git a/src-tauri/src/application/commands/premium_account_rotation.rs b/src-tauri/src/application/commands/premium_account_rotation.rs index 759b6641..b1b8e4e4 100644 --- a/src-tauri/src/application/commands/premium_account_rotation.rs +++ b/src-tauri/src/application/commands/premium_account_rotation.rs @@ -29,8 +29,8 @@ impl ResolvePremiumSourceHandler { } account_id = match self.next_account(service)? { NextAccountOutcome::Picked(account) => account.id().clone(), - NextAccountOutcome::AllExhausted { .. } => { - return Err(DomainError::AccountQuotaExceeded); + NextAccountOutcome::AllExhausted { reason, .. } => { + return Err(last_error.unwrap_or_else(|| reason.into_domain_error())); } NextAccountOutcome::NoneAvailable => break, }; @@ -55,9 +55,32 @@ impl ResolvePremiumSourceHandler { download.url().as_str().to_string(), ); let link = self.resolve_locked(command)?; - if download.account_id() != Some(account_id) { - self.downloads - .save(&download.clone().with_account_id(account_id.clone()))?; + if let Some(expected) = download + .account_id() + .filter(|expected| *expected != account_id) + { + let updated = self.downloads.compare_and_set_account_reference( + download.id(), + expected, + account_id, + )?; + if !updated { + let current = self.downloads.find_by_id(download.id())?; + match current { + None => { + return Err(DomainError::NotFound(format!( + "download {}", + download.id().0 + ))); + } + Some(current) if current.account_id() == Some(account_id) => {} + Some(_) => { + return Err(DomainError::ValidationError( + "download account association changed during resolution".into(), + )); + } + } + } } self.publish_success(account_id); Ok(link) diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index a9957a8d..6fbcd1bb 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -220,8 +220,8 @@ impl CommandBus { account_id: Some(account.id().as_str().to_string()), }); } - NextAccountOutcome::AllExhausted { .. } => { - return Err(DomainError::AccountQuotaExceeded.into()); + NextAccountOutcome::AllExhausted { reason, .. } => { + return Err(reason.into_domain_error().into()); } NextAccountOutcome::NoneAvailable => {} } @@ -478,7 +478,7 @@ mod tests { async fn resolve_with_primary_credential( primary_password: Option<&str>, include_backup: bool, - primary_exhausted: bool, + temporary_status: Option, ) -> ( Vec, Arc, @@ -491,8 +491,10 @@ mod tests { let plugin = Arc::new(PremiumPluginLoader::new()); let mut primary = premium_account("primary", 100); let backup = premium_account("backup", 50); - if primary_exhausted { - primary.mark_exhausted(1_700_000_060_000); + match temporary_status { + Some(AccountStatus::QuotaExhausted) => primary.mark_exhausted(1_700_000_060_000), + Some(AccountStatus::Cooldown) => primary.mark_cooldown(1_700_000_060_000), + _ => {} } repo.save(&primary).unwrap(); if include_backup { @@ -532,7 +534,7 @@ mod tests { #[tokio::test] async fn resolve_hoster_selects_account_without_reading_secret_or_issuing_token() { let (result, repo, plugin, primary) = - resolve_with_primary_credential(Some("working-key"), true, false).await; + resolve_with_primary_credential(Some("working-key"), true, None).await; assert_eq!( result[0].resolved_url.as_deref(), @@ -556,8 +558,12 @@ mod tests { #[tokio::test] async fn resolve_hoster_surfaces_persisted_exhaustion_without_free_fallback() { - let (result, _, plugin, _) = - resolve_with_primary_credential(Some("quota-key"), false, true).await; + let (result, _, plugin, _) = resolve_with_primary_credential( + Some("quota-key"), + false, + Some(AccountStatus::QuotaExhausted), + ) + .await; assert_eq!(result[0].status, "error"); assert_eq!( @@ -568,6 +574,24 @@ mod tests { assert!(plugin.services.lock().unwrap().is_empty()); } + #[tokio::test] + async fn resolve_hoster_preserves_persisted_cooldown_without_free_fallback() { + let (result, _, plugin, _) = resolve_with_primary_credential( + Some("cooldown-key"), + false, + Some(AccountStatus::Cooldown), + ) + .await; + + assert_eq!(result[0].status, "error"); + assert_eq!( + result[0].error_message.as_deref(), + Some("Account is temporarily rate-limited") + ); + assert!(plugin.credentials.lock().unwrap().is_empty()); + assert!(plugin.services.lock().unwrap().is_empty()); + } + #[test] fn test_extract_filename_from_url_returns_last_path_segment() { assert_eq!( diff --git a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs index 8872d212..916ef919 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs @@ -72,12 +72,20 @@ impl PluginLoader for DirectUrlPlugin { if credential.password() == "expired-key" { return Err(DomainError::AccountExpired); } + if credential.password() == "cooldown-key" { + return Err(DomainError::AccountCooldown); + } + let traffic_used_bytes = if credential.password() == "zero-traffic-key" { + Some(100) + } else { + Some(10) + }; Ok(ExtractedHosterLink { source_url: url.to_string(), filename: Some("file.zip".into()), size_bytes: Some(42), direct_url: Some("https://1.1.1.1/short-lived-token".into()), - traffic_used_bytes: Some(10), + traffic_used_bytes, traffic_total_bytes: Some(100), }) } diff --git a/src-tauri/src/application/commands/resolve_premium_source_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_tests.rs index 629604ea..955284d1 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_tests.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_tests.rs @@ -62,6 +62,11 @@ async fn rotates_jit_failures_and_persists_the_selected_backup() { AccountStatus::Expired, vec!["expired-key", "working-key"], ), + ( + Some("zero-traffic-key"), + AccountStatus::QuotaExhausted, + vec!["zero-traffic-key", "working-key"], + ), (None, AccountStatus::MissingCredential, vec!["working-key"]), ] { assert_jit_rotation(password, expected_status, &expected_calls).await; @@ -123,6 +128,108 @@ async fn assert_jit_rotation( ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_jit_rotation_does_not_recreate_a_deleted_download() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("primary"); + let backup = valid_account("backup"); + repo.save(&primary).unwrap(); + repo.save(&backup).unwrap(); + credentials + .store_password(primary.id(), "quota-key") + .unwrap(); + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let stale_snapshot = download(primary.id().clone()); + let download_id = stale_snapshot.id(); + let resolver = handler_with_downloads( + repo, + credentials, + plugin, + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + let result = tokio::task::spawn_blocking(move || resolver.resolve(&stale_snapshot)) + .await + .unwrap(); + + assert!(matches!(result, Err(DomainError::NotFound(_)))); + assert!(downloads.find_by_id(download_id).unwrap().is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_jit_rotation_updates_only_the_account_reference() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("primary"); + let backup = valid_account("backup"); + repo.save(&primary).unwrap(); + repo.save(&backup).unwrap(); + credentials + .store_password(primary.id(), "quota-key") + .unwrap(); + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let mut stale_snapshot = download(primary.id().clone()); + stale_snapshot.start().unwrap(); + let mut paused = stale_snapshot.clone(); + paused.pause().unwrap(); + downloads.seed(paused); + let download_id = stale_snapshot.id(); + let resolver = handler_with_downloads( + repo, + credentials, + plugin, + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + tokio::task::spawn_blocking(move || resolver.resolve(&stale_snapshot)) + .await + .unwrap() + .expect("backup account resolves the source"); + + let persisted = downloads.find_by_id(download_id).unwrap().unwrap(); + assert_eq!( + persisted.state(), + crate::domain::model::download::DownloadState::Paused + ); + assert_eq!(persisted.account_id(), Some(backup.id())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_jit_resolution_preserves_cooldown_when_no_backup_exists() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account = valid_account("primary"); + repo.save(&account).unwrap(); + credentials + .store_password(account.id(), "cooldown-key") + .unwrap(); + let resolver = handler( + repo, + credentials, + Arc::new(DirectUrlPlugin::new()), + Arc::new(CapturingEventBus::new()), + ); + let download = download(account.id().clone()); + + let error = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .unwrap() + .expect_err("the typed cooldown must reach the engine"); + + assert!(matches!(error, DomainError::AccountCooldown)); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn missing_credential_persistence_failure_is_not_suppressed() { let repo = Arc::new(SaveFailingRepo::new()); diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 31a9d95e..5fb1e3a1 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -703,6 +703,23 @@ impl DownloadRepository for InMemoryDownloadRepo { Ok(()) } + fn compare_and_set_account_reference( + &self, + id: DownloadId, + expected: &AccountId, + replacement: &AccountId, + ) -> Result { + let mut store = self.store.lock().unwrap(); + let Some(download) = store.get_mut(&id) else { + return Ok(false); + }; + if download.account_id() != Some(expected) { + return Ok(false); + } + *download = download.clone().with_account_id(replacement.clone()); + Ok(true) + } + fn find_by_state(&self, state: DownloadState) -> Result, DomainError> { Ok(self .store diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index c2dcfa81..08a9c222 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -20,6 +20,7 @@ use std::sync::{Arc, Mutex}; use crate::application::error::AppError; use crate::application::services::AccountSelector; +use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountId, AccountSelectionStrategy, AccountStatus}; use crate::domain::ports::driven::clock::Clock; @@ -43,7 +44,32 @@ pub enum NextAccountOutcome { /// `Waiting` until `next_eligible_at_ms` (Unix epoch ms — the /// earliest cooldown deadline among the exhausted set) so the /// scheduler can retry without busy-looping. - AllExhausted { next_eligible_at_ms: u64 }, + AllExhausted { + next_eligible_at_ms: u64, + reason: AccountExhaustionReason, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountExhaustionReason { + Quota, + Cooldown, +} + +impl AccountExhaustionReason { + pub fn into_domain_error(self) -> DomainError { + match self { + Self::Quota => DomainError::AccountQuotaExceeded, + Self::Cooldown => DomainError::AccountCooldown, + } + } + + fn from_status(status: AccountStatus) -> Self { + match status { + AccountStatus::Cooldown => Self::Cooldown, + _ => Self::Quota, + } + } } impl NextAccountOutcome { @@ -174,9 +200,10 @@ impl AccountRotator { if live.is_empty() { return Ok(NextAccountOutcome::NoneAvailable); } - let next_eligible_at_ms = self.earliest_deadline_for_service(&live, now_ms)?; + let (next_eligible_at_ms, reason) = self.earliest_exhaustion_for_service(&live, now_ms)?; Ok(NextAccountOutcome::AllExhausted { next_eligible_at_ms, + reason, }) } @@ -254,11 +281,11 @@ impl AccountRotator { Ok(guard.keys().cloned().collect()) } - fn earliest_deadline_for_service( + fn earliest_exhaustion_for_service( &self, live_candidates: &[&Account], now_ms: u64, - ) -> Result { + ) -> Result<(u64, AccountExhaustionReason), AppError> { // Restrict the deadline scan to accounts that actually belong // to the queried service so a parallel-service entry cannot // leak its cooldown into an unrelated `AllExhausted` answer. @@ -266,17 +293,20 @@ impl AccountRotator { let next = live_candidates .iter() .filter_map(|account| { - guard + let deadline = guard .get(account.id()) .copied() .into_iter() .chain(account.exhausted_until()) .filter(|deadline| now_ms < *deadline) - .max() + .max()?; + Some(( + deadline, + AccountExhaustionReason::from_status(account.status()), + )) }) - .filter(|deadline| now_ms < *deadline) - .min() - .unwrap_or(now_ms); + .min_by_key(|(deadline, _)| *deadline) + .unwrap_or((now_ms, AccountExhaustionReason::Quota)); Ok(next) } @@ -571,6 +601,26 @@ mod tests { outcome, NextAccountOutcome::AllExhausted { next_eligible_at_ms: 1_700_000_600_000, + reason: AccountExhaustionReason::Quota, + } + ); + } + + #[test] + fn test_all_exhausted_preserves_the_temporary_failure_reason() { + let mut cooling_down = account("a", "Uploaded", Some(50), true); + cooling_down.mark_cooldown(1_700_000_600_000); + let (rotator, _bus, _clock) = build_rotator(vec![cooling_down], 1_700_000_000); + + let outcome = rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("rotation succeeds"); + + assert_eq!( + outcome, + NextAccountOutcome::AllExhausted { + next_eligible_at_ms: 1_700_000_600_000, + reason: AccountExhaustionReason::Cooldown, } ); } @@ -608,6 +658,7 @@ mod tests { match outcome { NextAccountOutcome::AllExhausted { next_eligible_at_ms, + .. } => { let now_ms = 1_700_000_000_u64.saturating_mul(1_000); let earliest = now_ms.saturating_add(600 * 1_000); @@ -861,6 +912,7 @@ mod tests { fn test_outcome_error_message_uses_prd_wording() { let outcome = NextAccountOutcome::AllExhausted { next_eligible_at_ms: 1_700_000_000_000, + reason: AccountExhaustionReason::Quota, }; assert_eq!( outcome.error_message("Uploaded"), @@ -902,6 +954,7 @@ mod tests { match outcome { NextAccountOutcome::AllExhausted { next_eligible_at_ms, + .. } => { let now_ms = 1_700_000_000_u64 * 1_000; assert_eq!( diff --git a/src-tauri/src/domain/ports/driven/download_repository.rs b/src-tauri/src/domain/ports/driven/download_repository.rs index e75be8d8..11a0504b 100644 --- a/src-tauri/src/domain/ports/driven/download_repository.rs +++ b/src-tauri/src/domain/ports/driven/download_repository.rs @@ -39,6 +39,22 @@ pub trait DownloadRepository: Send + Sync { /// Delete a download by its identifier. fn delete(&self, id: DownloadId) -> Result<(), DomainError>; + /// Change only the account association of an existing download. + /// + /// Implementations must not insert a missing row or overwrite any other + /// aggregate field. The boolean reports whether the expected reference + /// matched and was replaced atomically. + fn compare_and_set_account_reference( + &self, + _id: DownloadId, + _expected: &AccountId, + _replacement: &AccountId, + ) -> Result { + Err(DomainError::StorageError( + "atomic account reference updates are unavailable".into(), + )) + } + /// Find all downloads in a given state. fn find_by_state(&self, state: DownloadState) -> Result, DomainError>; diff --git a/src/views/AccountsView/AccountRow.tsx b/src/views/AccountsView/AccountRow.tsx index fe466c48..fc54620f 100644 --- a/src/views/AccountsView/AccountRow.tsx +++ b/src/views/AccountsView/AccountRow.tsx @@ -44,7 +44,7 @@ const STATUS_VARIANT: Record { act(() => vi.advanceTimersByTime(1_000)); expect(screen.getByText("Active")).toBeInTheDocument(); }); + + it("should wake when a valid account reaches its entitlement expiry", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + renderRow({ + id: "account-1", + serviceName: "vortex-mod-1fichier", + username: "alice", + accountType: "premium", + enabled: true, + trafficLeft: null, + trafficTotal: null, + validUntil: 1_700_000_001_000, + lastValidated: 1_700_000_000_000, + createdAt: 1_699_999_000_000, + status: "valid", + exhaustedUntil: null, + }); + + expect(screen.getByText("Active")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1_001)); + expect(screen.getByText("Expired")).toBeInTheDocument(); + }); }); diff --git a/src/views/AccountsView/useAccountStatusNow.ts b/src/views/AccountsView/useAccountStatusNow.ts index 526af240..1ef2acbe 100644 --- a/src/views/AccountsView/useAccountStatusNow.ts +++ b/src/views/AccountsView/useAccountStatusNow.ts @@ -6,26 +6,37 @@ const MAX_TIMEOUT_MS = 2_147_483_647; export function useAccountStatusNow( status: PersistedAccountStatus, exhaustedUntil: number | null, + validUntil: number | null, ): number { const [nowMs, setNowMs] = useState(Date.now); useEffect(() => { - if (!isTemporary(status) || exhaustedUntil === null) return; - const remainingMs = exhaustedUntil - Date.now(); - if (remainingMs <= 0) { - setNowMs((current) => (current >= exhaustedUntil ? current : Date.now())); - return; - } + const currentTime = Date.now(); + const nextDeadline = nextStatusDeadline(status, exhaustedUntil, validUntil, currentTime); + if (nextDeadline === null) return; const timeout = window.setTimeout( () => setNowMs(Date.now()), - Math.min(remainingMs, MAX_TIMEOUT_MS), + Math.min(nextDeadline - currentTime, MAX_TIMEOUT_MS), ); return () => window.clearTimeout(timeout); - }, [status, exhaustedUntil, nowMs]); + }, [status, exhaustedUntil, validUntil, nowMs]); return nowMs; } +function nextStatusDeadline( + status: PersistedAccountStatus, + exhaustedUntil: number | null, + validUntil: number | null, + nowMs: number, +): number | null { + const deadlines = [ + isTemporary(status) ? exhaustedUntil : null, + validUntil === null ? null : validUntil + 1, + ].filter((deadline): deadline is number => deadline !== null && deadline > nowMs); + return deadlines.length === 0 ? null : Math.min(...deadlines); +} + function isTemporary(status: PersistedAccountStatus): boolean { return status === "quota_exhausted" || status === "cooldown"; } From 0db4d0579e144ba6745fc0dcf130edaa01cd4a40 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:22:21 +0200 Subject: [PATCH 31/33] chore(registry): bump vortex-mod-1fichier to 1.1.0 --- CHANGELOG.md | 4 ++-- registry/registry.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec01efe..e6bca33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,8 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Account failures persist before typed events, referenced accounts cannot be deleted, cooldown badges wake at their deadline, and restricted HTTP clients ignore system proxies while rejecting special IPv6 and discovered NAT64 - mappings to private IPv4. The registry stays on published 1fichier v1.0.0; - v1.1.0 registration is deliberately deferred until its release assets exist. + mappings to private IPv4. The registry now pins the published 1fichier + v1.1.0 release assets. - **MAT-132 review regressions**: added coverage for fail-closed account state persistence, premium account lifecycle races, account-event refreshes, lock reclamation, and IPv6/proxy SSRF boundaries. diff --git a/registry/registry.toml b/registry/registry.toml index 7ec90006..3e563a12 100644 --- a/registry/registry.toml +++ b/registry/registry.toml @@ -112,11 +112,11 @@ min_vortex_version = "0.1.0" name = "vortex-mod-1fichier" description = "1fichier hoster — free (60s wait + captcha stub) and premium (API key)" author = "vortex-community" -version = "1.0.0" +version = "1.1.0" category = "hoster" repository = "https://github.com/mpiton/vortex-mod-1fichier" -checksum_sha256 = "02035a49da81566cf7e4ff64cd0b5ed858e05dfdca643dbd14e53b359d93232c" -checksum_sha256_toml = "c16cb7d418ee6d261114b0ed66701629f7aa7b92317dc97fb7593ede9487c091" +checksum_sha256 = "bd2a19c45fd3681cb788ee43bdaf287028630a68c30bf8159692bd4bea087c23" +checksum_sha256_toml = "bcf3ba595c323d2d0a4ea680e4c6354285153020e07d1efe05c856d5e281dd6d" official = true min_vortex_version = "0.1.0" From 38c12d37cac3c5860855d70461696faf22711e29 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:35:36 +0200 Subject: [PATCH 32/33] fix: address PR review comments --- CHANGELOG.md | 7 + .../driven/network/download_engine.rs | 146 ++++++++++++- .../src/adapters/driven/network/nat64.rs | 13 +- .../src/adapters/driven/network/safe_url.rs | 7 +- .../adapters/driven/network/safe_url_tests.rs | 7 + .../adapters/driven/plugin/capabilities.rs | 27 ++- .../adapters/driven/plugin/host_functions.rs | 30 ++- .../src/adapters/driven/plugin/registry.rs | 61 ++++-- .../adapters/driven/sqlite/account_repo.rs | 20 +- .../src/adapters/driven/sqlite/connection.rs | 52 +++++ .../m20260716_000010_wire_premium_accounts.rs | 28 +++ src-tauri/src/application/command_bus.rs | 18 +- .../src/application/commands/add_account.rs | 15 +- .../commands/premium_account_resolution.rs | 50 +++-- .../commands/premium_account_rotation.rs | 92 ++++---- .../src/application/commands/redownload.rs | 6 +- .../commands/resolve_premium_source.rs | 26 ++- .../resolve_premium_source_test_support.rs | 4 +- .../commands/resolve_premium_source_tests.rs | 205 +++++++++++++++++- .../application/commands/start_download.rs | 2 +- .../application/commands/update_account.rs | 61 +++++- .../application/commands/validate_account.rs | 165 ++++++++++++-- .../application/services/account_rotator.rs | 205 +++++++++++++++--- src-tauri/src/domain/model/account.rs | 18 +- .../ports/driven/download_repository.rs | 8 + .../ports/driven/download_source_resolver.rs | 64 ++++++ .../src/domain/ports/driven/hoster_link.rs | 39 +++- src-tauri/src/domain/ports/driven/mod.rs | 4 +- .../__tests__/AccountRow.test.tsx | 24 ++ src/views/AccountsView/useAccountStatusNow.ts | 18 +- src/views/LinkGrabberView/LinkGrabberView.tsx | 19 +- .../__tests__/LinkGrabberView.test.tsx | 11 +- 32 files changed, 1241 insertions(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bca33e..2fcaf1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-132 PR review hardening**: premium selection now excludes free + accounts, serializes persisted cooldowns with rotation, revalidates download + associations on every JIT resolution, and lets cancellation win without late + writes. Plugin calls enforce enablement at credential injection, retained + credential logs and bearer URL diagnostics stay redacted, plugin HTTP is + HTTPS-only, legacy account references are backfilled and indexed, and account + validation runs outside Tokio workers while preserving typed failures. - **MAT-132 final concurrency and quota fixes**: JIT rotation now updates only the existing download's account reference, preserving concurrent state and never recreating a removed row. Cooldown and quota exhaustion remain distinct diff --git a/src-tauri/src/adapters/driven/network/download_engine.rs b/src-tauri/src/adapters/driven/network/download_engine.rs index 0d78adb3..47e25d46 100644 --- a/src-tauri/src/adapters/driven/network/download_engine.rs +++ b/src-tauri/src/adapters/driven/network/download_engine.rs @@ -11,13 +11,16 @@ use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::download::{Download, DownloadId}; use crate::domain::model::meta::{DownloadMeta, SegmentMeta}; -use crate::domain::ports::driven::{DownloadEngine, DownloadSourceResolver, EventBus, FileStorage}; +use crate::domain::ports::driven::{ + DownloadEngine, DownloadSourceResolver, EventBus, FileStorage, ResolutionCancellation, +}; use super::segment_worker::{SegmentError, SegmentParams, download_segment}; use super::{format_error_chain, restricted_download_client}; struct ActiveDownload { cancel_token: CancellationToken, + resolution_cancellation: ResolutionCancellation, pause_sender: watch::Sender, } @@ -295,24 +298,49 @@ async fn prepare_sources( download: Download, resolver: Option>, client: reqwest::Client, + cancel_token: CancellationToken, + resolution_cancellation: ResolutionCancellation, ) -> Result { + if cancel_token.is_cancelled() { + return Err(DomainError::PluginError( + "premium source resolution cancelled".into(), + )); + } let resume_url = download.url().as_str().to_string(); if download.account_id().is_some() { let resolver = resolver.ok_or_else(|| { DomainError::PluginError("premium source resolver is not configured".into()) })?; - let source = tokio::task::spawn_blocking(move || resolver.resolve(&download)) - .await - .map_err(|_| DomainError::PluginError("premium source resolver stopped".into()))??; + let mut resolve_task = tokio::task::spawn_blocking(move || { + resolver.resolve_cancellable(&download, &resolution_cancellation) + }); + let source = tokio::select! { + biased; + _ = cancel_token.cancelled() => { + return Err(DomainError::PluginError("premium source resolution cancelled".into())); + } + result = &mut resolve_task => { + result + .map_err(|_| DomainError::PluginError("premium source resolver stopped".into()))?? + } + }; let request_url = source.request_url().to_string(); - let (request_url, client) = tokio::task::spawn_blocking(move || { + let mut safety_task = tokio::task::spawn_blocking(move || { let parsed = reqwest::Url::parse(&request_url) .map_err(|_| DomainError::NetworkError("plugin returned an invalid URL".into()))?; let client = restricted_download_client(&parsed)?; Ok::<_, DomainError>((request_url, client)) - }) - .await - .map_err(|_| DomainError::NetworkError("URL safety check stopped".into()))??; + }); + let (request_url, client) = tokio::select! { + biased; + _ = cancel_token.cancelled() => { + return Err(DomainError::PluginError("premium source resolution cancelled".into())); + } + result = &mut safety_task => { + result + .map_err(|_| DomainError::NetworkError("URL safety check stopped".into()))?? + } + }; return Ok(PreparedSources { urls: vec![request_url], initial_index: 0, @@ -365,6 +393,7 @@ impl DownloadEngine for SegmentedDownloadEngine { }; let cancel_token = CancellationToken::new(); + let resolution_cancellation = ResolutionCancellation::default(); let (pause_tx, pause_rx) = watch::channel(false); { @@ -382,6 +411,7 @@ impl DownloadEngine for SegmentedDownloadEngine { download_id, ActiveDownload { cancel_token: cancel_token.clone(), + resolution_cancellation: resolution_cancellation.clone(), pause_sender: pause_tx, }, ); @@ -398,7 +428,15 @@ impl DownloadEngine for SegmentedDownloadEngine { let download = download.clone(); tokio::spawn(async move { - let prepared = match prepare_sources(download, source_resolver, client).await { + let prepared = match prepare_sources( + download, + source_resolver, + client, + cancel_token.clone(), + resolution_cancellation, + ) + .await + { Ok(prepared) => prepared, Err(error) => { let event = if cancel_token.is_cancelled() { @@ -611,6 +649,7 @@ impl DownloadEngine for SegmentedDownloadEngine { let active = map .get(&id) .ok_or_else(|| DomainError::NotFound(format!("download {}", id.0)))?; + active.resolution_cancellation.cancel(); active.cancel_token.cancel(); Ok(()) } @@ -919,8 +958,8 @@ mod tests { use super::*; use std::path::Path; - use std::sync::Mutex; - use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering}; + use std::sync::{Condvar, Mutex}; use std::time::Duration; use wiremock::matchers::{method, path}; @@ -1039,6 +1078,44 @@ mod tests { calls: AtomicUsize, } + struct BlockingSourceResolver { + entered: Mutex>>, + release: Arc<(Mutex, Condvar)>, + committed: AtomicBool, + finished: AtomicBool, + } + + impl DownloadSourceResolver for BlockingSourceResolver { + fn resolve(&self, _: &Download) -> Result { + Err(DomainError::PluginError( + "cancellable path was not used".into(), + )) + } + + fn resolve_cancellable( + &self, + _: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + if let Some(entered) = self.entered.lock().unwrap().take() { + entered.send(()).unwrap(); + } + let (released, condition) = &*self.release; + let mut released = released.lock().unwrap(); + while !*released { + released = condition.wait(released).unwrap(); + } + let result = cancellation.run_if_active(|| { + self.committed.store(true, AtomicOrdering::SeqCst); + Ok(ResolvedDownloadSource::sensitive( + "https://1.1.1.1/late-token".into(), + )) + }); + self.finished.store(true, AtomicOrdering::SeqCst); + result + } + } + impl DownloadSourceResolver for LoopbackSourceResolver { fn resolve(&self, _: &Download) -> Result { self.calls.fetch_add(1, AtomicOrdering::SeqCst); @@ -1075,6 +1152,53 @@ mod tests { assert_eq!(download.url().as_str(), "https://1fichier.com/?abc123"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancellation_wins_against_blocked_jit_resolution_without_late_commit() { + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let resolver = Arc::new(BlockingSourceResolver { + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + committed: AtomicBool::new(false), + finished: AtomicBool::new(false), + }); + let engine = make_engine(storage, bus.clone()).with_source_resolver(resolver.clone()); + let download = make_download(98, "https://1fichier.com/?abc123") + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(AccountId::new("account-1")); + engine.start(&download).expect("spawn download"); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) + .await + .unwrap() + .expect("resolver entered"); + + engine + .cancel(download.id()) + .expect("cancel active download"); + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadCancelled { id } if id.0 == 98), + Duration::from_millis(300), + ) + .await, + "cancellation must not wait for the blocking resolver" + ); + + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + while !resolver.finished.load(AtomicOrdering::SeqCst) + && tokio::time::Instant::now() < deadline + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(resolver.finished.load(AtomicOrdering::SeqCst)); + assert!(!resolver.committed.load(AtomicOrdering::SeqCst)); + } + // --- Tests --- #[tokio::test] diff --git a/src-tauri/src/adapters/driven/network/nat64.rs b/src-tauri/src/adapters/driven/network/nat64.rs index acdb5923..b9c385e9 100644 --- a/src-tauri/src/adapters/driven/network/nat64.rs +++ b/src-tauri/src/adapters/driven/network/nat64.rs @@ -13,7 +13,7 @@ pub(super) struct Nat64Prefix { impl Nat64Prefix { pub(super) fn new(address: Ipv6Addr, length: u8) -> Option { - if !PREFIX_LENGTHS.contains(&length) || (length == 96 && address.octets()[8] != 0) { + if !PREFIX_LENGTHS.contains(&length) { return None; } Some(Self { @@ -101,4 +101,15 @@ mod tests { ); } } + + #[test] + fn accepts_96_prefixes_with_nonzero_fifth_segment() { + let address: Ipv6Addr = "2606:4700:64:1:ab00:cd00:c0a8:1".parse().unwrap(); + let prefix = Nat64Prefix::new(address, 96).expect("valid /96 prefix"); + + assert_eq!( + prefix.embedded_ipv4(address), + Some(Ipv4Addr::new(192, 168, 0, 1)) + ); + } } diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs index 6cb5e9cb..e1f74136 100644 --- a/src-tauri/src/adapters/driven/network/safe_url.rs +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -10,6 +10,11 @@ use super::nat64::{Nat64Prefix, discovered_prefixes}; pub(crate) fn validate_public_url( url: &reqwest::Url, ) -> Result>, DomainError> { + if url.scheme() != "https" { + return Err(DomainError::NetworkError( + "plugin URL must use HTTPS".into(), + )); + } let host = url .host_str() .ok_or_else(|| DomainError::NetworkError("URL has no host".into()))?; @@ -39,7 +44,7 @@ pub(crate) fn validate_public_url( pub(crate) fn restricted_download_client( url: &reqwest::Url, ) -> Result { - if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + if !url.username().is_empty() || url.password().is_some() { return Err(DomainError::NetworkError( "plugin download URL must be credential-free HTTPS".into(), )); diff --git a/src-tauri/src/adapters/driven/network/safe_url_tests.rs b/src-tauri/src/adapters/driven/network/safe_url_tests.rs index 1b79fc91..cafe37af 100644 --- a/src-tauri/src/adapters/driven/network/safe_url_tests.rs +++ b/src-tauri/src/adapters/driven/network/safe_url_tests.rs @@ -37,3 +37,10 @@ fn restricted_client_requires_https_and_public_destination() { assert!(restricted_download_client(&http).is_err()); assert!(restricted_download_client(&local).is_err()); } + +#[test] +fn plugin_http_validator_rejects_cleartext_urls() { + let url = reqwest::Url::parse("http://1.1.1.1/account").unwrap(); + + assert!(validate_public_url(&url).is_err()); +} diff --git a/src-tauri/src/adapters/driven/plugin/capabilities.rs b/src-tauri/src/adapters/driven/plugin/capabilities.rs index 6e286464..e039ce14 100644 --- a/src-tauri/src/adapters/driven/plugin/capabilities.rs +++ b/src-tauri/src/adapters/driven/plugin/capabilities.rs @@ -1,6 +1,7 @@ //! Capability-based host function registration for WASM plugins. use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -10,7 +11,29 @@ use crate::domain::model::credential::Credential; use crate::domain::model::plugin::PluginManifest; use crate::domain::ports::driven::CredentialStore; -pub(crate) type CredentialSlot = Arc>>; +#[derive(Default)] +pub struct CredentialSlotState { + credential: Mutex>, + exposed: AtomicBool, +} + +impl CredentialSlotState { + pub(crate) fn lock( + &self, + ) -> std::sync::LockResult>> { + self.credential.lock() + } + + pub(crate) fn mark_exposed(&self) { + self.exposed.store(true, Ordering::SeqCst); + } + + pub(crate) fn has_been_exposed(&self) -> bool { + self.exposed.load(Ordering::SeqCst) + } +} + +pub(crate) type CredentialSlot = Arc; /// Shared resources across all plugins (singleton). pub struct SharedHostResources { @@ -135,7 +158,7 @@ pub(super) fn build_host_functions_for_instance( shared: &Arc, grants: HostFunctionGrants, ) -> (Vec, CredentialSlot) { - let credential_slot = Arc::new(Mutex::new(None)); + let credential_slot = Arc::new(CredentialSlotState::default()); let functions = build_host_functions_with_slot(manifest, shared, grants, Arc::clone(&credential_slot)); (functions, credential_slot) diff --git a/src-tauri/src/adapters/driven/plugin/host_functions.rs b/src-tauri/src/adapters/driven/plugin/host_functions.rs index 7a2e4d58..80e453a7 100644 --- a/src-tauri/src/adapters/driven/plugin/host_functions.rs +++ b/src-tauri/src/adapters/driven/plugin/host_functions.rs @@ -72,12 +72,8 @@ fn safe_plugin_log_message( message: &str, credential_slot: &CredentialSlot, ) -> Result { - let carries_credential = credential_slot - .lock() - .map_err(|_| anyhow::anyhow!("log: credential slot poisoned"))? - .is_some(); - Ok(if carries_credential { - "plugin log redacted during credential operation".into() + Ok(if credential_slot.has_been_exposed() { + "plugin log redacted after credential access".into() } else { message.to_string() }) @@ -376,6 +372,7 @@ pub fn make_get_credential_function( .map_err(|e| anyhow::anyhow!("get_credential: store error: {e}"))? .ok_or_else(|| anyhow::anyhow!("get_credential: no credential found"))?, }; + slot.mark_exposed(); let resp = CredentialResponse { username: cred.username().to_string(), @@ -535,10 +532,9 @@ mod tests { #[test] fn plugin_logs_are_fully_redacted_while_a_credential_is_scoped() { - let slot = Arc::new(std::sync::Mutex::new(Some(Credential::new( - "alice", - "super-secret", - )))); + let slot = Arc::new(super::super::capabilities::CredentialSlotState::default()); + *slot.lock().unwrap() = Some(Credential::new("alice", "super-secret")); + slot.mark_exposed(); let message = safe_plugin_log_message( "Authorization: Bearer super-secret; direct=https://cdn/token", @@ -546,8 +542,20 @@ mod tests { ) .unwrap(); - assert_eq!(message, "plugin log redacted during credential operation"); + assert_eq!(message, "plugin log redacted after credential access"); assert!(!message.contains("super-secret")); assert!(!message.contains("cdn/token")); } + + #[test] + fn plugin_logs_remain_redacted_after_the_credential_scope_is_cleared() { + let slot = Arc::new(super::super::capabilities::CredentialSlotState::default()); + slot.mark_exposed(); + assert!(slot.lock().unwrap().is_none()); + + let message = safe_plugin_log_message("retained secret", &slot).unwrap(); + + assert_eq!(message, "plugin log redacted after credential access"); + assert!(!message.contains("retained secret")); + } } diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs index 3a83c9fc..8694696c 100644 --- a/src-tauri/src/adapters/driven/plugin/registry.rs +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -27,6 +27,7 @@ impl CredentialScope { )); } *current = Some(credential); + slot.mark_exposed(); } Ok(Self { slot }) } @@ -164,24 +165,25 @@ impl PluginRegistry { where I: extism::convert::ToBytes<'a>, { - // Clone the Arc> and drop the DashMap shard guard - // before locking — holding the shard across a slow WASM call would - // block every other plugin lookup behind the same shard. - let (plugin_handle, credential_slot) = { - let entry = self - .plugins - .get(name) - .ok_or_else(|| DomainError::NotFound(name.to_string()))?; - ( - Arc::clone(&entry.plugin), - Arc::clone(&entry.credential_slot), - ) - }; - let mut plugin = plugin_handle + // Keep the registry guard through the call so `set_enabled` and + // credential injection share one linearization point. A disable that + // wins the guard is observed before any credential enters the slot; + // a call that wins completes before the disable is committed. + let entry = self + .plugins + .get(name) + .ok_or_else(|| DomainError::NotFound(name.to_string()))?; + if !entry.enabled { + return Err(DomainError::NotFound(format!( + "plugin '{name}' is disabled" + ))); + } + let mut plugin = entry + .plugin .lock() .map_err(|_| DomainError::PluginError(format!("plugin '{name}' mutex poisoned")))?; let _credential_scope = scoped_credential - .map(|credential| CredentialScope::new(credential_slot, credential)) + .map(|credential| CredentialScope::new(Arc::clone(&entry.credential_slot), credential)) .transpose()?; let fn_exists = plugin.function_exists(func); tracing::debug!(plugin = name, func, fn_exists, "plugin call pre-call"); @@ -234,7 +236,7 @@ mod tests { LoadedPlugin { manifest: make_manifest(name), plugin: Arc::new(Mutex::new(make_extism_plugin())), - credential_slot: Arc::new(Mutex::new(None)), + credential_slot: Arc::new(super::super::capabilities::CredentialSlotState::default()), enabled: true, } } @@ -297,6 +299,33 @@ mod tests { assert!(slot.lock().unwrap().is_none()); } + #[test] + fn disabled_plugin_is_rejected_before_credential_injection() { + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + let slot = Arc::clone( + ®istry + .plugins + .get("plug-a") + .expect("loaded plugin") + .credential_slot, + ); + registry.set_enabled("plug-a", false).unwrap(); + + let error = registry + .call_plugin_with_credential( + "plug-a", + "missing", + "", + Credential::new("alice", "secret"), + ) + .expect_err("disabled plugin"); + + assert!(matches!(error, DomainError::NotFound(message) if message.contains("disabled"))); + assert!(slot.lock().unwrap().is_none()); + assert!(!slot.has_been_exposed()); + } + #[test] fn test_set_enabled() { let registry = PluginRegistry::new(); diff --git a/src-tauri/src/adapters/driven/sqlite/account_repo.rs b/src-tauri/src/adapters/driven/sqlite/account_repo.rs index 64b57066..23953fff 100644 --- a/src-tauri/src/adapters/driven/sqlite/account_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/account_repo.rs @@ -151,7 +151,7 @@ fn is_unique_violation(msg: &str) -> bool { mod tests { use super::*; use crate::adapters::driven::sqlite::connection::setup_test_db; - use crate::domain::model::account::{Account, AccountId, AccountType}; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; fn make_account(id: &str, service: &str, user: &str) -> Account { Account::new( @@ -214,6 +214,24 @@ mod tests { assert_eq!(found.traffic_left(), Some(999)); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_save_upsert_persists_status_and_cooldown_deadline() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteAccountRepo::new(db); + let mut account = make_account("acc-1", "real-debrid", "alice"); + repo.save(&account).expect("first save"); + + account.mark_cooldown(1_700_000_060_000); + repo.save(&account).expect("upsert cooldown"); + + let found = repo + .find_by_id(account.id()) + .expect("find_by_id") + .expect("present"); + assert_eq!(found.status(), AccountStatus::Cooldown); + assert_eq!(found.exhausted_until(), Some(1_700_000_060_000)); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_save_upsert_preserves_original_created_at() { let db = setup_test_db().await.expect("test db"); diff --git a/src-tauri/src/adapters/driven/sqlite/connection.rs b/src-tauri/src/adapters/driven/sqlite/connection.rs index 360e753b..5eebdc04 100644 --- a/src-tauri/src/adapters/driven/sqlite/connection.rs +++ b/src-tauri/src/adapters/driven/sqlite/connection.rs @@ -219,6 +219,58 @@ mod tests { .map(|row| row.try_get_by_index::(1).unwrap()) .collect(); assert!(download_names.contains(&"account_ref".to_string())); + + let indexes = db + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='downloads'" + .to_string(), + )) + .await + .unwrap(); + let index_names: Vec = indexes + .iter() + .map(|row| row.try_get_by_index::(0).unwrap()) + .collect(); + assert!(index_names.contains(&"idx_downloads_account_ref".to_string())); + } + + #[tokio::test] + async fn test_premium_account_migration_backfills_legacy_download_association() { + let sqlite_opts = sea_orm::sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .pragma("foreign_keys", "ON"); + let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with(sqlite_opts) + .await + .unwrap(); + let db = sea_orm::SqlxSqliteConnector::from_sqlx_sqlite_pool(pool); + Migrator::up(&db, Some(9)) + .await + .expect("first 9 migrations"); + + db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "INSERT INTO downloads (id, url, file_name, state, priority, queue_position, downloaded_bytes, speed_bytes_per_sec, retry_count, max_retries, segments_count, source_hostname, protocol, resume_supported, account_id, destination_path, created_at, updated_at) VALUES (1, 'https://example.com/f.zip', 'f.zip', 'Queued', 5, 0, 0, 0, 0, 5, 1, 'example.com', 'https', 0, 7, '/tmp/f.zip', 1, 1)" + .to_string(), + )) + .await + .expect("seed legacy download"); + + Migrator::up(&db, None) + .await + .expect("premium wiring migration"); + + let row = db + .query_one(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT account_ref FROM downloads WHERE id = 1".to_string(), + )) + .await + .unwrap() + .expect("download remains"); + assert_eq!(row.try_get_by_index::(0).unwrap(), "7"); } #[tokio::test] diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs index fa64be4d..9335b5d4 100644 --- a/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs +++ b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs @@ -1,3 +1,4 @@ +use sea_orm::{ConnectionTrait, Statement}; use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] @@ -34,10 +35,37 @@ impl MigrationTrait for Migration { .add_column(ColumnDef::new(Downloads::AccountRef).text().null()) .to_owned(), ) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "UPDATE downloads SET account_ref = CAST(account_id AS TEXT) WHERE account_id IS NOT NULL" + .to_string(), + )) + .await?; + + manager + .create_index( + Index::create() + .name("idx_downloads_account_ref") + .table(Downloads::Table) + .col(Downloads::AccountRef) + .to_owned(), + ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_downloads_account_ref") + .table(Downloads::Table) + .to_owned(), + ) + .await?; manager .alter_table( Table::alter() diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index 6d532794..d76af40d 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -10,7 +10,8 @@ use tokio::sync::Semaphore; use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::services::{AccountRotator, AccountSelector}; -use crate::domain::model::account::AccountId; +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountId}; use crate::domain::model::config::{ DEFAULT_LINK_CHECK_PARALLELISM, normalize_link_check_parallelism, }; @@ -260,6 +261,10 @@ impl CommandBus { self.account_validator.as_deref() } + pub(crate) fn account_validator_arc(&self) -> Option> { + self.account_validator.clone() + } + pub fn account_selector(&self) -> Option<&AccountSelector> { self.account_selector.as_deref() } @@ -268,6 +273,17 @@ impl CommandBus { self.account_rotator.as_deref() } + pub(crate) fn save_account_availability( + &self, + repo: &dyn AccountRepository, + account: &Account, + ) -> Result<(), DomainError> { + match self.account_rotator() { + Some(rotator) => rotator.save_account(account), + None => repo.save(account), + } + } + pub(crate) fn account_operation_lock( &self, id: &AccountId, diff --git a/src-tauri/src/application/commands/add_account.rs b/src-tauri/src/application/commands/add_account.rs index abcf22b1..c88b0227 100644 --- a/src-tauri/src/application/commands/add_account.rs +++ b/src-tauri/src/application/commands/add_account.rs @@ -15,7 +15,9 @@ use crate::application::error::AppError; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountId}; -use super::validate_account::{apply_validation, publish_validation, validate_credentials}; +use super::validate_account::{ + apply_validation, publish_validation, validate_credentials_blocking, +}; impl CommandBus { pub async fn handle_add_account( @@ -78,12 +80,15 @@ impl CommandBus { return Err(e.into()); } - let validation = self - .account_validator() - .map(|validator| validate_credentials(validator, &account, &cmd.password)); + let validation = match self.account_validator_arc() { + Some(validator) => { + Some(validate_credentials_blocking(validator, account.clone(), cmd.password).await?) + } + None => None, + }; if let Some(attempt) = &validation { let validated = apply_validation(&account, &attempt.outcome, cmd.created_at_ms); - if let Err(error) = repo.save(&validated) { + if let Err(error) = self.save_account_availability(repo, &validated) { if let Err(rollback_error) = repo.delete(&id) { tracing::warn!( account_id = %id.as_str(), diff --git a/src-tauri/src/application/commands/premium_account_resolution.rs b/src-tauri/src/application/commands/premium_account_resolution.rs index d08824ce..935d2001 100644 --- a/src-tauri/src/application/commands/premium_account_resolution.rs +++ b/src-tauri/src/application/commands/premium_account_resolution.rs @@ -3,7 +3,7 @@ use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountStatus}; use crate::domain::model::credential::Credential; -use crate::domain::ports::driven::ExtractedHosterLink; +use crate::domain::ports::driven::{ExtractedHosterLink, ResolutionCancellation}; use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; @@ -11,18 +11,22 @@ impl ResolvePremiumSourceHandler { pub(super) fn resolve_locked( &self, command: ResolvePremiumSourceCommand, + cancellation: &ResolutionCancellation, ) -> Result { + cancellation.ensure_active()?; let mut account = self.load_available_account(&command)?; - let credential = self.load_credential(&mut account)?; + let credential = self.load_credential(&mut account, cancellation)?; let link = match self.plugins.extract_hoster_link( &command.service_name, &command.source_url, Some(&credential), ) { Ok(link) => link, - Err(error) => return self.reject_plugin_failure(&mut account, error), + Err(error) => { + return self.reject_plugin_failure(&mut account, error, cancellation); + } }; - self.persist_success(&mut account, &link)?; + self.persist_success(&mut account, &link, cancellation)?; Ok(link) } @@ -43,16 +47,24 @@ impl ResolvePremiumSourceHandler { Ok(account) } - fn load_credential(&self, account: &mut Account) -> Result { + fn load_credential( + &self, + account: &mut Account, + cancellation: &ResolutionCancellation, + ) -> Result { let Some(password) = self.credentials.get_password(account.id())? else { account.set_status(AccountStatus::MissingCredential); - self.repo.save(account)?; - self.publish_failure(account, "Account credential is unavailable"); + cancellation.run_if_active(|| { + self.rotator.save_account(account)?; + self.publish_failure(account, "Account credential is unavailable"); + Ok(()) + })?; return Err(DomainError::NotFound(format!( "credential for account {}", account.id().as_str() ))); }; + cancellation.ensure_active()?; Ok(Credential::new(account.username(), password)) } @@ -60,6 +72,7 @@ impl ResolvePremiumSourceHandler { &self, account: &mut Account, error: DomainError, + cancellation: &ResolutionCancellation, ) -> Result { let Some(status) = status_for_plugin_error(&error) else { return Err(DomainError::PluginError( @@ -67,11 +80,11 @@ impl ResolvePremiumSourceHandler { )); }; apply_status(account, status, self.clock.now_unix_ms()); - self.repo.save(account)?; - if let Some(deadline) = account.exhausted_until() { - self.rotator.cache_exhausted(account.id(), deadline); - } - self.publish_typed_failure(account, status); + cancellation.run_if_active(|| { + self.rotator.save_account(account)?; + self.publish_typed_failure(account, status); + Ok(()) + })?; Err(error) } @@ -79,6 +92,7 @@ impl ResolvePremiumSourceHandler { &self, account: &mut Account, link: &ExtractedHosterLink, + cancellation: &ResolutionCancellation, ) -> Result<(), DomainError> { if link.direct_url.is_none() { return Err(DomainError::PluginError( @@ -96,17 +110,17 @@ impl ResolvePremiumSourceHandler { AccountStatus::QuotaExhausted, self.clock.now_unix_ms(), ); - self.repo.save(account)?; - if let Some(deadline) = account.exhausted_until() { - self.rotator.cache_exhausted(account.id(), deadline); - } - self.publish_typed_failure(account, AccountStatus::QuotaExhausted); + cancellation.run_if_active(|| { + self.rotator.save_account(account)?; + self.publish_typed_failure(account, AccountStatus::QuotaExhausted); + Ok(()) + })?; return Err(DomainError::AccountQuotaExceeded); } } } account.set_status(AccountStatus::Valid); - self.repo.save(account)?; + cancellation.run_if_active(|| self.rotator.save_account(account))?; Ok(()) } diff --git a/src-tauri/src/application/commands/premium_account_rotation.rs b/src-tauri/src/application/commands/premium_account_rotation.rs index b1b8e4e4..9986d83e 100644 --- a/src-tauri/src/application/commands/premium_account_rotation.rs +++ b/src-tauri/src/application/commands/premium_account_rotation.rs @@ -3,7 +3,9 @@ use crate::application::services::account_rotator::NextAccountOutcome; use crate::domain::error::DomainError; use crate::domain::model::account::AccountId; use crate::domain::model::download::Download; -use crate::domain::ports::driven::{ExtractedHosterLink, ResolvedDownloadSource}; +use crate::domain::ports::driven::{ + ExtractedHosterLink, ResolutionCancellation, ResolvedDownloadSource, +}; use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; @@ -11,7 +13,9 @@ impl ResolvePremiumSourceHandler { pub(super) fn resolve_download( &self, download: &Download, + cancellation: &ResolutionCancellation, ) -> Result { + cancellation.ensure_active()?; let service = download.module_name().ok_or_else(|| { DomainError::ValidationError("premium download has no plugin association".into()) })?; @@ -22,12 +26,16 @@ impl ResolvePremiumSourceHandler { let mut last_error = None; for _ in 0..attempts { - match self.resolve_once(download, service, &account_id) { - Ok(link) => return sensitive_source(link), + cancellation.ensure_active()?; + match self.resolve_once(download, service, &account_id, cancellation) { + Ok(link) => { + cancellation.ensure_active()?; + return sensitive_source(link); + } Err(error) if is_rotatable(&error) => last_error = Some(error), Err(error) => return Err(error), } - account_id = match self.next_account(service)? { + account_id = match self.next_account(service, cancellation)? { NextAccountOutcome::Picked(account) => account.id().clone(), NextAccountOutcome::AllExhausted { reason, .. } => { return Err(last_error.unwrap_or_else(|| reason.into_domain_error())); @@ -46,6 +54,7 @@ impl ResolvePremiumSourceHandler { download: &Download, service: &str, account_id: &AccountId, + cancellation: &ResolutionCancellation, ) -> Result { let lock = self.account_lock(account_id)?; let _guard = lock.blocking_lock(); @@ -54,39 +63,44 @@ impl ResolvePremiumSourceHandler { service.to_string(), download.url().as_str().to_string(), ); - let link = self.resolve_locked(command)?; - if let Some(expected) = download - .account_id() - .filter(|expected| *expected != account_id) - { - let updated = self.downloads.compare_and_set_account_reference( - download.id(), - expected, - account_id, - )?; - if !updated { - let current = self.downloads.find_by_id(download.id())?; - match current { - None => { - return Err(DomainError::NotFound(format!( - "download {}", - download.id().0 - ))); - } - Some(current) if current.account_id() == Some(account_id) => {} - Some(_) => { - return Err(DomainError::ValidationError( - "download account association changed during resolution".into(), - )); - } + let link = self.resolve_locked(command, cancellation)?; + let expected = download.account_id().ok_or_else(|| { + DomainError::ValidationError("premium download has no account association".into()) + })?; + let updated = cancellation.run_if_active(|| { + self.downloads + .compare_and_set_account_reference(download.id(), expected, account_id) + })?; + if !updated { + let current = self.downloads.find_by_id(download.id())?; + match current { + None => { + return Err(DomainError::NotFound(format!( + "download {}", + download.id().0 + ))); + } + Some(current) if current.account_id() == Some(account_id) => {} + Some(_) => { + return Err(DomainError::ValidationError( + "download account association changed during resolution".into(), + )); } } } - self.publish_success(account_id); + cancellation.run_if_active(|| { + self.publish_success(account_id); + Ok(()) + })?; Ok(link) } - fn next_account(&self, service: &str) -> Result { + fn next_account( + &self, + service: &str, + cancellation: &ResolutionCancellation, + ) -> Result { + cancellation.ensure_active()?; let strategy = self.config.get_config()?.account_selection_strategy; self.rotator .next_account(service, strategy) @@ -102,15 +116,15 @@ fn sensitive_source(link: ExtractedHosterLink) -> Result bool { - matches!( - error, + match error { DomainError::AccountInvalidCredentials - | DomainError::AccountExpired - | DomainError::AccountCooldown - | DomainError::AccountQuotaExceeded - | DomainError::NotFound(_) - | DomainError::ValidationError(_) - ) + | DomainError::AccountExpired + | DomainError::AccountCooldown + | DomainError::AccountQuotaExceeded => true, + DomainError::NotFound(message) => message.starts_with("credential for account "), + DomainError::ValidationError(message) => message == "premium account is unavailable", + _ => false, + } } fn app_error_to_domain(error: AppError) -> DomainError { diff --git a/src-tauri/src/application/commands/redownload.rs b/src-tauri/src/application/commands/redownload.rs index fc103be1..5036be9f 100644 --- a/src-tauri/src/application/commands/redownload.rs +++ b/src-tauri/src/application/commands/redownload.rs @@ -463,7 +463,9 @@ mod tests { let history: Arc = Arc::new(InMemoryHistoryRepo::new()); let original = completed_download(10).with_account_id(AccountId::new("missing")); repo.save(&original).unwrap(); - let bus = make_bus(repo, events, history); + let bus = make_bus(repo, events, history) + .with_account_repo(Arc::new(InMemoryAccountRepo::new())) + .with_account_credential_store(Arc::new(FakeAccountCredentialStore::new())); let error = bus .handle_redownload(RedownloadCommand { @@ -473,7 +475,7 @@ mod tests { .await .expect_err("orphaned account must not be copied"); - assert!(matches!(error, AppError::Validation(_))); + assert!(matches!(error, AppError::NotFound(_))); } #[tokio::test] diff --git a/src-tauri/src/application/commands/resolve_premium_source.rs b/src-tauri/src/application/commands/resolve_premium_source.rs index 412be684..6eb79a3e 100644 --- a/src-tauri/src/application/commands/resolve_premium_source.rs +++ b/src-tauri/src/application/commands/resolve_premium_source.rs @@ -9,7 +9,8 @@ use crate::domain::model::account::AccountId; use crate::domain::model::download::Download; use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, Clock, ConfigStore, DownloadRepository, - DownloadSourceResolver, EventBus, ExtractedHosterLink, PluginLoader, ResolvedDownloadSource, + DownloadSourceResolver, EventBus, ExtractedHosterLink, PluginLoader, ResolutionCancellation, + ResolvedDownloadSource, }; use crate::domain::ports::driving::Command; @@ -37,6 +38,7 @@ impl ResolvePremiumSourceCommand { impl Command for ResolvePremiumSourceCommand {} +#[derive(Clone)] pub struct ResolvePremiumSourceHandler { repo: Arc, credentials: Arc, @@ -80,9 +82,15 @@ impl ResolvePremiumSourceHandler { command: ResolvePremiumSourceCommand, ) -> Result { let account_id = command.account_id.clone(); - let lock = self.account_lock(&command.account_id)?; - let _guard = lock.lock().await; - let link = self.resolve_locked(command)?; + let handler = self.clone(); + let cancellation = ResolutionCancellation::default(); + let link = tokio::task::spawn_blocking(move || { + let lock = handler.account_lock(&command.account_id)?; + let _guard = lock.blocking_lock(); + handler.resolve_locked(command, &cancellation) + }) + .await + .map_err(|_| DomainError::PluginError("premium source resolver stopped".into()))??; self.publish_success(&account_id); Ok(link) } @@ -96,7 +104,15 @@ impl ResolvePremiumSourceHandler { impl DownloadSourceResolver for ResolvePremiumSourceHandler { fn resolve(&self, download: &Download) -> Result { - self.resolve_download(download) + self.resolve_download(download, &ResolutionCancellation::default()) + } + + fn resolve_cancellable( + &self, + download: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + self.resolve_download(download, cancellation) } } diff --git a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs index 916ef919..5037df8f 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs @@ -13,7 +13,7 @@ use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, Url}; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::{ - AccountRepository, Clock, ConfigStore, ExtractedHosterLink, PluginLoader, + AccountRepository, Clock, ConfigStore, DownloadRepository, ExtractedHosterLink, PluginLoader, }; use super::ResolvePremiumSourceHandler; @@ -181,7 +181,7 @@ pub(super) fn handler_with_downloads( credentials: Arc, plugin: Arc, events: Arc, - downloads: Arc, + downloads: Arc, ) -> Arc { let clock: Arc = Arc::new(FixedClock); let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); diff --git a/src-tauri/src/application/commands/resolve_premium_source_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_tests.rs index 955284d1..cf5ead4c 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_tests.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_tests.rs @@ -1,5 +1,6 @@ -use std::sync::Arc; use std::sync::atomic::Ordering; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; use super::*; use crate::application::commands::tests_support::{ @@ -24,8 +25,16 @@ async fn resolves_the_direct_url_only_when_the_engine_requests_it() { let account = valid_account("account-1"); repo.save(&account).unwrap(); credentials.store_password(account.id(), "api-key").unwrap(); - let resolver = handler(repo.clone(), credentials, plugin.clone(), events.clone()); let download = download(account.id().clone()); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + downloads.seed(download.clone()); + let resolver = handler_with_downloads( + repo.clone(), + credentials, + plugin.clone(), + events.clone(), + downloads, + ); let source = tokio::task::spawn_blocking(move || resolver.resolve(&download)) .await @@ -129,7 +138,113 @@ async fn assert_jit_rotation( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_jit_rotation_does_not_recreate_a_deleted_download() { +async fn rotates_across_two_failed_accounts_before_committing_the_third() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("a-primary"); + let secondary = valid_account("b-secondary"); + let tertiary = valid_account("c-tertiary"); + for account in [&primary, &secondary, &tertiary] { + repo.save(account).unwrap(); + } + credentials + .store_password(primary.id(), "quota-key") + .unwrap(); + credentials + .store_password(secondary.id(), "quota-key") + .unwrap(); + credentials + .store_password(tertiary.id(), "working-key") + .unwrap(); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let queued = download(primary.id().clone()); + let download_id = queued.id(); + downloads.seed(queued.clone()); + let resolver = handler_with_downloads( + repo, + credentials, + plugin.clone(), + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + let source = tokio::task::spawn_blocking(move || resolver.resolve(&queued)) + .await + .unwrap() + .expect("third account resolves the source"); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert_eq!( + plugin + .calls + .lock() + .unwrap() + .iter() + .map(|call| call.2.as_str()) + .collect::>(), + ["quota-key", "quota-key", "working-key"] + ); + assert_eq!( + downloads + .find_by_id(download_id) + .unwrap() + .unwrap() + .account_id(), + Some(tertiary.id()) + ); +} + +struct CasBarrierDownloadRepo { + inner: InMemoryDownloadRepo, + entered: Mutex>>, + release: Arc<(Mutex, Condvar)>, +} + +impl DownloadRepository for CasBarrierDownloadRepo { + fn find_by_id( + &self, + id: crate::domain::model::download::DownloadId, + ) -> Result, DomainError> { + self.inner.find_by_id(id) + } + + fn save(&self, download: &crate::domain::model::download::Download) -> Result<(), DomainError> { + self.inner.save(download) + } + + fn delete(&self, id: crate::domain::model::download::DownloadId) -> Result<(), DomainError> { + self.inner.delete(id) + } + + fn compare_and_set_account_reference( + &self, + id: crate::domain::model::download::DownloadId, + expected: &crate::domain::model::account::AccountId, + replacement: &crate::domain::model::account::AccountId, + ) -> Result { + if let Some(entered) = self.entered.lock().unwrap().take() { + entered.send(()).unwrap(); + } + let (released, condition) = &*self.release; + let mut released = released.lock().unwrap(); + while !*released { + released = condition.wait(released).unwrap(); + } + self.inner + .compare_and_set_account_reference(id, expected, replacement) + } + + fn find_by_state( + &self, + state: crate::domain::model::download::DownloadState, + ) -> Result, DomainError> { + self.inner.find_by_state(state) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_jit_rotation_does_not_recreate_a_concurrently_deleted_download() { let repo = Arc::new(InMemoryAccountRepo::new()); let credentials = Arc::new(FakeAccountCredentialStore::new()); let plugin = Arc::new(DirectUrlPlugin::new()); @@ -143,23 +258,99 @@ async fn test_jit_rotation_does_not_recreate_a_deleted_download() { credentials .store_password(backup.id(), "working-key") .unwrap(); - let downloads = Arc::new(InMemoryDownloadRepo::new()); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let downloads = Arc::new(CasBarrierDownloadRepo { + inner: InMemoryDownloadRepo::new(), + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + }); let stale_snapshot = download(primary.id().clone()); let download_id = stale_snapshot.id(); + downloads.save(&stale_snapshot).unwrap(); let resolver = handler_with_downloads( repo, credentials, - plugin, + plugin.clone(), Arc::new(CapturingEventBus::new()), downloads.clone(), ); - let result = tokio::task::spawn_blocking(move || resolver.resolve(&stale_snapshot)) + let resolving = tokio::task::spawn_blocking(move || resolver.resolve(&stale_snapshot)); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) .await - .unwrap(); + .unwrap() + .expect("rotation reached account CAS"); + downloads.delete(download_id).unwrap(); + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + let result = resolving.await.unwrap(); assert!(matches!(result, Err(DomainError::NotFound(_)))); assert!(downloads.find_by_id(download_id).unwrap().is_none()); + assert_eq!(plugin.calls.lock().unwrap().len(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn initial_success_rejects_a_concurrent_account_reassociation_without_rotating() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("primary"); + let replacement = valid_account("replacement"); + repo.save(&primary).unwrap(); + repo.save(&replacement).unwrap(); + credentials + .store_password(primary.id(), "working-key") + .unwrap(); + credentials + .store_password(replacement.id(), "working-key") + .unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let downloads = Arc::new(CasBarrierDownloadRepo { + inner: InMemoryDownloadRepo::new(), + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + }); + let snapshot = download(primary.id().clone()); + let download_id = snapshot.id(); + downloads.save(&snapshot).unwrap(); + let resolver = handler_with_downloads( + repo, + credentials, + plugin.clone(), + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + let resolving = tokio::task::spawn_blocking(move || resolver.resolve(&snapshot)); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) + .await + .unwrap() + .expect("resolution reached account CAS"); + assert!( + downloads + .inner + .compare_and_set_account_reference(download_id, primary.id(), replacement.id()) + .unwrap() + ); + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + + let error = resolving.await.unwrap().expect_err("association conflict"); + assert!(matches!(error, DomainError::ValidationError(_))); + assert_eq!(plugin.calls.lock().unwrap().len(), 1); + assert_eq!( + downloads + .find_by_id(download_id) + .unwrap() + .unwrap() + .account_id(), + Some(replacement.id()) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index 279b8924..b723d322 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -133,7 +133,7 @@ impl CommandBus { } if store.get_password(account_id)?.is_none() { account.set_status(AccountStatus::MissingCredential); - repo.save(&account)?; + self.save_account_availability(repo, &account)?; self.event_bus() .publish(DomainEvent::AccountValidationFailed { id: account_id.clone(), diff --git a/src-tauri/src/application/commands/update_account.rs b/src-tauri/src/application/commands/update_account.rs index 89d242d8..d10bb260 100644 --- a/src-tauri/src/application/commands/update_account.rs +++ b/src-tauri/src/application/commands/update_account.rs @@ -16,7 +16,7 @@ use crate::domain::model::account::Account; use crate::domain::ports::driven::ValidationOutcome; use super::validate_account::{ - apply_validation, publish_validation, sync_validation_availability, validate_credentials, + apply_validation, publish_validation, validate_credentials_blocking, validation_error_to_app, }; impl CommandBus { @@ -82,13 +82,15 @@ impl CommandBus { None }; - let validation = if let Some(validator) = self.account_validator() { + let validation = if let Some(validator) = self.account_validator_arc() { let password = match cmd.patch.password.as_deref() { Some(password) => Some(password.to_string()), None => store.get_password(&cmd.id)?, }; let attempt = match password { - Some(password) => validate_credentials(validator, &next, &password), + Some(password) => { + validate_credentials_blocking(validator, next.clone(), password).await? + } None => super::validate_account::AccountValidationAttempt { outcome: ValidationOutcome::rejected( crate::domain::model::account::AccountStatus::MissingCredential, @@ -110,11 +112,11 @@ impl CommandBus { return Err(error.into()); } - if let Err(error) = repo.save(&next) { + if let Err(error) = self.save_account_availability(repo, &next) { if cmd.patch.password.is_some() { restore_password(store, &cmd.id, previous_password.as_deref(), &error); } - if let Err(rollback_error) = repo.save(&account) { + if let Err(rollback_error) = self.save_account_availability(repo, &account) { tracing::warn!( account_id = %cmd.id.as_str(), save_error = %error, @@ -124,14 +126,13 @@ impl CommandBus { } return Err(error.into()); } - if let Some(attempt) = &validation { - sync_validation_availability(self, &cmd.id, &attempt.outcome); - } - self.event_bus() .publish(DomainEvent::AccountUpdated { id: cmd.id.clone() }); if let Some(attempt) = validation { publish_validation(self, cmd.id, &attempt.outcome); + if let Some(error) = attempt.error { + return Err(validation_error_to_app(error)); + } } Ok(()) } @@ -273,6 +274,48 @@ mod tests { assert_eq!(stored.last_validated(), Some(1_800_000_000_000)); } + #[tokio::test] + async fn test_update_account_returns_validator_infrastructure_error_after_persisting_outcome() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Storage("upstream unavailable".into()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events.clone(), Some(validator), None); + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "old-key")) + .await + .expect("account remains configured"); + + let error = bus + .handle_update_account(UpdateAccountCommand { + id: id.clone(), + now_ms: 1_800_000_000_000, + patch: AccountPatch { + password: Some("new-key".into()), + ..AccountPatch::default() + }, + }) + .await + .expect_err("validator infrastructure failure must surface"); + + assert!(matches!( + error, + AppError::Domain(DomainError::StorageError(_)) + )); + assert_eq!( + repo.find_by_id(&id).unwrap().unwrap().status(), + AccountStatus::Error + ); + assert!(events.snapshot().iter().any(|event| matches!( + event, + DomainEvent::AccountValidationFailed { id: event_id, .. } if event_id == &id + ))); + } + #[tokio::test] async fn test_update_account_unknown_id_returns_not_found() { let repo = Arc::new(InMemoryAccountRepo::new()); diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index 76057086..6539c4b7 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -8,6 +8,8 @@ //! (full latency + traffic readout) without re-reading the row. use super::ValidationOutcomeDto; +use std::sync::Arc; + use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::application::services::account_state::{apply_status, status_for_plugin_error}; @@ -41,6 +43,22 @@ pub(super) fn validate_credentials( } } +pub(super) async fn validate_credentials_blocking( + validator: Arc, + account: Account, + password: String, +) -> Result { + tokio::task::spawn_blocking(move || { + validate_credentials(validator.as_ref(), &account, &password) + }) + .await + .map_err(|_| { + AppError::Domain(DomainError::PluginError( + "account validation worker stopped".into(), + )) + }) +} + fn typed_rejection(status: AccountStatus, error: DomainError) -> AccountValidationAttempt { AccountValidationAttempt { outcome: ValidationOutcome::rejected(status, error.to_string()), @@ -48,6 +66,13 @@ fn typed_rejection(status: AccountStatus, error: DomainError) -> AccountValidati } } +pub(super) fn validation_error_to_app(error: DomainError) -> AppError { + match error { + DomainError::NotFound(message) => AppError::NotFound(message), + other => other.into(), + } +} + pub(super) fn apply_validation( account: &Account, outcome: &ValidationOutcome, @@ -93,18 +118,6 @@ pub(super) fn publish_validation( } } -pub(super) fn sync_validation_availability( - bus: &CommandBus, - id: &crate::domain::model::account::AccountId, - outcome: &ValidationOutcome, -) { - if outcome.is_valid() - && let Some(rotator) = bus.account_rotator() - { - rotator.clear_cached_exhausted(id); - } -} - impl CommandBus { pub async fn handle_validate_account( &self, @@ -117,7 +130,7 @@ impl CommandBus { AppError::Validation("account credential store not configured".into()) })?; let validator = self - .account_validator() + .account_validator_arc() .ok_or_else(|| AppError::Validation("account validator not configured".into()))?; let operation_lock = self.account_operation_lock(&cmd.id)?; let _operation_guard = operation_lock.lock().await; @@ -133,7 +146,10 @@ impl CommandBus { AccountStatus::MissingCredential, format!("no stored password for account {}", cmd.id.as_str()), ); - repo.save(&apply_validation(&account, &outcome, cmd.now_ms))?; + self.save_account_availability( + repo, + &apply_validation(&account, &outcome, cmd.now_ms), + )?; publish_validation(self, cmd.id.clone(), &outcome); return Err(AppError::NotFound( outcome.error_message.unwrap_or_default(), @@ -141,14 +157,15 @@ impl CommandBus { } }; - let attempt = validate_credentials(validator, &account, &password); - repo.save(&apply_validation(&account, &attempt.outcome, cmd.now_ms))?; - sync_validation_availability(self, &cmd.id, &attempt.outcome); + let attempt = validate_credentials_blocking(validator, account.clone(), password).await?; + self.save_account_availability( + repo, + &apply_validation(&account, &attempt.outcome, cmd.now_ms), + )?; publish_validation(self, cmd.id, &attempt.outcome); match attempt.error { - Some(DomainError::NotFound(message)) => Err(AppError::NotFound(message)), - Some(error) => Err(error.into()), + Some(error) => Err(validation_error_to_app(error)), None => Ok(attempt.outcome.into()), } } @@ -325,6 +342,64 @@ mod tests { ); } + #[tokio::test] + async fn blocking_plugin_validation_does_not_stall_the_async_worker() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let validator = Arc::new(BlockingValidator { + entered: Mutex::new(None), + release: release.clone(), + }); + let bus = Arc::new(build_account_bus( + repo, + credentials, + Arc::new(CapturingEventBus::new()), + Some(validator), + None, + )); + let failsafe_release = release.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(500)); + let (released, condition) = &*failsafe_release; + *released.lock().expect("release mutex") = true; + condition.notify_all(); + }); + let validating = tokio::spawn(async move { + bus.handle_validate_account(ValidateAccountCommand { + id: AccountId::new("account-1"), + now_ms: 2, + }) + .await + }); + + let started = std::time::Instant::now(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + started.elapsed() < Duration::from_millis(250), + "synchronous plugin validation blocked the Tokio worker" + ); + + let (released, condition) = &*release; + *released.lock().expect("release mutex") = true; + condition.notify_all(); + validating + .await + .expect("validation join") + .expect("validation succeeds"); + } + #[tokio::test] async fn successful_validation_clears_the_rotator_cooldown_cache() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -376,6 +451,58 @@ mod tests { ); } + #[tokio::test] + async fn temporary_validation_failure_commits_the_rotator_exclusion_immediately() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::rejected( + AccountStatus::Cooldown, + "rate limited", + )), + ); + let mut account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + let clock: Arc = Arc::new(ValidationClock); + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock); + let bus = build_account_bus(repo, credentials, events, Some(validator), None) + .with_account_rotator(rotator.clone()); + + let outcome = bus + .handle_validate_account(ValidateAccountCommand { + id: account.id().clone(), + now_ms: 1_700_000_000_000, + }) + .await + .expect("typed cooldown is a validation outcome"); + + assert!(!outcome.valid); + assert!(rotator.is_exhausted(account.id()).unwrap()); + assert!(matches!( + rotator + .next_account( + account.service_name(), + crate::domain::model::account::AccountSelectionStrategy::BestTraffic, + ) + .unwrap(), + crate::application::services::account_rotator::NextAccountOutcome::AllExhausted { .. } + )); + } + #[tokio::test] async fn test_validate_account_unknown_service_returns_not_found() { let repo = Arc::new(InMemoryAccountRepo::new()); diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index 08a9c222..cdeda4cf 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -94,6 +94,10 @@ pub struct AccountRotator { repo: Arc, event_bus: Arc, clock: Arc, + /// Serializes persisted availability changes with account selection. + /// The cache and repository remain separate stores, but every transition + /// that can affect selection crosses this single boundary. + availability_transition: Mutex<()>, /// `account_id → cooldown deadline (Unix epoch ms)`. An entry whose /// deadline is `<= now_ms` is considered expired and pruned on the /// next read. This avoids needing a background sweeper. @@ -112,6 +116,7 @@ impl AccountRotator { repo, event_bus, clock, + availability_transition: Mutex::new(()), exhausted: Mutex::new(HashMap::new()), }) } @@ -128,6 +133,7 @@ impl AccountRotator { service_name: &str, strategy: AccountSelectionStrategy, ) -> Result { + let transition = self.lock_availability_transition()?; let now_ms = self.now_ms(); let mut exhausted_ids = self.snapshot_exhausted(now_ms)?; // Linearise with command-handler cache updates: @@ -147,23 +153,30 @@ impl AccountRotator { &exhausted_ids, )?; if let Some(account) = picked { - let still_available = { + let cache_available = { let guard = self.lock_exhausted()?; guard .get(account.id()) .is_none_or(|deadline| now_ms >= *deadline) }; - if still_available { + let persisted = self.repo.find_by_id(account.id())?; + if cache_available + && let Some(persisted) = persisted + && persisted.service_name() == service_name + && persisted.is_selectable(now_ms) + { // Emit AccountSelected only on the committed pick, not // on probes that lose the race to a parallel // a committed cooldown. Otherwise UI/telemetry would see // "selected" for an account never returned to the caller. + let selected_id = persisted.id().clone(); + drop(transition); self.event_bus.publish(DomainEvent::AccountSelected { - id: account.id().clone(), + id: selected_id, service_name: service_name.to_string(), strategy: strategy.to_string(), }); - return Ok(NextAccountOutcome::Picked(account)); + return Ok(NextAccountOutcome::Picked(persisted)); } exhausted_ids.push(account.id().clone()); continue; @@ -184,7 +197,9 @@ impl AccountRotator { let candidates = self.repo.list_by_service(service_name)?; let live: Vec<&Account> = candidates .iter() - .filter(|account| account.is_enabled() && !account.is_expired(now_ms)) + .filter(|account| { + account.is_enabled() && account.is_premium() && !account.is_expired(now_ms) + }) .filter(|account| match account.status() { AccountStatus::Valid => true, AccountStatus::QuotaExhausted | AccountStatus::Cooldown => { @@ -207,33 +222,30 @@ impl AccountRotator { }) } - /// Cache a cooldown only after a command handler committed it. - pub(crate) fn cache_exhausted(&self, account_id: &AccountId, deadline_ms: u64) { - let mut guard = match self.exhausted.lock() { - Ok(guard) => guard, - Err(poisoned) => { - tracing::warn!("recovering poisoned non-canonical account cooldown cache"); - poisoned.into_inner() + /// Persist an account and synchronize the selection cache atomically with + /// respect to `next_account`. On repository failure the cache is left + /// untouched, so the two stores never advertise an uncommitted state. + pub(crate) fn save_account(&self, account: &Account) -> Result<(), DomainError> { + let _transition = self.availability_transition.lock().map_err(|_| { + DomainError::StorageError("account availability transition mutex poisoned".into()) + })?; + let mut exhausted = self + .exhausted + .lock() + .map_err(|_| DomainError::StorageError("exhausted accounts mutex poisoned".into()))?; + self.repo.save(account)?; + match account + .exhausted_until() + .filter(|deadline| self.now_ms() < *deadline) + { + Some(deadline) => { + exhausted.insert(account.id().clone(), deadline); } - }; - guard.insert(account_id.clone(), deadline_ms); - } - - /// Clear only the non-canonical in-memory cooldown cache. - /// - /// Command handlers use this after atomically saving an aggregate whose - /// validated status already cleared the persisted deadline. Keeping this - /// operation repository-free avoids a second save that could fail after - /// the validated state was committed. - pub(crate) fn clear_cached_exhausted(&self, account_id: &AccountId) { - let mut guard = match self.exhausted.lock() { - Ok(guard) => guard, - Err(poisoned) => { - tracing::warn!("recovering poisoned non-canonical account cooldown cache"); - poisoned.into_inner() + None => { + exhausted.remove(account.id()); } - }; - guard.remove(account_id); + } + Ok(()) } /// `true` when `account_id` has an active cooldown at the current @@ -242,6 +254,7 @@ impl AccountRotator { /// `snapshot_exhausted`. The check is read-only by design so it can /// be called from log paths without surprising state changes. pub fn is_exhausted(&self, account_id: &AccountId) -> Result { + let _transition = self.lock_availability_transition()?; let now_ms = self.now_ms(); let guard = self.lock_exhausted()?; let in_memory = guard @@ -318,6 +331,12 @@ impl AccountRotator { .map_err(|_| AppError::Validation("exhausted accounts mutex poisoned".to_string())) } + fn lock_availability_transition(&self) -> Result, AppError> { + self.availability_transition.lock().map_err(|_| { + AppError::Validation("account availability transition mutex poisoned".to_string()) + }) + } + fn now_ms(&self) -> u64 { self.clock.now_unix_secs().saturating_mul(1_000) } @@ -331,17 +350,20 @@ mod tests { use crate::domain::model::account::{Account, AccountStatus, AccountType}; use crate::domain::ports::driven::AccountRepository; use std::sync::Mutex as StdMutex; + use std::sync::atomic::{AtomicBool, Ordering}; // --- Inline mocks (mirroring account_selector tests) --- struct InMemoryRepo { accounts: StdMutex>, + fail_saves: AtomicBool, } impl InMemoryRepo { fn new(accounts: Vec) -> Self { Self { accounts: StdMutex::new(accounts), + fail_saves: AtomicBool::new(false), } } } @@ -358,6 +380,9 @@ mod tests { } fn save(&self, account: &Account) -> Result<(), DomainError> { + if self.fail_saves.load(Ordering::SeqCst) { + return Err(DomainError::StorageError("save failed".into())); + } let mut guard = self.accounts.lock().unwrap(); if let Some(existing) = guard.iter_mut().find(|a| a.id() == account.id()) { *existing = account.clone(); @@ -497,17 +522,15 @@ mod tests { let proposed = self.now_ms().saturating_add(ttl_secs.saturating_mul(1_000)); let deadline = account.exhausted_until().unwrap_or_default().max(proposed); account.mark_exhausted(deadline); - self.repo.save(&account)?; - self.cache_exhausted(account_id, deadline); + self.save_account(&account)?; Ok(()) } fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError> { if let Some(mut account) = self.repo.find_by_id(account_id)? { account.clear_exhausted(); - self.repo.save(&account)?; + self.save_account(&account)?; } - self.clear_cached_exhausted(account_id); Ok(()) } @@ -536,6 +559,120 @@ mod tests { (rotator, bus, clock) } + struct PersistThenBlockRepo { + account: StdMutex, + entered: StdMutex>>, + release: Arc<(StdMutex, std::sync::Condvar)>, + } + + impl AccountRepository for PersistThenBlockRepo { + fn find_by_id(&self, id: &AccountId) -> Result, DomainError> { + let account = self.account.lock().unwrap(); + Ok((account.id() == id).then(|| account.clone())) + } + + fn save(&self, account: &Account) -> Result<(), DomainError> { + *self.account.lock().unwrap() = account.clone(); + if let Some(entered) = self.entered.lock().unwrap().take() { + entered.send(()).unwrap(); + } + let (released, condition) = &*self.release; + let mut released = released.lock().unwrap(); + while !*released { + released = condition.wait(released).unwrap(); + } + Ok(()) + } + + fn list(&self) -> Result, DomainError> { + Ok(vec![self.account.lock().unwrap().clone()]) + } + + fn list_by_service(&self, service_name: &str) -> Result, DomainError> { + Ok(self + .list()? + .into_iter() + .filter(|account| account.service_name() == service_name) + .collect()) + } + + fn delete(&self, _: &AccountId) -> Result<(), DomainError> { + Ok(()) + } + } + + #[test] + fn selection_waits_until_persisted_exhaustion_and_cache_commit_together() { + let initial = account("a", "Uploaded", Some(50), true); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((StdMutex::new(false), std::sync::Condvar::new())); + let repo = Arc::new(PersistThenBlockRepo { + account: StdMutex::new(initial.clone()), + entered: StdMutex::new(Some(entered_tx)), + release: release.clone(), + }); + let bus = Arc::new(CollectingBus::new()); + let clock = TestClock::new(1_700_000_000); + let selector = AccountSelector::new(repo.clone(), bus.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo, bus, clock); + let mut exhausted = initial; + exhausted.mark_exhausted(1_700_000_600_000); + + let saving_rotator = rotator.clone(); + let saving = std::thread::spawn(move || saving_rotator.save_account(&exhausted)); + entered_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("persisted row reached pre-cache gap"); + let (selected_tx, selected_rx) = std::sync::mpsc::channel(); + let selecting_rotator = rotator.clone(); + let selecting = std::thread::spawn(move || { + selected_tx + .send( + selecting_rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic), + ) + .unwrap(); + }); + assert!( + selected_rx + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err(), + "selection must wait for the coordinated cache update" + ); + + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + saving.join().unwrap().unwrap(); + let outcome = selected_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("selection completes") + .unwrap(); + selecting.join().unwrap(); + assert!(matches!(outcome, NextAccountOutcome::AllExhausted { .. })); + } + + #[test] + fn failed_exhaustion_persistence_leaves_the_selection_cache_unchanged() { + let initial = account("a", "Uploaded", Some(50), true); + let repo = Arc::new(InMemoryRepo::new(vec![initial.clone()])); + let bus = Arc::new(CollectingBus::new()); + let clock = TestClock::new(1_700_000_000); + let selector = AccountSelector::new(repo.clone(), bus.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), bus, clock); + let mut exhausted = initial; + exhausted.mark_exhausted(1_700_000_600_000); + repo.fail_saves.store(true, Ordering::SeqCst); + + assert!(rotator.save_account(&exhausted).is_err()); + repo.fail_saves.store(false, Ordering::SeqCst); + + let outcome = rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .unwrap(); + assert!(matches!(outcome, NextAccountOutcome::Picked(_))); + } + // --- AC #1: 429 → rotation vers 2ème account visible --- #[test] fn test_mark_exhausted_routes_next_account_to_remaining_candidate() { diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index bca1fa9a..d385f898 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -321,7 +321,7 @@ impl Account { } pub fn is_selectable(&self, now_ms: u64) -> bool { - if !self.enabled || self.is_expired(now_ms) { + if !self.enabled || !self.is_premium() || self.is_expired(now_ms) { return false; } match self.status { @@ -583,7 +583,13 @@ mod tests { #[test] fn test_only_valid_or_elapsed_cooldown_accounts_are_selectable() { - let mut account = make_account(); + let mut account = Account::new( + AccountId::new("premium-1"), + "ExampleHost".to_string(), + "user@example.com".to_string(), + AccountType::Premium, + 1_700_000_000_000, + ); assert!(!account.is_selectable(1_000)); account.set_status(AccountStatus::Valid); @@ -601,6 +607,14 @@ mod tests { assert!(account.is_selectable(3_000)); } + #[test] + fn test_free_account_is_never_selectable_for_premium_resolution() { + let mut account = make_account(); + account.set_status(AccountStatus::Valid); + + assert!(!account.is_selectable(1_000)); + } + #[test] fn test_reconstruct_with_status_preserves_operational_state() { let account = Account::reconstruct_with_status( diff --git a/src-tauri/src/domain/ports/driven/download_repository.rs b/src-tauri/src/domain/ports/driven/download_repository.rs index 11a0504b..e92f693c 100644 --- a/src-tauri/src/domain/ports/driven/download_repository.rs +++ b/src-tauri/src/domain/ports/driven/download_repository.rs @@ -59,6 +59,14 @@ pub trait DownloadRepository: Send + Sync { fn find_by_state(&self, state: DownloadState) -> Result, DomainError>; /// Whether any persisted download still depends on an account. + /// Implementations must query independently of mutable download state so + /// a concurrent state transition cannot create a false negative. + #[cfg(not(test))] + fn has_account_reference(&self, account_id: &AccountId) -> Result; + + /// Unit-test fakes are allowed a compatibility scan so every focused + /// command test does not need an unrelated persistence primitive. + #[cfg(test)] fn has_account_reference(&self, account_id: &AccountId) -> Result { const STATES: [DownloadState; 9] = [ DownloadState::Queued, diff --git a/src-tauri/src/domain/ports/driven/download_source_resolver.rs b/src-tauri/src/domain/ports/driven/download_source_resolver.rs index d01e34ba..dbabe237 100644 --- a/src-tauri/src/domain/ports/driven/download_source_resolver.rs +++ b/src-tauri/src/domain/ports/driven/download_source_resolver.rs @@ -1,5 +1,7 @@ //! Resolves a persisted source URL into an ephemeral download capability. +use std::sync::{Arc, Mutex}; + use crate::domain::error::DomainError; use crate::domain::model::download::Download; @@ -24,8 +26,70 @@ impl ResolvedDownloadSource { } } +/// Linearizes cancellation with resolver-side persistence. The engine can +/// return promptly while a blocking plugin call is still unwinding, and the +/// resolver uses `run_if_active` around each later write so that detached work +/// cannot commit after cancellation won. +#[derive(Clone, Default)] +pub struct ResolutionCancellation { + cancelled: Arc>, +} + +impl ResolutionCancellation { + pub fn cancel(&self) { + let mut cancelled = self + .cancelled + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *cancelled = true; + } + + pub fn is_cancelled(&self) -> bool { + *self + .cancelled + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn ensure_active(&self) -> Result<(), DomainError> { + if self.is_cancelled() { + Err(cancelled_error()) + } else { + Ok(()) + } + } + + pub fn run_if_active( + &self, + operation: impl FnOnce() -> Result, + ) -> Result { + let cancelled = self.cancelled.lock().map_err(|_| { + DomainError::PluginError("premium source cancellation state unavailable".into()) + })?; + if *cancelled { + return Err(cancelled_error()); + } + operation() + } +} + +fn cancelled_error() -> DomainError { + DomainError::PluginError("premium source resolution cancelled".into()) +} + /// Called by the download engine immediately before opening the connection. /// Implementations must never persist or log the returned URL. pub trait DownloadSourceResolver: Send + Sync { fn resolve(&self, download: &Download) -> Result; + + fn resolve_cancellable( + &self, + download: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + cancellation.ensure_active()?; + let source = self.resolve(download)?; + cancellation.ensure_active()?; + Ok(source) + } } diff --git a/src-tauri/src/domain/ports/driven/hoster_link.rs b/src-tauri/src/domain/ports/driven/hoster_link.rs index eeaf58ba..796ffd9f 100644 --- a/src-tauri/src/domain/ports/driven/hoster_link.rs +++ b/src-tauri/src/domain/ports/driven/hoster_link.rs @@ -4,7 +4,7 @@ /// /// The adapter owns deserialisation of the plugin wire format. Application /// services receive this std-only type and never depend on JSON field names. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct ExtractedHosterLink { pub source_url: String, pub filename: Option, @@ -14,3 +14,40 @@ pub struct ExtractedHosterLink { pub traffic_used_bytes: Option, pub traffic_total_bytes: Option, } + +impl std::fmt::Debug for ExtractedHosterLink { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let direct_url = self.direct_url.as_ref().map(|_| ""); + formatter + .debug_struct("ExtractedHosterLink") + .field("source_url", &self.source_url) + .field("filename", &self.filename) + .field("size_bytes", &self.size_bytes) + .field("direct_url", &direct_url) + .field("traffic_used_bytes", &self.traffic_used_bytes) + .field("traffic_total_bytes", &self.traffic_total_bytes) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_output_redacts_the_direct_url() { + let link = ExtractedHosterLink { + source_url: "https://1fichier.com/?abc".into(), + filename: Some("file.zip".into()), + size_bytes: Some(42), + direct_url: Some("https://cdn.example/secret-token".into()), + traffic_used_bytes: None, + traffic_total_bytes: None, + }; + + let debug = format!("{link:?}"); + assert!(debug.contains("source_url")); + assert!(debug.contains("")); + assert!(!debug.contains("secret-token")); + } +} diff --git a/src-tauri/src/domain/ports/driven/mod.rs b/src-tauri/src/domain/ports/driven/mod.rs index b3278014..c9f1a6c2 100644 --- a/src-tauri/src/domain/ports/driven/mod.rs +++ b/src-tauri/src/domain/ports/driven/mod.rs @@ -42,7 +42,9 @@ pub use credential_store::CredentialStore; pub use download_engine::DownloadEngine; pub use download_read_repository::DownloadReadRepository; pub use download_repository::DownloadRepository; -pub use download_source_resolver::{DownloadSourceResolver, ResolvedDownloadSource}; +pub use download_source_resolver::{ + DownloadSourceResolver, ResolutionCancellation, ResolvedDownloadSource, +}; pub use event_bus::EventBus; pub use file_opener::FileOpener; pub use file_storage::FileStorage; diff --git a/src/views/AccountsView/__tests__/AccountRow.test.tsx b/src/views/AccountsView/__tests__/AccountRow.test.tsx index 1b35e863..4e4b5494 100644 --- a/src/views/AccountsView/__tests__/AccountRow.test.tsx +++ b/src/views/AccountsView/__tests__/AccountRow.test.tsx @@ -21,6 +21,7 @@ function renderRow(account: AccountView) { } afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -70,4 +71,27 @@ describe("AccountRow", () => { act(() => vi.advanceTimersByTime(1_001)); expect(screen.getByText("Expired")).toBeInTheDocument(); }); + + it("updates immediately when the deadline elapses before the effect is installed", () => { + const beforeDeadline = 1_700_000_000_999; + const deadline = beforeDeadline + 1; + vi.spyOn(Date, "now").mockReturnValueOnce(beforeDeadline).mockReturnValue(deadline); + + renderRow({ + id: "account-1", + serviceName: "vortex-mod-1fichier", + username: "alice", + accountType: "premium", + enabled: true, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + lastValidated: null, + createdAt: 1_699_999_000_000, + status: "cooldown", + exhaustedUntil: deadline, + }); + + expect(screen.getByText("Active")).toBeInTheDocument(); + }); }); diff --git a/src/views/AccountsView/useAccountStatusNow.ts b/src/views/AccountsView/useAccountStatusNow.ts index 1ef2acbe..d47b0726 100644 --- a/src/views/AccountsView/useAccountStatusNow.ts +++ b/src/views/AccountsView/useAccountStatusNow.ts @@ -12,7 +12,13 @@ export function useAccountStatusNow( useEffect(() => { const currentTime = Date.now(); - const nextDeadline = nextStatusDeadline(status, exhaustedUntil, validUntil, currentTime); + const deadlines = statusDeadlines(status, exhaustedUntil, validUntil); + if (deadlines.some((deadline) => nowMs < deadline && deadline <= currentTime)) { + setNowMs(currentTime); + return; + } + const nextDeadline = + deadlines.filter((deadline) => deadline > currentTime).sort((a, b) => a - b)[0] ?? null; if (nextDeadline === null) return; const timeout = window.setTimeout( () => setNowMs(Date.now()), @@ -24,17 +30,15 @@ export function useAccountStatusNow( return nowMs; } -function nextStatusDeadline( +function statusDeadlines( status: PersistedAccountStatus, exhaustedUntil: number | null, validUntil: number | null, - nowMs: number, -): number | null { - const deadlines = [ +): number[] { + return [ isTemporary(status) ? exhaustedUntil : null, validUntil === null ? null : validUntil + 1, - ].filter((deadline): deadline is number => deadline !== null && deadline > nowMs); - return deadlines.length === 0 ? null : Math.min(...deadlines); + ].filter((deadline): deadline is number => deadline !== null); } function isTemporary(status: PersistedAccountStatus): boolean { diff --git a/src/views/LinkGrabberView/LinkGrabberView.tsx b/src/views/LinkGrabberView/LinkGrabberView.tsx index 5359a5ed..6e1352fe 100644 --- a/src/views/LinkGrabberView/LinkGrabberView.tsx +++ b/src/views/LinkGrabberView/LinkGrabberView.tsx @@ -55,12 +55,9 @@ export function LinkGrabberView() { "link_check_online", ); - // Single source of truth for which URL represents a row across both - // duplicate detection and `download_start`. A redirected link with a - // `resolvedUrl` of the post-redirect canonical form must dedupe on - // that same canonical — otherwise the row could pass dedupe (since - // `originalUrl` is unique) yet `startDownload` re-queues a URL that - // already exists in active/history. + // Single source of truth for duplicate detection and in-batch + // de-duplication. `download_start` still receives `originalUrl` so a + // short-lived resolved token is never persisted or exposed over IPC. const getDuplicateKey = (link: ResolvedLink) => link.resolvedUrl ?? link.originalUrl; // Monotonic counter so a stale `link_detect_duplicates` response from @@ -340,12 +337,12 @@ export function LinkGrabberView() { const started = new Set(); for (const link of links) { if (!isStartable(link)) continue; - const url = getDuplicateKey(link); - if (!url) continue; - if (skipDuplicates && started.has(url)) continue; - started.add(url); + const duplicateKey = getDuplicateKey(link); + if (!duplicateKey || !link.originalUrl) continue; + if (skipDuplicates && started.has(duplicateKey)) continue; + started.add(duplicateKey); startDownload({ - url, + url: link.originalUrl, moduleName: link.moduleName, accountId: link.accountId, }); diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index b7b24ae1..901a70b6 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -191,7 +191,7 @@ describe("LinkGrabberView", () => { { id: "premium-link", originalUrl: sourceUrl, - resolvedUrl: sourceUrl, + resolvedUrl: directUrl, filename: "file.zip", sizeBytes: 42, status: "online", @@ -204,7 +204,7 @@ describe("LinkGrabberView", () => { if (command === "link_detect_duplicates") { return Promise.resolve([ { - url: sourceUrl, + url: directUrl, isDuplicate: false, source: null, existingId: null, @@ -221,7 +221,7 @@ describe("LinkGrabberView", () => { await user.click(screen.getByRole("button", { name: "Analyze Links" })); await waitFor(() => { expect(mockInvoke).toHaveBeenCalledWith("link_detect_duplicates", { - urls: [sourceUrl], + urls: [directUrl], }); }); @@ -234,7 +234,10 @@ describe("LinkGrabberView", () => { accountId: "account-uuid", }); }); - expect(JSON.stringify(mockInvoke.mock.calls)).not.toContain(directUrl); + const downloadStartCalls = mockInvoke.mock.calls.filter( + ([command]) => command === "download_start", + ); + expect(JSON.stringify(downloadStartCalls)).not.toContain(directUrl); }); it("should surface error toast on failure and success toast on retry", async () => { From 13123fd90e3d9da617d6d3e9b027e0977dfb3771 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:21:20 +0200 Subject: [PATCH 33/33] fix: address PR review comments --- CHANGELOG.md | 5 +- .../src/adapters/driven/plugin/registry.rs | 126 +++++++++++++++--- .../src/adapters/driven/sqlite/connection.rs | 4 +- .../m20260716_000010_wire_premium_accounts.rs | 10 -- .../src/application/commands/add_account.rs | 67 +++++++++- 5 files changed, 178 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcaf1b1..c109aa44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,8 +58,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 associations on every JIT resolution, and lets cancellation win without late writes. Plugin calls enforce enablement at credential injection, retained credential logs and bearer URL diagnostics stay redacted, plugin HTTP is - HTTPS-only, legacy account references are backfilled and indexed, and account - validation runs outside Tokio workers while preserving typed failures. + HTTPS-only, legacy numeric account IDs remain unassociated while UUID refs are + indexed, validation worker failures roll back the new account and secret, and + WASM calls no longer pin registry shards. - **MAT-132 final concurrency and quota fixes**: JIT rotation now updates only the existing download's account reference, preserving concurrent state and never recreating a removed row. Cooldown and quota exhaustion remain distinct diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs index 8694696c..c4c71682 100644 --- a/src-tauri/src/adapters/driven/plugin/registry.rs +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -165,26 +165,45 @@ impl PluginRegistry { where I: extism::convert::ToBytes<'a>, { - // Keep the registry guard through the call so `set_enabled` and - // credential injection share one linearization point. A disable that - // wins the guard is observed before any credential enters the slot; - // a call that wins completes before the disable is committed. - let entry = self - .plugins - .get(name) - .ok_or_else(|| DomainError::NotFound(name.to_string()))?; - if !entry.enabled { - return Err(DomainError::NotFound(format!( - "plugin '{name}' is disabled" - ))); - } - let mut plugin = entry - .plugin + let plugin_handle = { + let entry = self + .plugins + .get(name) + .ok_or_else(|| DomainError::NotFound(name.to_string()))?; + if !entry.enabled { + return Err(DomainError::NotFound(format!( + "plugin '{name}' is disabled" + ))); + } + Arc::clone(&entry.plugin) + }; + let mut plugin = plugin_handle .lock() .map_err(|_| DomainError::PluginError(format!("plugin '{name}' mutex poisoned")))?; - let _credential_scope = scoped_credential - .map(|credential| CredentialScope::new(Arc::clone(&entry.credential_slot), credential)) - .transpose()?; + // Re-check after taking the per-plugin lock. The short registry guard + // makes enablement and credential injection atomic without pinning a + // DashMap shard during the WASM call. + let _credential_scope = { + let entry = self + .plugins + .get(name) + .ok_or_else(|| DomainError::NotFound(name.to_string()))?; + if !Arc::ptr_eq(&entry.plugin, &plugin_handle) { + return Err(DomainError::NotFound(format!( + "plugin '{name}' was reloaded" + ))); + } + if !entry.enabled { + return Err(DomainError::NotFound(format!( + "plugin '{name}' is disabled" + ))); + } + scoped_credential + .map(|credential| { + CredentialScope::new(Arc::clone(&entry.credential_slot), credential) + }) + .transpose()? + }; let fn_exists = plugin.function_exists(func); tracing::debug!(plugin = name, func, fn_exists, "plugin call pre-call"); let result = plugin.call::(func, input).map_err(|e| { @@ -210,6 +229,12 @@ impl PluginReadRepository for PluginRegistry { #[cfg(test)] mod tests { + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, Instant}; + + use dashmap::try_result::TryResult; + use super::*; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; @@ -326,6 +351,71 @@ mod tests { assert!(!slot.has_been_exposed()); } + #[test] + fn disabling_plugin_does_not_wait_for_blocked_plugin_call() { + let registry = Arc::new(PluginRegistry::new()); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + let plugin_handle = Arc::clone( + ®istry + .plugins + .get("plug-a") + .expect("loaded plugin") + .plugin, + ); + let plugin_guard = plugin_handle.lock().unwrap(); + + let caller_registry = Arc::clone(®istry); + let caller = thread::spawn(move || caller_registry.call_plugin("plug-a", "missing", "")); + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let registry_guard_held = + matches!(registry.plugins.try_get_mut("plug-a"), TryResult::Locked); + let plugin_handle_cloned = Arc::strong_count(&plugin_handle) >= 3; + if registry_guard_held || plugin_handle_cloned { + break; + } + assert!( + Instant::now() < deadline, + "plugin call did not reach the plugin mutex" + ); + thread::yield_now(); + } + + let (disable_tx, disable_rx) = mpsc::channel(); + let disabler_registry = Arc::clone(®istry); + let disabler = thread::spawn(move || { + disable_tx + .send(disabler_registry.set_enabled("plug-a", false)) + .expect("disable receiver remains connected"); + }); + + let prompt_disable = disable_rx.recv_timeout(Duration::from_secs(1)); + let completed_while_call_blocked = prompt_disable.is_ok(); + drop(plugin_guard); + + let disable_result = match prompt_disable { + Ok(result) => result, + Err(_) => disable_rx + .recv_timeout(Duration::from_secs(1)) + .expect("disable completes after plugin call unblocks"), + }; + disable_result.expect("disable succeeds"); + disabler.join().expect("disable thread does not panic"); + let call_error = caller + .join() + .expect("call thread does not panic") + .expect_err("disabled call is rejected"); + + assert!( + completed_while_call_blocked, + "disable must not wait for a blocked plugin call" + ); + assert!( + matches!(call_error, DomainError::NotFound(message) if message.contains("disabled")) + ); + } + #[test] fn test_set_enabled() { let registry = PluginRegistry::new(); diff --git a/src-tauri/src/adapters/driven/sqlite/connection.rs b/src-tauri/src/adapters/driven/sqlite/connection.rs index 5eebdc04..9cfc3716 100644 --- a/src-tauri/src/adapters/driven/sqlite/connection.rs +++ b/src-tauri/src/adapters/driven/sqlite/connection.rs @@ -236,7 +236,7 @@ mod tests { } #[tokio::test] - async fn test_premium_account_migration_backfills_legacy_download_association() { + async fn test_premium_account_migration_leaves_legacy_numeric_association_unset() { let sqlite_opts = sea_orm::sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:") .unwrap() .pragma("foreign_keys", "ON"); @@ -270,7 +270,7 @@ mod tests { .await .unwrap() .expect("download remains"); - assert_eq!(row.try_get_by_index::(0).unwrap(), "7"); + assert_eq!(row.try_get_by_index::>(0).unwrap(), None); } #[tokio::test] diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs index 9335b5d4..7740136c 100644 --- a/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs +++ b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs @@ -1,4 +1,3 @@ -use sea_orm::{ConnectionTrait, Statement}; use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] @@ -37,15 +36,6 @@ impl MigrationTrait for Migration { ) .await?; - manager - .get_connection() - .execute(Statement::from_string( - sea_orm::DatabaseBackend::Sqlite, - "UPDATE downloads SET account_ref = CAST(account_id AS TEXT) WHERE account_id IS NOT NULL" - .to_string(), - )) - .await?; - manager .create_index( Index::create() diff --git a/src-tauri/src/application/commands/add_account.rs b/src-tauri/src/application/commands/add_account.rs index c88b0227..51bed030 100644 --- a/src-tauri/src/application/commands/add_account.rs +++ b/src-tauri/src/application/commands/add_account.rs @@ -82,7 +82,29 @@ impl CommandBus { let validation = match self.account_validator_arc() { Some(validator) => { - Some(validate_credentials_blocking(validator, account.clone(), cmd.password).await?) + match validate_credentials_blocking(validator, account.clone(), cmd.password).await + { + Ok(attempt) => Some(attempt), + Err(error) => { + if let Err(rollback_error) = repo.delete(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + rollback_error = %rollback_error, + "account validation worker failed and row rollback also failed" + ); + } + if let Err(cleanup_error) = store.delete_password(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + cleanup_error = %cleanup_error, + "account validation worker failed and credential cleanup also failed" + ); + } + return Err(error); + } + } } None => None, }; @@ -143,9 +165,22 @@ mod tests { use crate::domain::event::DomainEvent; use crate::domain::model::account::{AccountStatus, AccountType}; use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, ValidationOutcome, + AccountCredentialStore, AccountRepository, AccountValidator, ValidationOutcome, }; + struct PanickingValidator; + + impl AccountValidator for PanickingValidator { + fn validate( + &self, + _service_name: &str, + _username: &str, + _password: &str, + ) -> Result { + panic!("simulated validation worker failure") + } + } + fn add_command(service: &str, user: &str, password: &str) -> AddAccountCommand { AddAccountCommand { service_name: service.into(), @@ -315,6 +350,34 @@ mod tests { assert!(events.snapshot().is_empty(), "no event on failure"); } + #[tokio::test] + async fn test_add_account_rolls_back_when_validation_worker_stops() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus( + repo.clone(), + creds.clone(), + events.clone(), + Some(Arc::new(PanickingValidator)), + None, + ); + + let err = bus + .handle_add_account(add_command("real-debrid", "alice", "pw")) + .await + .expect_err("validation worker failure surfaces"); + + assert!(matches!( + err, + AppError::Domain(DomainError::PluginError(message)) + if message.contains("validation worker stopped") + )); + assert!(repo.list().unwrap().is_empty(), "row must be rolled back"); + assert_eq!(creds.entry_count(), 0, "credential must be removed"); + assert!(events.snapshot().is_empty(), "no event on failure"); + } + #[tokio::test] async fn test_add_account_emits_no_event_when_repo_missing() { let creds = Arc::new(FakeAccountCredentialStore::new());