Skip to main content

max / makenotwork

tests: contract tests for db::users, db::items core, db::synckit_billing + cross-session password revoke Closes the audit Run 18 Testing gap on the largest DB-facing modules that had no direct layer tests (behaviour was only reachable through HTTP/webhook flows): - db_users_layer: id/username/email lookups (round-trip + None-on-miss), exact vs case-insensitive matching, creator grant, pause/unpause, stripe-status write. - db_items_layer: single-item CRUD not covered by the bulk file — create/get, sales_count increment/decrement floor, owner-scoped publish, hide/unhide, admin-removal republish block. - db_synckit_billing_layer: apply_billing_update persistence + canceled/inverted- period guards, activate-only-from-draft, atomic key-cap enforcement, claim/ release idempotency. (The module's //! doc claiming migration 117 is unapplied is stale — the schema is applied and exercised by sibling tests.) - adversarial_auth: password change revokes OTHER active web sessions while keeping the changing session alive (the delete_other_sessions sweep), via a new TestClient::fork_fresh second-session helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:41 UTC
Commit: 87a6fd079e647959dd644c923659f69b8fed8dab
Parent: 5af43c0
6 files changed, +922 insertions, -0 deletions
@@ -39,6 +39,14 @@ impl TestClient {
39 39 }
40 40 }
41 41
42 + /// Fork a fresh client that talks to the same app but with its own empty
43 + /// cookie jar, CSRF token, and unique IP — simulates a second, independent
44 + /// browser session (e.g. the same user logged in on another device).
45 + #[allow(dead_code)]
46 + pub fn fork_fresh(&self) -> TestClient {
47 + TestClient::new(self.app.clone())
48 + }
49 +
42 50 /// Set the IP address used in the X-Forwarded-For header.
43 51 #[allow(dead_code)]
44 52 pub fn set_forwarded_ip(&mut self, ip: &str) {
@@ -588,3 +588,56 @@ async fn old_password_rejected_after_change() {
588 588 let resp = h.client.get("/dashboard").await;
589 589 assert_eq!(resp.status, 200, "New password should work");
590 590 }
591 +
592 + /// Contract: changing the password revokes OTHER active web sessions (a
593 + /// stolen/leaked cookie must not outlive a password change) while keeping the
594 + /// session that made the change logged in. This pins the `delete_other_sessions`
595 + /// sweep in `update_password` — the subtle "revoke everyone else, keep me" half
596 + /// of the security contract that `old_password_rejected_after_change` (single
597 + /// session) does not exercise.
598 + #[tokio::test]
599 + async fn password_change_revokes_other_sessions() {
600 + let mut h = TestHarness::new().await;
601 + // Session A: signup leaves h.client authenticated as the user.
602 + h.signup("multisess", "multisess@test.com", "oldpassword1").await;
603 + let resp = h.client.get("/dashboard").await;
604 + assert_eq!(resp.status, 200, "session A should start logged in: {}", resp.text);
605 +
606 + // Session B: a second, independent client logs in as the same user.
607 + let mut other = h.client.fork_fresh();
608 + other.fetch_csrf_token().await;
609 + let resp = other
610 + .post_form("/login", "login=multisess&password=oldpassword1")
611 + .await;
612 + assert!(
613 + resp.status.is_success() || resp.status.is_redirection(),
614 + "session B login failed: {} {}",
615 + resp.status,
616 + resp.text
617 + );
618 + let resp = other.get("/dashboard").await;
619 + assert_eq!(resp.status, 200, "session B should be logged in before the change");
620 +
621 + // Session A changes the password.
622 + let resp = h
623 + .client
624 + .put_form(
625 + "/api/users/me/password",
626 + "current_password=oldpassword1&new_password=newpassword1",
627 + )
628 + .await;
629 + assert!(resp.status.is_success(), "password change failed: {}", resp.text);
630 +
631 + // Session B is now revoked (cookie no longer resolves to a live session).
632 + let resp = other.get("/dashboard").await;
633 + assert!(
634 + resp.status == 401 || resp.status.is_redirection(),
635 + "session B must be revoked after the password change: {} {}",
636 + resp.status,
637 + resp.text
638 + );
639 +
640 + // Session A — the session that made the change — stays logged in.
641 + let resp = h.client.get("/dashboard").await;
642 + assert_eq!(resp.status, 200, "the changing session must remain logged in");
643 + }
@@ -0,0 +1,245 @@
1 + //! DB-layer contract tests for core `db::items` CRUD — the single-item side.
2 + //!
3 + //! Audit Run 18 Testing flagged the item core as asserted only through the HTTP
4 + //! surface. The batch mutations already have their own layer file
5 + //! (`db_items_bulk_layer.rs`); these pin the single-item contracts it leaves
6 + //! out: `create_item` + `get_item_by_id` round-trip (and the not-found `None`),
7 + //! the denormalized `sales_count` increment/decrement (with the floor-at-zero
8 + //! clamp), `update_item`'s publish toggle + ownership seal, the
9 + //! post-grace hide/unhide round-trip, and the admin-removal republish block.
10 +
11 + use crate::harness::TestHarness;
12 + use makenotwork::db::{items, AiTier, ItemId, ItemType, PriceCents, ProjectId, UserId};
13 +
14 + /// Create a logged-in creator with an empty-ish project and return
15 + /// `(user_id, project_id)`. (The helper also seeds one HTTP-created item, which
16 + /// these tests ignore — they add their own items via `items::create_item`.)
17 + async fn creator_project(h: &mut TestHarness, tag: &str) -> (UserId, ProjectId) {
18 + let setup = h.create_creator_with_item(&format!("itm_{tag}"), "digital", 1000).await;
19 + let project_id: ProjectId = setup.project_id.parse().expect("project id parses");
20 + (setup.user_id, project_id)
21 + }
22 +
23 + /// Seed a bare user directly (no HTTP-created item), for tests that assert on a
24 + /// per-user, project-spanning count and so need a contamination-free item set.
25 + async fn seed_user(h: &TestHarness, username: &str) -> UserId {
26 + let hash = makenotwork::auth::hash_password("password123").expect("hash");
27 + sqlx::query_scalar::<_, UserId>(
28 + "INSERT INTO users (username, email, password_hash, email_verified)
29 + VALUES ($1, $2, $3, true) RETURNING id",
30 + )
31 + .bind(username)
32 + .bind(format!("{username}@test.com"))
33 + .bind(&hash)
34 + .fetch_one(&h.db)
35 + .await
36 + .expect("seed user")
37 + }
38 +
39 + /// Seed a bare project directly for a user.
40 + async fn seed_project(h: &TestHarness, user: UserId, slug: &str) -> ProjectId {
41 + sqlx::query_scalar::<_, ProjectId>(
42 + "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id",
43 + )
44 + .bind(user)
45 + .bind(slug)
46 + .fetch_one(&h.db)
47 + .await
48 + .expect("seed project")
49 + }
50 +
51 + /// Insert a fresh item into a project via the real `create_item` path.
52 + async fn make_item(h: &TestHarness, project: ProjectId, title: &str) -> ItemId {
53 + let item = items::create_item(
54 + &h.db,
55 + project,
56 + title,
57 + None,
58 + PriceCents::new(1000).expect("valid price"),
59 + ItemType::Digital,
60 + AiTier::Handmade,
61 + None,
62 + )
63 + .await
64 + .expect("create_item");
65 + item.id
66 + }
67 +
68 + async fn is_public(h: &TestHarness, item: ItemId) -> bool {
69 + sqlx::query_scalar::<_, bool>("SELECT is_public FROM items WHERE id = $1")
70 + .bind(item)
71 + .fetch_one(&h.db)
72 + .await
73 + .expect("read is_public")
74 + }
75 +
76 + async fn sales_count(h: &TestHarness, item: ItemId) -> i32 {
77 + sqlx::query_scalar::<_, i32>("SELECT sales_count FROM items WHERE id = $1")
78 + .bind(item)
79 + .fetch_one(&h.db)
80 + .await
81 + .expect("read sales_count")
82 + }
83 +
84 + // ── create_item + get_item_by_id: round-trip and not-found ────────────────────
85 +
86 + #[tokio::test]
87 + async fn create_item_round_trips_through_get_by_id_and_missing_reads_none() {
88 + let mut h = TestHarness::new().await;
89 + let (_owner, project) = creator_project(&mut h, "get").await;
90 +
91 + let created = items::create_item(
92 + &h.db,
93 + project,
94 + "My First Track",
95 + Some("a description"),
96 + PriceCents::new(2500).expect("valid price"),
97 + ItemType::Audio,
98 + AiTier::Handmade,
99 + None,
100 + )
101 + .await
102 + .expect("create_item");
103 +
104 + // create_item auto-generates a URL-safe slug from the title.
105 + assert_eq!(created.slug, "my-first-track", "slug is derived from the title");
106 +
107 + let fetched = items::get_item_by_id(&h.db, created.id)
108 + .await
109 + .expect("get_item_by_id ok")
110 + .expect("the created item is found by id");
111 + assert_eq!(fetched.id, created.id);
112 + assert_eq!(fetched.title, "My First Track");
113 + assert_eq!(fetched.price_cents, 2500, "stored price is the created 2500 cents");
114 +
115 + // A random id that was never inserted reads as None, not an error.
116 + let missing = items::get_item_by_id(&h.db, ItemId::new())
117 + .await
118 + .expect("get_item_by_id ok for a missing id");
119 + assert!(missing.is_none(), "an unknown item id returns None");
120 + }
121 +
122 + // ── increment/decrement_sales_count: the denormalized counter ─────────────────
123 +
124 + #[tokio::test]
125 + async fn sales_count_increments_decrements_and_floors_at_zero() {
126 + let mut h = TestHarness::new().await;
127 + let (_owner, project) = creator_project(&mut h, "sales").await;
128 + let item = make_item(&h, project, "Counter Item").await;
129 +
130 + assert_eq!(sales_count(&h, item).await, 0, "a new item starts at zero sales");
131 +
132 + items::increment_sales_count(&h.db, item).await.expect("increment #1");
133 + items::increment_sales_count(&h.db, item).await.expect("increment #2");
134 + assert_eq!(sales_count(&h, item).await, 2, "two increments bump the counter to 2");
135 +
136 + items::decrement_sales_count(&h.db, item).await.expect("decrement");
137 + assert_eq!(sales_count(&h, item).await, 1, "a decrement (refund) walks it back");
138 +
139 + // The clamp: decrementing past zero can't go negative (GREATEST(.. , 0)).
140 + items::decrement_sales_count(&h.db, item).await.expect("decrement to zero");
141 + items::decrement_sales_count(&h.db, item).await.expect("decrement below zero is clamped");
142 + assert_eq!(sales_count(&h, item).await, 0, "sales_count never goes negative");
143 + }
144 +
145 + // ── update_item: publish toggle + ownership seal ──────────────────────────────
146 +
147 + #[tokio::test]
148 + async fn update_item_toggles_publish_and_is_owner_scoped() {
149 + let mut h = TestHarness::new().await;
150 + let (owner, project) = creator_project(&mut h, "upd").await;
151 + let attacker = h.signup("itm_upd_attacker", "itm_upd_attacker@test.com", "password123").await;
152 + let item = make_item(&h, project, "Toggle Me").await;
153 +
154 + // Start unpublished, then the owner publishes via update_item.
155 + items::update_item(
156 + &h.db, item, owner,
157 + None, None, None, None, Some(false),
158 + None, None, None, None, None, None,
159 + )
160 + .await
161 + .expect("owner unpublish");
162 + assert!(!is_public(&h, item).await);
163 +
164 + // A non-owner update matches zero rows: the ownership subquery excludes them,
165 + // so `fetch_one` finds nothing and the call errors — nothing changes.
166 + let hijack = items::update_item(
167 + &h.db, item, attacker,
168 + None, None, None, None, Some(true),
169 + None, None, None, None, None, None,
170 + )
171 + .await;
172 + assert!(hijack.is_err(), "a non-owner update_item must not touch the row");
173 + assert!(!is_public(&h, item).await, "the item stays unpublished after the hijack attempt");
174 +
175 + // The owner flips it public.
176 + let published = items::update_item(
177 + &h.db, item, owner,
178 + None, None, None, None, Some(true),
179 + None, None, None, None, None, None,
180 + )
181 + .await
182 + .expect("owner publish");
183 + assert!(published.is_public, "the owner can publish their own item");
184 + }
185 +
186 + // ── hide/unhide_all_items_for_user: the post-grace round-trip ─────────────────
187 +
188 + #[tokio::test]
189 + async fn hide_then_unhide_all_items_for_user_round_trips() {
190 + let h = TestHarness::new().await;
191 + // Seed a contamination-free creator: `hide_all_items_for_users` spans every
192 + // project the user owns, so the count must not include a stray HTTP-seeded item.
193 + let owner = seed_user(&h, "itm_hide_owner").await;
194 + let project = seed_project(&h, owner, "itm-hide-proj").await;
195 + // create_item leaves is_public at its column default (true), so both start public.
196 + let a = make_item(&h, project, "Public A").await;
197 + let b = make_item(&h, project, "Public B").await;
198 + assert!(is_public(&h, a).await && is_public(&h, b).await, "items start public");
199 +
200 + // The scheduler sweep hides every public item for the creator in one shot.
201 + let hidden = items::hide_all_items_for_users(&h.db, &[owner]).await.expect("hide");
202 + assert_eq!(hidden, 2, "both public items are hidden");
203 + assert!(!is_public(&h, a).await && !is_public(&h, b).await);
204 +
205 + // An empty slice is a no-op, not a full-table update.
206 + assert_eq!(items::hide_all_items_for_users(&h.db, &[]).await.expect("empty hide"), 0);
207 +
208 + // Re-subscribing unhides them again and reports the count restored.
209 + let unhidden = items::unhide_all_items_for_user(&h.db, owner).await.expect("unhide");
210 + assert_eq!(unhidden, 2, "both items are unhidden on re-subscribe");
211 + assert!(is_public(&h, a).await && is_public(&h, b).await);
212 + }
213 +
214 + // ── admin_remove_item: hides + blocks creator republish ───────────────────────
215 +
216 + #[tokio::test]
217 + async fn admin_removal_hides_the_item_and_blocks_creator_republish() {
218 + let mut h = TestHarness::new().await;
219 + let (owner, project) = creator_project(&mut h, "adm").await;
220 + let item = make_item(&h, project, "Flagged Item").await;
221 + assert!(is_public(&h, item).await, "item starts public");
222 +
223 + let removed = items::admin_remove_item(&h.db, item, "violates policy")
224 + .await
225 + .expect("admin_remove_item");
226 + assert!(removed.removed_by_admin, "the item is flagged removed_by_admin");
227 + assert!(!removed.is_public, "admin removal unpublishes the item");
228 + assert_eq!(removed.removal_reason.as_deref(), Some("violates policy"));
229 +
230 + // The creator cannot republish an admin-removed item: update_item's CASE
231 + // forces is_public back to false while removed_by_admin is set.
232 + let attempt = items::update_item(
233 + &h.db, item, owner,
234 + None, None, None, None, Some(true),
235 + None, None, None, None, None, None,
236 + )
237 + .await
238 + .expect("owner update runs (row is theirs)");
239 + assert!(!attempt.is_public, "a removed item cannot be republished by its creator");
240 +
241 + // Admin restore clears the flags (but leaves it unpublished for manual republish).
242 + let restored = items::admin_restore_item(&h.db, item).await.expect("admin_restore_item");
243 + assert!(!restored.removed_by_admin, "restore clears the admin-removed flag");
244 + assert!(restored.removal_reason.is_none());
245 + }
@@ -0,0 +1,365 @@
1 + //! DB-layer contract tests for `db::synckit_billing` — the SyncKit v2
2 + //! developer-billing writes.
3 + //!
4 + //! Audit Run 18 (Testing) flagged this module's guarded UPDATEs as asserted
5 + //! only indirectly through the HTTP/webhook billing flow. These call the
6 + //! `db::synckit_billing` functions directly against real Postgres so the
7 + //! invariants the billing safety leans on are pinned at the layer they live in:
8 + //!
9 + //! - `apply_billing_update` persists status + period in one guarded statement,
10 + //! - the terminal-`canceled` guard refuses to revive a canceled app (a stray
11 + //! `invoice.paid` after a `deleted` can't resurrect it or refresh its period),
12 + //! - the epoch-period guard drops a non-positive / inverted Stripe window
13 + //! rather than stamping a 1970 period onto a live app,
14 + //! - `activate_billing` is only valid from `draft` (replay / TOCTOU conflict),
15 + //! - `claim_key` enforces `key_cap` under the `FOR UPDATE` lock — a claim that
16 + //! would exceed the cap is refused atomically, never over-allocated,
17 + //! - `claim_key` / `release_key` are idempotent.
18 +
19 + use crate::harness::db::TestDb;
20 + use chrono::{DateTime, Utc};
21 + use makenotwork::db::synckit_billing;
22 + use makenotwork::db::{SyncAppId, SyncBillingStatus, SyncEnforcementMode, UserId};
23 +
24 + /// Seed a verified user.
25 + async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId {
26 + let hash = makenotwork::auth::hash_password("password123").expect("hash");
27 + sqlx::query_scalar::<_, UserId>(
28 + "INSERT INTO users (username, email, password_hash, email_verified)
29 + VALUES ($1, $2, $3, true) RETURNING id",
30 + )
31 + .bind(username)
32 + .bind(format!("{username}@test.com"))
33 + .bind(&hash)
34 + .fetch_one(pool)
35 + .await
36 + .expect("seed user")
37 + }
38 +
39 + /// Seed a non-internal draft sync app plus its live-usage row (the row the
40 + /// storage layer normally inserts on app create, and that `claim_key` locks
41 + /// `FOR UPDATE`).
42 + async fn seed_billable_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
43 + let app: SyncAppId = sqlx::query_scalar::<_, SyncAppId>(
44 + "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status)
45 + VALUES ($1, $2, $3, $4, FALSE, 'draft') RETURNING id",
46 + )
47 + .bind(user)
48 + .bind(name)
49 + .bind(format!("hash_{name}"))
50 + .bind(&name[..name.len().min(8)])
51 + .fetch_one(pool)
52 + .await
53 + .expect("seed sync app");
54 +
55 + sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1)")
56 + .bind(app)
57 + .execute(pool)
58 + .await
59 + .expect("seed usage row");
60 +
61 + app
62 + }
63 +
64 + /// Read the current `keys_claimed` counter off `sync_app_usage_current`.
65 + async fn keys_claimed(pool: &sqlx::PgPool, app: SyncAppId) -> i32 {
66 + sqlx::query_scalar::<_, i32>("SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1")
67 + .bind(app)
68 + .fetch_one(pool)
69 + .await
70 + .expect("read keys_claimed")
71 + }
72 +
73 + /// Count of currently-active (un-released) key rows for the app.
74 + async fn active_key_rows(pool: &sqlx::PgPool, app: SyncAppId) -> i64 {
75 + sqlx::query_scalar::<_, i64>(
76 + "SELECT COUNT(*) FROM sync_app_keys WHERE app_id = $1 AND released_at IS NULL",
77 + )
78 + .bind(app)
79 + .fetch_one(pool)
80 + .await
81 + .expect("count active keys")
82 + }
83 +
84 + fn ts(secs: i64) -> DateTime<Utc> {
85 + DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp")
86 + }
87 +
88 + /// Activate billing on a draft app in `bulk` mode with a 100 GB cap. Leaves the
89 + /// app `active` with the shape constraint satisfied (storage_gb_cap set, per-key
90 + /// knobs NULL).
91 + async fn activate_bulk(pool: &sqlx::PgPool, app: SyncAppId, sub_id: &str, start: i64, end: i64) {
92 + synckit_billing::activate_billing(
93 + pool,
94 + app,
95 + SyncEnforcementMode::Bulk,
96 + Some(100),
97 + None,
98 + None,
99 + sub_id,
100 + ts(start),
101 + ts(end),
102 + )
103 + .await
104 + .expect("activate billing");
105 + }
106 +
107 + // ── apply_billing_update: persistence ────────────────────────────────────────
108 +
109 + #[tokio::test]
110 + async fn apply_billing_update_persists_status_and_period() {
111 + let db = TestDb::new().await;
112 + let user = seed_user(&db.pool, "sbb_persist").await;
113 + let app = seed_billable_app(&db.pool, user, "sbbpersist").await;
114 + activate_bulk(&db.pool, app, "sub_persist", 1_700_000_000, 1_700_100_000).await;
115 +
116 + // A Stripe-driven update flips status and refreshes the period atomically.
117 + let updated = synckit_billing::apply_billing_update(
118 + &db.pool,
119 + app,
120 + Some("suspended_unpaid"),
121 + Some((1_700_200_000, 1_700_300_000)),
122 + )
123 + .await
124 + .expect("apply update");
125 + assert!(updated, "a live app row is updated");
126 +
127 + let billing = synckit_billing::get_app_with_billing(&db.pool, app)
128 + .await
129 + .expect("load billing")
130 + .expect("app exists");
131 + assert_eq!(billing.billing_status, SyncBillingStatus::SuspendedUnpaid);
132 + assert_eq!(billing.current_period_start, Some(ts(1_700_200_000)));
133 + assert_eq!(billing.current_period_end, Some(ts(1_700_300_000)));
134 + }
135 +
136 + // ── apply_billing_update: terminal-canceled guard ────────────────────────────
137 +
138 + #[tokio::test]
139 + async fn apply_billing_update_cannot_revive_a_canceled_app() {
140 + let db = TestDb::new().await;
141 + let user = seed_user(&db.pool, "sbb_revive").await;
142 + let app = seed_billable_app(&db.pool, user, "sbbrevive").await;
143 + activate_bulk(&db.pool, app, "sub_revive", 1_700_000_000, 1_700_100_000).await;
144 +
145 + // Cancel is terminal. (A `customer.subscription.deleted` webhook.)
146 + let canceled = synckit_billing::apply_billing_update(&db.pool, app, Some("canceled"), None)
147 + .await
148 + .expect("cancel");
149 + assert!(canceled, "the cancel transition itself matches a live row");
150 +
151 + // A stray `invoice.paid` afterward tries to move canceled -> active AND
152 + // refresh the period. The guard must refuse both in one statement.
153 + let revived = synckit_billing::apply_billing_update(
154 + &db.pool,
155 + app,
156 + Some("active"),
157 + Some((1_700_900_000, 1_701_000_000)),
158 + )
159 + .await
160 + .expect("apply update");
161 + assert!(!revived, "no row updated: canceled is terminal");
162 +
163 + let billing = synckit_billing::get_app_with_billing(&db.pool, app)
164 + .await
165 + .expect("load billing")
166 + .expect("app exists");
167 + assert_eq!(billing.billing_status, SyncBillingStatus::Canceled, "status stays canceled");
168 + // The period must not have been refreshed by the refused update.
169 + assert_eq!(
170 + billing.current_period_end,
171 + Some(ts(1_700_100_000)),
172 + "period is not refreshed on a canceled app"
173 + );
174 + }
175 +
176 + // ── apply_billing_update: epoch-period guard ─────────────────────────────────
177 +
178 + #[tokio::test]
179 + async fn apply_billing_update_ignores_a_nonpositive_period() {
180 + let db = TestDb::new().await;
181 + let user = seed_user(&db.pool, "sbb_epoch").await;
182 + let app = seed_billable_app(&db.pool, user, "sbbepoch").await;
183 + activate_bulk(&db.pool, app, "sub_epoch", 1_700_000_000, 1_700_100_000).await;
184 +
185 + // A thin/zero webhook: end <= 0. The row still matches (status unchanged),
186 + // but the COALESCE must keep the existing period rather than stamp 1970.
187 + let matched = synckit_billing::apply_billing_update(&db.pool, app, None, Some((0, 0)))
188 + .await
189 + .expect("apply update");
190 + assert!(matched, "the live row still matches even with no writable fields");
191 +
192 + let billing = synckit_billing::get_app_with_billing(&db.pool, app)
193 + .await
194 + .expect("load billing")
195 + .expect("app exists");
196 + assert_eq!(
197 + billing.current_period_start,
198 + Some(ts(1_700_000_000)),
199 + "existing period start is preserved"
200 + );
201 + assert_eq!(
202 + billing.current_period_end,
203 + Some(ts(1_700_100_000)),
204 + "a non-positive window never stamps a 1970 period"
205 + );
206 + }
207 +
208 + #[tokio::test]
209 + async fn apply_billing_update_ignores_an_inverted_period() {
210 + let db = TestDb::new().await;
211 + let user = seed_user(&db.pool, "sbb_inverted").await;
212 + let app = seed_billable_app(&db.pool, user, "sbbinvert").await;
213 + activate_bulk(&db.pool, app, "sub_invert", 1_700_000_000, 1_700_100_000).await;
214 +
215 + // end > 0 but start > end: an inverted range writes nothing.
216 + let matched =
217 + synckit_billing::apply_billing_update(&db.pool, app, None, Some((1_800_000_000, 1_700_000_000)))
218 + .await
219 + .expect("apply update");
220 + assert!(matched);
221 +
222 + let billing = synckit_billing::get_app_with_billing(&db.pool, app)
223 + .await
224 + .expect("load billing")
225 + .expect("app exists");
226 + assert_eq!(billing.current_period_start, Some(ts(1_700_000_000)));
227 + assert_eq!(billing.current_period_end, Some(ts(1_700_100_000)));
228 + }
229 +
230 + // ── activate_billing: draft-only guard ───────────────────────────────────────
231 +
232 + #[tokio::test]
233 + async fn activate_billing_is_only_valid_from_draft() {
234 + let db = TestDb::new().await;
235 + let user = seed_user(&db.pool, "sbb_activate").await;
236 + let app = seed_billable_app(&db.pool, user, "sbbactiv").await;
237 +
238 + activate_bulk(&db.pool, app, "sub_first", 1_700_000_000, 1_700_100_000).await;
239 +
240 + let billing = synckit_billing::get_app_with_billing(&db.pool, app)
241 + .await
242 + .expect("load billing")
243 + .expect("app exists");
244 + assert_eq!(billing.billing_status, SyncBillingStatus::Active);
245 + assert_eq!(billing.stripe_subscription_id.as_deref(), Some("sub_first"));
246 + assert_eq!(billing.storage_gb_cap, Some(100));
247 +
248 + // A replay / TOCTOU race that reaches activation again against a now-active
249 + // app must conflict, not silently orphan the live subscription.
250 + let err = synckit_billing::activate_billing(
251 + &db.pool,
252 + app,
253 + SyncEnforcementMode::Bulk,
254 + Some(250),
255 + None,
256 + None,
257 + "sub_second",
258 + ts(1_700_500_000),
259 + ts(1_700_600_000),
260 + )
261 + .await
262 + .expect_err("re-activation of a non-draft app must fail");
263 + assert!(
264 + matches!(err, makenotwork::error::AppError::Conflict(_)),
265 + "expected a Conflict, got {err:?}"
266 + );
267 +
268 + // The first subscription id is untouched.
269 + let after = synckit_billing::get_app_with_billing(&db.pool, app)
270 + .await
271 + .expect("load billing")
272 + .expect("app exists");
273 + assert_eq!(after.stripe_subscription_id.as_deref(), Some("sub_first"));
274 + assert_eq!(after.storage_gb_cap, Some(100), "knobs are not overwritten by the refused activation");
275 + }
276 +
277 + // ── claim_key: cap enforcement under the FOR UPDATE lock ──────────────────────
278 +
279 + #[tokio::test]
280 + async fn claim_key_enforces_cap_atomically() {
281 + let db = TestDb::new().await;
282 + let user = seed_user(&db.pool, "sbb_cap").await;
283 + let app = seed_billable_app(&db.pool, user, "sbbcap").await;
284 +
285 + // key_cap = 2. Two distinct claims fill it; the third is refused.
286 + let first = synckit_billing::claim_key(&db.pool, app, "k1", Some(2))
287 + .await
288 + .expect("claim k1");
289 + assert!(first.newly_claimed);
290 + assert!(!first.cap_reached);
291 + assert_eq!(first.total_claimed, 1);
292 +
293 + let second = synckit_billing::claim_key(&db.pool, app, "k2", Some(2))
294 + .await
295 + .expect("claim k2");
296 + assert!(second.newly_claimed);
297 + assert_eq!(second.total_claimed, 2);
298 +
299 + let third = synckit_billing::claim_key(&db.pool, app, "k3", Some(2))
300 + .await
301 + .expect("claim k3");
302 + assert!(!third.newly_claimed, "a new slot over the cap is refused");
303 + assert!(third.cap_reached);
304 + assert_eq!(third.total_claimed, 2, "the cap is not overshot");
305 +
306 + // The refused claim inserted no row and left the counter at the cap.
307 + assert_eq!(keys_claimed(&db.pool, app).await, 2);
308 + assert_eq!(active_key_rows(&db.pool, app).await, 2);
309 + }
310 +
311 + #[tokio::test]
312 + async fn claim_key_is_idempotent_and_admits_reclaims_at_cap() {
313 + let db = TestDb::new().await;
314 + let user = seed_user(&db.pool, "sbb_reclaim").await;
315 + let app = seed_billable_app(&db.pool, user, "sbbreclaim").await;
316 +
317 + // key_cap = 1, filled by k1.
318 + let first = synckit_billing::claim_key(&db.pool, app, "k1", Some(1))
319 + .await
320 + .expect("claim k1");
321 + assert!(first.newly_claimed);
322 + assert_eq!(first.total_claimed, 1);
323 +
324 + // Re-claiming the already-active key consumes no new slot: not newly
325 + // claimed, and NOT reported as cap_reached (it's admitted).
326 + let reclaim = synckit_billing::claim_key(&db.pool, app, "k1", Some(1))
327 + .await
328 + .expect("re-claim k1");
329 + assert!(!reclaim.newly_claimed, "re-claim inserts nothing");
330 + assert!(!reclaim.cap_reached, "an admitted re-claim is not a cap refusal");
331 + assert_eq!(reclaim.total_claimed, 1);
332 +
333 + // Exactly one active row and counter of 1 — no double count.
334 + assert_eq!(keys_claimed(&db.pool, app).await, 1);
335 + assert_eq!(active_key_rows(&db.pool, app).await, 1);
336 + }
337 +
338 + // ── release_key: idempotency ─────────────────────────────────────────────────
339 +
340 + #[tokio::test]
341 + async fn release_key_is_idempotent() {
342 + let db = TestDb::new().await;
343 + let user = seed_user(&db.pool, "sbb_release").await;
344 + let app = seed_billable_app(&db.pool, user, "sbbrelease").await;
345 +
346 + synckit_billing::claim_key(&db.pool, app, "k1", Some(5))
347 + .await
348 + .expect("claim k1");
349 +
350 + let first = synckit_billing::release_key(&db.pool, app, "k1")
351 + .await
352 + .expect("release k1");
353 + assert!(first.newly_released);
354 + assert_eq!(first.total_claimed, 0);
355 +
356 + // Releasing an already-released key is a no-op, and the counter never goes
357 + // negative.
358 + let second = synckit_billing::release_key(&db.pool, app, "k1")
359 + .await
360 + .expect("re-release k1");
361 + assert!(!second.newly_released, "no active row to release");
362 + assert_eq!(second.total_claimed, 0);
363 + assert_eq!(keys_claimed(&db.pool, app).await, 0);
364 + assert_eq!(active_key_rows(&db.pool, app).await, 0);
365 + }
@@ -0,0 +1,248 @@
1 + //! DB-layer contract tests for the account core (`db::users`).
2 + //!
3 + //! `users.rs` is the widest CRUD/lookup module and its contracts were asserted
4 + //! only indirectly through HTTP flows (auth, dashboard, admin) — audit Run 18
5 + //! Testing. These call the `db::users::` functions directly so the invariants
6 + //! the account surface leans on are pinned at the layer they live in: lookup
7 + //! round-trip (found vs not-found), exact-vs-normalized key matching for
8 + //! username/email, the creator-permission flag, the voluntary creator-pause
9 + //! toggle, and the Stripe-webhook status write keyed by connected account.
10 +
11 + use crate::harness::TestHarness;
12 + use makenotwork::db::{self, Email, Username};
13 +
14 + // ── lookup round-trip: found vs not-found ──
15 +
16 + #[tokio::test]
17 + async fn lookups_round_trip_after_signup() {
18 + let mut h = TestHarness::new().await;
19 + let user_id = h
20 + .signup("lookup_rt", "lookup_rt@test.com", "password123")
21 + .await;
22 +
23 + // by id
24 + let by_id = db::users::get_user_by_id(&h.db, user_id)
25 + .await
26 + .expect("get_user_by_id ok")
27 + .expect("signed-up user is found by id");
28 + assert_eq!(by_id.id, user_id);
29 + assert_eq!(by_id.username.as_str(), "lookup_rt");
30 + assert_eq!(by_id.email.as_str(), "lookup_rt@test.com");
31 +
32 + // by username (exact match)
33 + let username = Username::new("lookup_rt").expect("valid username");
34 + let by_username = db::users::get_user_by_username(&h.db, &username)
35 + .await
36 + .expect("get_user_by_username ok")
37 + .expect("signed-up user is found by username");
38 + assert_eq!(by_username.id, user_id);
39 +
40 + // by email (normalized match)
41 + let email = Email::new("lookup_rt@test.com").expect("valid email");
42 + let by_email = db::users::get_user_by_email(&h.db, &email)
43 + .await
44 + .expect("get_user_by_email ok")
45 + .expect("signed-up user is found by email");
46 + assert_eq!(by_email.id, user_id);
47 + }
48 +
49 + #[tokio::test]
50 + async fn lookups_return_none_when_absent() {
51 + let h = TestHarness::new().await;
52 +
53 + // A random UUID that was never inserted.
54 + let missing_id = db::UserId::new();
55 + assert!(
56 + db::users::get_user_by_id(&h.db, missing_id)
57 + .await
58 + .expect("get_user_by_id ok")
59 + .is_none(),
60 + "an unknown id resolves to None, not an error"
61 + );
62 +
63 + let missing_username = Username::new("nobody_here").expect("valid username");
64 + assert!(
65 + db::users::get_user_by_username(&h.db, &missing_username)
66 + .await
67 + .expect("get_user_by_username ok")
68 + .is_none(),
69 + "an unknown username resolves to None"
70 + );
71 +
72 + let missing_email = Email::new("nobody@test.com").expect("valid email");
73 + assert!(
74 + db::users::get_user_by_email(&h.db, &missing_email)
75 + .await
76 + .expect("get_user_by_email ok")
77 + .is_none(),
78 + "an unknown email resolves to None"
79 + );
80 + }
81 +
82 + // ── key matching: username is exact, email is normalized ──
83 +
84 + #[tokio::test]
85 + async fn username_lookup_is_case_exact() {
86 + let mut h = TestHarness::new().await;
87 + // Signup stores the username verbatim (no lowercasing); the lookup SQL is
88 + // `WHERE username = $1`, so a differently-cased handle must NOT collide.
89 + let user_id = h
90 + .signup("CaseUser", "caseuser@test.com", "password123")
91 + .await;
92 +
93 + let exact = Username::new("CaseUser").expect("valid username");
94 + let found = db::users::get_user_by_username(&h.db, &exact)
95 + .await
96 + .expect("get_user_by_username ok")
97 + .expect("exact-case username is found");
98 + assert_eq!(found.id, user_id);
99 +
100 + let wrong_case = Username::new("caseuser").expect("valid username");
101 + assert!(
102 + db::users::get_user_by_username(&h.db, &wrong_case)
103 + .await
104 + .expect("get_user_by_username ok")
105 + .is_none(),
106 + "username match is case-exact, so a lowercased variant does not resolve"
107 + );
108 + }
109 +
110 + #[tokio::test]
111 + async fn email_lookup_is_case_insensitive_by_normalization() {
112 + let mut h = TestHarness::new().await;
113 + let user_id = h
114 + .signup("email_norm", "email_norm@test.com", "password123")
115 + .await;
116 +
117 + // `Email::new` trims + lowercases, so a mixed-case spelling normalizes to
118 + // the same stored value and still resolves to the same row.
119 + let mixed = Email::new("Email_Norm@Test.Com").expect("valid email");
120 + let found = db::users::get_user_by_email(&h.db, &mixed)
121 + .await
122 + .expect("get_user_by_email ok")
123 + .expect("mixed-case email normalizes and is found");
124 + assert_eq!(found.id, user_id);
125 + }
126 +
127 + // ── creator permission flag ──
128 +
129 + #[tokio::test]
130 + async fn grant_creator_flips_the_creator_flag() {
131 + let mut h = TestHarness::new().await;
132 + let user_id = h
133 + .signup("creator_flag", "creator_flag@test.com", "password123")
134 + .await;
135 +
136 + let before = db::users::get_user_by_id(&h.db, user_id)
137 + .await
138 + .expect("get_user_by_id ok")
139 + .expect("user found");
140 + assert!(
141 + !before.can_create_projects,
142 + "a fresh signup cannot create projects"
143 + );
144 +
145 + h.grant_creator(user_id).await;
146 +
147 + let after = db::users::get_user_by_id(&h.db, user_id)
148 + .await
149 + .expect("get_user_by_id ok")
150 + .expect("user found");
151 + assert!(
152 + after.can_create_projects,
153 + "granting creator access sets can_create_projects"
154 + );
155 + }
156 +
157 + // ── voluntary creator pause toggle ──
158 +
159 + #[tokio::test]
160 + async fn pause_and_unpause_creator_toggle_the_flag() {
161 + let mut h = TestHarness::new().await;
162 + let user_id = h.create_creator("pause_toggle").await;
163 +
164 + let fresh = db::users::get_user_by_id(&h.db, user_id)
165 + .await
166 + .expect("get_user_by_id ok")
167 + .expect("user found");
168 + assert!(!fresh.is_creator_paused(), "a new creator is not paused");
169 +
170 + db::users::pause_creator(&h.db, user_id)
171 + .await
172 + .expect("pause_creator ok");
173 + let paused = db::users::get_user_by_id(&h.db, user_id)
174 + .await
175 + .expect("get_user_by_id ok")
176 + .expect("user found");
177 + assert!(
178 + paused.is_creator_paused(),
179 + "pause_creator stamps creator_paused_at"
180 + );
181 +
182 + db::users::unpause_creator(&h.db, user_id)
183 + .await
184 + .expect("unpause_creator ok");
185 + let resumed = db::users::get_user_by_id(&h.db, user_id)
186 + .await
187 + .expect("get_user_by_id ok")
188 + .expect("user found");
189 + assert!(
190 + !resumed.is_creator_paused(),
191 + "unpause_creator clears creator_paused_at"
192 + );
193 + }
194 +
195 + // ── Stripe webhook status write (keyed by connected account) ──
196 +
197 + #[tokio::test]
198 + async fn update_user_stripe_status_persists_flags_by_account() {
199 + let mut h = TestHarness::new().await;
200 + let user_id = h.create_creator("stripe_status").await;
201 + // connect_stripe sets stripe_account_id and flips all three Stripe flags on.
202 + h.connect_stripe(user_id, "acct_dbul_status").await;
203 +
204 + // The webhook write finds the row by stripe_account_id and returns it.
205 + let updated = db::users::update_user_stripe_status(
206 + &h.db,
207 + "acct_dbul_status",
208 + true, // onboarding_complete
209 + false, // payouts_enabled
210 + true, // charges_enabled
211 + )
212 + .await
213 + .expect("update_user_stripe_status ok")
214 + .expect("a matching connected account returns the updated row");
215 + assert_eq!(updated.id, user_id);
216 + assert!(updated.stripe_onboarding_complete);
217 + assert!(!updated.stripe_payouts_enabled);
218 + assert!(updated.stripe_charges_enabled);
219 +
220 + // The flags are durable, not just echoed by the RETURNING clause.
221 + let reread = db::users::get_user_by_id(&h.db, user_id)
222 + .await
223 + .expect("get_user_by_id ok")
224 + .expect("user found");
225 + assert!(reread.stripe_onboarding_complete);
226 + assert!(!reread.stripe_payouts_enabled);
227 + assert!(reread.stripe_charges_enabled);
228 + }
229 +
230 + #[tokio::test]
231 + async fn update_user_stripe_status_is_noop_for_unknown_account() {
232 + let h = TestHarness::new().await;
233 + // No user carries this account id, so the UPDATE matches nothing and the
234 + // webhook handler gets None (rather than an error) to ignore the event.
235 + let result = db::users::update_user_stripe_status(
236 + &h.db,
237 + "acct_dbul_nonexistent",
238 + true,
239 + true,
240 + true,
241 + )
242 + .await
243 + .expect("update_user_stripe_status ok");
244 + assert!(
245 + result.is_none(),
246 + "an unmatched stripe_account_id is a no-op returning None"
247 + );
248 + }
@@ -97,8 +97,11 @@ mod lifecycle;
97 97 mod cart;
98 98 mod bundles;
99 99 mod idempotency;
100 + mod db_items_layer;
100 101 mod db_payments_layer;
102 + mod db_synckit_billing_layer;
101 103 mod db_transactions_layer;
104 + mod db_users_layer;
102 105 mod db_collections_layer;
103 106 mod db_creator_tiers_layer;
104 107 mod db_issues_layer;