Skip to main content

max / synckit

Cover the OTA client surface over the mock server The five ota_* methods had no integration test, so nothing checked the URLs the SDK builds from the session app id and the release id. Add ota.rs with exact path matchers for create, register and confirm, a presigned PUT that asserts the bytes go up unencrypted, and an updater check covering both the manifest and the 204 up-to-date answer.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 22:27 UTC
Signed with PGP, not checked
Commit: 03a9e71f758a4b234235ed9fdac6ae0f98f18174
Parent: 682c0d3
2 files changed, +203 insertions, -0 deletions
@@ -18,6 +18,7 @@
18 18 mod device;
19 19 mod encryption;
20 20 mod group_rotation;
21 + mod ota;
21 22 // `rotate_key` finishes by caching the new key through `keystore::store_key`,
22 23 // which hits the OS secret service under `keychain` and is unavailable on a
23 24 // headless host. With the feature off it is the no-op stub, so the orchestration
@@ -1,0 +1,202 @@
1 + //! OTA release publishing.
2 +
3 + // ── The publisher's sequence ──
4 + //
5 + // Four authenticated calls in a fixed order (create, register, upload, confirm)
6 + // plus one public check. Each writes to a URL the SDK builds itself out of the
7 + // session's app id and the release id the server handed back, so the exact path
8 + // is the contract: a release created at the wrong URL is a 404 the publisher
9 + // only finds out about at release time.
10 + use crate::common::*;
11 + use uuid::Uuid;
12 +
13 + const APP: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
14 + const RELEASE: &str = "11111111-2222-3333-4444-555555555555";
15 +
16 + fn release_id() -> Uuid {
17 + Uuid::parse_str(RELEASE).unwrap()
18 + }
19 +
20 + fn releases_path() -> String {
21 + format!("/api/v1/sync/ota/apps/{APP}/releases")
22 + }
23 +
24 + fn artifacts_path() -> String {
25 + format!("{}/{RELEASE}/artifacts", releases_path())
26 + }
27 +
28 + fn confirm_path() -> String {
29 + format!("{}/confirm", artifacts_path())
30 + }
31 +
32 + #[tokio::test]
33 + async fn create_release_posts_to_the_app_releases_url() {
34 + let kit = MockKit::start().await;
35 + let client = kit.authed();
36 +
37 + kit.post(&releases_path())
38 + .json(json!({
39 + "id": RELEASE,
40 + "version": "0.4.1",
41 + "notes": "Bug fixes",
42 + }))
43 + .await;
44 +
45 + let release = client
46 + .ota_create_release("0.4.1", "Bug fixes")
47 + .await
48 + .unwrap();
49 + assert_eq!(release.id, release_id());
50 + assert_eq!(release.version, "0.4.1");
51 + assert_eq!(release.notes, "Bug fixes");
52 +
53 + // The app id comes from the session, not from the caller, so the path is
54 + // the only place a wrong session would show.
55 + assert_eq!(kit.hits(&releases_path()).await, 1);
56 + let body = kit.body(&releases_path()).await;
57 + assert_eq!(body["version"], "0.4.1");
58 + assert_eq!(body["notes"], "Bug fixes");
59 +
60 + let req = &kit.requests_to(&releases_path()).await[0];
61 + assert!(
62 + req.headers
63 + .get("authorization")
64 + .expect("the publisher call is authenticated")
65 + .to_str()
66 + .unwrap()
67 + .starts_with("Bearer "),
68 + );
69 + }
70 +
71 + #[tokio::test]
72 + async fn register_artifact_posts_under_its_release_and_returns_the_upload_target() {
73 + let kit = MockKit::start().await;
74 + let client = kit.authed();
75 +
76 + kit.post(&artifacts_path())
77 + .json(json!({
78 + "upload_url": "https://s3.example/put?sig=abc",
79 + "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact",
80 + }))
81 + .await;
82 +
83 + let upload = client
84 + .ota_register_artifact(release_id(), "darwin", "aarch64", 12_345, "RWS...==")
85 + .await
86 + .unwrap();
87 + assert_eq!(upload.upload_url, "https://s3.example/put?sig=abc");
88 + assert_eq!(upload.s3_key, "ota/app/0.4.1/darwin/aarch64/artifact");
89 +
90 + assert_eq!(kit.hits(&artifacts_path()).await, 1);
91 + let body = kit.body(&artifacts_path()).await;
92 + assert_eq!(body["target"], "darwin");
93 + assert_eq!(body["arch"], "aarch64");
94 + assert_eq!(body["file_size"], 12_345);
95 + assert_eq!(body["signature"], "RWS...==");
96 + }
97 +
98 + #[tokio::test]
99 + async fn confirm_artifact_posts_to_the_confirm_url_under_its_release() {
100 + let kit = MockKit::start().await;
101 + let client = kit.authed();
102 +
103 + // Both routes are mounted so a confirm that went to the register URL would
104 + // still get a 200 and be caught by the hit counts rather than by an error.
105 + kit.post(&artifacts_path()).json(json!({})).await;
106 + kit.post(&confirm_path()).code(204).empty().await;
107 +
108 + client
109 + .ota_confirm_artifact(release_id(), "linux", "x86_64")
110 + .await
111 + .unwrap();
112 +
113 + assert_eq!(kit.hits(&confirm_path()).await, 1);
114 + assert_eq!(kit.hits(&artifacts_path()).await, 0);
115 + let body = kit.body(&confirm_path()).await;
116 + assert_eq!(body["target"], "linux");
117 + assert_eq!(body["arch"], "x86_64");
118 + }
119 +
120 + #[tokio::test]
121 + async fn upload_artifact_puts_the_bytes_unencrypted_to_the_presigned_url() {
122 + let kit = MockKit::start().await;
123 + let client = kit.authed();
124 +
125 + const PRESIGNED: &str = "/s3/ota-artifact";
126 + kit.put(PRESIGNED).code(200).empty().await;
127 +
128 + let bytes = b"a tauri bundle, signed elsewhere".to_vec();
129 + client
130 + .ota_upload_artifact(&kit.url(PRESIGNED), bytes.clone())
131 + .await
132 + .unwrap();
133 +
134 + let req = &kit.requests_to(PRESIGNED).await[0];
135 + // OTA artifacts are public downloads, so the bytes go up as they are: an
136 + // encrypted one would fail Tauri's signature check on every installed app.
137 + assert_eq!(req.body, bytes);
138 + assert_eq!(
139 + req.headers.get("content-type").unwrap().to_str().unwrap(),
140 + "application/octet-stream"
141 + );
142 + }
143 +
144 + #[tokio::test]
145 + async fn updater_check_reads_the_public_slug_url_and_maps_204_to_no_update() {
146 + let kit = MockKit::start().await;
147 + let client = kit.client();
148 +
149 + let up_to_date = "/api/v1/sync/ota/goingson/darwin/aarch64/0.4.1";
150 + let behind = "/api/v1/sync/ota/goingson/darwin/aarch64/0.4.0";
151 +
152 + kit.get(up_to_date).code(204).empty().await;
153 + kit.get(behind)
154 + .json(json!({
155 + "version": "0.4.1",
156 + "url": "https://makenot.work/api/v1/sync/ota/download/abc",
157 + "signature": "RWS=",
158 + "notes": "Bug fixes",
159 + "pub_date": "2026-06-07T00:00:00+00:00",
160 + }))
161 + .await;
162 +
163 + // The updater endpoint is unauthenticated, so this runs on a client with no
164 + // session at all: the version in the path is what selects the answer.
165 + let manifest = client
166 + .ota_updater_check("goingson", "darwin", "aarch64", "0.4.0")
167 + .await
168 + .unwrap()
169 + .expect("a newer version is offered");
170 + assert_eq!(manifest.version, "0.4.1");
171 + assert_eq!(manifest.signature, "RWS=");
172 + assert_eq!(manifest.notes, "Bug fixes");
173 + assert_eq!(manifest.pub_date, "2026-06-07T00:00:00+00:00");
174 +
175 + assert!(
176 + client
177 + .ota_updater_check("goingson", "darwin", "aarch64", "0.4.1")
178 + .await
179 + .unwrap()
180 + .is_none(),
181 + "204 means the caller is current"
182 + );
183 +
184 + assert_eq!(kit.hits(behind).await, 1);
185 + assert_eq!(kit.hits(up_to_date).await, 1);
186 + }
187 +
188 + #[tokio::test]
189 + async fn a_publisher_call_without_a_session_never_reaches_the_wire() {
190 + let kit = MockKit::start().await;
191 + let client = kit.client();
192 +
193 + kit.post(&releases_path()).json(json!({})).await;
194 +
195 + let err = client.ota_create_release("0.4.1", "").await.unwrap_err();
196 + assert!(matches!(err, SyncKitError::NotAuthenticated), "got {err:?}");
197 + assert_eq!(
198 + kit.requests().await.len(),
199 + 0,
200 + "the session check happens before the URL is built"
201 + );
202 + }