Skip to main content

max / makenotwork

32.5 KB · 786 lines History Blame Raw
1 //! HTTP contract tests for `routes::synckit::auth`, `routes::synckit::apps` and
2 //! `routes::synckit::subscribe`: the SyncKit perimeter a developer's SDK and a
3 //! developer's dashboard actually touch.
4 //!
5 //! What they pin. Token minting decides which SDK key a session is billed
6 //! under and charges a `per_key` app's cap at that moment, so a re-auth under a
7 //! claimed key must be a no-op rather than a second slot (an SDK
8 //! re-authenticates on every cold start). Validation order is a contract too:
9 //! key shape is checked before the app lookup, and swapping them turns the
10 //! endpoint into an api_key oracle. App management answers 403 for a stranger's
11 //! app and 404 for one that does not exist. Rotating the api_key or the
12 //! keys-endpoint secret retires the previous value rather than adding a second
13 //! working one. Subscribe matches its `app_id` against the JWT's app claim, not
14 //! against the caller's own apps.
15 //!
16 //! Delete this file and the dashboard could hand out a key that never stops
17 //! working, a reconnect could spend a paid key slot twice, and an expired or
18 //! wrong-audience token could open a push stream.
19 //!
20 //! Not covered here, and deliberately not named in backticks above so the
21 //! coverage seal cannot credit them: the groups and sync route modules, which
22 //! are too large for one pass, and the SSE stream body, which `synckit_sse`
23 //! covers behind an ignore because an open stream outlives the test.
24
25 use serde::Deserialize;
26 use serde_json::json;
27 use sqlx::PgPool;
28
29 use makenotwork::constants::SYNCKIT_JWT_EXPIRY_SECS;
30 use makenotwork::db::{ProjectId, SyncAppId, UserId};
31 use makenotwork::synckit_auth::{SyncClaims, decode_sync_token};
32
33 use crate::harness::client::TestResponse;
34 use crate::harness::{TestHarness, seed_project, seed_user};
35
36 /// The JWT secret the harness configures. Minting by hand is the only way to
37 /// reach the expired / wrong-audience / empty-key branches of the bearer gate,
38 /// since `create_sync_token` always stamps a live, correct one.
39 const JWT_SECRET: &str = "test-synckit-jwt-secret";
40
41 /// Issuer and audience the sync gate pins. Spelled out rather than imported:
42 /// they are wire values, and a test that silently followed a rename would hide
43 /// that every token in the field had just been invalidated.
44 const SYNC_ISSUER: &str = "makenotwork-synckit";
45 const SYNC_AUDIENCE: &str = "makenotwork-synckit-clients";
46 /// Audience of an OAuth userinfo token: same secret, different audience.
47 const USERINFO_AUDIENCE: &str = "makenotwork-oauth-userinfo";
48
49 const DEV_USER: &str = "skdev";
50 const DEV_EMAIL: &str = "skdev@example.com";
51 const DEV_PASSWORD: &str = "Password1!";
52
53 // ── Response shapes ─────────────────────────────────────────────────────────
54
55 /// `AppWithKey`: the app flattened, plus the plaintext key.
56 #[derive(Deserialize)]
57 struct CreatedApp {
58 id: SyncAppId,
59 name: String,
60 api_key: String,
61 api_key_prefix: String,
62 is_active: bool,
63 }
64
65 #[derive(Deserialize)]
66 struct ListedApp {
67 id: SyncAppId,
68 api_key_prefix: String,
69 project_id: Option<ProjectId>,
70 }
71
72 #[derive(Deserialize)]
73 struct AuthOk {
74 token: String,
75 user_id: UserId,
76 app_id: SyncAppId,
77 }
78
79 #[derive(Deserialize)]
80 struct ValidateAppOk {
81 app_name: String,
82 }
83
84 #[derive(Deserialize)]
85 struct KeysSecretOk {
86 id: SyncAppId,
87 app_secret: String,
88 keys_secret_prefix: Option<String>,
89 }
90
91 #[derive(Deserialize)]
92 struct KeyList {
93 keys: Vec<serde_json::Value>,
94 }
95
96 // ── Helpers ─────────────────────────────────────────────────────────────────
97
98 /// Assert the exact status a handler promised, carrying the body into the
99 /// failure. Exact codes only: `is_success()` passes a 200 where the contract
100 /// says 201, and `is_client_error()` passes the 404 a vanished route answers.
101 #[track_caller]
102 fn status_is(resp: &TestResponse, want: u16, why: &str) {
103 assert_eq!(resp.status, want, "{why}: {}", resp.text);
104 }
105
106 /// Sign up the developer whose session owns every app in these tests.
107 async fn signup_dev(h: &mut TestHarness) -> UserId {
108 h.signup(DEV_USER, DEV_EMAIL, DEV_PASSWORD).await
109 }
110
111 /// `POST /api/sync/apps`.
112 async fn post_app(h: &mut TestHarness, name: &str) -> TestResponse {
113 h.client
114 .post_json("/api/sync/apps", &json!({ "name": name }).to_string())
115 .await
116 }
117
118 /// Create an app through the real handler. Asserts the created status here so
119 /// each caller's own assertions stay about its own subject.
120 async fn create_app(h: &mut TestHarness, name: &str) -> CreatedApp {
121 let resp = post_app(h, name).await;
122 status_is(&resp, 201, &format!("creating app {name}"));
123 resp.json()
124 }
125
126 /// `POST /api/sync/auth` with the developer's credentials.
127 async fn sync_auth(h: &mut TestHarness, api_key: &str, sdk_key: &str) -> TestResponse {
128 let body = json!({
129 "email": DEV_EMAIL,
130 "password": DEV_PASSWORD,
131 "api_key": api_key,
132 "key": sdk_key,
133 });
134 h.client
135 .post_json("/api/sync/auth", &body.to_string())
136 .await
137 }
138
139 /// `POST /api/sync/validate-app`.
140 async fn validate_app(h: &mut TestHarness, api_key: &str) -> TestResponse {
141 let body = json!({ "api_key": api_key });
142 h.client
143 .post_json("/api/sync/validate-app", &body.to_string())
144 .await
145 }
146
147 /// `POST /api/sync/apps/{app}/{leaf}` with no body.
148 async fn post_app_route(h: &mut TestHarness, app: SyncAppId, leaf: &str) -> TestResponse {
149 h.client
150 .post_json(&format!("/api/sync/apps/{app}/{leaf}"), "")
151 .await
152 }
153
154 /// `POST /api/sync/keys/list`, the server-to-server route the keys-endpoint
155 /// secret exists to open.
156 async fn keys_list(h: &mut TestHarness, app_secret: &str) -> TestResponse {
157 let body = json!({ "app_secret": app_secret });
158 h.client
159 .post_json("/api/sync/keys/list", &body.to_string())
160 .await
161 }
162
163 /// `PUT /api/sync/apps/{app}/link` with a raw JSON body.
164 async fn put_link(h: &mut TestHarness, app: SyncAppId, body: serde_json::Value) -> TestResponse {
165 h.client
166 .put_json(&format!("/api/sync/apps/{app}/link"), &body.to_string())
167 .await
168 }
169
170 /// `PUT /api/sync/apps/{app}/slug`.
171 async fn put_slug(h: &mut TestHarness, app: SyncAppId, slug: &str) -> TestResponse {
172 let body = json!({ "slug": slug });
173 h.client
174 .put_json(&format!("/api/sync/apps/{app}/slug"), &body.to_string())
175 .await
176 }
177
178 /// Put the app on the per-key plan with `key_cap` slots. The plan is set by the
179 /// billing routes, which have their own tests, so it is written directly here.
180 async fn set_per_key_plan(pool: &PgPool, app: SyncAppId, key_cap: i32) {
181 sqlx::query(
182 "UPDATE sync_apps SET enforcement_mode = 'per_key', key_cap = $2,
183 is_internal = false WHERE id = $1",
184 )
185 .bind(app)
186 .bind(key_cap)
187 .execute(pool)
188 .await
189 .expect("switch app to the per-key plan");
190 }
191
192 /// Assert how many key slots the app has spent. The counter is the money: a
193 /// slot is a paid unit of the developer's per-key plan.
194 async fn assert_claimed(pool: &PgPool, app: SyncAppId, want: i32, why: &str) {
195 assert_eq!(keys_claimed(pool, app).await, want, "{why}");
196 }
197
198 /// How many key slots the app has spent.
199 async fn keys_claimed(pool: &PgPool, app: SyncAppId) -> i32 {
200 sqlx::query_scalar::<_, i32>(
201 "SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1",
202 )
203 .bind(app)
204 .fetch_one(pool)
205 .await
206 .expect("usage row exists for an app created through the handler")
207 }
208
209 /// The claims a correct, live token carries. Each subscribe case takes this and
210 /// breaks exactly one thing, so the refusal is attributable to that one thing.
211 fn live_claims(user: UserId, app: SyncAppId, now: i64) -> SyncClaims {
212 SyncClaims {
213 sub: user,
214 app,
215 key: "workspace-7".to_string(),
216 iss: SYNC_ISSUER.to_string(),
217 aud: SYNC_AUDIENCE.to_string(),
218 exp: now + 3600,
219 iat: now,
220 }
221 }
222
223 /// Sign claims with the harness secret, however malformed they are.
224 fn sign(claims: &SyncClaims) -> String {
225 jsonwebtoken::encode(
226 &jsonwebtoken::Header::default(),
227 claims,
228 &jsonwebtoken::EncodingKey::from_secret(JWT_SECRET.as_bytes()),
229 )
230 .expect("hand-minted token encodes")
231 }
232
233 // ── routes::synckit::auth ───────────────────────────────────────────────────
234
235 #[tokio::test]
236 async fn the_minted_token_carries_the_caller_the_app_and_the_requested_sdk_key() {
237 let mut h = TestHarness::new().await;
238 let user = signup_dev(&mut h).await;
239 let app = create_app(&mut h, "Ledger").await;
240
241 let before = chrono::Utc::now().timestamp();
242 let resp = sync_auth(&mut h, &app.api_key, "workspace-7").await;
243 status_is(&resp, 200, "auth with live credentials");
244 let body: AuthOk = resp.json();
245 assert_eq!(body.user_id, user, "the token is minted for the caller");
246 assert_eq!(body.app_id, app.id, "and for the app the api_key names");
247
248 // The `key` claim is the billing attribution for everything this session
249 // writes, so a token minted under the wrong key bills another tenant.
250 let claims = decode_sync_token(JWT_SECRET, &body.token).expect("issued token must decode");
251 assert_eq!(claims.sub, user, "sub claim: {}", resp.text);
252 assert_eq!(claims.app, app.id, "app claim: {}", resp.text);
253 assert_eq!(claims.key, "workspace-7", "key claim is the one requested");
254 assert_eq!(claims.iss, SYNC_ISSUER, "issuer is pinned on the wire");
255 assert_eq!(claims.aud, SYNC_AUDIENCE, "audience is pinned on the wire");
256 // A fixed lifetime, not an open-ended token.
257 assert_eq!(claims.exp - claims.iat, SYNCKIT_JWT_EXPIRY_SECS, "lifetime");
258 assert!(claims.iat >= before, "iat stamped at mint: {}", claims.iat);
259 }
260
261 #[tokio::test]
262 async fn sync_auth_validates_the_sdk_key_before_it_looks_up_the_app() {
263 let mut h = TestHarness::new().await;
264 signup_dev(&mut h).await;
265 create_app(&mut h, "Ledger").await;
266 let bogus = "0123456789abcdef0123456789abcdef";
267
268 // If the app lookup ran first this would be a 401, and the 401/422 split
269 // would then tell a caller whether an api_key exists.
270 let resp = sync_auth(&mut h, bogus, "").await;
271 status_is(&resp, 422, "an empty SDK key fails validation first");
272
273 // Same bogus api_key, well-formed SDK key: now it reaches the lookup. This
274 // half is what makes the 422 above attributable to the key check.
275 let resp = sync_auth(&mut h, bogus, "workspace-7").await;
276 status_is(&resp, 401, "a well-formed SDK key reaches the app lookup");
277 }
278
279 #[tokio::test]
280 async fn sync_auth_accepts_a_tab_in_the_sdk_key_and_refuses_other_control_bytes() {
281 let mut h = TestHarness::new().await;
282 signup_dev(&mut h).await;
283 let app = create_app(&mut h, "Ledger").await;
284
285 // Both sides of the control-byte boundary. Tab (0x09) is the one control
286 // byte the rule allows and 0x01 sits just below it; a check written as
287 // "any byte below 0x20" would refuse both.
288 let resp = sync_auth(&mut h, &app.api_key, "work\tspace").await;
289 status_is(&resp, 200, "tab is allowed in an SDK key");
290 let resp = sync_auth(&mut h, &app.api_key, "work\u{1}space").await;
291 status_is(&resp, 422, "a 0x01 byte in the SDK key is refused");
292 }
293
294 #[tokio::test]
295 async fn sync_auth_refuses_a_deactivated_app_even_with_the_right_password() {
296 let mut h = TestHarness::new().await;
297 signup_dev(&mut h).await;
298 let app = create_app(&mut h, "Ledger").await;
299 sqlx::query("UPDATE sync_apps SET is_active = false WHERE id = $1")
300 .bind(app.id)
301 .execute(&h.db)
302 .await
303 .expect("deactivate app");
304
305 let resp = sync_auth(&mut h, &app.api_key, "workspace-7").await;
306 status_is(&resp, 401, "a deactivated app authenticates nobody");
307 }
308
309 #[tokio::test]
310 async fn validate_app_names_a_live_app_and_refuses_an_unknown_key() {
311 let mut h = TestHarness::new().await;
312 signup_dev(&mut h).await;
313 let app = create_app(&mut h, "Ledger Deluxe").await;
314
315 let resp = validate_app(&mut h, &app.api_key).await;
316 status_is(&resp, 200, "validate-app on a live key");
317 let body: ValidateAppOk = resp.json();
318 assert_eq!(body.app_name, "Ledger Deluxe", "app name: {}", resp.text);
319 // The name only: the stored hash is not part of this answer.
320 assert!(
321 !resp.text.contains("api_key_hash"),
322 "no hash: {}",
323 resp.text
324 );
325
326 // One character different: the lookup is over a hash, so a near miss is as
327 // unknown as anything else.
328 let mut near_miss = app.api_key.clone();
329 near_miss.pop();
330 near_miss.push(if app.api_key.ends_with('a') { 'b' } else { 'a' });
331 let resp = validate_app(&mut h, &near_miss).await;
332 status_is(&resp, 401, "a key differing by one character is unknown");
333 }
334
335 #[tokio::test]
336 async fn sync_auth_claims_each_sdk_key_once_and_refuses_the_one_past_the_cap() {
337 let mut h = TestHarness::new().await;
338 signup_dev(&mut h).await;
339 let app = create_app(&mut h, "Ledger").await;
340 // Cap of 2, not 1: at a cap of 1 a `>` and a `>=` comparison agree on every
341 // input, so the boundary would go untested.
342 set_per_key_plan(&h.db, app.id, 2).await;
343 assert_claimed(&h.db, app.id, 0, "new app, no slots").await;
344
345 let resp = sync_auth(&mut h, &app.api_key, "workspace-alpha").await;
346 status_is(&resp, 200, "the first key claims a slot");
347 assert_claimed(&h.db, app.id, 1, "one slot spent").await;
348
349 // Replay. An SDK re-authenticates on every cold start, so the same key
350 // arriving again must cost nothing; charging a second slot would let one
351 // client restarting exhaust a developer's paid allowance.
352 let resp = sync_auth(&mut h, &app.api_key, "workspace-alpha").await;
353 status_is(&resp, 200, "re-auth under a claimed key still mints");
354 assert_claimed(&h.db, app.id, 1, "a claimed key re-auths free").await;
355
356 // Exactly at the cap is still served: 2 of 2 succeeds.
357 let resp = sync_auth(&mut h, &app.api_key, "workspace-beta").await;
358 status_is(&resp, 200, "the key that fills the cap is served");
359 assert_claimed(&h.db, app.id, 2, "second slot spent").await;
360
361 // One past the cap is money-shaped 402, not 400 or 403: the remedy is to
362 // pay for more slots or release one.
363 let resp = sync_auth(&mut h, &app.api_key, "workspace-gamma").await;
364 status_is(&resp, 402, "the key past the cap is Payment Required");
365 let counts = "key limit reached (2 of 2 keys claimed)";
366 assert!(resp.text.contains(counts), "counts stated: {}", resp.text);
367 assert_claimed(&h.db, app.id, 2, "a refusal charges nothing").await;
368 }
369
370 #[tokio::test]
371 async fn sync_auth_claims_no_slots_for_a_bulk_app() {
372 let mut h = TestHarness::new().await;
373 signup_dev(&mut h).await;
374 let app = create_app(&mut h, "Ledger").await;
375
376 // `bulk` is the default plan: storage is billed in aggregate and keys are
377 // not slots. A claim that ran regardless of mode would read 3 below.
378 for key in ["workspace-alpha", "workspace-beta", "workspace-gamma"] {
379 let resp = sync_auth(&mut h, &app.api_key, key).await;
380 status_is(&resp, 200, &format!("bulk app mints for {key}"));
381 }
382 assert_claimed(&h.db, app.id, 0, "a bulk app spends no slots").await;
383 }
384
385 #[tokio::test]
386 async fn an_internal_app_is_not_charged_key_slots_even_on_the_per_key_plan() {
387 let mut h = TestHarness::new().await;
388 signup_dev(&mut h).await;
389 let app = create_app(&mut h, "Ledger").await;
390 // Per-key with a cap of 1, then first-party. Without the is_internal bypass
391 // the second key below would be a 402.
392 set_per_key_plan(&h.db, app.id, 1).await;
393 sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1")
394 .bind(app.id)
395 .execute(&h.db)
396 .await
397 .expect("mark app first-party");
398
399 let resp = sync_auth(&mut h, &app.api_key, "workspace-alpha").await;
400 status_is(&resp, 200, "first key on an internal app");
401 let resp = sync_auth(&mut h, &app.api_key, "workspace-beta").await;
402 status_is(&resp, 200, "a first-party app is uncapped");
403 assert_claimed(&h.db, app.id, 0, "internal spends no slot").await;
404 }
405
406 // ── routes::synckit::apps ───────────────────────────────────────────────────
407
408 #[tokio::test]
409 async fn create_app_returns_201_with_a_working_key_that_is_shown_exactly_once() {
410 let mut h = TestHarness::new().await;
411 let user = signup_dev(&mut h).await;
412 let app = create_app(&mut h, "Ledger").await;
413
414 assert_eq!(app.name, "Ledger", "the app keeps the name it was given");
415 assert!(app.is_active, "a new app is active");
416 assert_eq!(app.api_key.len(), 64, "32 bytes hex: {}", app.api_key);
417 let hex = app.api_key.chars().all(|c| c.is_ascii_hexdigit());
418 assert!(hex, "the key is hex: {}", app.api_key);
419 assert_eq!(app.api_key_prefix, &app.api_key[..8], "prefix is key[..8]");
420
421 // The plaintext is returned on create and never again; the list route is
422 // the dashboard's only other view of an app.
423 let resp = h.client.get("/api/sync/apps").await;
424 status_is(&resp, 200, "listing the caller's apps");
425 assert!(!resp.text.contains(&app.api_key), "no key: {}", resp.text);
426 let listed: Vec<ListedApp> = resp.json();
427 assert_eq!(listed.len(), 1, "one app was created: {}", resp.text);
428 assert_eq!(listed[0].id, app.id, "and it is the one just created");
429 assert_eq!(listed[0].api_key_prefix, app.api_key_prefix, "prefix shown");
430
431 let resp = validate_app(&mut h, &app.api_key).await;
432 status_is(&resp, 200, "the key handed back on create authenticates");
433
434 let owner = sqlx::query_scalar::<_, UserId>("SELECT creator_id FROM sync_apps WHERE id = $1")
435 .bind(app.id)
436 .fetch_one(&h.db)
437 .await
438 .expect("app row");
439 assert_eq!(owner, user, "the creator is the session's user");
440 }
441
442 #[tokio::test]
443 async fn create_app_refuses_an_empty_name_a_control_character_and_an_overlong_one() {
444 let mut h = TestHarness::new().await;
445 signup_dev(&mut h).await;
446
447 let resp = post_app(&mut h, "").await;
448 status_is(&resp, 422, "an empty app name fails validation");
449 let resp = post_app(&mut h, "Led\nger").await;
450 status_is(&resp, 422, "a newline in the app name is refused");
451
452 // 100 characters is the maximum and must be accepted; 101 must not. One
453 // side alone cannot tell `>` from `>=`.
454 let resp = post_app(&mut h, &"n".repeat(100)).await;
455 status_is(&resp, 201, "a name of exactly the maximum is accepted");
456 let resp = post_app(&mut h, &"n".repeat(101)).await;
457 status_is(&resp, 422, "one character past the maximum is refused");
458
459 let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sync_apps")
460 .fetch_one(&h.db)
461 .await
462 .expect("count apps");
463 assert_eq!(count, 1, "only the accepted name created a row");
464 }
465
466 #[tokio::test]
467 async fn app_routes_answer_403_for_a_stranger_and_404_for_an_app_that_does_not_exist() {
468 let mut h = TestHarness::new().await;
469 signup_dev(&mut h).await;
470 let app = create_app(&mut h, "Ledger").await;
471
472 // A second developer with their own session.
473 seed_user(&h.db, "otherdev").await;
474 let mut other = h.client.fork_fresh();
475 other.fetch_csrf_token().await;
476 let resp = other
477 .post_form("/login", "login=otherdev&password=password123")
478 .await;
479 status_is(&resp, 303, "second developer logs in");
480 other.fetch_csrf_token().await;
481
482 // Existing app, wrong owner: 403 on every route that takes an app id.
483 let theirs = format!("/api/sync/apps/{}", app.id);
484 let resp = other
485 .post_json(&format!("{theirs}/regenerate-key"), "")
486 .await;
487 status_is(&resp, 403, "regenerating a stranger's key");
488 let resp = other.post_json(&format!("{theirs}/keys-secret"), "").await;
489 status_is(&resp, 403, "rotating a stranger's app secret");
490 let resp = other.put_json(&format!("{theirs}/link"), "{}").await;
491 status_is(&resp, 403, "relinking a stranger's app");
492 let resp = other
493 .put_json(&format!("{theirs}/slug"), r#"{"slug":"stolen-slug"}"#)
494 .await;
495 status_is(&resp, 403, "renaming a stranger's OTA slug");
496 let resp = other.delete(&theirs).await;
497 status_is(&resp, 403, "deleting a stranger's app");
498
499 // The other side of the boundary: an id nobody owns is 404, from the same
500 // routes that just answered 403.
501 let missing = SyncAppId::new();
502 let resp = other
503 .post_json(&format!("/api/sync/apps/{missing}/regenerate-key"), "")
504 .await;
505 status_is(&resp, 404, "regenerating a key for no app");
506 let resp = other.delete(&format!("/api/sync/apps/{missing}")).await;
507 status_is(&resp, 404, "deleting an app that exists nowhere");
508
509 let resp = other.get("/api/sync/apps").await;
510 status_is(&resp, 200, "stranger lists their apps");
511 let listed: Vec<ListedApp> = resp.json();
512 assert!(listed.is_empty(), "list is caller-scoped: {}", resp.text);
513 let alive = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sync_apps WHERE id = $1")
514 .bind(app.id)
515 .fetch_one(&h.db)
516 .await
517 .expect("count app");
518 assert_eq!(alive, 1, "none of the refused calls touched the app");
519 }
520
521 #[tokio::test]
522 async fn regenerating_the_api_key_retires_the_previous_one() {
523 let mut h = TestHarness::new().await;
524 signup_dev(&mut h).await;
525 let app = create_app(&mut h, "Ledger").await;
526
527 let resp = post_app_route(&mut h, app.id, "regenerate-key").await;
528 status_is(&resp, 200, "regenerate answers 200");
529 let rotated: CreatedApp = resp.json();
530 assert_eq!(rotated.id, app.id, "rotation keeps the same app");
531 assert_ne!(rotated.api_key, app.api_key, "the key is different");
532 let prefix = &rotated.api_key[..8];
533 assert_eq!(rotated.api_key_prefix, prefix, "prefix follows the new key");
534
535 // Retirement is the point: the old key must stop working rather than be
536 // joined by a second working one.
537 let resp = validate_app(&mut h, &app.api_key).await;
538 status_is(&resp, 401, "the superseded key no longer authenticates");
539 let resp = validate_app(&mut h, &rotated.api_key).await;
540 status_is(&resp, 200, "the new key authenticates the same app");
541 }
542
543 #[tokio::test]
544 async fn regenerating_the_keys_secret_retires_the_previous_secret() {
545 let mut h = TestHarness::new().await;
546 signup_dev(&mut h).await;
547 let app = create_app(&mut h, "Ledger").await;
548
549 let resp = post_app_route(&mut h, app.id, "keys-secret").await;
550 status_is(&resp, 200, "first secret issued");
551 let first: KeysSecretOk = resp.json();
552 assert_eq!(first.id, app.id, "the secret belongs to this app");
553 assert_ne!(
554 first.app_secret, app.api_key,
555 "the keys-endpoint secret is not the api_key: the api_key ships inside \
556 client binaries and must not open the server-to-server routes"
557 );
558 let prefix = Some(&first.app_secret[..8]);
559 assert_eq!(first.keys_secret_prefix.as_deref(), prefix, "secret[..8]");
560
561 let resp = keys_list(&mut h, &first.app_secret).await;
562 status_is(&resp, 200, "the issued secret opens the keys routes");
563 let list: KeyList = resp.json();
564 assert!(list.keys.is_empty(), "no key claimed yet: {}", resp.text);
565
566 let resp = post_app_route(&mut h, app.id, "keys-secret").await;
567 status_is(&resp, 200, "second secret issued");
568 let second: KeysSecretOk = resp.json();
569 assert_ne!(second.app_secret, first.app_secret, "a different secret");
570
571 let resp = keys_list(&mut h, &first.app_secret).await;
572 status_is(&resp, 401, "the superseded secret is refused");
573 }
574
575 #[tokio::test]
576 async fn linking_an_app_takes_the_owners_project_and_refuses_everything_else() {
577 let mut h = TestHarness::new().await;
578 let user = signup_dev(&mut h).await;
579 let app = create_app(&mut h, "Ledger").await;
580 let mine = seed_project(&h.db, user, "mine").await;
581 let stranger = seed_user(&h.db, "otherdev").await;
582 let theirs = seed_project(&h.db, stranger, "theirs").await;
583
584 let resp = put_link(&mut h, app.id, json!({ "project_id": mine.to_string() })).await;
585 status_is(&resp, 200, "linking an owned project");
586 let linked: ListedApp = resp.json();
587 assert_eq!(linked.project_id, Some(mine), "linked: {}", resp.text);
588
589 // Someone else's project is 403, and an id for no project at all is 400:
590 // one says not yours, the other says no such project.
591 let resp = put_link(&mut h, app.id, json!({ "project_id": theirs.to_string() })).await;
592 status_is(&resp, 403, "linking a project the caller does not own");
593 let nowhere = ProjectId::new().to_string();
594 let resp = put_link(&mut h, app.id, json!({ "project_id": nowhere })).await;
595 status_is(&resp, 400, "an unknown project id is a bad request");
596
597 // A string that is not a UUID is refused before any lookup.
598 let resp = put_link(&mut h, app.id, json!({ "project_id": "not-a-uuid" })).await;
599 status_is(&resp, 400, "a malformed project id is a bad request");
600 let resp = put_link(&mut h, app.id, json!({ "item_id": "not-a-uuid" })).await;
601 status_is(&resp, 400, "a malformed item id is a bad request");
602
603 let current = sqlx::query_scalar::<_, Option<ProjectId>>(
604 "SELECT project_id FROM sync_apps WHERE id = $1",
605 )
606 .bind(app.id)
607 .fetch_one(&h.db)
608 .await
609 .expect("app row");
610 assert_eq!(current, Some(mine), "no refusal disturbed the live link");
611
612 // An empty string clears the link rather than failing to parse: that is how
613 // the dashboard's "no project" option arrives.
614 let resp = put_link(&mut h, app.id, json!({ "project_id": "" })).await;
615 status_is(&resp, 200, "clearing the link");
616 let cleared: ListedApp = resp.json();
617 assert_eq!(cleared.project_id, None, "cleared: {}", resp.text);
618 }
619
620 #[tokio::test]
621 async fn setting_the_ota_slug_accepts_the_boundary_lengths_and_refuses_outside_them() {
622 let mut h = TestHarness::new().await;
623 signup_dev(&mut h).await;
624 let app = create_app(&mut h, "Ledger").await;
625
626 // 3 and 40 are the inclusive bounds and 2 and 41 the first values outside
627 // them; a comparison written one off would pass on only half of these.
628 let resp = put_slug(&mut h, app.id, "abc").await;
629 status_is(&resp, 204, "a three-character slug is accepted");
630 let resp = put_slug(&mut h, app.id, &"a".repeat(40)).await;
631 status_is(&resp, 204, "a forty-character slug is accepted");
632 let resp = put_slug(&mut h, app.id, "ab").await;
633 status_is(&resp, 400, "a two-character slug is refused");
634 let resp = put_slug(&mut h, app.id, &"a".repeat(41)).await;
635 status_is(&resp, 400, "a forty-one-character slug is refused");
636 let resp = put_slug(&mut h, app.id, "Ledger").await;
637 status_is(&resp, 400, "an uppercase slug is refused");
638 let resp = put_slug(&mut h, app.id, "-ledger").await;
639 status_is(&resp, 400, "a slug starting with a hyphen is refused");
640
641 let stored =
642 sqlx::query_scalar::<_, Option<String>>("SELECT slug FROM sync_apps WHERE id = $1")
643 .bind(app.id)
644 .fetch_one(&h.db)
645 .await
646 .expect("app row");
647 // The last slug that passed: no refusal wrote through.
648 assert_eq!(stored, Some("a".repeat(40)), "stored slug");
649 }
650
651 #[tokio::test]
652 async fn deleting_an_app_returns_204_and_its_key_stops_authenticating() {
653 let mut h = TestHarness::new().await;
654 signup_dev(&mut h).await;
655 let keeper = create_app(&mut h, "Keeper").await;
656 let doomed = create_app(&mut h, "Doomed").await;
657
658 let resp = h
659 .client
660 .delete(&format!("/api/sync/apps/{}", doomed.id))
661 .await;
662 status_is(&resp, 204, "delete answers 204");
663 let resp = validate_app(&mut h, &doomed.api_key).await;
664 status_is(&resp, 401, "a deleted app's key authenticates nothing");
665
666 let resp = h.client.get("/api/sync/apps").await;
667 status_is(&resp, 200, "listing after delete");
668 let listed: Vec<ListedApp> = resp.json();
669 assert_eq!(listed.len(), 1, "one app removed: {}", resp.text);
670 assert_eq!(listed[0].id, keeper.id, "the survivor is the other app");
671 }
672
673 // ── routes::synckit::subscribe ──────────────────────────────────────────────
674
675 #[tokio::test]
676 async fn subscribe_refuses_every_token_the_sync_gate_does_not_accept() {
677 let mut h = TestHarness::new().await;
678 let user = signup_dev(&mut h).await;
679 let app = create_app(&mut h, "Ledger").await;
680 let route = format!("/api/sync/subscribe?app_id={}", app.id);
681 let now = chrono::Utc::now().timestamp();
682
683 let resp = h.client.get(&route).await;
684 status_is(&resp, 401, "a missing bearer token is unauthorized");
685
686 // Expired: issued two hours ago, expired an hour ago, correct in every
687 // other claim, so only the expiry check can be refusing it.
688 let mut claims = live_claims(user, app.id, now);
689 claims.iat = now - 7200;
690 claims.exp = now - 3600;
691 h.client.set_bearer_token(&sign(&claims));
692 let resp = h.client.get(&route).await;
693 status_is(&resp, 401, "an expired token is unauthorized");
694
695 // Minted for the OAuth userinfo audience. Audience pinning is what stops
696 // one credential family being replayed against the other under one secret.
697 let mut claims = live_claims(user, app.id, now);
698 claims.aud = USERINFO_AUDIENCE.to_string();
699 h.client.set_bearer_token(&sign(&claims));
700 let resp = h.client.get(&route).await;
701 status_is(&resp, 401, "a userinfo-audience token cannot reach sync");
702
703 let mut claims = live_claims(user, app.id, now);
704 claims.iss = "someone-elses-issuer".to_string();
705 h.client.set_bearer_token(&sign(&claims));
706 let resp = h.client.get(&route).await;
707 status_is(&resp, 401, "a foreign issuer is unauthorized");
708
709 // Future-dated: a token stamped an hour ahead would outlive any revocation
710 // recorded between now and then, so it is refused outright.
711 let mut claims = live_claims(user, app.id, now);
712 claims.iat = now + 3600;
713 claims.exp = now + 7200;
714 h.client.set_bearer_token(&sign(&claims));
715 let resp = h.client.get(&route).await;
716 status_is(&resp, 401, "a future-dated token is unauthorized");
717
718 // Every write is attributed to the key claim, so a session without one has
719 // nowhere to bill.
720 let mut claims = live_claims(user, app.id, now);
721 claims.key = String::new();
722 h.client.set_bearer_token(&sign(&claims));
723 let resp = h.client.get(&route).await;
724 status_is(&resp, 401, "a token with no SDK key claim is unauthorized");
725
726 // A token for an app that does not exist dies at the liveness gate. Like
727 // the six above it is refused before the handler, so this test opens no
728 // stream and leaks no handler task.
729 let mut claims = live_claims(user, app.id, now);
730 claims.app = SyncAppId::new();
731 h.client.set_bearer_token(&sign(&claims));
732 let resp = h.client.get(&route).await;
733 status_is(&resp, 401, "a token for a nonexistent app is unauthorized");
734 }
735
736 #[tokio::test]
737 async fn subscribe_matches_the_app_id_against_the_token_not_the_callers_other_apps() {
738 let mut h = TestHarness::new().await;
739 signup_dev(&mut h).await;
740 let first = create_app(&mut h, "Ledger").await;
741 let second = create_app(&mut h, "Ledger Two").await;
742
743 let resp = sync_auth(&mut h, &first.api_key, "workspace-7").await;
744 status_is(&resp, 200, "auth for the first app");
745 let auth: AuthOk = resp.json();
746 h.client.set_bearer_token(&auth.token);
747
748 // Both apps belong to the caller and both are live, so an ownership check
749 // would let this through. The contract is stricter: a stream is bound to
750 // the app its token was minted for.
751 let resp = h
752 .client
753 .get(&format!("/api/sync/subscribe?app_id={}", second.id))
754 .await;
755 status_is(&resp, 400, "one app's token cannot open another's stream");
756 assert!(
757 resp.text
758 .contains("app_id does not match authenticated session"),
759 "the refusal names the mismatch: {}",
760 resp.text
761 );
762 }
763
764 #[tokio::test]
765 async fn subscribe_refuses_a_token_for_an_app_that_has_since_been_deleted() {
766 let mut h = TestHarness::new().await;
767 signup_dev(&mut h).await;
768 let app = create_app(&mut h, "Ledger").await;
769
770 let resp = sync_auth(&mut h, &app.api_key, "workspace-7").await;
771 status_is(&resp, 200, "auth before deletion");
772 let auth: AuthOk = resp.json();
773
774 // Deleting from the dashboard must kill the sessions that app minted.
775 let resp = h.client.delete(&format!("/api/sync/apps/{}", app.id)).await;
776 status_is(&resp, 204, "deleting the app");
777
778 h.client.set_bearer_token(&auth.token);
779 let resp = h
780 .client
781 .get(&format!("/api/sync/subscribe?app_id={}", app.id))
782 .await;
783 // Not a 400, and not a live stream.
784 status_is(&resp, 401, "a token for a deleted app is unauthorized");
785 }
786