From b0f64dbe33079fd91f52c2432bec45f397d8e653 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 7 Aug 2026 21:33:30 +0200 Subject: [PATCH 1/2] fix(dal): preserve stored keys the struct does not model --- Cargo.lock | 36 +++---- crates/dpp-dal/src/pg/repo_passport.rs | 27 +++++- .../fixtures/passport_docs/battery_1.0.0.json | 37 +++++++ .../fixtures/passport_docs/battery_2.1.0.json | 36 +++++++ .../fixtures/passport_docs/battery_2.2.0.json | 37 +++++++ .../fixtures/passport_docs/battery_2.3.0.json | 36 +++++++ .../fixtures/passport_docs/battery_2.4.0.json | 52 ++++++++++ crates/dpp-dal/tests/passport_doc_compat.rs | 97 +++++++++++++++++++ crates/dpp-dal/tests/pg_integration.rs | 66 +++++++++++++ 9 files changed, 405 insertions(+), 19 deletions(-) create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/battery_1.0.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/battery_2.1.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/battery_2.2.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/battery_2.3.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/battery_2.4.0.json diff --git a/Cargo.lock b/Cargo.lock index 9b8b5ee..d48a154 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2053,9 +2053,9 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpp-aas" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34989453d86842de5dc5eef49da95dfb606e3f37615a7046576262f68d4f06fb" +checksum = "fb808069486b9d6cdc8244053451b232a654215bf0d6acdf36445f4822a8324c" dependencies = [ "dpp-domain", "serde", @@ -2064,9 +2064,9 @@ dependencies = [ [[package]] name = "dpp-calc" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b6d0a0bbbf1414b26e1ebb3bbe42d6c06f5ef309960c0f5317ad949ab94fed4" +checksum = "f61d69178918c3e171c570e10aaae4152ffe7555f9e09646f9801cbcf66e0451" dependencies = [ "chrono", "hex", @@ -2128,9 +2128,9 @@ dependencies = [ [[package]] name = "dpp-crypto" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786838c84fdcbaa8f0cd793c3a2830d391a24d69c85fe6d78cfa7c0bbb067b2d" +checksum = "882e26e78ae425b1e87c61669986b84dfc505dc44c65fb187aec29d44ebb4392" dependencies = [ "aes-gcm", "anyhow", @@ -2177,9 +2177,9 @@ dependencies = [ [[package]] name = "dpp-digital-link" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5ca4c19d59419ceafef2f6802791c1aa9a62d189e9b6b5c1b272e17adfec10e" +checksum = "3917b2560a9d98325aa63cd3b9751579634b79ed188a891f0bb9341d9bd846bf" dependencies = [ "dpp-domain", "serde", @@ -2189,9 +2189,9 @@ dependencies = [ [[package]] name = "dpp-domain" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1100b43c982ea3a07ce6a5dca645ca8f4366b64aa87693457d422d4beba0814" +checksum = "70d15eb9875382001e8ab2f824c86debfe21182714253a715a2995fca12cf0ee" dependencies = [ "async-trait", "chrono", @@ -2356,9 +2356,9 @@ dependencies = [ [[package]] name = "dpp-plugin-traits" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36371e820f2ede0c8d95c91e3debfb9b01046f073a22390da0981b6da6f78ec8" +checksum = "e96b5bf986bf6f7ba71113ad5a963ca9d64529a0dad1d5a76ee0e4f050b55253" dependencies = [ "semver", "serde", @@ -2368,9 +2368,9 @@ dependencies = [ [[package]] name = "dpp-registry" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7668419eaa74e92ca4738a11eac69dfae2cdd091e64f0c99a8da16debb5b48d4" +checksum = "6cd58e1c01cc319f613f45fd25a3aeafc2841885e35541a4b55a8a8bcfae4ea9" dependencies = [ "chrono", "dpp-domain", @@ -2432,9 +2432,9 @@ dependencies = [ [[package]] name = "dpp-rules" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688338672758d7fe5e8f0491473640eb770e3c22fc00115645b536fcd97a44f9" +checksum = "880d63f38df7def5db8af05fc94fb4e8c2a0306f4588dc808ac477272392268d" dependencies = [ "base64 0.23.1", "chrono", @@ -2528,9 +2528,9 @@ dependencies = [ [[package]] name = "dpp-vc" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04b5bb40619f6fe210b30fa35b9b53fbd00ba024760ac0c3e6c029294d03b39e" +checksum = "75a82c1859f1426b6451228eef06cad5a3854d97ccf87e279af2f89d8bd79a61" dependencies = [ "anyhow", "async-trait", diff --git a/crates/dpp-dal/src/pg/repo_passport.rs b/crates/dpp-dal/src/pg/repo_passport.rs index 6f86caa..43c6e40 100644 --- a/crates/dpp-dal/src/pg/repo_passport.rs +++ b/crates/dpp-dal/src/pg/repo_passport.rs @@ -53,6 +53,31 @@ const PROTECTED_PATCH_FIELDS: [&str; 13] = [ /// transaction. Shared by [`PgPassportRepo::update`] and the transactional /// outbox's `commit_publish`, so the publish-write and the outbox insert commit /// atomically without duplicating this SQL. Errors `NotFound` if no row matched. +/// +/// # Why `doc || $2` rather than `doc = $2` +/// +/// Writing the serialised struct over the whole column erases every stored key +/// the struct does not model. That is not a stale value in memory — it is gone +/// from the database. `update_status` is a read-modify-write (`find_by_id`, +/// mutate, `update`), and publish takes the same path, so the erasure happens on +/// the one write that matters most: the retention guard tests +/// `OLD.retention_locked`, which is still false while the row is a draft, so the +/// guard does not fire, the lossy write lands, and `retention_locked` becomes +/// true in that same statement. Every later write is guarded, so it can never be +/// repaired in place. +/// +/// `||` is a shallow merge at the top level: the struct wins on every key it +/// models, and keys it does not model survive. That is exactly the +/// envelope/`sectorData` split — `sectorData` is fully modelled and versioned +/// through the lens chain, so replacing it wholesale is correct; the envelope is +/// the axis with no such mechanism. +/// +/// **Constraint this carries:** the `Passport` fields are +/// `skip_serializing_if = "Option::is_none"`, so a field going `Some` -> `None` +/// is absent from `$2` and will no longer clear the stored key. No production +/// path does that today (checked: every envelope-field assignment to `None` is +/// inside a test). A field that genuinely needs clearing must write an explicit +/// JSON `null` rather than rely on omission. pub(crate) async fn update_passport_in_tx( tx: &mut Transaction<'_, Postgres>, passport: &Passport, @@ -66,7 +91,7 @@ pub(crate) async fn update_passport_in_tx( retention_locked = COALESCE(($2->>'retentionLocked')::boolean, retention_locked), schema_version = COALESCE($2->>'schemaVersion', schema_version), published_at = COALESCE(NULLIF($2->>'publishedAt','')::timestamptz, published_at), - doc = $2 + doc = doc || $2 WHERE id = $1"#, ) .bind(passport.id.0) diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_1.0.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery_1.0.0.json new file mode 100644 index 0000000..9470657 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/battery_1.0.0.json @@ -0,0 +1,37 @@ +{ + "id": "019f19b0-1111-7342-adb1-d6ab1f410001", + "sector": "battery", + "status": "active", + "batchId": "VB-2026-001", + "version": 1, + "createdAt": "2026-03-04T09:12:44.100000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-03-04T09:12:44.100000000Z", + "sectorData": { + "gtin": "04901234567009", + "sector": "battery", + "co2ePerUnitKg": 51.4, + "nominalVoltageV": 48.0, + "batteryChemistry": "LFP", + "ratedCapacityKwh": 4.8, + "stateOfHealthPct": null, + "nominalCapacityAh": 100.0, + "expectedLifetimeCycles": 3000, + "recycledContentCobaltPct": null, + "recycledContentNickelPct": null, + "recycledContentLithiumPct": null + }, + "co2ePerUnit": { "valueKg": 51.4 }, + "productName": "Voltex Industrial Cell 4.8kWh", + "publishedAt": "2026-03-04T09:30:00.000000000Z", + "jwsSignature": null, + "manufacturer": { + "name": "Voltex Battery GmbH", + "address": "DE", + "didWebUrl": null + }, + "schemaVersion": "1.0.0", + "retentionLocked": true, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.1.0.json new file mode 100644 index 0000000..5def68b --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.1.0.json @@ -0,0 +1,36 @@ +{ + "id": "019f19b0-2222-7342-adb1-d6ab1f410021", + "sector": "battery", + "status": "active", + "batchId": "VB-2026-021", + "version": 1, + "createdAt": "2026-05-11T11:02:07.400000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-05-11T11:02:07.400000000Z", + "sectorData": { + "gtin": "04901234567009", + "sector": "battery", + "co2ePerUnitKg": 44.9, + "nominalVoltageV": 400.0, + "batteryChemistry": "NMC", + "nominalCapacityAh": 180.0, + "expectedLifetimeCycles": 1500, + "placedOnMarketDate": "2026-05-01", + "carbonFootprintClass": "B", + "carbonFootprintClassRulesetId": "eu-battery-cfb", + "carbonFootprintClassRulesetVersion": "1.0.0" + }, + "co2ePerUnit": { "valueKg": 44.9 }, + "productName": "Voltex EV Pack 72kWh", + "publishedAt": "2026-05-11T12:00:00.000000000Z", + "jwsSignature": null, + "manufacturer": { + "name": "Voltex Battery GmbH", + "address": "DE", + "didWebUrl": null + }, + "schemaVersion": "2.1.0", + "retentionLocked": true, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.2.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.2.0.json new file mode 100644 index 0000000..cde8924 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.2.0.json @@ -0,0 +1,37 @@ +{ + "id": "019f19b0-3333-7342-adb1-d6ab1f410022", + "sector": "battery", + "status": "active", + "batchId": "VB-2026-022", + "version": 1, + "createdAt": "2026-06-02T14:41:19.900000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-06-02T14:41:19.900000000Z", + "sectorData": { + "gtin": "04901234567009", + "sector": "battery", + "co2ePerUnitKg": 44.9, + "nominalVoltageV": 400.0, + "batteryChemistry": "NMC", + "nominalCapacityAh": 180.0, + "expectedLifetimeCycles": 1500, + "placedOnMarketDate": "2026-06-01", + "stateOfHealth": { + "parameterSet": "electricVehicle", + "socePct": 97.5 + } + }, + "co2ePerUnit": { "valueKg": 44.9 }, + "productName": "Voltex EV Pack 72kWh", + "publishedAt": "2026-06-02T15:10:00.000000000Z", + "jwsSignature": null, + "manufacturer": { + "name": "Voltex Battery GmbH", + "address": "DE", + "didWebUrl": null + }, + "schemaVersion": "2.2.0", + "retentionLocked": true, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.3.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.3.0.json new file mode 100644 index 0000000..9cce901 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.3.0.json @@ -0,0 +1,36 @@ +{ + "id": "019f19b0-4444-7342-adb1-d6ab1f410023", + "sector": "battery", + "status": "active", + "batchId": "VB-2026-023", + "version": 1, + "createdAt": "2026-06-24T08:15:33.200000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-06-24T08:15:33.200000000Z", + "sectorData": { + "gtin": "04901234567009", + "sector": "battery", + "co2ePerUnitKg": 39.1, + "nominalVoltageV": 48.0, + "batteryChemistry": "LFP", + "nominalCapacityAh": 100.0, + "expectedLifetimeCycles": 3500, + "recycledContentCobaltPct": 16.0, + "recycledContentLithiumPct": 6.0, + "recycledContentNickelPct": 6.0, + "recycledContentReportingYear": 2025 + }, + "co2ePerUnit": { "valueKg": 39.1 }, + "productName": "Voltex Stationary Cell 4.8kWh", + "publishedAt": "2026-06-24T09:00:00.000000000Z", + "jwsSignature": null, + "manufacturer": { + "name": "Voltex Battery GmbH", + "address": "DE", + "didWebUrl": null + }, + "schemaVersion": "2.3.0", + "retentionLocked": true, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.4.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.4.0.json new file mode 100644 index 0000000..9188fc6 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.4.0.json @@ -0,0 +1,52 @@ +{ + "id": "019f19b0-5555-7342-adb1-d6ab1f410024", + "sector": "battery", + "status": "active", + "batchId": "VB-2026-024", + "version": 1, + "createdAt": "2026-07-30T16:27:51.700000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-07-30T16:27:51.700000000Z", + "sectorData": { + "gtin": "04901234567009", + "sector": "battery", + "co2ePerUnitKg": 39.1, + "nominalVoltageV": 48.0, + "batteryChemistry": "LFP", + "nominalCapacityAh": 100.0, + "expectedLifetimeCycles": 3500, + "placedOnMarketDate": "2026-07-15", + "stateOfHealth": { + "parameterSet": "stationaryOrLmt", + "remainingCapacityPct": 98.2, + "remainingPowerCapabilityPct": null, + "remainingRoundTripEfficiencyPct": null, + "selfDischargeRatePctPerMonth": 1.4, + "ohmicResistanceMohm": null + }, + "expectedLifetime": { + "putIntoServiceDate": "2026-07-20", + "energyThroughputKwh": 1240.0, + "capacityThroughputAh": 25800.0, + "harmfulEvents": { + "deepDischargeEvents": 2, + "hoursInExtremeTemperature": 11.5, + "hoursChargingInExtremeTemperature": null + }, + "fullEquivalentCycles": 258.0 + } + }, + "co2ePerUnit": { "valueKg": 39.1 }, + "productName": "Voltex Stationary Cell 4.8kWh", + "publishedAt": "2026-07-30T17:00:00.000000000Z", + "jwsSignature": null, + "manufacturer": { + "name": "Voltex Battery GmbH", + "address": "DE", + "didWebUrl": null + }, + "schemaVersion": "2.4.0", + "retentionLocked": true, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/passport_doc_compat.rs b/crates/dpp-dal/tests/passport_doc_compat.rs index 1fa4eef..c7ade90 100644 --- a/crates/dpp-dal/tests/passport_doc_compat.rs +++ b/crates/dpp-dal/tests/passport_doc_compat.rs @@ -30,6 +30,7 @@ //! Pure filesystem + in-memory check — no Docker/Postgres required, runs in //! the fast `cargo nextest run --workspace` gate. +use std::collections::BTreeSet; use std::fs; use std::path::Path; @@ -37,6 +38,29 @@ use dpp_domain::Passport; use dpp_domain::catalog::SectorCatalog; use dpp_domain::schemas::lens::LensRegistry; +/// Envelope keys that appear in a frozen document and are deliberately no longer +/// modelled by `Passport`. +/// +/// Each entry is a decision that a stored key is not carried into the type any +/// more, recorded once, here. The list exists so +/// [`no_frozen_doc_loses_an_envelope_key_unrecorded`] can tell a *deliberate* +/// retirement from an *accidental* one — without it the check would either flag +/// every retired field forever, or have to be silenced by editing a fixture, +/// and a frozen document that gets edited to make a test pass has stopped being +/// evidence about anything. +/// +/// Adding a row here is a claim that old documents carrying the key are still +/// correct on disk and the value is simply not represented in the struct. It is +/// **not** a claim that losing it is harmless — see the note on each. +const RETIRED_ENVELOPE_KEYS: &[(&str, &str)] = &[( + "facilityId", + "Superseded by the `facility` FacilitySnapshot, which carries the identifier \ + plus the address and registry provenance a bare string could not. The old \ + key is preserved on disk by the write path; it is simply not lifted into \ + the type, because a snapshot cannot be honestly reconstructed from an \ + identifier alone.", +)]; + #[test] fn every_frozen_passport_doc_still_reads() { let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/passport_docs"); @@ -90,3 +114,76 @@ fn every_frozen_passport_doc_still_reads() { failures.join("\n") ); } + +/// Every top-level key in a frozen document must either survive a read/write +/// round-trip through `Passport`, or be named in [`RETIRED_ENVELOPE_KEYS`]. +/// +/// This catches the defect the test above structurally cannot: a renamed or +/// removed **optional** envelope field does not fail deserialization at all. It +/// is silently dropped, `None` takes its place, and nothing anywhere reports a +/// problem — which is worse than a loud break, because the loud one is visible +/// the first time anybody reads an old row. +/// +/// It is a *read-side* check: it says which keys the type no longer represents. +/// Whether the value survives in the database is a property of the write path +/// and is asserted against real Postgres in `pg_doc_key_preservation`. +/// +/// A stored key whose value is `null` is not counted as lost. `Passport` is +/// `skip_serializing_if = "Option::is_none"` throughout, so a null in a fixture +/// round-trips to an absent key, and treating that as loss would fire on almost +/// every fixture and get the check switched off. +#[test] +fn no_frozen_doc_loses_an_envelope_key_unrecorded() { + let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/passport_docs"); + let lenses = LensRegistry::new(); + let catalog = SectorCatalog::new(); + let retired: BTreeSet<&str> = RETIRED_ENVELOPE_KEYS.iter().map(|(k, _)| *k).collect(); + + let mut fixtures: Vec<_> = fs::read_dir(&fixtures_dir) + .expect("read tests/fixtures/passport_docs") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "json")) + .collect(); + fixtures.sort(); + + let mut failures = Vec::new(); + for path in &fixtures { + let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let stored: serde_json::Value = + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path:?} is not JSON: {e}")); + + let Ok(passport) = Passport::from_stored(stored.clone(), &lenses, &catalog) else { + continue; // the test above owns unreadable fixtures + }; + let round_tripped = serde_json::to_value(&passport).expect("serialise"); + + let before = stored.as_object().expect("fixture is a JSON object"); + let after = round_tripped + .as_object() + .expect("passport serialises to an object"); + + for (key, value) in before { + // A stored null has nothing to lose. + if value.is_null() || after.contains_key(key) || retired.contains(key.as_str()) { + continue; + } + failures.push(format!( + "{}: `{key}` is present in the stored document and absent after a \ + round-trip through Passport", + path.display() + )); + } + } + + assert!( + failures.is_empty(), + "a frozen passport document carries an envelope key the current `Passport` no \ + longer represents, and it is not recorded as retired.\n\n{}\n\nDo not edit the \ + fixture — it is the historical record, and changing it to make this pass \ + removes the only evidence the check runs on. Either restore the field (a \ + rename must keep accepting the old key), or add it to RETIRED_ENVELOPE_KEYS \ + with the reason it is no longer carried.", + failures.join("\n") + ); +} diff --git a/crates/dpp-dal/tests/pg_integration.rs b/crates/dpp-dal/tests/pg_integration.rs index dab7e21..d3700c0 100644 --- a/crates/dpp-dal/tests/pg_integration.rs +++ b/crates/dpp-dal/tests/pg_integration.rs @@ -1180,3 +1180,69 @@ async fn t16_connect_refuses_a_superuser_role() { Err(e) => assert!(matches!(e, dpp_domain::DppError::Internal(_))), } } + +// T17 — a lifecycle write preserves stored keys the `Passport` struct does not +// model. +// +// This is the write-side half of the guard in `passport_doc_compat.rs`. That one +// says which keys the *type* no longer represents; this one says whether the +// *database* still holds them after a write, which is the part that is +// irreversible. +// +// The failure it exists for: `update_status` is a read-modify-write, publish +// takes the same path, and writing the serialised struct over the whole `doc` +// erases every key the struct does not know. It bites hardest at publish, +// because the retention guard tests `OLD.retention_locked` — still false while +// the row is a draft — so the guard does not fire, the lossy write lands, and +// `retention_locked` becomes true in the same statement. Every later write is +// guarded, so the loss can never be repaired in place. +// +// `facilityId` is the real instance: it is present in the committed +// `battery_2.0.0.json` fixture and superseded in the type by `facility`. +#[tokio::test] +async fn t17_lifecycle_write_preserves_unmodelled_keys() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + + let p = make_passport(); + let id = p.id; + repo.create(p).await.expect("create draft"); + + // Put a key on the stored document that `Passport` does not model, exactly + // as a document written by an older dpp-domain would carry it. + let admin = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&pg.admin_url) + .await + .expect("admin connect"); + sqlx::query("UPDATE odal.passport SET doc = doc || '{\"facilityId\":\"LEGACY-1\"}'::jsonb WHERE id = $1") + .bind(id.0) + .execute(&admin) + .await + .expect("seed an unmodelled key"); + + // The publish transition: the one write that is unguarded and then freezes. + repo.update_status(id, PassportStatus::Published) + .await + .expect("publish"); + + let row = sqlx::query("SELECT doc, retention_locked FROM odal.passport WHERE id = $1") + .bind(id.0) + .fetch_one(&admin) + .await + .expect("read back"); + let doc: serde_json::Value = row.get("doc"); + + assert_eq!( + doc.get("facilityId").and_then(|v| v.as_str()), + Some("LEGACY-1"), + "publish erased a stored key the struct does not model — this is \ + unrecoverable, because the row is retention-locked by the same statement" + ); + assert_eq!( + doc.get("status").and_then(|v| v.as_str()), + Some("active"), + "the struct must still win on every key it does model" + ); + admin.close().await; +} From 706e20b9317ff00c0519b481244299b5f9ab008a Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Mon, 10 Aug 2026 20:11:13 +0200 Subject: [PATCH 2/2] test(dal): cover all 11 sectors, nest fixtures per-sector like schemas --- .../passport_docs/aluminium/v1.1.0.json | 32 +++++++ .../v1.0.0.json} | 0 .../v2.0.0.json} | 0 .../v2.1.0.json} | 0 .../v2.2.0.json} | 0 .../v2.3.0.json} | 0 .../v2.4.0.json} | 0 .../passport_docs/construction/v1.1.0.json | 31 +++++++ .../passport_docs/detergent/v1.1.0.json | 37 +++++++++ .../passport_docs/electronics/v1.1.0.json | 30 +++++++ .../passport_docs/furniture/v1.1.0.json | 30 +++++++ .../fixtures/passport_docs/steel/v1.1.0.json | 32 +++++++ .../v1.1.0.json} | 0 .../fixtures/passport_docs/toy/v1.1.0.json | 31 +++++++ .../fixtures/passport_docs/tyre/v1.0.0.json | 31 +++++++ .../passport_docs/unsold-goods/v1.0.0.json | 34 ++++++++ crates/dpp-dal/tests/passport_doc_compat.rs | 83 ++++++++++++------- 17 files changed, 339 insertions(+), 32 deletions(-) create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/aluminium/v1.1.0.json rename crates/dpp-dal/tests/fixtures/passport_docs/{battery_1.0.0.json => battery/v1.0.0.json} (100%) rename crates/dpp-dal/tests/fixtures/passport_docs/{battery_2.0.0.json => battery/v2.0.0.json} (100%) rename crates/dpp-dal/tests/fixtures/passport_docs/{battery_2.1.0.json => battery/v2.1.0.json} (100%) rename crates/dpp-dal/tests/fixtures/passport_docs/{battery_2.2.0.json => battery/v2.2.0.json} (100%) rename crates/dpp-dal/tests/fixtures/passport_docs/{battery_2.3.0.json => battery/v2.3.0.json} (100%) rename crates/dpp-dal/tests/fixtures/passport_docs/{battery_2.4.0.json => battery/v2.4.0.json} (100%) create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/construction/v1.1.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/detergent/v1.1.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/electronics/v1.1.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/furniture/v1.1.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/steel/v1.1.0.json rename crates/dpp-dal/tests/fixtures/passport_docs/{textile_1.1.0.json => textile/v1.1.0.json} (100%) create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/toy/v1.1.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/tyre/v1.0.0.json create mode 100644 crates/dpp-dal/tests/fixtures/passport_docs/unsold-goods/v1.0.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/aluminium/v1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/aluminium/v1.1.0.json new file mode 100644 index 0000000..7127a53 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/aluminium/v1.1.0.json @@ -0,0 +1,32 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e071", + "sector": "aluminium", + "status": "draft", + "batchId": "AL-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "aluminium", + "gtin": "09506000134352", + "alloyGrade": "6xxx", + "productionRoute": "primary", + "co2ePerTonneKg": 4200.0, + "recycledContentPct": 15.0, + "countryOfOrigin": "NO" + }, + "co2ePerUnit": null, + "productName": "Example Aluminium Extrusion Profile", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Aluminium Works AS", + "address": "NO", + "didWebUrl": null + }, + "schemaVersion": "1.1.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_1.0.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v1.0.0.json similarity index 100% rename from crates/dpp-dal/tests/fixtures/passport_docs/battery_1.0.0.json rename to crates/dpp-dal/tests/fixtures/passport_docs/battery/v1.0.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.0.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.0.0.json similarity index 100% rename from crates/dpp-dal/tests/fixtures/passport_docs/battery_2.0.0.json rename to crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.0.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.1.0.json similarity index 100% rename from crates/dpp-dal/tests/fixtures/passport_docs/battery_2.1.0.json rename to crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.1.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.2.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.2.0.json similarity index 100% rename from crates/dpp-dal/tests/fixtures/passport_docs/battery_2.2.0.json rename to crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.2.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.3.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.3.0.json similarity index 100% rename from crates/dpp-dal/tests/fixtures/passport_docs/battery_2.3.0.json rename to crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.3.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery_2.4.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.4.0.json similarity index 100% rename from crates/dpp-dal/tests/fixtures/passport_docs/battery_2.4.0.json rename to crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.4.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/construction/v1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/construction/v1.1.0.json new file mode 100644 index 0000000..9d25dd7 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/construction/v1.1.0.json @@ -0,0 +1,31 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e072", + "sector": "construction", + "status": "draft", + "batchId": "CN-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "construction", + "gtin": "09506000134352", + "productFamily": "cement", + "countryOfOrigin": "DE", + "co2ePerFunctionalUnitKg": 850.0, + "functionalUnit": "per tonne" + }, + "co2ePerUnit": null, + "productName": "Example Portland Cement CEM I", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Baustoffe GmbH", + "address": "DE", + "didWebUrl": null + }, + "schemaVersion": "1.1.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/detergent/v1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/detergent/v1.1.0.json new file mode 100644 index 0000000..7cf8182 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/detergent/v1.1.0.json @@ -0,0 +1,37 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e073", + "sector": "detergent", + "status": "draft", + "batchId": "DT-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "detergent", + "gtin": "09506000134352", + "productType": "laundry", + "format": "liquid", + "surfactants": [ + { + "name": "Sodium Laureth Sulfate", + "biodegradable": true, + "concentrationBand": "5-15%" + } + ], + "countryOfOrigin": "NL" + }, + "co2ePerUnit": null, + "productName": "Example Laundry Liquid 1.5L", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Cleaning Products BV", + "address": "NL", + "didWebUrl": null + }, + "schemaVersion": "1.1.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/electronics/v1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/electronics/v1.1.0.json new file mode 100644 index 0000000..4856efe --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/electronics/v1.1.0.json @@ -0,0 +1,30 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e074", + "sector": "electronics", + "status": "draft", + "batchId": "EL-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "electronics", + "gtin": "09506000134352", + "productCategory": "smartphone", + "energyEfficiencyClass": "B", + "co2ePerUnitKg": 62.5 + }, + "co2ePerUnit": null, + "productName": "Example Smartphone X12", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Electronics Ltd", + "address": "IE", + "didWebUrl": null + }, + "schemaVersion": "1.1.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/furniture/v1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/furniture/v1.1.0.json new file mode 100644 index 0000000..3ef9fef --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/furniture/v1.1.0.json @@ -0,0 +1,30 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e075", + "sector": "furniture", + "status": "draft", + "batchId": "FN-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "furniture", + "gtin": "09506000134352", + "productType": "chair", + "primaryMaterial": "solid-wood", + "countryOfOrigin": "PL" + }, + "co2ePerUnit": null, + "productName": "Example Oak Dining Chair", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Furniture Sp. z o.o.", + "address": "PL", + "didWebUrl": null + }, + "schemaVersion": "1.1.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/steel/v1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/steel/v1.1.0.json new file mode 100644 index 0000000..4f8c538 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/steel/v1.1.0.json @@ -0,0 +1,32 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e076", + "sector": "steel", + "status": "draft", + "batchId": "ST-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "steel", + "gtin": "09506000134352", + "co2ePerTonneSteel": 1.9, + "recycledScrapContentPct": 32.0, + "productCategory": "flat", + "countryOfOrigin": "DE", + "productionRoute": "blast-furnace" + }, + "co2ePerUnit": null, + "productName": "Example Hot-Rolled Steel Coil", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Stahlwerke GmbH", + "address": "DE", + "didWebUrl": null + }, + "schemaVersion": "1.1.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/textile_1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/textile/v1.1.0.json similarity index 100% rename from crates/dpp-dal/tests/fixtures/passport_docs/textile_1.1.0.json rename to crates/dpp-dal/tests/fixtures/passport_docs/textile/v1.1.0.json diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/toy/v1.1.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/toy/v1.1.0.json new file mode 100644 index 0000000..2425ceb --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/toy/v1.1.0.json @@ -0,0 +1,31 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e077", + "sector": "toy", + "status": "draft", + "batchId": "TY-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "toy", + "gtin": "09506000134352", + "ageGroup": "3-6", + "primaryMaterial": "plastic", + "ceMarking": true, + "countryOfOrigin": "CZ" + }, + "co2ePerUnit": null, + "productName": "Example Building Blocks Set", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Toys s.r.o.", + "address": "CZ", + "didWebUrl": null + }, + "schemaVersion": "1.1.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/tyre/v1.0.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/tyre/v1.0.0.json new file mode 100644 index 0000000..8d51e99 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/tyre/v1.0.0.json @@ -0,0 +1,31 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e078", + "sector": "tyre", + "status": "draft", + "batchId": "TR-2026-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "tyre", + "gtin": "09506000134352", + "tyreClass": "C1", + "fuelEfficiencyClass": "B", + "wetGripClass": "B", + "externalRollingNoiseDb": 70.5 + }, + "co2ePerUnit": null, + "productName": "Example All-Season Tyre 205/55R16", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Tyre Manufacturing SA", + "address": "FR", + "didWebUrl": null + }, + "schemaVersion": "1.0.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/unsold-goods/v1.0.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/unsold-goods/v1.0.0.json new file mode 100644 index 0000000..13bc3d0 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/unsold-goods/v1.0.0.json @@ -0,0 +1,34 @@ +{ + "id": "019f3aa5-579d-73c1-a3e6-a8002df5e079", + "sector": "unsoldGoods", + "status": "draft", + "batchId": "UG-2026-Q3-BATCH-01", + "version": 1, + "createdAt": "2026-08-01T09:00:00.000000000Z", + "materials": [], + "qrCodeUrl": null, + "updatedAt": "2026-08-01T09:00:00.000000000Z", + "sectorData": { + "sector": "unsoldGoods", + "reportingPeriod": "2026-Q3", + "volumeKg": 480.5, + "productCategory": "apparel", + "reason": "end_of_season", + "destination": "donation", + "destructionJustification": null, + "countryOfDisposal": "FR", + "operatorName": "Example Charity Partner" + }, + "co2ePerUnit": null, + "productName": "Example Unsold Goods Report Q3 2026", + "publishedAt": null, + "jwsSignature": null, + "manufacturer": { + "name": "Example Fashion Retail SA", + "address": "FR", + "didWebUrl": null + }, + "schemaVersion": "1.0.0", + "retentionLocked": false, + "repairabilityScore": null +} diff --git a/crates/dpp-dal/tests/passport_doc_compat.rs b/crates/dpp-dal/tests/passport_doc_compat.rs index c7ade90..c1c90ae 100644 --- a/crates/dpp-dal/tests/passport_doc_compat.rs +++ b/crates/dpp-dal/tests/passport_doc_compat.rs @@ -22,22 +22,60 @@ //! //! Not covered here: a renamed *optional* envelope field (old key silently //! unrecognised, new field silently `None`) does not fail this check, because -//! it does not fail deserialization at all — see `battery_2.0.0.json`'s +//! it does not fail deserialization at all — see `battery/v2.0.0.json`'s //! `facilityId`, a real historical case predating the additive-only envelope //! rule. That is a distinct defect class (silent data loss vs. a loud //! refusal) tracked separately, not something this guard claims to catch. //! //! Pure filesystem + in-memory check — no Docker/Postgres required, runs in //! the fast `cargo nextest run --workspace` gate. +//! +//! # Layout +//! +//! One directory per sector, one file per frozen schema version — +//! `{catalog_key}/v{version}.json`, mirroring +//! `dpp-core/crates/dpp-domain/schemas/{sector}/v{version}.json` exactly, so +//! a reader who knows one convention already knows the other. A flat +//! `{sector}_{version}.json` naming was tried first and abandoned: it does not +//! scale past a handful of sectors before every sector's versions interleave +//! in one listing. use std::collections::BTreeSet; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use dpp_domain::Passport; use dpp_domain::catalog::SectorCatalog; use dpp_domain::schemas::lens::LensRegistry; +/// Every frozen fixture, paired with the catalog key its parent directory +/// claims — `(catalog_key, path)`, sorted for a stable failure order. +fn collect_fixtures() -> Vec<(String, PathBuf)> { + let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/passport_docs"); + let mut fixtures: Vec<(String, PathBuf)> = fs::read_dir(&fixtures_dir) + .expect("read tests/fixtures/passport_docs") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .flat_map(|sector_dir| { + let sector = sector_dir + .file_name() + .and_then(|n| n.to_str()) + .expect("sector directory name is valid UTF-8") + .to_owned(); + fs::read_dir(§or_dir) + .unwrap_or_else(|e| panic!("read {sector_dir:?}: {e}")) + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "json")) + .map(move |path| (sector.clone(), path)) + .collect::>() + }) + .collect(); + fixtures.sort(); + fixtures +} + /// Envelope keys that appear in a frozen document and are deliberately no longer /// modelled by `Passport`. /// @@ -63,24 +101,16 @@ const RETIRED_ENVELOPE_KEYS: &[(&str, &str)] = &[( #[test] fn every_frozen_passport_doc_still_reads() { - let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/passport_docs"); let lenses = LensRegistry::new(); let catalog = SectorCatalog::new(); - - let mut fixtures: Vec<_> = fs::read_dir(&fixtures_dir) - .expect("read tests/fixtures/passport_docs") - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.extension().is_some_and(|ext| ext == "json")) - .collect(); - fixtures.sort(); + let fixtures = collect_fixtures(); assert!( !fixtures.is_empty(), "expected at least one frozen doc under tests/fixtures/passport_docs" ); let mut failures = Vec::new(); - for path in &fixtures { + for (sector, path) in &fixtures { let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); let value: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path:?} is not JSON: {e}")); @@ -89,16 +119,13 @@ fn every_frozen_passport_doc_still_reads() { Ok(passport) => { // A fixture that parses into the wrong document (e.g. an // empty object matching every field's default) would pass - // silently — pin it to the id/sector this file claims to be. - let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - if let Some(sector) = file_name.split('_').next() { - let actual = passport.sector.wire_str(); - if actual != sector { - failures.push(format!( - "{}: expected sector `{sector}` from filename, deserialised as `{actual}`", - path.display() - )); - } + // silently — pin it to the sector its directory claims. + let actual = passport.sector.catalog_key(); + if actual != sector { + failures.push(format!( + "{}: expected sector `{sector}` from its directory, deserialised as `{actual}`", + path.display() + )); } } Err(e) => failures.push(format!("{}: {e}", path.display())), @@ -134,21 +161,13 @@ fn every_frozen_passport_doc_still_reads() { /// every fixture and get the check switched off. #[test] fn no_frozen_doc_loses_an_envelope_key_unrecorded() { - let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/passport_docs"); let lenses = LensRegistry::new(); let catalog = SectorCatalog::new(); let retired: BTreeSet<&str> = RETIRED_ENVELOPE_KEYS.iter().map(|(k, _)| *k).collect(); - - let mut fixtures: Vec<_> = fs::read_dir(&fixtures_dir) - .expect("read tests/fixtures/passport_docs") - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.extension().is_some_and(|ext| ext == "json")) - .collect(); - fixtures.sort(); + let fixtures = collect_fixtures(); let mut failures = Vec::new(); - for path in &fixtures { + for (_sector, path) in &fixtures { let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); let stored: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path:?} is not JSON: {e}"));