//! The wire contract, checked against the server's OpenAPI spec. //! //! Every other module in this suite asserts against fixtures this crate wrote //! itself, and the MNW server asserts its `/api/v1/sync/*` routes against its //! own harness. Neither side depends on the other, so both suites can be green //! while the two disagree, and the disagreement surfaces in production. The //! contract used to be held by a comment in the server //! (`routes/synckit/mod.rs`: "Shape matches synckit_client::SubscriptionStatus"). //! //! The fix is not to make the two suites talk to each other. Two implementations //! of one interface must be checked against the interface, never against each //! other's imitation of it. `tests/openapi.json` is that interface: the server //! generates it from its handlers (`cargo run --bin export-openapi`) and a test //! there fails if the committed copy drifts from the code. //! //! Refreshing the vendored copy is a deliberate step, and the diff is the point: //! a changed response schema shows up as a review-sized change here rather than //! as a production incident. //! //! Rationale: wiki `testing-posture`, the "imitation oracle" section. use crate::common::*; /// The vendored spec, resolved at compile time. /// /// `CARGO_MANIFEST_DIR` rather than a relative path so the suite still finds it /// when cargo-mutants copies the crate to a temp directory and runs there. const SPEC_JSON: &str = include_str!("../openapi.json"); fn spec() -> serde_json::Value { serde_json::from_str(SPEC_JSON).expect("the vendored spec is valid JSON") } /// Validate `body` against the named schema in `components/schemas`. /// /// jsonschema resolves `$ref` against the document root, so the whole spec is /// handed over as the schema document with a `$ref` bolted on at the top. That /// keeps every internal pointer working without inlining anything by hand. fn assert_matches_schema(schema_name: &str, body: &serde_json::Value) { let mut doc = spec(); doc.as_object_mut().expect("spec root is an object").insert( "$ref".to_string(), serde_json::Value::String(format!("#/components/schemas/{schema_name}")), ); let validator = jsonschema::validator_for(&doc) .unwrap_or_else(|e| panic!("{schema_name} is not a usable schema: {e}")); let errors: Vec = validator.iter_errors(body).map(|e| e.to_string()).collect(); assert!( errors.is_empty(), "fixture does not match the server's {schema_name} schema:\n {}\n\nfixture was:\n{}", errors.join("\n "), serde_json::to_string_pretty(body).unwrap_or_default(), ); } // The shared fixtures #[test] fn auth_response_fixture_matches_the_spec() { assert_matches_schema("SyncAuthResponse", &auth_response_json()); } #[test] fn device_fixture_matches_the_spec() { assert_matches_schema("SyncDeviceResponse", &device_json()); } // Per-endpoint response fixtures // // These mirror the bodies the modules named in each test actually mount. A // fixture that drifts from its module is a bug in this file, and the module's // own assertions still cover behaviour; what these add is that the shape the // module assumes is the shape the server documents. #[test] fn push_response_fixture_matches_the_spec() { // sync.rs: `json!({"cursor": 1})` assert_matches_schema("PushResponse", &json!({"cursor": 1})); } #[test] fn pull_response_fixture_matches_the_spec() { // sync.rs, the paginated form. assert_matches_schema( "PullResponse", &json!({"changes": [], "cursor": 50, "has_more": true}), ); } #[test] fn pull_change_entry_fixture_matches_the_spec() { // sync.rs, one decryptable row. let (_user_id, _app_id) = test_ids(); assert_matches_schema( "PullChangeEntry", &json!({ "seq": 1, "device_id": Uuid::new_v4(), "table": "tasks", "op": "INSERT", "row_id": "row-1", "timestamp": "2025-06-01T12:00:00Z", "data": "ciphertext", }), ); } #[test] fn blob_upload_url_fixture_matches_the_spec() { // blob.rs assert_matches_schema( "BlobUploadUrlResponse", &json!({"upload_url": "https://s3.example.com/put", "already_exists": false}), ); } #[test] fn blob_download_url_fixture_matches_the_spec() { // blob.rs assert_matches_schema( "BlobDownloadUrlResponse", &json!({"download_url": "https://s3.example.com/get"}), ); } // Endpoint coverage /// Static paths this client calls. Mostly `Endpoints::new` in /// `src/client/mod.rs`, plus `validate-app`, which builds its URL inline at /// `client/mod.rs:554` instead of going through `Endpoints`. /// /// Kept as a literal list rather than reaching into the private type: the /// duplication is the point, because [`every_client_path_is_listed_here`] fails /// if the two ever diverge, and a list is what the ratchet below can be stated /// against. That test is also what caught `validate-app`, which reading /// `Endpoints::new` alone would have missed. const CLIENT_PATHS: &[&str] = &[ "/api/v1/sync/auth", "/api/v1/sync/validate-app", "/api/v1/sync/devices", "/api/v1/sync/push", "/api/v1/sync/pull", "/api/v1/sync/subscribe", "/api/v1/sync/status", "/api/v1/sync/keys", "/api/v1/sync/blobs/upload", "/api/v1/sync/blobs/confirm", "/api/v1/sync/blobs/download", "/api/v1/sync/blobs/multipart/start", "/api/v1/sync/blobs/multipart/parts", "/api/v1/sync/blobs/multipart/complete", "/api/v1/sync/blobs/multipart/abort", "/api/v1/sync/subscription", "/api/v1/sync/subscription/checkout", "/api/v1/sync/subscription/quote", "/api/v1/sync/subscription/storage-cap", "/api/v1/sync/app/pricing", "/api/v1/sync/account", "/api/v1/sync/ota", "/api/v1/sync/groups", "/api/v1/sync/invitations", ]; /// Client paths the server's spec does not document, pinned so the number can /// only go down. /// /// This is a real gap, not a formatting quirk. `subscribe` is the SSE stream the /// whole reconnect state machine in `subscribe.rs` is built on, and the OTA and /// group surfaces are entire feature areas. An undocumented endpoint has no /// schema, so nothing here or on the server checks that the two agree about it, /// which is precisely the condition this module exists to remove. /// /// Fix one by annotating the handler and adding it to `openapi::ApiDoc`, then /// re-exporting and re-vendoring the spec. Never add to this list: a new /// undocumented endpoint is the thing it exists to refuse. /// /// `invitations` (2026-08-06) is the one entry added after that rule was written, /// and the reason is worth stating rather than leaving as an apparent violation. /// Its handlers *are* annotated. It is undocumented because it is part of the /// group surface, and `groups` as a whole is absent from `openapi::ApiDoc`, whose /// stated scope is "public/stable endpoints". Promoting groups into the published /// spec is a product call about what SyncKit commits to third parties, not a /// cleanup, so invitations sits with the rest of its feature area until that call /// is made. Documenting groups removes both lines at once. const UNDOCUMENTED: &[&str] = &[ "/api/v1/sync/subscribe", "/api/v1/sync/ota", "/api/v1/sync/groups", "/api/v1/sync/invitations", ]; fn documented_paths() -> std::collections::BTreeSet { spec()["paths"] .as_object() .expect("spec has a paths object") .keys() .cloned() .collect() } #[test] fn every_client_path_is_documented_or_pinned_as_a_known_gap() { let documented = documented_paths(); let missing: Vec<&str> = CLIENT_PATHS .iter() .copied() .filter(|p| !documented.contains(*p)) .collect(); assert_eq!( missing, UNDOCUMENTED, "the set of undocumented client endpoints changed.\n\ If it shrank, remove the fixed entries from UNDOCUMENTED.\n\ If it grew, the new endpoint needs a utoipa annotation on the server \ and a re-exported spec, not an entry here." ); } /// The list above is hand-maintained, so this checks it against the source it /// mirrors. Deliberately dumb: it greps `client/mod.rs` for `/api/v1/sync` /// literals. A rule cheap enough to state as "which paths appear in the client" /// is a rule that stays honest. #[test] fn every_client_path_is_listed_here() { let src = include_str!("../../src/client/mod.rs"); let mut found: Vec = Vec::new(); for (idx, _) in src.match_indices("/api/v1/sync") { let tail = &src[idx..]; let end = tail .find(|c: char| !(c.is_ascii_alphanumeric() || "/-_".contains(c))) .unwrap_or(tail.len()); let path = tail[..end].trim_end_matches('/').to_string(); if !found.contains(&path) { found.push(path); } } found.sort(); let mut listed: Vec = CLIENT_PATHS.iter().map(|s| (*s).to_string()).collect(); listed.sort(); assert_eq!( found, listed, "CLIENT_PATHS has drifted from Endpoints::new in src/client/mod.rs" ); }