Skip to main content

max / makenotwork

26.8 KB · 721 lines History Blame Raw
1 //! DB-layer contract tests for the `--seed-examples` flow (`seed::run`, Phases 1-2).
2 //!
3 //! These exercise the seed against real Postgres to prove the invariants the
4 //! example marketplace rests on:
5 //!
6 //! 1. It clears the no-real-users guard on a *freshly-migrated* DB (migration
7 //! `080_remove_demo_data` leaves no non-example accounts) and creates the
8 //! full roster.
9 //! 2. Every seeded creator+project is publicly visible, `is_sandbox = FALSE`
10 //! on the user and `is_public = true` on the project, the only two gates the
11 //! discover feed applies. The discover predicate returning them is the proof.
12 //! 3. Phase-2 content: one item of every `ItemType`, spanning every pricing
13 //! kind, with tags and subscription tiers, and all items **hidden**
14 //! (`scan_status='pending'`) until Phase 3 attaches media.
15 //! 4. Idempotency: a second `run()` wipes the prior example data first, so
16 //! counts stay fixed instead of doubling.
17 //! 5. The prod-host guard still refuses even with the opt-in flag set.
18
19 use std::sync::Arc;
20
21 use crate::harness::db::TestDb;
22 use crate::harness::storage::InMemoryStorage;
23 use makenotwork::seed::{self, SeedMedia, SeedOptions};
24 use makenotwork::storage::StorageBackend;
25
26 /// The Phase-1 roster size (see `seed::creators::ROSTER`).
27 pub(super) const SEEDED_CREATORS: i64 = 5;
28 /// Phase-2 item count: five per project, spanning every `ItemType`. Wide enough
29 /// that the storefront grids read as grids.
30 const SEEDED_ITEMS: i64 = 25;
31
32 /// Every `ItemType` must still appear at least once across the catalog. Held
33 /// separately from the item count now that projects carry several of a type.
34 const ITEM_TYPES: i64 = 11;
35
36 /// Guard-passing options for a test run: opt-in on, approved example host.
37 pub(super) fn testnot_opts() -> SeedOptions {
38 SeedOptions {
39 allow_example_seed: true,
40 host_url: "https://testnot.work".to_string(),
41 harness: None,
42 buyer: None,
43 }
44 }
45
46 /// Seeded creators and harness accounts, which is to say every example account
47 /// except the background-buyer pool. Those are seeded by `seed::sales` purely to
48 /// own transactions and are counted by
49 /// [`every_item_reports_the_sales_it_actually_has`] instead; folding them in here
50 /// would make the roster size a function of how many sales the demo shows.
51 pub(super) async fn count_example_creators(pool: &sqlx::PgPool) -> i64 {
52 sqlx::query_scalar(
53 "SELECT COUNT(*) FROM users \
54 WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE \
55 AND username NOT LIKE $1",
56 )
57 .bind(format!("{}%", seed::sales::BUYER_HANDLE_PREFIX))
58 .fetch_one(pool)
59 .await
60 .expect("count example creators")
61 }
62
63 async fn count_public_example_projects(pool: &sqlx::PgPool) -> i64 {
64 sqlx::query_scalar(
65 "SELECT COUNT(*) FROM projects p \
66 JOIN users u ON u.id = p.user_id \
67 WHERE lower(split_part(u.email, '@', 2)) = 'example.test' AND p.is_public = TRUE",
68 )
69 .fetch_one(pool)
70 .await
71 .expect("count public example projects")
72 }
73
74 /// Slugs of seeded projects that clear the *exact* public-discover gate
75 /// (`p.is_public AND u.is_sandbox = FALSE`, per `db::discover::discover_projects`).
76 /// Mirrors that predicate in SQL because the `discover` module is `pub(crate)` and
77 /// so unreachable from this external test crate.
78 pub(super) async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> {
79 sqlx::query_scalar::<_, String>(
80 "SELECT p.slug::text FROM projects p \
81 JOIN users u ON u.id = p.user_id \
82 WHERE p.is_public = TRUE AND u.is_sandbox = FALSE \
83 AND lower(split_part(u.email, '@', 2)) = 'example.test' \
84 ORDER BY p.slug",
85 )
86 .fetch_all(pool)
87 .await
88 .expect("discover-visible example slugs")
89 }
90
91 /// Total items under the seeded example projects.
92 pub(super) async fn count_example_items(pool: &sqlx::PgPool) -> i64 {
93 sqlx::query_scalar(
94 "SELECT COUNT(*) FROM items i \
95 JOIN projects p ON p.id = i.project_id \
96 JOIN users u ON u.id = p.user_id \
97 WHERE lower(split_part(u.email, '@', 2)) = 'example.test'",
98 )
99 .fetch_one(pool)
100 .await
101 .expect("count example items")
102 }
103
104 /// Example items that clear the *exact* item-discover gate. Phase 2 seeds items
105 /// hidden (`scan_status='pending'`), so this must be zero until Phase 3.
106 async fn count_discover_visible_example_items(pool: &sqlx::PgPool) -> i64 {
107 sqlx::query_scalar(
108 "SELECT COUNT(*) FROM items i \
109 JOIN projects p ON p.id = i.project_id \
110 JOIN users u ON u.id = p.user_id \
111 WHERE i.is_public = TRUE AND i.listed = TRUE AND p.is_public = TRUE \
112 AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL \
113 AND lower(split_part(u.email, '@', 2)) = 'example.test'",
114 )
115 .fetch_one(pool)
116 .await
117 .expect("count discover-visible example items")
118 }
119
120 #[tokio::test]
121 async fn seeds_publicly_visible_creators_and_projects() {
122 let db = TestDb::new().await;
123
124 // Precondition the guard relies on: a freshly-migrated DB holds no real
125 // accounts (080_remove_demo_data cleared the 003 demo seed).
126 let real: i64 = sqlx::query_scalar(
127 "SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE '%@example.test'",
128 )
129 .fetch_one(&db.pool)
130 .await
131 .expect("count real users");
132 assert_eq!(real, 0, "a migrated DB should hold no non-example accounts");
133
134 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
135 .await
136 .expect("seed should run on a clean migrated DB");
137
138 // All five creators exist and are non-sandbox (publicly visible), and each
139 // owns a public project.
140 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
141 assert_eq!(
142 count_public_example_projects(&db.pool).await,
143 SEEDED_CREATORS
144 );
145
146 // Public visibility proof: all five projects clear the discover gate.
147 let slugs = discover_visible_example_slugs(&db.pool).await;
148 assert_eq!(slugs.len() as i64, SEEDED_CREATORS);
149 for slug in [
150 "restored-reels-vol-1",
151 "deskriver-suite",
152 "cc0-field-library",
153 "the-marginalia-reader",
154 "commons-sampler",
155 ] {
156 assert!(
157 slugs.iter().any(|s| s == slug),
158 "discover-visible set missing seeded project {slug}"
159 );
160 }
161 }
162
163 #[tokio::test]
164 async fn seeds_items_across_types_and_pricing_but_hidden() {
165 let db = TestDb::new().await;
166 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
167 .await
168 .expect("seed");
169
170 // Five items per project, and every ItemType still represented.
171 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
172 let distinct_types: i64 = sqlx::query_scalar(
173 "SELECT COUNT(DISTINCT i.item_type) FROM items i \
174 JOIN projects p ON p.id = i.project_id \
175 JOIN users u ON u.id = p.user_id \
176 WHERE lower(split_part(u.email, '@', 2)) = 'example.test'",
177 )
178 .fetch_one(&db.pool)
179 .await
180 .expect("distinct item types");
181 assert_eq!(distinct_types, ITEM_TYPES, "every ItemType should appear");
182
183 // Phase-2 boundary: nothing is discover-visible yet (all pending).
184 assert_eq!(
185 count_discover_visible_example_items(&db.pool).await,
186 0,
187 "Phase 2 items must stay hidden until Phase 3 attaches media"
188 );
189
190 // Pricing kinds are represented: Pwyw (pwyw_enabled), BuyOnce (price > 0),
191 // and Free (price 0) items all exist.
192 let pwyw: i64 = sqlx::query_scalar(
193 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
194 JOIN users u ON u.id = p.user_id \
195 WHERE lower(split_part(u.email,'@',2))='example.test' AND i.pwyw_enabled = TRUE",
196 )
197 .fetch_one(&db.pool)
198 .await
199 .expect("pwyw count");
200 assert!(pwyw >= 1, "expected Pwyw items");
201 let buyonce: i64 = sqlx::query_scalar(
202 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
203 JOIN users u ON u.id = p.user_id \
204 WHERE lower(split_part(u.email,'@',2))='example.test' \
205 AND i.pwyw_enabled = FALSE AND i.price_cents > 0",
206 )
207 .fetch_one(&db.pool)
208 .await
209 .expect("buyonce count");
210 assert!(buyonce >= 1, "expected BuyOnce items");
211
212 // The subscription project has its pricing_model set and >= 2 tiers.
213 let sub_tiers: i64 = sqlx::query_scalar(
214 "SELECT COUNT(*) FROM subscription_tiers st JOIN projects p ON p.id = st.project_id \
215 WHERE p.slug = 'the-marginalia-reader' AND p.pricing_model = 'subscription'",
216 )
217 .fetch_one(&db.pool)
218 .await
219 .expect("subscription tiers");
220 assert!(
221 sub_tiers >= 2,
222 "subscription project should have >= 2 tiers, got {sub_tiers}"
223 );
224
225 // Every item is tagged (for the browse/filter surfaces), no example item
226 // lacks a tag, which would mean a roster slug failed to resolve.
227 let untagged: i64 = sqlx::query_scalar(
228 "SELECT COUNT(*) FROM items i \
229 JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \
230 WHERE lower(split_part(u.email,'@',2))='example.test' \
231 AND NOT EXISTS (SELECT 1 FROM item_tags it WHERE it.item_id = i.id)",
232 )
233 .fetch_one(&db.pool)
234 .await
235 .expect("untagged item count");
236 assert_eq!(
237 untagged, 0,
238 "every seeded item should have at least one tag"
239 );
240 }
241
242 /// Only the subscription project may be non-free at the project level.
243 ///
244 /// A paid project renders `ProjectPaywallTemplate` to anyone without access, and
245 /// that template lists no items at all. Every testnot visitor is anonymous and
246 /// therefore permanently without access, so a paid project is a storefront nobody
247 /// can see inside. Spreading the four `PricingKind`s across projects hides most
248 /// of the seeded catalog, which this guards against. Paid *items* are fine and
249 /// are how BuyOnce and Pwyw get demonstrated.
250 #[tokio::test]
251 async fn only_the_subscription_project_is_paywalled() {
252 let db = TestDb::new().await;
253
254 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
255 .await
256 .expect("seed should succeed");
257
258 let paywalled: Vec<String> = sqlx::query_scalar(
259 "SELECT p.slug FROM projects p JOIN users u ON u.id = p.user_id \
260 WHERE lower(split_part(u.email,'@',2))='example.test' \
261 AND p.pricing_model IS NOT NULL AND p.pricing_model <> 'free' \
262 ORDER BY p.slug",
263 )
264 .fetch_all(&db.pool)
265 .await
266 .expect("paywalled project slugs");
267
268 assert_eq!(
269 paywalled,
270 vec!["the-marginalia-reader".to_string()],
271 "only the subscription project may be paywalled; the rest must be \
272 project-level free so their items are reachable anonymously"
273 );
274 }
275
276 #[tokio::test]
277 async fn every_item_reports_the_sales_it_actually_has() {
278 let db = TestDb::new().await;
279
280 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
281 .await
282 .expect("seed should succeed");
283
284 // The exact query `scheduler::integrity::check_sales_count_drift` runs. It
285 // pages WAM on every row it returns, so a demo whose sales figures were
286 // written rather than earned would alert forever.
287 let drifted: Vec<(String, i32, i64)> = sqlx::query_as(
288 r"
289 SELECT i.title, i.sales_count, COUNT(t.id)
290 FROM items i
291 LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'
292 GROUP BY i.id, i.title, i.sales_count
293 HAVING i.sales_count != COUNT(t.id)
294 ",
295 )
296 .fetch_all(&db.pool)
297 .await
298 .expect("drift query");
299
300 assert!(
301 drifted.is_empty(),
302 "sales_count disagrees with the transactions behind it: {drifted:?}"
303 );
304
305 // And the figure is not uniformly zero, which is the state this phase
306 // exists to leave behind.
307 let sold: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM items WHERE sales_count > 0")
308 .fetch_one(&db.pool)
309 .await
310 .expect("count of items with sales");
311 assert!(sold > 0, "no item reports a single sale");
312
313 // The pool those transactions belong to. Counted here because
314 // `count_example_creators` deliberately excludes it.
315 let buyers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE $1")
316 .bind(format!("{}%", seed::sales::BUYER_HANDLE_PREFIX))
317 .fetch_one(&db.pool)
318 .await
319 .expect("count background buyers");
320 assert_eq!(buyers, seed::sales::BUYER_POOL as i64);
321 }
322
323 #[tokio::test]
324 async fn no_item_is_released_on_the_day_the_seed_ran() {
325 let db = TestDb::new().await;
326
327 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
328 .await
329 .expect("seed should succeed");
330
331 // `items.created_at` is what the item page renders as "Released". A catalog
332 // that all came out today is the clearest tell that it was generated.
333 let stamped: Vec<String> = sqlx::query_scalar(
334 "SELECT title FROM items WHERE created_at > NOW() - INTERVAL '1 day' ORDER BY title",
335 )
336 .fetch_all(&db.pool)
337 .await
338 .expect("release dates");
339
340 assert!(
341 stamped.is_empty(),
342 "these items carry the seed run's own date as their release: {stamped:?}"
343 );
344 }
345
346 #[tokio::test]
347 async fn seed_is_idempotent() {
348 let db = TestDb::new().await;
349
350 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
351 .await
352 .expect("first seed");
353 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
354 .await
355 .expect("second seed");
356
357 // Re-running wipes prior example data first (cascading to projects + items),
358 // so counts stay fixed instead of doubling.
359 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
360 assert_eq!(
361 count_public_example_projects(&db.pool).await,
362 SEEDED_CREATORS
363 );
364 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
365 }
366
367 #[tokio::test]
368 async fn refuses_on_prod_host() {
369 let db = TestDb::new().await;
370
371 let err = seed::run(
372 &db.pool,
373 &SeedOptions {
374 allow_example_seed: true,
375 host_url: "https://makenot.work".to_string(),
376 harness: None,
377 buyer: None,
378 },
379 &SeedMedia::none(),
380 )
381 .await
382 .expect_err("prod host must be refused");
383 assert!(matches!(err, seed::SeedError::ProdHost(_)));
384
385 // Nothing was created, and the demo account is untouched.
386 assert_eq!(count_example_creators(&db.pool).await, 0);
387 }
388
389 #[tokio::test]
390 async fn seeds_blog_posts_and_follows() {
391 let db = TestDb::new().await;
392 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
393 .await
394 .expect("seed");
395
396 // Two published posts per project (10), all published.
397 let posts: i64 = sqlx::query_scalar(
398 "SELECT COUNT(*) FROM blog_posts bp JOIN projects p ON p.id = bp.project_id \
399 JOIN users u ON u.id = p.user_id \
400 WHERE lower(split_part(u.email,'@',2))='example.test' AND bp.published_at IS NOT NULL",
401 )
402 .fetch_one(&db.pool)
403 .await
404 .expect("published posts");
405 assert_eq!(posts, 10, "expected 2 published blog posts per project");
406
407 // Follow graph: 20 user-follows + 20 project-follows among example accounts.
408 let user_follows: i64 = sqlx::query_scalar(
409 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
410 WHERE f.target_type = 'user' AND lower(split_part(fu.email,'@',2))='example.test'",
411 )
412 .fetch_one(&db.pool)
413 .await
414 .expect("user follows");
415 assert_eq!(user_follows, 20);
416 let project_follows: i64 = sqlx::query_scalar(
417 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
418 WHERE f.target_type = 'project' AND lower(split_part(fu.email,'@',2))='example.test'",
419 )
420 .fetch_one(&db.pool)
421 .await
422 .expect("project follows");
423 assert_eq!(project_follows, 20);
424
425 // Spot-check a rendered count: each creator has 4 followers.
426 let followers_of_openreels: i64 = sqlx::query_scalar(
427 "SELECT COUNT(*) FROM follows f \
428 WHERE f.target_type = 'user' \
429 AND f.target_id = (SELECT id FROM users WHERE username = 'openreels')",
430 )
431 .fetch_one(&db.pool)
432 .await
433 .expect("openreels followers");
434 assert_eq!(followers_of_openreels, 4);
435
436 // Idempotent: re-run keeps 10 posts + 40 follows, not doubled.
437 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
438 .await
439 .expect("re-seed");
440 let posts2: i64 = sqlx::query_scalar(
441 "SELECT COUNT(*) FROM blog_posts bp JOIN projects p ON p.id = bp.project_id \
442 JOIN users u ON u.id = p.user_id \
443 WHERE lower(split_part(u.email,'@',2))='example.test'",
444 )
445 .fetch_one(&db.pool)
446 .await
447 .expect("posts after re-run");
448 assert_eq!(posts2, 10, "re-run should not duplicate blog posts");
449 let follows2: i64 = sqlx::query_scalar(
450 "SELECT COUNT(*) FROM follows f JOIN users fu ON fu.id = f.follower_id \
451 WHERE lower(split_part(fu.email,'@',2))='example.test'",
452 )
453 .fetch_one(&db.pool)
454 .await
455 .expect("follows after re-run");
456 assert_eq!(follows2, 40, "re-run should not duplicate follows");
457 }
458
459 /// Guard-passing options bundled with an in-memory storage backend, so the media
460 /// phase runs and flips items visible.
461 pub(super) fn media_ctx() -> SeedMedia {
462 let s3: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
463 let public: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
464 SeedMedia {
465 s3: Some(s3),
466 public_s3: Some(public),
467 cdn_base_url: Some("https://cdn.example.test".to_string()),
468 // No curated assets: every slot takes its generated placeholder, and the
469 // test never reaches the network. Resolving the real manifest is a
470 // separate, network-touching concern (`seed::manifest`).
471 assets: makenotwork::seed::manifest::ResolvedAssets::default(),
472 }
473 }
474
475 #[tokio::test]
476 async fn seeds_media_makes_items_visible() {
477 let db = TestDb::new().await;
478 seed::run(&db.pool, &testnot_opts(), &media_ctx())
479 .await
480 .expect("seed with media");
481
482 // With media attached, every item is promoted 'clean' and now clears the
483 // full item-discover gate (contrast the no-storage `..._but_hidden` test).
484 assert_eq!(
485 count_discover_visible_example_items(&db.pool).await,
486 SEEDED_ITEMS,
487 "all items should be visible once media is attached"
488 );
489
490 // The audio item has its audio key set.
491 let audio_keyed: i64 = sqlx::query_scalar(
492 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
493 JOIN users u ON u.id = p.user_id \
494 WHERE lower(split_part(u.email,'@',2))='example.test' \
495 AND i.item_type = 'audio' AND i.audio_s3_key IS NOT NULL",
496 )
497 .fetch_one(&db.pool)
498 .await
499 .expect("audio keyed");
500 assert!(audio_keyed >= 1, "audio item should have an audio_s3_key");
501
502 // Download-type items produced clean, keyed versions.
503 let clean_versions: i64 = sqlx::query_scalar(
504 "SELECT COUNT(*) FROM versions v JOIN items i ON i.id = v.item_id \
505 JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \
506 WHERE lower(split_part(u.email,'@',2))='example.test' \
507 AND v.s3_key IS NOT NULL AND v.scan_status = 'clean'",
508 )
509 .fetch_one(&db.pool)
510 .await
511 .expect("clean versions");
512 assert!(
513 clean_versions >= 1,
514 "expected a clean, keyed download version"
515 );
516
517 // Covers were attached (public bucket + CDN base present).
518 let covered: i64 = sqlx::query_scalar(
519 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
520 JOIN users u ON u.id = p.user_id \
521 WHERE lower(split_part(u.email,'@',2))='example.test' \
522 AND i.cover_s3_key IS NOT NULL AND i.cover_scan_status = 'clean'",
523 )
524 .fetch_one(&db.pool)
525 .await
526 .expect("covered items");
527 assert!(
528 covered >= 1,
529 "expected item covers with cover_scan_status clean"
530 );
531
532 // Idempotent with media too: re-running stays at the roster size.
533 seed::run(&db.pool, &testnot_opts(), &media_ctx())
534 .await
535 .expect("re-seed with media");
536 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
537 assert_eq!(
538 count_discover_visible_example_items(&db.pool).await,
539 SEEDED_ITEMS
540 );
541 }
542
543 // ── Harness phase ──
544 //
545 // The accounts and OAuth client the mt browser axis logs in with. Their whole
546 // value is that a testnot reseed reproduces them exactly: an audit run that
547 // cannot log in stops at reads, which is the coverage gap the phase exists to
548 // close. These tests pin the three things a login actually depends on, the
549 // password verifying, the perks MNW will report, and the redirect URI being
550 // registered, plus survival across a reseed.
551
552 use makenotwork::seed::harness::{
553 self, CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, HarnessOptions, OWNER_ACCOUNT_ID,
554 };
555
556 const HARNESS_REDIRECT_URI: &str = "http://mt.example.test:3400/auth/callback";
557
558 fn harness_opts() -> HarnessOptions {
559 HarnessOptions {
560 password: "harness-test-password".to_string(),
561 redirect_uri: HARNESS_REDIRECT_URI.to_string(),
562 }
563 }
564
565 /// Options that run the catalog *and* the harness phase, as a testnot box with
566 /// both env vars set does.
567 fn testnot_opts_with_harness() -> SeedOptions {
568 SeedOptions {
569 harness: Some(harness_opts()),
570 ..testnot_opts()
571 }
572 }
573
574 pub(super) async fn password_hash_of(pool: &sqlx::PgPool, id: uuid::Uuid) -> String {
575 sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
576 .bind(id)
577 .fetch_one(pool)
578 .await
579 .expect("harness account should exist")
580 }
581
582 #[tokio::test]
583 async fn harness_accounts_can_log_in_and_carry_their_perks() {
584 let db = TestDb::new().await;
585 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
586 .await
587 .expect("seed with harness phase");
588
589 // The password verifies: this is the login the browser run performs.
590 for id in [FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID] {
591 let hash = password_hash_of(&db.pool, id).await;
592 assert!(
593 makenotwork::auth::verify_password_async(harness_opts().password, hash)
594 .await
595 .expect("verify"),
596 "seeded password should verify for {id}"
597 );
598 }
599
600 // Perks, as `/oauth/userinfo` computes them: fan_plus from an active
601 // subscription row, is_creator from a non-null creator_tier. The pair is
602 // what mt's `UserPerks::effective_plus` gate reads, and the two accounts
603 // exercise its two halves separately.
604 let fan_plus_active: bool = sqlx::query_scalar(
605 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \
606 WHERE user_id = $1 AND status = 'active')",
607 )
608 .bind(FAN_ACCOUNT_ID)
609 .fetch_one(&db.pool)
610 .await
611 .expect("fan plus lookup");
612 assert!(fan_plus_active, "fan account should hold active Fan+");
613
614 let tiers: Vec<Option<String>> =
615 sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = ANY($1) ORDER BY username")
616 .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
617 .fetch_all(&db.pool)
618 .await
619 .expect("creator tiers");
620 // Ordered by username: harness_creator, harness_fan, harness_owner.
621 assert_eq!(
622 tiers,
623 vec![Some("everything".to_string()), None, None],
624 "only the creator account should report is_creator"
625 );
626
627 // Creator powers follow the tier. Three accounts that all hold them would be
628 // one role tested three times.
629 let can_create: Vec<bool> = sqlx::query_scalar(
630 "SELECT can_create_projects FROM users WHERE id = ANY($1) ORDER BY username",
631 )
632 .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
633 .fetch_all(&db.pool)
634 .await
635 .expect("project rights");
636 assert_eq!(can_create, vec![true, false, false]);
637
638 // Not locked out, not suspended: a prior run's failed logins must not
639 // survive the reset.
640 let clean: i64 = sqlx::query_scalar(
641 "SELECT COUNT(*) FROM users WHERE id = ANY($1) \
642 AND failed_login_attempts = 0 AND locked_until IS NULL \
643 AND suspended_at IS NULL AND deactivated_at IS NULL",
644 )
645 .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
646 .fetch_one(&db.pool)
647 .await
648 .expect("account state");
649 assert_eq!(clean, 3);
650 }
651
652 #[tokio::test]
653 async fn harness_client_registers_its_redirect_uri() {
654 let db = TestDb::new().await;
655 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
656 .await
657 .expect("seed with harness phase");
658
659 // `validate_redirect_uri` waves through localhost only; the harness instance
660 // is reached over the tailnet, so the URI has to be registered or the
661 // authorize call fails with "redirect_uri is not allowed".
662 let row: (uuid::Uuid, Vec<String>, bool) = sqlx::query_as(
663 "SELECT creator_id, redirect_uris, is_active FROM sync_apps WHERE api_key_hash = $1",
664 )
665 .bind(makenotwork::db::synckit::hash_api_key(harness::CLIENT_ID))
666 .fetch_one(&db.pool)
667 .await
668 .expect("harness OAuth client should be registered");
669
670 assert_eq!(row.0, CREATOR_ACCOUNT_ID, "client is owned by the creator");
671 assert_eq!(row.1, vec![HARNESS_REDIRECT_URI.to_string()]);
672 assert!(row.2, "client must be active");
673 }
674
675 #[tokio::test]
676 async fn harness_survives_a_reseed_with_stable_ids() {
677 let db = TestDb::new().await;
678 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
679 .await
680 .expect("first seed");
681
682 // A reseed wipes every @example.test account first, harness accounts
683 // included, then rebuilds them. Stable ids are the property mt's seed
684 // depends on: it pre-assigns community roles by mnw_account_id, so an id
685 // that moved would leave the harness a plain member with no way to moderate.
686 seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
687 .await
688 .expect("reseed");
689
690 let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
691 "SELECT id FROM users WHERE username LIKE 'harness\\_%' ORDER BY username",
692 )
693 .fetch_all(&db.pool)
694 .await
695 .expect("harness ids");
696 assert_eq!(
697 ids,
698 vec![CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, OWNER_ACCOUNT_ID],
699 "harness ids must be identical after a reseed"
700 );
701
702 // And the catalog is unchanged by the phase running twice.
703 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS + 3);
704 }
705
706 #[tokio::test]
707 async fn without_harness_options_the_phase_does_not_run() {
708 let db = TestDb::new().await;
709 seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
710 .await
711 .expect("seed without harness");
712
713 let harness_accounts: i64 =
714 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE 'harness\\_%'")
715 .fetch_one(&db.pool)
716 .await
717 .expect("count");
718 assert_eq!(harness_accounts, 0, "harness phase must be opt-in");
719 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
720 }
721