//! HTTP contract tests for `routes::synckit::auth`, `routes::synckit::apps` and //! `routes::synckit::subscribe`: the SyncKit perimeter a developer's SDK and a //! developer's dashboard actually touch. //! //! What they pin. Token minting decides which SDK key a session is billed //! under and charges a `per_key` app's cap at that moment, so a re-auth under a //! claimed key must be a no-op rather than a second slot (an SDK //! re-authenticates on every cold start). Validation order is a contract too: //! key shape is checked before the app lookup, and swapping them turns the //! endpoint into an api_key oracle. App management answers 403 for a stranger's //! app and 404 for one that does not exist. Rotating the api_key or the //! keys-endpoint secret retires the previous value rather than adding a second //! working one. Subscribe matches its `app_id` against the JWT's app claim, not //! against the caller's own apps. //! //! Delete this file and the dashboard could hand out a key that never stops //! working, a reconnect could spend a paid key slot twice, and an expired or //! wrong-audience token could open a push stream. //! //! Not covered here, and deliberately not named in backticks above so the //! coverage seal cannot credit them: the groups and sync route modules, which //! are too large for one pass, and the SSE stream body, which `synckit_sse` //! covers behind an ignore because an open stream outlives the test. use serde::Deserialize; use serde_json::json; use sqlx::PgPool; use makenotwork::constants::SYNCKIT_JWT_EXPIRY_SECS; use makenotwork::db::{ProjectId, SyncAppId, UserId}; use makenotwork::synckit_auth::{SyncClaims, decode_sync_token}; use crate::harness::client::TestResponse; use crate::harness::{TestHarness, seed_project, seed_user}; /// The JWT secret the harness configures. Minting by hand is the only way to /// reach the expired / wrong-audience / empty-key branches of the bearer gate, /// since `create_sync_token` always stamps a live, correct one. const JWT_SECRET: &str = "test-synckit-jwt-secret"; /// Issuer and audience the sync gate pins. Spelled out rather than imported: /// they are wire values, and a test that silently followed a rename would hide /// that every token in the field had just been invalidated. const SYNC_ISSUER: &str = "makenotwork-synckit"; const SYNC_AUDIENCE: &str = "makenotwork-synckit-clients"; /// Audience of an OAuth userinfo token: same secret, different audience. const USERINFO_AUDIENCE: &str = "makenotwork-oauth-userinfo"; const DEV_USER: &str = "skdev"; const DEV_EMAIL: &str = "skdev@example.com"; const DEV_PASSWORD: &str = "Password1!"; // ── Response shapes ───────────────────────────────────────────────────────── /// `AppWithKey`: the app flattened, plus the plaintext key. #[derive(Deserialize)] struct CreatedApp { id: SyncAppId, name: String, api_key: String, api_key_prefix: String, is_active: bool, } #[derive(Deserialize)] struct ListedApp { id: SyncAppId, api_key_prefix: String, project_id: Option, } #[derive(Deserialize)] struct AuthOk { token: String, user_id: UserId, app_id: SyncAppId, } #[derive(Deserialize)] struct ValidateAppOk { app_name: String, } #[derive(Deserialize)] struct KeysSecretOk { id: SyncAppId, app_secret: String, keys_secret_prefix: Option, } #[derive(Deserialize)] struct KeyList { keys: Vec, } // ── Helpers ───────────────────────────────────────────────────────────────── /// Assert the exact status a handler promised, carrying the body into the /// failure. Exact codes only: `is_success()` passes a 200 where the contract /// says 201, and `is_client_error()` passes the 404 a vanished route answers. #[track_caller] fn status_is(resp: &TestResponse, want: u16, why: &str) { assert_eq!(resp.status, want, "{why}: {}", resp.text); } /// Sign up the developer whose session owns every app in these tests. async fn signup_dev(h: &mut TestHarness) -> UserId { h.signup(DEV_USER, DEV_EMAIL, DEV_PASSWORD).await } /// `POST /api/sync/apps`. async fn post_app(h: &mut TestHarness, name: &str) -> TestResponse { h.client .post_json("/api/sync/apps", &json!({ "name": name }).to_string()) .await } /// Create an app through the real handler. Asserts the created status here so /// each caller's own assertions stay about its own subject. async fn create_app(h: &mut TestHarness, name: &str) -> CreatedApp { let resp = post_app(h, name).await; status_is(&resp, 201, &format!("creating app {name}")); resp.json() } /// `POST /api/sync/auth` with the developer's credentials. async fn sync_auth(h: &mut TestHarness, api_key: &str, sdk_key: &str) -> TestResponse { let body = json!({ "email": DEV_EMAIL, "password": DEV_PASSWORD, "api_key": api_key, "key": sdk_key, }); h.client .post_json("/api/sync/auth", &body.to_string()) .await } /// `POST /api/sync/validate-app`. async fn validate_app(h: &mut TestHarness, api_key: &str) -> TestResponse { let body = json!({ "api_key": api_key }); h.client .post_json("/api/sync/validate-app", &body.to_string()) .await } /// `POST /api/sync/apps/{app}/{leaf}` with no body. async fn post_app_route(h: &mut TestHarness, app: SyncAppId, leaf: &str) -> TestResponse { h.client .post_json(&format!("/api/sync/apps/{app}/{leaf}"), "") .await } /// `POST /api/sync/keys/list`, the server-to-server route the keys-endpoint /// secret exists to open. async fn keys_list(h: &mut TestHarness, app_secret: &str) -> TestResponse { let body = json!({ "app_secret": app_secret }); h.client .post_json("/api/sync/keys/list", &body.to_string()) .await } /// `PUT /api/sync/apps/{app}/link` with a raw JSON body. async fn put_link(h: &mut TestHarness, app: SyncAppId, body: serde_json::Value) -> TestResponse { h.client .put_json(&format!("/api/sync/apps/{app}/link"), &body.to_string()) .await } /// `PUT /api/sync/apps/{app}/slug`. async fn put_slug(h: &mut TestHarness, app: SyncAppId, slug: &str) -> TestResponse { let body = json!({ "slug": slug }); h.client .put_json(&format!("/api/sync/apps/{app}/slug"), &body.to_string()) .await } /// Put the app on the per-key plan with `key_cap` slots. The plan is set by the /// billing routes, which have their own tests, so it is written directly here. async fn set_per_key_plan(pool: &PgPool, app: SyncAppId, key_cap: i32) { sqlx::query( "UPDATE sync_apps SET enforcement_mode = 'per_key', key_cap = $2, is_internal = false WHERE id = $1", ) .bind(app) .bind(key_cap) .execute(pool) .await .expect("switch app to the per-key plan"); } /// Assert how many key slots the app has spent. The counter is the money: a /// slot is a paid unit of the developer's per-key plan. async fn assert_claimed(pool: &PgPool, app: SyncAppId, want: i32, why: &str) { assert_eq!(keys_claimed(pool, app).await, want, "{why}"); } /// How many key slots the app has spent. async fn keys_claimed(pool: &PgPool, app: SyncAppId) -> i32 { sqlx::query_scalar::<_, i32>( "SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1", ) .bind(app) .fetch_one(pool) .await .expect("usage row exists for an app created through the handler") } /// The claims a correct, live token carries. Each subscribe case takes this and /// breaks exactly one thing, so the refusal is attributable to that one thing. fn live_claims(user: UserId, app: SyncAppId, now: i64) -> SyncClaims { SyncClaims { sub: user, app, key: "workspace-7".to_string(), iss: SYNC_ISSUER.to_string(), aud: SYNC_AUDIENCE.to_string(), exp: now + 3600, iat: now, } } /// Sign claims with the harness secret, however malformed they are. fn sign(claims: &SyncClaims) -> String { jsonwebtoken::encode( &jsonwebtoken::Header::default(), claims, &jsonwebtoken::EncodingKey::from_secret(JWT_SECRET.as_bytes()), ) .expect("hand-minted token encodes") } // ── routes::synckit::auth ─────────────────────────────────────────────────── #[tokio::test] async fn the_minted_token_carries_the_caller_the_app_and_the_requested_sdk_key() { let mut h = TestHarness::new().await; let user = signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; let before = chrono::Utc::now().timestamp(); let resp = sync_auth(&mut h, &app.api_key, "workspace-7").await; status_is(&resp, 200, "auth with live credentials"); let body: AuthOk = resp.json(); assert_eq!(body.user_id, user, "the token is minted for the caller"); assert_eq!(body.app_id, app.id, "and for the app the api_key names"); // The `key` claim is the billing attribution for everything this session // writes, so a token minted under the wrong key bills another tenant. let claims = decode_sync_token(JWT_SECRET, &body.token).expect("issued token must decode"); assert_eq!(claims.sub, user, "sub claim: {}", resp.text); assert_eq!(claims.app, app.id, "app claim: {}", resp.text); assert_eq!(claims.key, "workspace-7", "key claim is the one requested"); assert_eq!(claims.iss, SYNC_ISSUER, "issuer is pinned on the wire"); assert_eq!(claims.aud, SYNC_AUDIENCE, "audience is pinned on the wire"); // A fixed lifetime, not an open-ended token. assert_eq!(claims.exp - claims.iat, SYNCKIT_JWT_EXPIRY_SECS, "lifetime"); assert!(claims.iat >= before, "iat stamped at mint: {}", claims.iat); } #[tokio::test] async fn sync_auth_validates_the_sdk_key_before_it_looks_up_the_app() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; create_app(&mut h, "Ledger").await; let bogus = "0123456789abcdef0123456789abcdef"; // If the app lookup ran first this would be a 401, and the 401/422 split // would then tell a caller whether an api_key exists. let resp = sync_auth(&mut h, bogus, "").await; status_is(&resp, 422, "an empty SDK key fails validation first"); // Same bogus api_key, well-formed SDK key: now it reaches the lookup. This // half is what makes the 422 above attributable to the key check. let resp = sync_auth(&mut h, bogus, "workspace-7").await; status_is(&resp, 401, "a well-formed SDK key reaches the app lookup"); } #[tokio::test] async fn sync_auth_accepts_a_tab_in_the_sdk_key_and_refuses_other_control_bytes() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; // Both sides of the control-byte boundary. Tab (0x09) is the one control // byte the rule allows and 0x01 sits just below it; a check written as // "any byte below 0x20" would refuse both. let resp = sync_auth(&mut h, &app.api_key, "work\tspace").await; status_is(&resp, 200, "tab is allowed in an SDK key"); let resp = sync_auth(&mut h, &app.api_key, "work\u{1}space").await; status_is(&resp, 422, "a 0x01 byte in the SDK key is refused"); } #[tokio::test] async fn sync_auth_refuses_a_deactivated_app_even_with_the_right_password() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; sqlx::query("UPDATE sync_apps SET is_active = false WHERE id = $1") .bind(app.id) .execute(&h.db) .await .expect("deactivate app"); let resp = sync_auth(&mut h, &app.api_key, "workspace-7").await; status_is(&resp, 401, "a deactivated app authenticates nobody"); } #[tokio::test] async fn validate_app_names_a_live_app_and_refuses_an_unknown_key() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger Deluxe").await; let resp = validate_app(&mut h, &app.api_key).await; status_is(&resp, 200, "validate-app on a live key"); let body: ValidateAppOk = resp.json(); assert_eq!(body.app_name, "Ledger Deluxe", "app name: {}", resp.text); // The name only: the stored hash is not part of this answer. assert!( !resp.text.contains("api_key_hash"), "no hash: {}", resp.text ); // One character different: the lookup is over a hash, so a near miss is as // unknown as anything else. let mut near_miss = app.api_key.clone(); near_miss.pop(); near_miss.push(if app.api_key.ends_with('a') { 'b' } else { 'a' }); let resp = validate_app(&mut h, &near_miss).await; status_is(&resp, 401, "a key differing by one character is unknown"); } #[tokio::test] async fn sync_auth_claims_each_sdk_key_once_and_refuses_the_one_past_the_cap() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; // Cap of 2, not 1: at a cap of 1 a `>` and a `>=` comparison agree on every // input, so the boundary would go untested. set_per_key_plan(&h.db, app.id, 2).await; assert_claimed(&h.db, app.id, 0, "new app, no slots").await; let resp = sync_auth(&mut h, &app.api_key, "workspace-alpha").await; status_is(&resp, 200, "the first key claims a slot"); assert_claimed(&h.db, app.id, 1, "one slot spent").await; // Replay. An SDK re-authenticates on every cold start, so the same key // arriving again must cost nothing; charging a second slot would let one // client restarting exhaust a developer's paid allowance. let resp = sync_auth(&mut h, &app.api_key, "workspace-alpha").await; status_is(&resp, 200, "re-auth under a claimed key still mints"); assert_claimed(&h.db, app.id, 1, "a claimed key re-auths free").await; // Exactly at the cap is still served: 2 of 2 succeeds. let resp = sync_auth(&mut h, &app.api_key, "workspace-beta").await; status_is(&resp, 200, "the key that fills the cap is served"); assert_claimed(&h.db, app.id, 2, "second slot spent").await; // One past the cap is money-shaped 402, not 400 or 403: the remedy is to // pay for more slots or release one. let resp = sync_auth(&mut h, &app.api_key, "workspace-gamma").await; status_is(&resp, 402, "the key past the cap is Payment Required"); let counts = "key limit reached (2 of 2 keys claimed)"; assert!(resp.text.contains(counts), "counts stated: {}", resp.text); assert_claimed(&h.db, app.id, 2, "a refusal charges nothing").await; } #[tokio::test] async fn sync_auth_claims_no_slots_for_a_bulk_app() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; // `bulk` is the default plan: storage is billed in aggregate and keys are // not slots. A claim that ran regardless of mode would read 3 below. for key in ["workspace-alpha", "workspace-beta", "workspace-gamma"] { let resp = sync_auth(&mut h, &app.api_key, key).await; status_is(&resp, 200, &format!("bulk app mints for {key}")); } assert_claimed(&h.db, app.id, 0, "a bulk app spends no slots").await; } #[tokio::test] async fn an_internal_app_is_not_charged_key_slots_even_on_the_per_key_plan() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; // Per-key with a cap of 1, then first-party. Without the is_internal bypass // the second key below would be a 402. set_per_key_plan(&h.db, app.id, 1).await; sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1") .bind(app.id) .execute(&h.db) .await .expect("mark app first-party"); let resp = sync_auth(&mut h, &app.api_key, "workspace-alpha").await; status_is(&resp, 200, "first key on an internal app"); let resp = sync_auth(&mut h, &app.api_key, "workspace-beta").await; status_is(&resp, 200, "a first-party app is uncapped"); assert_claimed(&h.db, app.id, 0, "internal spends no slot").await; } // ── routes::synckit::apps ─────────────────────────────────────────────────── #[tokio::test] async fn create_app_returns_201_with_a_working_key_that_is_shown_exactly_once() { let mut h = TestHarness::new().await; let user = signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; assert_eq!(app.name, "Ledger", "the app keeps the name it was given"); assert!(app.is_active, "a new app is active"); assert_eq!(app.api_key.len(), 64, "32 bytes hex: {}", app.api_key); let hex = app.api_key.chars().all(|c| c.is_ascii_hexdigit()); assert!(hex, "the key is hex: {}", app.api_key); assert_eq!(app.api_key_prefix, &app.api_key[..8], "prefix is key[..8]"); // The plaintext is returned on create and never again; the list route is // the dashboard's only other view of an app. let resp = h.client.get("/api/sync/apps").await; status_is(&resp, 200, "listing the caller's apps"); assert!(!resp.text.contains(&app.api_key), "no key: {}", resp.text); let listed: Vec = resp.json(); assert_eq!(listed.len(), 1, "one app was created: {}", resp.text); assert_eq!(listed[0].id, app.id, "and it is the one just created"); assert_eq!(listed[0].api_key_prefix, app.api_key_prefix, "prefix shown"); let resp = validate_app(&mut h, &app.api_key).await; status_is(&resp, 200, "the key handed back on create authenticates"); let owner = sqlx::query_scalar::<_, UserId>("SELECT creator_id FROM sync_apps WHERE id = $1") .bind(app.id) .fetch_one(&h.db) .await .expect("app row"); assert_eq!(owner, user, "the creator is the session's user"); } #[tokio::test] async fn create_app_refuses_an_empty_name_a_control_character_and_an_overlong_one() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let resp = post_app(&mut h, "").await; status_is(&resp, 422, "an empty app name fails validation"); let resp = post_app(&mut h, "Led\nger").await; status_is(&resp, 422, "a newline in the app name is refused"); // 100 characters is the maximum and must be accepted; 101 must not. One // side alone cannot tell `>` from `>=`. let resp = post_app(&mut h, &"n".repeat(100)).await; status_is(&resp, 201, "a name of exactly the maximum is accepted"); let resp = post_app(&mut h, &"n".repeat(101)).await; status_is(&resp, 422, "one character past the maximum is refused"); let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sync_apps") .fetch_one(&h.db) .await .expect("count apps"); assert_eq!(count, 1, "only the accepted name created a row"); } #[tokio::test] async fn app_routes_answer_403_for_a_stranger_and_404_for_an_app_that_does_not_exist() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; // A second developer with their own session. seed_user(&h.db, "otherdev").await; let mut other = h.client.fork_fresh(); other.fetch_csrf_token().await; let resp = other .post_form("/login", "login=otherdev&password=password123") .await; status_is(&resp, 303, "second developer logs in"); other.fetch_csrf_token().await; // Existing app, wrong owner: 403 on every route that takes an app id. let theirs = format!("/api/sync/apps/{}", app.id); let resp = other .post_json(&format!("{theirs}/regenerate-key"), "") .await; status_is(&resp, 403, "regenerating a stranger's key"); let resp = other.post_json(&format!("{theirs}/keys-secret"), "").await; status_is(&resp, 403, "rotating a stranger's app secret"); let resp = other.put_json(&format!("{theirs}/link"), "{}").await; status_is(&resp, 403, "relinking a stranger's app"); let resp = other .put_json(&format!("{theirs}/slug"), r#"{"slug":"stolen-slug"}"#) .await; status_is(&resp, 403, "renaming a stranger's OTA slug"); let resp = other.delete(&theirs).await; status_is(&resp, 403, "deleting a stranger's app"); // The other side of the boundary: an id nobody owns is 404, from the same // routes that just answered 403. let missing = SyncAppId::new(); let resp = other .post_json(&format!("/api/sync/apps/{missing}/regenerate-key"), "") .await; status_is(&resp, 404, "regenerating a key for no app"); let resp = other.delete(&format!("/api/sync/apps/{missing}")).await; status_is(&resp, 404, "deleting an app that exists nowhere"); let resp = other.get("/api/sync/apps").await; status_is(&resp, 200, "stranger lists their apps"); let listed: Vec = resp.json(); assert!(listed.is_empty(), "list is caller-scoped: {}", resp.text); let alive = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sync_apps WHERE id = $1") .bind(app.id) .fetch_one(&h.db) .await .expect("count app"); assert_eq!(alive, 1, "none of the refused calls touched the app"); } #[tokio::test] async fn regenerating_the_api_key_retires_the_previous_one() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; let resp = post_app_route(&mut h, app.id, "regenerate-key").await; status_is(&resp, 200, "regenerate answers 200"); let rotated: CreatedApp = resp.json(); assert_eq!(rotated.id, app.id, "rotation keeps the same app"); assert_ne!(rotated.api_key, app.api_key, "the key is different"); let prefix = &rotated.api_key[..8]; assert_eq!(rotated.api_key_prefix, prefix, "prefix follows the new key"); // Retirement is the point: the old key must stop working rather than be // joined by a second working one. let resp = validate_app(&mut h, &app.api_key).await; status_is(&resp, 401, "the superseded key no longer authenticates"); let resp = validate_app(&mut h, &rotated.api_key).await; status_is(&resp, 200, "the new key authenticates the same app"); } #[tokio::test] async fn regenerating_the_keys_secret_retires_the_previous_secret() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; let resp = post_app_route(&mut h, app.id, "keys-secret").await; status_is(&resp, 200, "first secret issued"); let first: KeysSecretOk = resp.json(); assert_eq!(first.id, app.id, "the secret belongs to this app"); assert_ne!( first.app_secret, app.api_key, "the keys-endpoint secret is not the api_key: the api_key ships inside \ client binaries and must not open the server-to-server routes" ); let prefix = Some(&first.app_secret[..8]); assert_eq!(first.keys_secret_prefix.as_deref(), prefix, "secret[..8]"); let resp = keys_list(&mut h, &first.app_secret).await; status_is(&resp, 200, "the issued secret opens the keys routes"); let list: KeyList = resp.json(); assert!(list.keys.is_empty(), "no key claimed yet: {}", resp.text); let resp = post_app_route(&mut h, app.id, "keys-secret").await; status_is(&resp, 200, "second secret issued"); let second: KeysSecretOk = resp.json(); assert_ne!(second.app_secret, first.app_secret, "a different secret"); let resp = keys_list(&mut h, &first.app_secret).await; status_is(&resp, 401, "the superseded secret is refused"); } #[tokio::test] async fn linking_an_app_takes_the_owners_project_and_refuses_everything_else() { let mut h = TestHarness::new().await; let user = signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; let mine = seed_project(&h.db, user, "mine").await; let stranger = seed_user(&h.db, "otherdev").await; let theirs = seed_project(&h.db, stranger, "theirs").await; let resp = put_link(&mut h, app.id, json!({ "project_id": mine.to_string() })).await; status_is(&resp, 200, "linking an owned project"); let linked: ListedApp = resp.json(); assert_eq!(linked.project_id, Some(mine), "linked: {}", resp.text); // Someone else's project is 403, and an id for no project at all is 400: // one says not yours, the other says no such project. let resp = put_link(&mut h, app.id, json!({ "project_id": theirs.to_string() })).await; status_is(&resp, 403, "linking a project the caller does not own"); let nowhere = ProjectId::new().to_string(); let resp = put_link(&mut h, app.id, json!({ "project_id": nowhere })).await; status_is(&resp, 400, "an unknown project id is a bad request"); // A string that is not a UUID is refused before any lookup. let resp = put_link(&mut h, app.id, json!({ "project_id": "not-a-uuid" })).await; status_is(&resp, 400, "a malformed project id is a bad request"); let resp = put_link(&mut h, app.id, json!({ "item_id": "not-a-uuid" })).await; status_is(&resp, 400, "a malformed item id is a bad request"); let current = sqlx::query_scalar::<_, Option>( "SELECT project_id FROM sync_apps WHERE id = $1", ) .bind(app.id) .fetch_one(&h.db) .await .expect("app row"); assert_eq!(current, Some(mine), "no refusal disturbed the live link"); // An empty string clears the link rather than failing to parse: that is how // the dashboard's "no project" option arrives. let resp = put_link(&mut h, app.id, json!({ "project_id": "" })).await; status_is(&resp, 200, "clearing the link"); let cleared: ListedApp = resp.json(); assert_eq!(cleared.project_id, None, "cleared: {}", resp.text); } #[tokio::test] async fn setting_the_ota_slug_accepts_the_boundary_lengths_and_refuses_outside_them() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; // 3 and 40 are the inclusive bounds and 2 and 41 the first values outside // them; a comparison written one off would pass on only half of these. let resp = put_slug(&mut h, app.id, "abc").await; status_is(&resp, 204, "a three-character slug is accepted"); let resp = put_slug(&mut h, app.id, &"a".repeat(40)).await; status_is(&resp, 204, "a forty-character slug is accepted"); let resp = put_slug(&mut h, app.id, "ab").await; status_is(&resp, 400, "a two-character slug is refused"); let resp = put_slug(&mut h, app.id, &"a".repeat(41)).await; status_is(&resp, 400, "a forty-one-character slug is refused"); let resp = put_slug(&mut h, app.id, "Ledger").await; status_is(&resp, 400, "an uppercase slug is refused"); let resp = put_slug(&mut h, app.id, "-ledger").await; status_is(&resp, 400, "a slug starting with a hyphen is refused"); let stored = sqlx::query_scalar::<_, Option>("SELECT slug FROM sync_apps WHERE id = $1") .bind(app.id) .fetch_one(&h.db) .await .expect("app row"); // The last slug that passed: no refusal wrote through. assert_eq!(stored, Some("a".repeat(40)), "stored slug"); } #[tokio::test] async fn deleting_an_app_returns_204_and_its_key_stops_authenticating() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let keeper = create_app(&mut h, "Keeper").await; let doomed = create_app(&mut h, "Doomed").await; let resp = h .client .delete(&format!("/api/sync/apps/{}", doomed.id)) .await; status_is(&resp, 204, "delete answers 204"); let resp = validate_app(&mut h, &doomed.api_key).await; status_is(&resp, 401, "a deleted app's key authenticates nothing"); let resp = h.client.get("/api/sync/apps").await; status_is(&resp, 200, "listing after delete"); let listed: Vec = resp.json(); assert_eq!(listed.len(), 1, "one app removed: {}", resp.text); assert_eq!(listed[0].id, keeper.id, "the survivor is the other app"); } // ── routes::synckit::subscribe ────────────────────────────────────────────── #[tokio::test] async fn subscribe_refuses_every_token_the_sync_gate_does_not_accept() { let mut h = TestHarness::new().await; let user = signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; let route = format!("/api/sync/subscribe?app_id={}", app.id); let now = chrono::Utc::now().timestamp(); let resp = h.client.get(&route).await; status_is(&resp, 401, "a missing bearer token is unauthorized"); // Expired: issued two hours ago, expired an hour ago, correct in every // other claim, so only the expiry check can be refusing it. let mut claims = live_claims(user, app.id, now); claims.iat = now - 7200; claims.exp = now - 3600; h.client.set_bearer_token(&sign(&claims)); let resp = h.client.get(&route).await; status_is(&resp, 401, "an expired token is unauthorized"); // Minted for the OAuth userinfo audience. Audience pinning is what stops // one credential family being replayed against the other under one secret. let mut claims = live_claims(user, app.id, now); claims.aud = USERINFO_AUDIENCE.to_string(); h.client.set_bearer_token(&sign(&claims)); let resp = h.client.get(&route).await; status_is(&resp, 401, "a userinfo-audience token cannot reach sync"); let mut claims = live_claims(user, app.id, now); claims.iss = "someone-elses-issuer".to_string(); h.client.set_bearer_token(&sign(&claims)); let resp = h.client.get(&route).await; status_is(&resp, 401, "a foreign issuer is unauthorized"); // Future-dated: a token stamped an hour ahead would outlive any revocation // recorded between now and then, so it is refused outright. let mut claims = live_claims(user, app.id, now); claims.iat = now + 3600; claims.exp = now + 7200; h.client.set_bearer_token(&sign(&claims)); let resp = h.client.get(&route).await; status_is(&resp, 401, "a future-dated token is unauthorized"); // Every write is attributed to the key claim, so a session without one has // nowhere to bill. let mut claims = live_claims(user, app.id, now); claims.key = String::new(); h.client.set_bearer_token(&sign(&claims)); let resp = h.client.get(&route).await; status_is(&resp, 401, "a token with no SDK key claim is unauthorized"); // A token for an app that does not exist dies at the liveness gate. Like // the six above it is refused before the handler, so this test opens no // stream and leaks no handler task. let mut claims = live_claims(user, app.id, now); claims.app = SyncAppId::new(); h.client.set_bearer_token(&sign(&claims)); let resp = h.client.get(&route).await; status_is(&resp, 401, "a token for a nonexistent app is unauthorized"); } #[tokio::test] async fn subscribe_matches_the_app_id_against_the_token_not_the_callers_other_apps() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let first = create_app(&mut h, "Ledger").await; let second = create_app(&mut h, "Ledger Two").await; let resp = sync_auth(&mut h, &first.api_key, "workspace-7").await; status_is(&resp, 200, "auth for the first app"); let auth: AuthOk = resp.json(); h.client.set_bearer_token(&auth.token); // Both apps belong to the caller and both are live, so an ownership check // would let this through. The contract is stricter: a stream is bound to // the app its token was minted for. let resp = h .client .get(&format!("/api/sync/subscribe?app_id={}", second.id)) .await; status_is(&resp, 400, "one app's token cannot open another's stream"); assert!( resp.text .contains("app_id does not match authenticated session"), "the refusal names the mismatch: {}", resp.text ); } #[tokio::test] async fn subscribe_refuses_a_token_for_an_app_that_has_since_been_deleted() { let mut h = TestHarness::new().await; signup_dev(&mut h).await; let app = create_app(&mut h, "Ledger").await; let resp = sync_auth(&mut h, &app.api_key, "workspace-7").await; status_is(&resp, 200, "auth before deletion"); let auth: AuthOk = resp.json(); // Deleting from the dashboard must kill the sessions that app minted. let resp = h.client.delete(&format!("/api/sync/apps/{}", app.id)).await; status_is(&resp, 204, "deleting the app"); h.client.set_bearer_token(&auth.token); let resp = h .client .get(&format!("/api/sync/subscribe?app_id={}", app.id)) .await; // Not a 400, and not a live stream. status_is(&resp, 401, "a token for a deleted app is unauthorized"); }