Skip to main content

max / synckit

9.0 KB · 249 lines History Blame Raw
1 //! The wire contract, checked against the server's OpenAPI spec.
2 //!
3 //! Every other module in this suite asserts against fixtures this crate wrote
4 //! itself, and the MNW server asserts its `/api/v1/sync/*` routes against its
5 //! own harness. Neither side depends on the other, so both suites can be green
6 //! while the two disagree, and the disagreement surfaces in production. The
7 //! contract used to be held by a comment in the server
8 //! (`routes/synckit/mod.rs`: "Shape matches synckit_client::SubscriptionStatus").
9 //!
10 //! The fix is not to make the two suites talk to each other. Two implementations
11 //! of one interface must be checked against the interface, never against each
12 //! other's imitation of it. `tests/openapi.json` is that interface: the server
13 //! generates it from its handlers (`cargo run --bin export-openapi`) and a test
14 //! there fails if the committed copy drifts from the code.
15 //!
16 //! Refreshing the vendored copy is a deliberate step, and the diff is the point:
17 //! a changed response schema shows up as a review-sized change here rather than
18 //! as a production incident.
19 //!
20 //! Rationale: wiki `testing-posture`, the "imitation oracle" section.
21
22 use crate::common::*;
23
24 /// The vendored spec, resolved at compile time.
25 ///
26 /// `CARGO_MANIFEST_DIR` rather than a relative path so the suite still finds it
27 /// when cargo-mutants copies the crate to a temp directory and runs there.
28 const SPEC_JSON: &str = include_str!("../openapi.json");
29
30 fn spec() -> serde_json::Value {
31 serde_json::from_str(SPEC_JSON).expect("the vendored spec is valid JSON")
32 }
33
34 /// Validate `body` against the named schema in `components/schemas`.
35 ///
36 /// jsonschema resolves `$ref` against the document root, so the whole spec is
37 /// handed over as the schema document with a `$ref` bolted on at the top. That
38 /// keeps every internal pointer working without inlining anything by hand.
39 fn assert_matches_schema(schema_name: &str, body: &serde_json::Value) {
40 let mut doc = spec();
41 doc.as_object_mut().expect("spec root is an object").insert(
42 "$ref".to_string(),
43 serde_json::Value::String(format!("#/components/schemas/{schema_name}")),
44 );
45
46 let validator = jsonschema::validator_for(&doc)
47 .unwrap_or_else(|e| panic!("{schema_name} is not a usable schema: {e}"));
48
49 let errors: Vec<String> = validator.iter_errors(body).map(|e| e.to_string()).collect();
50 assert!(
51 errors.is_empty(),
52 "fixture does not match the server's {schema_name} schema:\n {}\n\nfixture was:\n{}",
53 errors.join("\n "),
54 serde_json::to_string_pretty(body).unwrap_or_default(),
55 );
56 }
57
58 // The shared fixtures
59
60 #[test]
61 fn auth_response_fixture_matches_the_spec() {
62 assert_matches_schema("SyncAuthResponse", &auth_response_json());
63 }
64
65 #[test]
66 fn device_fixture_matches_the_spec() {
67 assert_matches_schema("SyncDeviceResponse", &device_json());
68 }
69
70 // Per-endpoint response fixtures
71 //
72 // These mirror the bodies the modules named in each test actually mount. A
73 // fixture that drifts from its module is a bug in this file, and the module's
74 // own assertions still cover behaviour; what these add is that the shape the
75 // module assumes is the shape the server documents.
76
77 #[test]
78 fn push_response_fixture_matches_the_spec() {
79 // sync.rs: `json!({"cursor": 1})`
80 assert_matches_schema("PushResponse", &json!({"cursor": 1}));
81 }
82
83 #[test]
84 fn pull_response_fixture_matches_the_spec() {
85 // sync.rs, the paginated form.
86 assert_matches_schema(
87 "PullResponse",
88 &json!({"changes": [], "cursor": 50, "has_more": true}),
89 );
90 }
91
92 #[test]
93 fn pull_change_entry_fixture_matches_the_spec() {
94 // sync.rs, one decryptable row.
95 let (_user_id, _app_id) = test_ids();
96 assert_matches_schema(
97 "PullChangeEntry",
98 &json!({
99 "seq": 1,
100 "device_id": Uuid::new_v4(),
101 "table": "tasks",
102 "op": "INSERT",
103 "row_id": "row-1",
104 "timestamp": "2025-06-01T12:00:00Z",
105 "data": "ciphertext",
106 }),
107 );
108 }
109
110 #[test]
111 fn blob_upload_url_fixture_matches_the_spec() {
112 // blob.rs
113 assert_matches_schema(
114 "BlobUploadUrlResponse",
115 &json!({"upload_url": "https://s3.example.com/put", "already_exists": false}),
116 );
117 }
118
119 #[test]
120 fn blob_download_url_fixture_matches_the_spec() {
121 // blob.rs
122 assert_matches_schema(
123 "BlobDownloadUrlResponse",
124 &json!({"download_url": "https://s3.example.com/get"}),
125 );
126 }
127
128 // Endpoint coverage
129
130 /// Static paths this client calls. Mostly `Endpoints::new` in
131 /// `src/client/mod.rs`, plus `validate-app`, which builds its URL inline at
132 /// `client/mod.rs:554` instead of going through `Endpoints`.
133 ///
134 /// Kept as a literal list rather than reaching into the private type: the
135 /// duplication is the point, because [`every_client_path_is_listed_here`] fails
136 /// if the two ever diverge, and a list is what the ratchet below can be stated
137 /// against. That test is also what caught `validate-app`, which reading
138 /// `Endpoints::new` alone would have missed.
139 const CLIENT_PATHS: &[&str] = &[
140 "/api/v1/sync/auth",
141 "/api/v1/sync/validate-app",
142 "/api/v1/sync/devices",
143 "/api/v1/sync/push",
144 "/api/v1/sync/pull",
145 "/api/v1/sync/subscribe",
146 "/api/v1/sync/status",
147 "/api/v1/sync/keys",
148 "/api/v1/sync/blobs/upload",
149 "/api/v1/sync/blobs/confirm",
150 "/api/v1/sync/blobs/download",
151 "/api/v1/sync/blobs/multipart/start",
152 "/api/v1/sync/blobs/multipart/parts",
153 "/api/v1/sync/blobs/multipart/complete",
154 "/api/v1/sync/blobs/multipart/abort",
155 "/api/v1/sync/subscription",
156 "/api/v1/sync/subscription/checkout",
157 "/api/v1/sync/subscription/quote",
158 "/api/v1/sync/subscription/storage-cap",
159 "/api/v1/sync/app/pricing",
160 "/api/v1/sync/account",
161 "/api/v1/sync/ota",
162 "/api/v1/sync/groups",
163 "/api/v1/sync/invitations",
164 ];
165
166 /// Client paths the server's spec does not document, pinned so the number can
167 /// only go down.
168 ///
169 /// This is a real gap, not a formatting quirk. `subscribe` is the SSE stream the
170 /// whole reconnect state machine in `subscribe.rs` is built on, and the OTA and
171 /// group surfaces are entire feature areas. An undocumented endpoint has no
172 /// schema, so nothing here or on the server checks that the two agree about it,
173 /// which is precisely the condition this module exists to remove.
174 ///
175 /// Fix one by annotating the handler and adding it to `openapi::ApiDoc`, then
176 /// re-exporting and re-vendoring the spec. Never add to this list: a new
177 /// undocumented endpoint is the thing it exists to refuse.
178 ///
179 /// `invitations` (2026-08-06) is the one entry added after that rule was written,
180 /// and the reason is worth stating rather than leaving as an apparent violation.
181 /// Its handlers *are* annotated. It is undocumented because it is part of the
182 /// group surface, and `groups` as a whole is absent from `openapi::ApiDoc`, whose
183 /// stated scope is "public/stable endpoints". Promoting groups into the published
184 /// spec is a product call about what SyncKit commits to third parties, not a
185 /// cleanup, so invitations sits with the rest of its feature area until that call
186 /// is made. Documenting groups removes both lines at once.
187 const UNDOCUMENTED: &[&str] = &[
188 "/api/v1/sync/subscribe",
189 "/api/v1/sync/ota",
190 "/api/v1/sync/groups",
191 "/api/v1/sync/invitations",
192 ];
193
194 fn documented_paths() -> std::collections::BTreeSet<String> {
195 spec()["paths"]
196 .as_object()
197 .expect("spec has a paths object")
198 .keys()
199 .cloned()
200 .collect()
201 }
202
203 #[test]
204 fn every_client_path_is_documented_or_pinned_as_a_known_gap() {
205 let documented = documented_paths();
206 let missing: Vec<&str> = CLIENT_PATHS
207 .iter()
208 .copied()
209 .filter(|p| !documented.contains(*p))
210 .collect();
211
212 assert_eq!(
213 missing, UNDOCUMENTED,
214 "the set of undocumented client endpoints changed.\n\
215 If it shrank, remove the fixed entries from UNDOCUMENTED.\n\
216 If it grew, the new endpoint needs a utoipa annotation on the server \
217 and a re-exported spec, not an entry here."
218 );
219 }
220
221 /// The list above is hand-maintained, so this checks it against the source it
222 /// mirrors. Deliberately dumb: it greps `client/mod.rs` for `/api/v1/sync`
223 /// literals. A rule cheap enough to state as "which paths appear in the client"
224 /// is a rule that stays honest.
225 #[test]
226 fn every_client_path_is_listed_here() {
227 let src = include_str!("../../src/client/mod.rs");
228 let mut found: Vec<String> = Vec::new();
229 for (idx, _) in src.match_indices("/api/v1/sync") {
230 let tail = &src[idx..];
231 let end = tail
232 .find(|c: char| !(c.is_ascii_alphanumeric() || "/-_".contains(c)))
233 .unwrap_or(tail.len());
234 let path = tail[..end].trim_end_matches('/').to_string();
235 if !found.contains(&path) {
236 found.push(path);
237 }
238 }
239 found.sort();
240
241 let mut listed: Vec<String> = CLIENT_PATHS.iter().map(|s| (*s).to_string()).collect();
242 listed.sort();
243
244 assert_eq!(
245 found, listed,
246 "CLIENT_PATHS has drifted from Endpoints::new in src/client/mod.rs"
247 );
248 }
249