Skip to main content

max / makenotwork

Audit Run 14: code quality, tests, docs, performance Server: - Replace anyhow passthrough with typed GitError variants in git/ - Add integration test for full startup config validation - Add fuzz tests for malformed JWT tokens in synckit_auth Multithreaded: - Add retry with backoff for MNW OAuth token exchange + userinfo - Add message bodies to bare StatusCode error responses - Add IF NOT EXISTS guards to early migrations (001-004) - Denormalize reply_count on threads table, remove LEFT JOIN - Add partial index on active threads for category listing - Write architecture.md covering system design + data flows - Add admin query tests (expired bans, membership counting) PoM: - Replace raw i64 IDs with TestRunId newtype in db helpers SyncKit SDK: - Add 30-second buffer to JWT expiry check Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-16 01:37 UTC
Commit: ae244d9f0e6bdeeb8d3a412a6efc2287a148e38c
Parent: 12ab281
24 files changed, +1082 insertions, -332 deletions
@@ -135,6 +135,8 @@ pub async fn create_thread(
135 135 }
136 136
137 137 /// Insert a new post and bump the thread's last_activity_at atomically.
138 + /// When `is_reply` is true, also increments the denormalized reply_count.
139 + /// Pass false for the opening post (OP), true for all subsequent replies.
138 140 #[tracing::instrument(skip_all)]
139 141 pub async fn create_post(
140 142 pool: &PgPool,
@@ -142,6 +144,7 @@ pub async fn create_post(
142 144 author_id: Uuid,
143 145 body_markdown: &str,
144 146 body_html: &str,
147 + is_reply: bool,
145 148 ) -> Result<Uuid, sqlx::Error> {
146 149 let mut tx = pool.begin().await?;
147 150
@@ -157,10 +160,19 @@ pub async fn create_post(
157 160 .fetch_one(&mut *tx)
158 161 .await?;
159 162
160 - sqlx::query("UPDATE threads SET last_activity_at = now() WHERE id = $1")
163 + if is_reply {
164 + sqlx::query(
165 + "UPDATE threads SET last_activity_at = now(), reply_count = reply_count + 1 WHERE id = $1",
166 + )
161 167 .bind(thread_id)
162 168 .execute(&mut *tx)
163 169 .await?;
170 + } else {
171 + sqlx::query("UPDATE threads SET last_activity_at = now() WHERE id = $1")
172 + .bind(thread_id)
173 + .execute(&mut *tx)
174 + .await?;
175 + }
164 176
165 177 tx.commit().await?;
166 178 Ok(row.0)
@@ -258,17 +258,14 @@ pub async fn list_threads_in_category_paginated(
258 258 "SELECT t.id, t.title,
259 259 COALESCE(u.display_name, u.username) AS author_name,
260 260 u.username AS author_username,
261 - (COUNT(p.id) - 1) AS reply_count,
261 + t.reply_count::BIGINT AS reply_count,
262 262 t.last_activity_at,
263 263 t.pinned, t.locked
264 264 FROM threads t
265 265 JOIN categories c ON c.id = t.category_id
266 266 JOIN communities co ON co.id = c.community_id
267 267 JOIN users u ON u.mnw_account_id = t.author_id
268 - LEFT JOIN posts p ON p.thread_id = t.id
269 268 WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL
270 - GROUP BY t.id, t.title, u.display_name, u.username,
271 - t.last_activity_at, t.pinned, t.locked
272 269 ORDER BY t.pinned DESC, t.last_activity_at DESC
273 270 LIMIT $3 OFFSET $4",
274 271 )
@@ -303,17 +300,14 @@ pub async fn list_threads_in_category_sorted(
303 300 "SELECT t.id, t.title,
304 301 COALESCE(u.display_name, u.username) AS author_name,
305 302 u.username AS author_username,
306 - (COUNT(p.id) - 1) AS reply_count,
303 + t.reply_count::BIGINT AS reply_count,
307 304 t.last_activity_at,
308 305 t.pinned, t.locked
309 306 FROM threads t
310 307 JOIN categories c ON c.id = t.category_id
311 308 JOIN communities co ON co.id = c.community_id
312 309 JOIN users u ON u.mnw_account_id = t.author_id
313 - LEFT JOIN posts p ON p.thread_id = t.id
314 310 WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL
315 - GROUP BY t.id, t.title, u.display_name, u.username,
316 - t.last_activity_at, t.pinned, t.locked
317 311 {order_clause}
318 312 LIMIT $3 OFFSET $4"
319 313 );
@@ -1229,19 +1223,16 @@ pub async fn list_threads_in_category_sorted_filtered(
1229 1223 "SELECT t.id, t.title,
1230 1224 COALESCE(u.display_name, u.username) AS author_name,
1231 1225 u.username AS author_username,
1232 - (COUNT(p.id) - 1) AS reply_count,
1226 + t.reply_count::BIGINT AS reply_count,
1233 1227 t.last_activity_at,
1234 1228 t.pinned, t.locked
1235 1229 FROM threads t
1236 1230 JOIN categories c ON c.id = t.category_id
1237 1231 JOIN communities co ON co.id = c.community_id
1238 1232 JOIN users u ON u.mnw_account_id = t.author_id
1239 - LEFT JOIN posts p ON p.thread_id = t.id
1240 1233 JOIN thread_tags tt ON tt.thread_id = t.id
1241 1234 JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id
1242 1235 WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL
1243 - GROUP BY t.id, t.title, u.display_name, u.username,
1244 - t.last_activity_at, t.pinned, t.locked
1245 1236 {order_clause}
1246 1237 LIMIT $4 OFFSET $5"
1247 1238 );
@@ -1,228 +1,188 @@
1 1 # Multithreaded Architecture
2 2
3 - Forum-first community software integrated with Makenot.work. Users authenticate via MNW OAuth (PKCE flow). Each forum community maps to an MNW project.
3 + ## 1. System Overview
4 4
5 - ## High-Level Overview
5 + Multithreaded (MT) is a forum platform for MNW creators. Each MNW project gets a community forum where creators and their audiences can discuss items, post devlogs, and organize conversations by category. MT is a standalone web service that delegates authentication to MNW and receives commands from MNW via an internal API.
6 6
7 - ```
8 - ┌──────────────────────────────────────────────┐
9 - │ Browser (Askama HTML + HTMX) │
10 - └──────────────────┬───────────────────────────┘
11 - │ HTTP
12 - ┌──────────────────▼───────────────────────────┐
13 - │ Axum HTTP Server │
14 - │ │
15 - │ ┌─────────┐ ┌────────┐ ┌──────┐ ┌────────┐ │
16 - │ │ routes │ │ auth │ │ csrf │ │ seed │ │
17 - │ └────┬────┘ └────┬───┘ └──────┘ └────────┘ │
18 - │ │ │ │
19 - │ ┌────▼───────────▼──────────────────────┐ │
20 - │ │ mt-db (PostgreSQL queries/mutations) │ │
21 - │ │ └── mt-core (domain models) │ │
22 - │ └───────────────────────────────────────┘ │
23 - └───────────────────────────────────────────────┘
24 - │ │
25 - ┌────────▼─────────┐ ┌─────────▼────────────┐
26 - │ PostgreSQL │ │ MNW API │
27 - │ (forum data) │ │ (OAuth, userinfo, │
28 - │ │ │ project directory) │
29 - └──────────────────┘ └──────────────────────┘
30 - ```
7 + MT serves HTML pages directly (server-side rendered via Askama templates and enhanced with HTMX). There is no SPA, no JavaScript framework, and no client-side routing. Users interact with MT through their browser; the server owns all rendering and state.
31 8
32 - ## Workspace Structure
9 + ### Position in the MNW ecosystem
33 10
34 11 ```
35 - multithreaded/
36 - ├── Cargo.toml # Workspace (v0.1.1, Rust 2024)
37 - ├── src/
38 - │ ├── main.rs # Entry point, server setup, migrations
39 - │ ├── lib.rs # Module exports, AppState
40 - │ ├── routes.rs # 30+ route handlers
41 - │ ├── auth.rs # MNW OAuth callback, userinfo fetch
42 - │ ├── csrf.rs # Token generation, constant-time comparison
43 - │ ├── config.rs # MNW_BASE_URL, OAUTH_CLIENT_ID from env
44 - │ ├── markdown.rs # pulldown-cmark with HTML stripping
45 - │ ├── seed.rs # Idempotent demo data seeding
46 - │ └── templates/ # Askama view models
47 - ├── crates/
48 - │ ├── mt-core/ # Domain models, error types
49 - │ └── mt-db/ # PostgreSQL queries and mutations
50 - ├── templates/ # HTML templates (Askama)
51 - ├── static/ # CSS, fonts, htmx.min.js
52 - ├── migrations/ # 10 PostgreSQL migrations
53 - ├── tests/ # Integration tests
54 - └── deploy/ # deploy.sh, systemd unit, env template
12 + MNW Server (makenot.work)
13 + |
14 + |-- OAuth provider (user accounts, tokens)
15 + |-- Internal API caller (community creation, cross-posted threads)
16 + |
17 + v
18 + Multithreaded (forums.makenot.work)
19 + |
20 + |-- PostgreSQL (forum data, sessions, search indexes)
21 + |-- S3 (image uploads, optional)
55 22 ```
56 23
57 - ## Crate Dependencies
24 + MNW is the source of truth for user identity. MT mirrors user data locally via ON CONFLICT upserts on every login and internal API call. Communities in MT map 1:1 to projects in MNW, created either when a user first visits or when MNW calls the internal API to provision one.
58 25
59 - ```
60 - multithreaded (src/)
61 - ├── mt-db
62 - │ └── mt-core
63 - └── mt-core (for models in templates/routes)
64 - ```
26 + ## 2. Crate Structure
65 27
66 - ## Domain Models (`mt-core`)
67 -
68 - | Model | Fields |
69 - |-------|--------|
70 - | `User` | mnw_account_id, username, display_name, avatar_url |
71 - | `Community` | id, name, slug, description, created_at |
72 - | `Category` | id, community_id, name, slug, description, sort_order |
73 - | `Thread` | id, category_id, author_id, title, pinned, locked, timestamps |
74 - | `Post` | id, thread_id, author_id, body, edited_at, deleted_at |
75 - | `Membership` | user_id, community_id, role (owner/moderator/member) |
76 - | `CommunityBan` | user_id, community_id, reason, expires_at, is_mute |
77 - | `ModLogEntry` | community_id, actor_id, action, target, details, timestamp |
78 -
79 - ## Database (PostgreSQL)
80 -
81 - 10 migrations:
82 -
83 - | Migration | Purpose |
84 - |-----------|---------|
85 - | 001 | Users table (MNW account references) |
86 - | 002 | Communities |
87 - | 003 | Categories (with sort_order) |
88 - | 004 | Threads (title, pinned, locked, last_activity_at) |
89 - | 005 | Posts (body, soft delete support) |
90 - | 006 | Memberships (role enum: owner, moderator, member) |
91 - | 007 | Soft delete columns on threads and posts |
92 - | 008 | Community bans (with expiry, mute flag) |
93 - | 009 | Mod log (action audit trail) |
94 - | 010 | Platform-level suspensions (communities + users) |
95 -
96 - ## Routes
97 -
98 - ### Forum (public)
99 - | Method | Path | Purpose |
100 - |--------|------|---------|
101 - | GET | `/` | Forum directory (fetches projects from MNW API) |
102 - | GET | `/p/{slug}` | Project forum (categories + recent threads) |
103 - | GET | `/p/{slug}/members` | Community member list with roles |
104 - | GET | `/p/{slug}/{category}` | Category (threads paginated, 25/page) |
105 - | GET | `/p/{slug}/{category}/new` | Create thread form |
106 - | POST | `/p/{slug}/{category}/new` | Create thread |
107 - | GET | `/p/{slug}/{category}/{thread_id}` | Thread (posts paginated, 50/page) |
108 - | POST | `/p/{slug}/{category}/{thread_id}/reply` | Reply to thread |
109 -
110 - ### Thread/Post Management (authenticated)
111 - | Method | Path | Purpose |
112 - |--------|------|---------|
113 - | GET/POST | `/p/{slug}/{category}/{thread_id}/edit` | Edit thread |
114 - | POST | `/p/{slug}/{category}/{thread_id}/delete` | Soft delete thread |
115 - | POST | `/p/{slug}/{category}/{thread_id}/pin` | Pin/unpin thread |
116 - | POST | `/p/{slug}/{category}/{thread_id}/lock` | Lock/unlock thread |
117 - | GET/POST | `.../posts/{post_id}/edit` | Edit post (15-min window) |
118 - | POST | `.../posts/{post_id}/delete` | Soft delete post |
119 -
120 - ### Community Settings (owner only)
121 - | Method | Path | Purpose |
122 - |--------|------|---------|
123 - | GET/POST | `/p/{slug}/settings` | Community name/description |
124 - | POST | `/p/{slug}/settings/categories/new` | Create category |
125 - | GET/POST | `/p/{slug}/settings/categories/{id}/edit` | Edit category |
126 - | POST | `/p/{slug}/settings/categories/{id}/move` | Reorder category |
127 -
128 - ### Moderation (moderator+)
129 - | Method | Path | Purpose |
130 - |--------|------|---------|
131 - | GET | `/p/{slug}/moderation` | Moderation dashboard |
132 - | POST | `/p/{slug}/moderation/ban` | Ban user (with expiry) |
133 - | POST | `/p/{slug}/moderation/unban` | Unban user |
134 - | POST | `/p/{slug}/moderation/mute` | Mute user (write-only restriction) |
135 - | POST | `/p/{slug}/moderation/unmute` | Unmute user |
136 - | GET | `/p/{slug}/moderation/log` | Mod log (paginated) |
137 -
138 - ### Platform Admin (PLATFORM_ADMIN_ID only)
139 - | Method | Path | Purpose |
140 - |--------|------|---------|
141 - | GET | `/_admin` | Platform admin dashboard |
142 - | POST | `/_admin/communities/{id}/suspend` | Suspend community |
143 - | POST | `/_admin/communities/{id}/unsuspend` | Unsuspend community |
144 - | POST | `/_admin/users/{id}/suspend` | Suspend user |
145 - | POST | `/_admin/users/{id}/unsuspend` | Unsuspend user |
146 -
147 - ### Auth & System
148 - | Method | Path | Purpose |
149 - |--------|------|---------|
150 - | GET | `/auth/login` | Initiate MNW OAuth (PKCE) |
151 - | GET | `/auth/callback` | OAuth callback (exchange code, set session) |
152 - | GET | `/auth/logout` | Clear session |
153 - | GET | `/api/health` | Health check (monitored by PoM) |
154 -
155 - ## Security
156 -
157 - - **CSRF**: SHA256 tokens on all POST/PUT/DELETE, constant-time comparison, middleware layer
158 - - **Sessions**: tower-sessions with PostgresStore, 7-day expiry, SameSite::Lax
159 - - **XSS**: Markdown rendered via pulldown-cmark with HTML tags stripped
160 - - **Auth**: MNW OAuth PKCE — no passwords stored locally
161 - - **Moderation**: Role hierarchy enforced on all read/write handlers
162 - - **Suspensions**: Platform-level suspension checks on all handlers
163 -
164 - ## Deployment
165 -
166 - Deployed to two targets. Both run on port 3400, use systemd, and share the same service unit (`deploy/multithreaded.service`). Public domain: `forums.makenot.work` (Cloudflare-proxied, points to hetzner).
167 -
168 - ### Hetzner (production)
169 -
170 - - **Host**: `alpha-west-1` (Tailscale `100.120.174.96`, public `5.78.144.244`)
171 - - **SSH**: `root@100.120.174.96` (via Tailscale)
172 - - **Install path**: `/opt/multithreaded/`
173 - - **Build**: cross-compiled on macOS via `cargo zigbuild --release --target x86_64-unknown-linux-gnu`
174 - - **Deploy script**: `deploy/deploy-hetzner.sh` (build, upload binary + static + migrations, restart)
175 - - `--setup` -- first-time: create system user, dirs, database, build, install, seed
176 - - `--config` -- upload systemd unit, static assets, migrations only
177 - - **Env file**: `deploy/env.hetzner` -> `/opt/multithreaded/.env`
178 - - **Reverse proxy**: Caddy (TLS termination via Cloudflare Origin CA, `forums.makenot.work`)
179 - - **Bind address**: `127.0.0.1:3400` (Caddy fronts it; no direct external access)
180 - - **OAuth**: `client_id=mt-forums-6378957b452bbbc906c3db8edd072d64`, redirect to `https://forums.makenot.work/auth/callback`, `MNW_BASE_URL=https://makenot.work`
181 -
182 - ### Astra (staging/dev)
183 -
184 - - **Host**: `astra` (Tailscale `100.106.221.39`)
185 - - **SSH**: `max@100.106.221.39` (via Tailscale)
186 - - **Install path**: `/opt/multithreaded/`
187 - - **Build**: native build on astra (aarch64). Source rsynced to `~/src/multithreaded/`, built with `cargo build --release`, binary copied to `/opt/multithreaded/`.
188 - - **Deploy script**: `deploy/deploy.sh` (rsync source, build remote, deploy files, restart)
189 - - `--setup` -- first-time: create system user, dirs, database, build, install, seed
190 - - **Env file**: `deploy/env.production` -> `/opt/multithreaded/.env`
191 - - **No reverse proxy**: direct access on port 3400 via Tailscale IP
192 - - **Bind address**: `0.0.0.0:3400`
193 - - **OAuth**: `MNW_BASE_URL=http://127.0.0.1:3000` (local MNW instance), redirect to `http://100.106.221.39:3400/auth/callback`
194 -
195 - ### Shared Details
196 -
197 - - **systemd unit**: `deploy/multithreaded.service`
198 - - Runs as `multithreaded` system user
199 - - `EnvironmentFile=/opt/multithreaded/.env`
200 - - Security hardening: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `MemoryMax=512M`
201 - - Depends on `postgresql.service`
202 - - **Migrations**: auto-applied on boot (`sqlx::migrate!()`)
203 - - **Seeding**: `./multithreaded --seed` (idempotent, run once after first deploy)
204 - - **reqwest TLS**: uses `rustls-tls` feature (not `native-tls`). Required for cross-compilation to x86_64 Linux from macOS -- native-tls depends on OpenSSL which complicates cross builds.
205 - - **Prerequisites for hetzner cross-compile**: `brew install zig`, `cargo install cargo-zigbuild`, `rustup target add x86_64-unknown-linux-gnu`
206 -
207 - ### Monitoring
208 -
209 - - PoM monitors `forums.makenot.work` -- health check on `/api/health`, TLS validation, route probes, DNS verification
210 - - PoM targets: MNW, MT (`forums.makenot.work`), htpy.app
211 -
212 - ### Deploy Files
28 + MT is a Cargo workspace with three crates. The boundary rule is strict: library crates contain no web framework types.
213 29
214 30 ```
215 - deploy/
216 - ├── deploy.sh # Astra deploy (rsync + native build)
217 - ├── deploy-hetzner.sh # Hetzner deploy (cross-compile + upload)
218 - ├── multithreaded.service # systemd unit (shared)
219 - ├── env.production # Env vars for astra
220 - └── env.hetzner # Env vars for hetzner
31 + multithreaded/ (workspace root)
32 + Cargo.toml # Workspace definition + root crate deps
33 + src/ # Root crate (binary)
34 + crates/
35 + mt-core/ # Domain types, zero internal deps
36 + mt-db/ # Database queries/mutations, depends on mt-core
221 37 ```
222 38
223 - ## Testing
39 + ### Root crate (multithreaded)
40 +
41 + The binary. Owns the Axum server, route handlers, templates, middleware (CSRF, sessions, rate limiting), OAuth client, S3 integration, link preview fetching, and the internal HMAC-authenticated API. Depends on both mt-core and mt-db, plus shared crates from `MNW/shared/` (docengine for Markdown rendering, tagtree for tag validation, s3-storage for object storage).
42 +
43 + ### mt-core
44 +
45 + Leaf crate with no internal dependencies. Defines domain enums used across the codebase:
46 +
47 + - `CommunityRole` (Owner, Moderator, Member) with permission helpers
48 + - `BanType` (Ban, Mute)
49 + - `ModAction` (19 variants covering all auditable actions)
50 + - `SortColumn` / `SortOrder` for thread listing queries
51 + - `time_format` module for relative timestamps ("3 hours ago")
52 +
53 + ### mt-db
54 +
55 + Database access layer. Depends only on mt-core, sqlx, chrono, and uuid. Split into two modules:
56 +
57 + - `queries.rs` -- read-only functions returning `sqlx::FromRow` projection structs shaped for templates
58 + - `mutations.rs` -- write functions (insert, update, upsert, soft delete)
59 +
60 + All SQL uses positional parameters (`$1`, `$2`). No ORM, no query builder. Projection structs are purpose-built for each query, not generic domain models.
61 +
62 + ## 3. Data Flows
63 +
64 + ### Post creation
65 +
66 + 1. User submits a form (POST to `/p/{slug}/{category}/new` or `/{thread_id}/reply`).
67 + 2. Rate limiter checks per-IP write budget (burst 10, then 2/sec).
68 + 3. CSRF middleware validates the synchronizer token.
69 + 4. Handler extracts `SessionUser` from the session, verifies community membership, checks ban/mute status and thread lock state.
70 + 5. Markdown body is rendered to HTML via `docengine::render_strict()` with @mention resolution.
71 + 6. `mt_db::mutations::create_post()` inserts the post and updates the thread's `last_activity_at`.
72 + 7. Link preview extraction runs in the background: URLs are parsed from the Markdown via pulldown-cmark, fetched with an SSRF-safe HTTP client, and OG metadata is stored.
73 + 8. Redirect back to the thread with a toast message.
74 +
75 + ### Moderation flow
76 +
77 + Content moderation operates at three levels:
78 +
79 + **User flagging.** Any authenticated user can flag a post with a reason (spam, rule_breaking, off_topic) and optional detail. Flags are stored in `post_flags` and visible on the moderation dashboard.
80 +
81 + **Auto-hide.** Each community has a configurable `auto_hide_threshold` (nullable). When a flag is inserted and the threshold is set, `auto_hide_if_threshold_met()` atomically counts distinct flaggers on that post and sets `removed_at` + `removed_by` if the count meets or exceeds the threshold. This is logged as `AutoHidePost` in the mod log.
82 +
83 + **Mod-remove.** Moderators and owners can directly remove posts via `/posts/{post_id}/remove` or through the flag queue. Removing via the flag queue also resolves all pending flags on that post. Both paths log the action to the mod log.
84 +
85 + **Bans and mutes.** Moderators can ban (full access revocation) or mute (write-only restriction) users within their community, with optional duration and reason. Role hierarchy is enforced: mods cannot ban other mods, only owners can. Expired bans are cleaned up opportunistically when the moderation page loads.
86 +
87 + All moderation actions are recorded in the `mod_log` table with actor, action type, target user/post, and optional reason. The mod log is paginated and visible to moderators.
88 +
89 + ### Thread tracking
90 +
91 + Users can track threads to monitor new activity. The tracked threads page shows unread counts (posts since last visit) and @mention indicators. Tracking is opt-in per thread and can be bulk-cleared.
92 +
93 + ## 4. Authentication
94 +
95 + MT has no user database of its own in the traditional sense. All authentication flows through MNW's OAuth 2.0 server with PKCE (Proof Key for Code Exchange).
96 +
97 + ### OAuth flow
98 +
99 + 1. `/auth/login` generates a PKCE verifier (32 random bytes, base64url-encoded) and challenge (SHA-256 of verifier), stores the verifier in the session, and redirects to `MNW_BASE_URL/oauth/authorize`.
100 + 2. MNW authenticates the user and redirects back to `/auth/callback` with an authorization code and state nonce.
101 + 3. Callback validates the state nonce, exchanges the code for an access token (POST to `/oauth/token` with the PKCE verifier), and fetches `/oauth/userinfo` with the token.
102 + 4. The user is upserted locally (`ON CONFLICT (mnw_account_id) DO UPDATE`), suspension status is checked, and a session is created with `user_id`, `username`, and `display_name`.
103 + 5. Session ID is cycled after login to prevent session fixation.
104 +
105 + Token exchange and userinfo fetch both retry up to 2 times on 5xx or network errors with exponential backoff (500ms, 1000ms).
106 +
107 + ### Extractors
108 +
109 + - `MaybeUser(Option<SessionUser>)` -- infallible, used on all routes. Returns `None` for anonymous users.
110 + - `PlatformAdmin(SessionUser)` -- returns 404 (not 403) to non-admins, hiding admin routes entirely.
111 +
112 + ### Internal API authentication
113 +
114 + MNW-to-MT requests (community creation, thread cross-posting) bypass OAuth and use HMAC-SHA256:
115 +
116 + - `X-Internal-Timestamp` -- Unix timestamp, rejected if >60 seconds from server time
117 + - `X-Internal-Signature` -- HMAC-SHA256 of `"timestamp\nbody"` using a shared secret
118 +
119 + The `InternalAuth` extractor validates both before passing the request body to the handler. Constant-time comparison prevents timing attacks on the signature.
120 +
121 + ## 5. Session Storage
122 +
123 + Sessions are stored in PostgreSQL via `tower-sessions-sqlx-store`. There is no Redis. Key details:
124 +
125 + - Cookie name: `mt_session`
126 + - SameSite: Lax
127 + - Expiry: 7 days of inactivity
128 + - Expired sessions are cleaned up hourly by a background task (`continuously_delete_expired`)
129 + - Session data stored: `user_id` (UUID), `username`, `display_name`, plus transient OAuth state (PKCE verifier, state nonce) during login
130 +
131 + ## 6. Rate Limiting
132 +
133 + Write endpoints (all POST routes) are rate-limited per IP using `tower_governor`:
134 +
135 + - Burst: 10 requests
136 + - Sustained: 2 requests/second (one token per 500ms)
137 + - Key extractor: `SmartIpKeyExtractor` (handles X-Forwarded-For behind reverse proxy)
138 +
139 + Rate limiting is applied as a route layer on the write routes group only. Read routes have no rate limit. The internal API is also exempt (it uses HMAC auth, not sessions).
140 +
141 + ## 7. Key Design Decisions
142 +
143 + ### HTMX-based SSR, no SPA
144 +
145 + MT serves complete HTML pages with HTMX for progressive enhancement (search results as swapped fragments). This eliminates client-side state management, reduces JavaScript to near zero, and makes the forum functional without JS. The search endpoint (`/search`) returns HTML fragments for HTMX swap, not JSON.
146 +
147 + ### Community-scoped permissions
148 +
149 + All permissions (roles, bans, mutes) are scoped to a single community. A user can be an owner in one community, a banned user in another, and a regular member in a third. There is no global moderator role -- only the platform admin (a single user ID set via env var) has cross-community authority.
150 +
151 + ### Immutable post bodies with footnotes and endorsements
152 +
153 + Post bodies cannot be edited after creation (enforced since migration 011). Authors can append footnotes (corrections, clarifications) and other users can endorse posts. This preserves conversation integrity while allowing authors to add context.
154 +
155 + ### Soft delete everywhere
156 +
157 + Threads and posts use `deleted_at` timestamps rather than hard deletes. Moderator removals use a separate `removed_at` / `removed_by` pair to distinguish author deletions from mod actions. This supports audit trails and potential appeals.
158 +
159 + ### Internal API for cross-service coordination
160 +
161 + Rather than sharing a database between MNW and MT, the two services communicate via a signed internal API. MNW can create communities, post threads (e.g., release notes), and query thread stats. This keeps the services independently deployable and the databases isolated.
162 +
163 + ### Security headers
164 +
165 + Every response includes: Content-Security-Policy (default-src 'self', no frame-ancestors), X-Content-Type-Options (nosniff), X-Frame-Options (DENY), and Cache-Control (private, no-cache by default). CSP is strict -- no inline scripts, no external resources.
166 +
167 + ## 8. Scaling Considerations
168 +
169 + ### Thread listing
170 +
171 + Thread lists are sorted by `last_activity_at` (or reply count), with pinned threads first. The `last_activity_at` column is denormalized on the threads table and updated on every new post, avoiding a JOIN/subquery on every listing page load. Pagination uses LIMIT/OFFSET with 25 threads per page.
172 +
173 + ### Search indexing
174 +
175 + Search uses a two-layer approach:
176 +
177 + - **Full-text search**: PostgreSQL `tsvector` columns (generated, stored) on `threads.title` and `posts.body_markdown`, with GIN indexes. Queries use `websearch_to_tsquery` for natural language input.
178 + - **Fuzzy matching**: `pg_trgm` extension with GIN trigram indexes on thread titles and post bodies. Combined with full-text ranking (`ts_rank * 2.0 + similarity`) to blend exact and fuzzy results.
179 +
180 + Search queries union thread title matches with post body matches (deduplicated), ordered by combined rank, capped at 20 results. Community-scoped search is supported via an optional `scope` parameter.
181 +
182 + ### Image uploads
183 +
184 + Images are stored in S3 (not in PostgreSQL), with metadata tracked in the database. Uploads are validated for type (png, jpg, gif, webp), size (5 MB max), and extension/content-type consistency. JPEG EXIF metadata is stripped server-side before upload. Image keys use the format `mt/{community_slug}/{uuid}.{ext}`.
185 +
186 + ### Connection pooling
224 187
225 - 90 tests total:
226 - - 56 integration tests (CSRF, auth, CRUD, permissions, moderation, bans, admin)
227 - - 18 unit tests in mt-core (error types, helpers)
228 - - 16 unit tests in mt-db (query builders, model conversions)
188 + MT uses sqlx's built-in connection pool (`PgPool`). Sessions, forum data, and search all share the same pool. The session store has its own cleanup task but no separate connection pool.
@@ -1,4 +1,4 @@
1 - CREATE TABLE users (
1 + CREATE TABLE IF NOT EXISTS users (
2 2 mnw_account_id UUID PRIMARY KEY,
3 3 username TEXT NOT NULL UNIQUE,
4 4 display_name TEXT,
@@ -7,4 +7,4 @@ CREATE TABLE users (
7 7 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
8 8 );
9 9
10 - CREATE INDEX idx_users_username ON users (username);
10 + CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
@@ -1,4 +1,4 @@
1 - CREATE TABLE communities (
1 + CREATE TABLE IF NOT EXISTS communities (
2 2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 3 name TEXT NOT NULL,
4 4 slug TEXT NOT NULL UNIQUE,
@@ -6,4 +6,4 @@ CREATE TABLE communities (
6 6 created_at TIMESTAMPTZ NOT NULL DEFAULT now()
7 7 );
8 8
9 - CREATE INDEX idx_communities_slug ON communities (slug);
9 + CREATE INDEX IF NOT EXISTS idx_communities_slug ON communities (slug);
@@ -1,4 +1,4 @@
1 - CREATE TABLE categories (
1 + CREATE TABLE IF NOT EXISTS categories (
2 2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 3 community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
4 4 name TEXT NOT NULL,
@@ -10,4 +10,4 @@ CREATE TABLE categories (
10 10 UNIQUE (community_id, slug)
11 11 );
12 12
13 - CREATE INDEX idx_categories_community ON categories (community_id, sort_order);
13 + CREATE INDEX IF NOT EXISTS idx_categories_community ON categories (community_id, sort_order);
@@ -1,4 +1,4 @@
1 - CREATE TABLE threads (
1 + CREATE TABLE IF NOT EXISTS threads (
2 2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 3 category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
4 4 author_id UUID NOT NULL REFERENCES users(mnw_account_id),
@@ -10,7 +10,7 @@ CREATE TABLE threads (
10 10 );
11 11
12 12 -- Category thread listing: pinned first, then by last activity
13 - CREATE INDEX idx_threads_category_listing
13 + CREATE INDEX IF NOT EXISTS idx_threads_category_listing
14 14 ON threads (category_id, pinned DESC, last_activity_at DESC);
15 15
16 - CREATE INDEX idx_threads_author ON threads (author_id);
16 + CREATE INDEX IF NOT EXISTS idx_threads_author ON threads (author_id);
@@ -0,0 +1,17 @@
1 + -- Denormalize reply_count onto threads to eliminate LEFT JOIN + COUNT on listings.
2 + -- reply_count = total posts minus 1 (the OP), floored at 0.
3 + ALTER TABLE threads ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0;
4 +
5 + -- Backfill from current data.
6 + UPDATE threads SET reply_count = GREATEST(
7 + (SELECT COUNT(*) FROM posts WHERE posts.thread_id = threads.id) - 1,
8 + 0
9 + );
10 +
11 + -- Add CHECK so it never goes negative.
12 + ALTER TABLE threads ADD CONSTRAINT reply_count_non_negative CHECK (reply_count >= 0);
13 +
14 + -- Partial index for the hot-path listing query (category thread list, active threads).
15 + CREATE INDEX IF NOT EXISTS idx_threads_category_active
16 + ON threads (category_id, pinned DESC, last_activity_at DESC)
17 + WHERE deleted_at IS NULL;
@@ -9,6 +9,7 @@ use base64::Engine;
9 9 use rand::RngCore;
10 10 use serde::Deserialize;
11 11 use sha2::{Digest, Sha256};
12 + use tokio::time::sleep;
12 13 use tower_sessions::Session;
13 14
14 15 use crate::AppState;
@@ -191,36 +192,63 @@ pub async fn callback(
191 192 let _ = session.remove::<String>(SESSION_OAUTH_STATE).await;
192 193 let _ = session.remove::<String>(SESSION_PKCE_VERIFIER).await;
193 194
194 - // Exchange code for token
195 + // Exchange code for token (retry up to 2 attempts on network/5xx errors)
195 196 let token_url = format!("{}/oauth/token", state.config.mnw_base_url);
196 197 tracing::info!(%token_url, "exchanging code for token");
197 - let token_res = state
198 - .http
199 - .post(&token_url)
200 - .json(&serde_json::json!({
201 - "grant_type": "authorization_code",
202 - "code": params.code,
203 - "redirect_uri": state.config.oauth_redirect_uri,
204 - "code_verifier": verifier,
205 - "client_id": state.config.oauth_client_id,
206 - }))
207 - .send()
208 - .await;
209 -
210 - let token_res = match token_res {
211 - Ok(r) => r,
212 - Err(e) => {
213 - tracing::error!(error = %e, "token request failed");
214 - return Redirect::to("/?error=token_request_failed");
215 - }
216 - };
198 + let backoffs = [
199 + std::time::Duration::from_millis(500),
200 + std::time::Duration::from_millis(1000),
201 + ];
202 + let mut token_res = None;
203 + for attempt in 0..=backoffs.len() {
204 + let res = state
205 + .http
206 + .post(&token_url)
207 + .json(&serde_json::json!({
208 + "grant_type": "authorization_code",
209 + "code": params.code,
210 + "redirect_uri": state.config.oauth_redirect_uri,
211 + "code_verifier": verifier,
212 + "client_id": state.config.oauth_client_id,
213 + }))
214 + .send()
215 + .await;
217 216
218 - if !token_res.status().is_success() {
219 - let status = token_res.status();
220 - let body = token_res.text().await.unwrap_or_default();
221 - tracing::error!(%status, %body, "token exchange failed");
222 - return Redirect::to("/?error=token_exchange_failed");
217 + match res {
218 + Ok(r) if r.status().is_server_error() => {
219 + let status = r.status();
220 + if attempt < backoffs.len() {
221 + tracing::warn!(%status, attempt, "token exchange got 5xx, retrying");
222 + sleep(backoffs[attempt]).await;
223 + continue;
224 + }
225 + let body = r.text().await.unwrap_or_default();
226 + tracing::error!(%status, %body, "token exchange failed after retries");
227 + return Redirect::to("/?error=token_exchange_failed");
228 + }
229 + Ok(r) if !r.status().is_success() => {
230 + let status = r.status();
231 + let body = r.text().await.unwrap_or_default();
232 + tracing::error!(%status, %body, "token exchange failed");
233 + return Redirect::to("/?error=token_exchange_failed");
234 + }
235 + Ok(r) => {
236 + token_res = Some(r);
237 + break;
238 + }
239 + Err(e) => {
240 + if attempt < backoffs.len() {
241 + tracing::warn!(error = %e, attempt, "token request failed, retrying");
242 + sleep(backoffs[attempt]).await;
243 + continue;
244 + }
245 + tracing::error!(error = %e, "token request failed after retries");
246 + return Redirect::to("/?error=token_request_failed");
247 + }
248 + }
223 249 }
250 + // Safety: loop always either sets token_res or returns early
251 + let token_res = token_res.unwrap();
224 252
225 253 let token: TokenResponse = match token_res.json().await {
226 254 Ok(t) => t,
@@ -230,30 +258,53 @@ pub async fn callback(
230 258 }
231 259 };
232 260
233 - // Fetch userinfo
261 + // Fetch userinfo (retry up to 2 attempts on network/5xx errors)
234 262 let userinfo_url = format!("{}/oauth/userinfo", state.config.mnw_base_url);
235 263 tracing::info!(%userinfo_url, "fetching userinfo");
236 - let userinfo_res = state
237 - .http
238 - .get(&userinfo_url)
239 - .bearer_auth(&token.access_token)
240 - .send()
241 - .await;
242 -
243 - let userinfo_res = match userinfo_res {
244 - Ok(r) => r,
245 - Err(e) => {
246 - tracing::error!(error = %e, "userinfo request failed");
247 - return Redirect::to("/?error=userinfo_request_failed");
248 - }
249 - };
264 + let mut userinfo_res = None;
265 + for attempt in 0..=backoffs.len() {
266 + let res = state
267 + .http
268 + .get(&userinfo_url)
269 + .bearer_auth(&token.access_token)
270 + .send()
271 + .await;
250 272
251 - if !userinfo_res.status().is_success() {
252 - let status = userinfo_res.status();
253 - let body = userinfo_res.text().await.unwrap_or_default();
254 - tracing::error!(%status, %body, "userinfo fetch failed");
255 - return Redirect::to("/?error=userinfo_fetch_failed");
273 + match res {
274 + Ok(r) if r.status().is_server_error() => {
275 + let status = r.status();
276 + if attempt < backoffs.len() {
277 + tracing::warn!(%status, attempt, "userinfo got 5xx, retrying");
278 + sleep(backoffs[attempt]).await;
279 + continue;
280 + }
281 + let body = r.text().await.unwrap_or_default();
282 + tracing::error!(%status, %body, "userinfo fetch failed after retries");
283 + return Redirect::to("/?error=userinfo_fetch_failed");
284 + }
285 + Ok(r) if !r.status().is_success() => {
286 + let status = r.status();
287 + let body = r.text().await.unwrap_or_default();
288 + tracing::error!(%status, %body, "userinfo fetch failed");
289 + return Redirect::to("/?error=userinfo_fetch_failed");
290 + }
291 + Ok(r) => {
292 + userinfo_res = Some(r);
293 + break;
294 + }
295 + Err(e) => {
296 + if attempt < backoffs.len() {
297 + tracing::warn!(error = %e, attempt, "userinfo request failed, retrying");
298 + sleep(backoffs[attempt]).await;
299 + continue;
300 + }
301 + tracing::error!(error = %e, "userinfo request failed after retries");
302 + return Redirect::to("/?error=userinfo_request_failed");
303 + }
304 + }
256 305 }
306 + // Safety: loop always either sets userinfo_res or returns early
307 + let userinfo_res = userinfo_res.unwrap();
257 308
258 309 let info: UserinfoResponse = match userinfo_res.json().await {
259 310 Ok(i) => i,
@@ -30,7 +30,7 @@ pub(super) async fn admin_dashboard(
30 30 .await
31 31 .map_err(|e| {
32 32 tracing::error!(error = ?e, "db error listing communities");
33 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
33 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
34 34 })?
35 35 .into_iter()
36 36 .map(|c| AdminCommunityViewRow {
@@ -48,7 +48,7 @@ pub(super) async fn admin_dashboard(
48 48 .await
49 49 .map_err(|e| {
50 50 tracing::error!(error = ?e, "db error searching users");
51 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
51 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
52 52 })?
53 53 .into_iter()
54 54 .map(|u| AdminUserViewRow {
@@ -90,7 +90,7 @@ pub(super) async fn suspend_community_handler(
90 90 .await
91 91 .map_err(|e| {
92 92 tracing::error!(error = ?e, "db error suspending community");
93 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
93 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
94 94 })?;
95 95
96 96 log_mod_action(
@@ -113,7 +113,7 @@ pub(super) async fn unsuspend_community_handler(
113 113 .await
114 114 .map_err(|e| {
115 115 tracing::error!(error = ?e, "db error unsuspending community");
116 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
116 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
117 117 })?;
118 118
119 119 log_mod_action(
@@ -138,7 +138,7 @@ pub(super) async fn suspend_user_handler(
138 138 .await
139 139 .map_err(|e| {
140 140 tracing::error!(error = ?e, "db error suspending user");
141 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
141 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
142 142 })?;
143 143
144 144 log_mod_action(
@@ -161,7 +161,7 @@ pub(super) async fn unsuspend_user_handler(
161 161 .await
162 162 .map_err(|e| {
163 163 tracing::error!(error = ?e, "db error unsuspending user");
164 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
164 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
165 165 })?;
166 166
167 167 log_mod_action(
@@ -46,9 +46,9 @@ pub(super) async fn flag_post_handler(
46 46 .await
47 47 .map_err(|e| {
48 48 tracing::error!(error = ?e, "db error fetching post for flag");
49 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
49 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
50 50 })?
51 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
51 + .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?;
52 52
53 53 // Cannot flag own post
54 54 if user.user_id == post_data.author_id {
@@ -71,7 +71,7 @@ pub(super) async fn flag_post_handler(
71 71 .await
72 72 .map_err(|e| {
73 73 tracing::error!(error = ?e, "db error inserting flag");
74 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
74 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
75 75 })?;
76 76
77 77 // Auto-hide: atomically check flag count and remove post if threshold met
@@ -114,7 +114,7 @@ pub(super) async fn dismiss_flag_handler(
114 114 .await
115 115 .map_err(|e| {
116 116 tracing::error!(error = ?e, "db error dismissing flag");
117 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
117 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
118 118 })?;
119 119
120 120 Ok(Redirect::to(&format!(
@@ -145,18 +145,18 @@ pub(super) async fn remove_flagged_post_handler(
145 145 .await
146 146 .map_err(|e| {
147 147 tracing::error!(error = ?e, "db error fetching flag");
148 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
148 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
149 149 })?;
150 150
151 151 let (post_id, author_id) = flag_row
152 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
152 + .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?;
153 153
154 154 // Mod-remove the post (idempotent — returns false if already removed)
155 155 let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
156 156 .await
157 157 .map_err(|e| {
158 158 tracing::error!(error = ?e, "db error removing flagged post");
159 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
159 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
160 160 })?;
161 161
162 162 // Resolve all flags on this post
@@ -164,7 +164,7 @@ pub(super) async fn remove_flagged_post_handler(
164 164 .await
165 165 .map_err(|e| {
166 166 tracing::error!(error = ?e, "db error resolving flags");
167 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
167 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
168 168 })?;
169 169
170 170 log_mod_action(
@@ -232,7 +232,7 @@ pub(in crate::routes) async fn create_thread_handler(
232 232 StatusCode::INTERNAL_SERVER_ERROR.into_response()
233 233 })?;
234 234
235 - let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
235 + let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html, false)
236 236 .await
237 237 .map_err(|e| {
238 238 tracing::error!(error = ?e, "db error creating post");
@@ -308,7 +308,7 @@ pub(in crate::routes) async fn create_reply_handler(
308 308 ).await?;
309 309
310 310 let thread_id = parse_uuid(&thread_id_str)?;
311 - let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
311 + let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html, true)
312 312 .await
313 313 .map_err(|e| {
314 314 tracing::error!(error = ?e, "db error creating reply");
@@ -55,9 +55,9 @@ pub(crate) async fn get_community(
55 55 .await
56 56 .map_err(|e| {
57 57 tracing::error!(error = ?e, "db error fetching community");
58 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
58 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
59 59 })?
60 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
60 + .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())
61 61 }
62 62
63 63 /// Fetch thread with breadcrumb, returning 404/500 on failure.
@@ -71,15 +71,15 @@ pub(crate) async fn get_thread(
71 71 .await
72 72 .map_err(|e| {
73 73 tracing::error!(error = ?e, "db error fetching thread");
74 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
74 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
75 75 })?
76 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
76 + .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())
77 77 }
78 78
79 79 /// Parse a UUID from a string, returning 404 on failure.
80 80 #[allow(clippy::result_large_err)]
81 81 pub(crate) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> {
82 - Uuid::parse_str(id_str).map_err(|_| StatusCode::NOT_FOUND.into_response())
82 + Uuid::parse_str(id_str).map_err(|_| (StatusCode::NOT_FOUND, "Not found").into_response())
83 83 }
84 84
85 85 /// Fetch a user's role in a community, returning 500 on DB error.
@@ -93,7 +93,7 @@ pub(crate) async fn get_role(
93 93 .await
94 94 .map_err(|e| {
95 95 tracing::error!(error = ?e, "db error fetching role");
96 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
96 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
97 97 })?;
98 98 Ok(role_str.and_then(|s| CommunityRole::from_db(&s)))
99 99 }
@@ -108,7 +108,7 @@ pub(crate) async fn get_user_by_username(
108 108 .await
109 109 .map_err(|e| {
110 110 tracing::error!(error = ?e, "db error looking up user");
111 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
111 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
112 112 })?
113 113 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response())
114 114 }
@@ -204,7 +204,7 @@ pub(crate) async fn check_community_access(
204 204 .await
205 205 .map_err(|e| {
206 206 tracing::error!(error = ?e, "db error checking ban status");
207 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
207 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
208 208 })?;
209 209 if banned {
210 210 return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
@@ -228,7 +228,7 @@ pub(crate) async fn check_write_access(
228 228 .await
229 229 .map_err(|e| {
230 230 tracing::error!(error = ?e, "db error checking user suspension");
231 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
231 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
232 232 })?;
233 233 if suspended {
234 234 return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
@@ -237,7 +237,7 @@ pub(crate) async fn check_write_access(
237 237 .await
238 238 .map_err(|e| {
239 239 tracing::error!(error = ?e, "db error checking ban status");
240 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
240 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
241 241 })?;
242 242 if banned {
243 243 return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
@@ -246,7 +246,7 @@ pub(crate) async fn check_write_access(
246 246 .await
247 247 .map_err(|e| {
248 248 tracing::error!(error = ?e, "db error checking mute status");
249 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
249 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
250 250 })?;
251 251 if muted {
252 252 return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response());
@@ -264,7 +264,7 @@ pub(crate) async fn check_user_post_rate(
264 264 .await
265 265 .map_err(|e| {
266 266 tracing::error!(error = ?e, "db error checking user post rate");
267 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
267 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
268 268 })?;
269 269 if count >= USER_POST_RATE_LIMIT {
270 270 return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response());
@@ -294,7 +294,7 @@ pub(crate) async fn require_owner(
294 294 let community = get_community(&state.db, slug).await?;
295 295 let role = get_role(&state.db, user.user_id, community.id).await?;
296 296 if !is_owner(&role) {
297 - return Err(StatusCode::FORBIDDEN.into_response());
297 + return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
298 298 }
299 299 Ok(community)
300 300 }
@@ -309,7 +309,7 @@ pub(crate) async fn require_mod_or_owner(
309 309 let community = get_community(&state.db, slug).await?;
310 310 let role = get_role(&state.db, user.user_id, community.id).await?;
311 311 if !is_mod_or_owner(&role) {
312 - return Err(StatusCode::FORBIDDEN.into_response());
312 + return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
313 313 }
314 314 Ok((community, role))
315 315 }
@@ -253,6 +253,7 @@ async fn create_thread(
253 253 req.author_mnw_id,
254 254 &req.body_markdown,
255 255 &body_html,
256 + false,
256 257 )
257 258 .await
258 259 .map_err(db_error)?;
@@ -345,6 +346,7 @@ async fn create_post(
345 346 req.author_mnw_id,
346 347 &req.body_markdown,
347 348 &body_html,
349 + true,
348 350 )
349 351 .await
350 352 .map_err(db_error)?;
@@ -99,6 +99,15 @@ pub async fn run(pool: &PgPool) {
99 99 seed_music_mixing(pool, music_mixing, &users).await;
100 100 seed_music_sound_design(pool, music_sound, &users).await;
101 101
102 + // Backfill denormalized reply_count from actual post data
103 + sqlx::query(
104 + "UPDATE threads SET reply_count = GREATEST(
105 + (SELECT COUNT(*) FROM posts WHERE posts.thread_id = threads.id) - 1, 0)",
106 + )
107 + .execute(pool)
108 + .await
109 + .expect("failed to backfill reply_count");
110 +
102 111 let total_threads: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM threads")
103 112 .fetch_one(pool)
104 113 .await
@@ -0,0 +1,229 @@
1 + //! Integration tests for admin queries and membership counting.
2 + //!
3 + //! Covers: list_all_communities, search_users (prefix, limit, LIKE escaping),
4 + //! get_user_membership_summary (post counts, suspended exclusion).
5 +
6 + use crate::harness::TestHarness;
7 + use uuid::Uuid;
8 +
9 + // ============================================================================
10 + // list_all_communities
11 + // ============================================================================
12 +
13 + #[tokio::test]
14 + async fn test_list_all_communities() {
15 + let h = TestHarness::new().await;
16 +
17 + let _c1 = h.create_community("Alpha Forum", "alpha").await;
18 + let _c2 = h.create_community("Beta Forum", "beta").await;
19 + let _c3 = h.create_community("Gamma Forum", "gamma").await;
20 +
21 + let rows = mt_db::queries::list_all_communities(&h.db)
22 + .await
23 + .unwrap();
24 +
25 + assert_eq!(rows.len(), 3, "Should return all 3 communities");
26 +
27 + let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
28 + assert_eq!(
29 + names,
30 + vec!["Alpha Forum", "Beta Forum", "Gamma Forum"],
31 + "Communities should be ordered by name"
32 + );
33 +
34 + // Verify slugs are present
35 + assert_eq!(rows[0].slug, "alpha");
36 + assert_eq!(rows[1].slug, "beta");
37 + assert_eq!(rows[2].slug, "gamma");
38 +
39 + // Verify suspended fields default to None
40 + assert!(rows[0].suspended_at.is_none());
41 + assert!(rows[0].suspension_reason.is_none());
42 + }
43 +
44 + // ============================================================================
45 + // search_users
46 + // ============================================================================
47 +
48 + #[tokio::test]
49 + async fn test_search_users_exact_prefix() {
50 + let h = TestHarness::new().await;
51 +
52 + // Insert users directly (no login needed for DB-level tests)
53 + for (name, display) in [("alice", "Alice"), ("alvin", "Alvin"), ("bob", "Bob")] {
54 + let id = Uuid::new_v4();
55 + sqlx::query(
56 + "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $3)",
57 + )
58 + .bind(id)
59 + .bind(name)
60 + .bind(display)
61 + .execute(&h.db)
62 + .await
63 + .unwrap();
64 + }
65 +
66 + let results = mt_db::queries::search_users(&h.db, "al").await.unwrap();
67 +
68 + let usernames: Vec<&str> = results.iter().map(|r| r.username.as_str()).collect();
69 + assert_eq!(
70 + usernames,
71 + vec!["alice", "alvin"],
72 + "Search for 'al' should match alice and alvin, ordered alphabetically"
73 + );
74 +
75 + // Verify bob is excluded
76 + assert!(
77 + !usernames.contains(&"bob"),
78 + "bob should not match prefix 'al'"
79 + );
80 + }
81 +
82 + #[tokio::test]
83 + async fn test_search_users_limit() {
84 + let h = TestHarness::new().await;
85 +
86 + // Insert a handful of users to verify the query executes with LIMIT 50
87 + for i in 0..5 {
88 + let id = Uuid::new_v4();
89 + let name = format!("limituser{i}");
90 + sqlx::query(
91 + "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)",
92 + )
93 + .bind(id)
94 + .bind(&name)
95 + .execute(&h.db)
96 + .await
97 + .unwrap();
98 + }
99 +
100 + let results = mt_db::queries::search_users(&h.db, "limituser")
101 + .await
102 + .unwrap();
103 +
104 + assert_eq!(results.len(), 5, "Should return all 5 matching users");
105 +
106 + // Verify they are ordered alphabetically
107 + let usernames: Vec<&str> = results.iter().map(|r| r.username.as_str()).collect();
108 + let mut sorted = usernames.clone();
109 + sorted.sort();
110 + assert_eq!(usernames, sorted, "Results should be alphabetically ordered");
111 + }
112 +
113 + #[tokio::test]
114 + async fn test_search_users_special_chars() {
115 + let h = TestHarness::new().await;
116 +
117 + // Insert users: one that looks like a LIKE wildcard match, one normal
118 + for (name, display) in [("alice", "Alice"), ("al%pha", "Al%pha")] {
119 + let id = Uuid::new_v4();
120 + sqlx::query(
121 + "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $3)",
122 + )
123 + .bind(id)
124 + .bind(name)
125 + .bind(display)
126 + .execute(&h.db)
127 + .await
128 + .unwrap();
129 + }
130 +
131 + // Search for literal "al%" -- should only match usernames starting with "al%"
132 + let results = mt_db::queries::search_users(&h.db, "al%").await.unwrap();
133 +
134 + let usernames: Vec<&str> = results.iter().map(|r| r.username.as_str()).collect();
135 + assert_eq!(
136 + usernames,
137 + vec!["al%pha"],
138 + "Searching 'al%' should match literal percent, not act as wildcard"
139 + );
140 +
141 + // alice should NOT be matched -- the % is escaped, so the query is 'al\%%'
142 + assert!(
143 + !usernames.contains(&"alice"),
144 + "alice should not match when searching for literal 'al%'"
145 + );
146 + }
147 +
148 + // ============================================================================
149 + // get_user_membership_summary
150 + // ============================================================================
151 +
152 + #[tokio::test]
153 + async fn test_membership_summary_post_count() {
154 + let mut h = TestHarness::new().await;
155 +
156 + let user_id = h.login_as("postwriter").await;
157 + let comm_id = h.create_community("Writers Guild", "writers").await;
158 + let cat_id = h.create_category(comm_id, "General", "general").await;
159 + h.add_membership(user_id, comm_id, "member").await;
160 +
161 + // Create a thread with an initial post, then add two more posts
162 + let thread_id = h
163 + .create_thread_with_post(cat_id, user_id, "First Thread", "First post body")
164 + .await;
165 +
166 + mt_db::mutations::create_post(
167 + &h.db,
168 + thread_id,
169 + user_id,
170 + "Second post",
171 + "<p>Second post</p>",
172 + )
173 + .await
174 + .unwrap();
175 +
176 + mt_db::mutations::create_post(
177 + &h.db,
178 + thread_id,
179 + user_id,
180 + "Third post",
181 + "<p>Third post</p>",
182 + )
183 + .await
184 + .unwrap();
185 +
186 + let summaries = mt_db::queries::get_user_membership_summary(&h.db, user_id)
187 + .await
188 + .unwrap();
189 +
190 + assert_eq!(summaries.len(), 1, "Should have one membership");
191 + assert_eq!(summaries[0].community_name, "Writers Guild");
192 + assert_eq!(summaries[0].community_slug, "writers");
193 + assert_eq!(summaries[0].role, "member");
194 + assert_eq!(
195 + summaries[0].post_count, 3,
196 + "Should count all 3 posts (initial + 2 replies)"
197 + );
198 + }
199 +
200 + #[tokio::test]
201 + async fn test_membership_summary_excludes_suspended() {
202 + let mut h = TestHarness::new().await;
203 +
204 + let user_id = h.login_as("suspendcheck").await;
205 +
206 + let active_id = h.create_community("Active Community", "active").await;
207 + h.add_membership(user_id, active_id, "member").await;
208 +
209 + let suspended_id = h
210 + .create_community("Suspended Community", "suspended")
211 + .await;
212 + h.add_membership(user_id, suspended_id, "member").await;
213 +
214 + // Suspend the second community
215 + mt_db::mutations::suspend_community(&h.db, suspended_id, Some("policy violation"))
216 + .await
217 + .unwrap();
218 +
219 + let summaries = mt_db::queries::get_user_membership_summary(&h.db, user_id)
220 + .await
221 + .unwrap();
222 +
223 + assert_eq!(
224 + summaries.len(),
225 + 1,
226 + "Should exclude the suspended community"
227 + );
228 + assert_eq!(summaries[0].community_name, "Active Community");
229 + }
@@ -1,4 +1,5 @@
1 1 mod admin;
2 + mod admin_queries;
2 3 mod auth;
3 4 mod bans;
4 5 mod crud;
M pom/src/db.rs +8 -8
@@ -11,7 +11,7 @@ use std::str::FromStr;
11 11 use tracing::{info, instrument};
12 12
13 13 use crate::error::Result;
14 - use crate::types::{CorsCheckResult, DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestSummary, TlsStatus, WhoisResult};
14 + use crate::types::{CorsCheckResult, DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestRunId, TestSummary, TlsStatus, WhoisResult};
15 15
16 16 /// Each migration is a (version, description, SQL) tuple. Versions start at 1.
17 17 /// The SQL may contain multiple statements separated by semicolons.
@@ -382,7 +382,7 @@ pub async fn get_latest_health(
382 382 pub async fn insert_test_run(
383 383 pool: &SqlitePool,
384 384 run: &TestRun,
385 - ) -> Result<i64> {
385 + ) -> Result<TestRunId> {
386 386 let summary_json = serde_json::to_string(&run.summary).unwrap_or_default();
387 387
388 388 let result = sqlx::query(
@@ -401,7 +401,7 @@ pub async fn insert_test_run(
401 401 .execute(pool)
402 402 .await?;
403 403
404 - Ok(result.last_insert_rowid())
404 + Ok(TestRunId(result.last_insert_rowid()))
405 405 }
406 406
407 407 #[instrument(skip_all)]
@@ -457,14 +457,14 @@ pub async fn get_latest_test_run(
457 457 #[instrument(skip_all)]
458 458 pub async fn insert_test_details(
459 459 pool: &SqlitePool,
460 - run_id: i64,
460 + run_id: TestRunId,
461 461 details: &[TestDetail],
462 462 ) -> Result<()> {
463 463 for detail in details {
464 464 sqlx::query(
465 465 "INSERT INTO test_details (run_id, test_name, passed) VALUES (?, ?, ?)",
466 466 )
467 - .bind(run_id)
467 + .bind(run_id.0)
468 468 .bind(&detail.test_name)
469 469 .bind(detail.passed)
470 470 .execute(pool)
@@ -478,7 +478,7 @@ pub async fn insert_test_details(
478 478 pub async fn get_test_regressions(
479 479 pool: &SqlitePool,
480 480 target: &str,
481 - current_run_id: i64,
481 + current_run_id: TestRunId,
482 482 ) -> Result<Vec<String>> {
483 483 // Find the run immediately before this one for the same target
484 484 let prev_run = sqlx::query_as::<_, (i64,)>(
@@ -487,7 +487,7 @@ pub async fn get_test_regressions(
487 487 ORDER BY id DESC LIMIT 1",
488 488 )
489 489 .bind(target)
490 - .bind(current_run_id)
490 + .bind(current_run_id.0)
491 491 .fetch_optional(pool)
492 492 .await?;
493 493
@@ -502,7 +502,7 @@ pub async fn get_test_regressions(
502 502 WHERE curr.run_id = ? AND curr.passed = 0 AND prev.passed = 1",
503 503 )
504 504 .bind(prev_id)
505 - .bind(current_run_id)
505 + .bind(current_run_id.0)
506 506 .fetch_all(pool)
507 507 .await?;
508 508
@@ -207,6 +207,10 @@ pub struct HealthDetails {
207 207 pub monitoring: Option<serde_json::Value>,
208 208 }
209 209
210 + /// Strongly-typed wrapper for test run row IDs.
211 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212 + pub struct TestRunId(pub i64);
213 +
210 214 #[derive(Debug, Clone, Serialize, Deserialize)]
211 215 pub struct TestRun {
212 216 /// Database row ID. `None` before the run is inserted into SQLite.
@@ -390,6 +390,70 @@ pub enum ConfigError {
390 390 #[cfg(test)]
391 391 mod tests {
392 392 use super::*;
393 + use std::sync::Mutex;
394 +
395 + /// Mutex to serialize tests that call Config::from_env(), since env vars are
396 + /// process-global and concurrent mutation causes flaky failures.
397 + static ENV_LOCK: Mutex<()> = Mutex::new(());
398 +
399 + /// All env var keys that Config::from_env() reads. Used by the guard to
400 + /// snapshot and restore state so tests don't leak into each other.
401 + const CONFIG_ENV_VARS: &[&str] = &[
402 + "HOST", "PORT", "DATABASE_URL", "HOST_URL", "SIGNING_SECRET",
403 + "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",
404 + "SYNCKIT_S3_ENDPOINT", "SYNCKIT_S3_BUCKET", "SYNCKIT_S3_ACCESS_KEY",
405 + "SYNCKIT_S3_SECRET_KEY", "SYNCKIT_S3_REGION",
406 + "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET_V2",
407 + "ADMIN_USER_ID", "SYNCKIT_JWT_SECRET", "SCAN_ENABLED", "CLAMAV_SOCKET",
408 + "YARA_RULES_DIR", "MALWAREBAZAAR_ENABLED", "GIT_REPOS_PATH",
409 + "POSTMARK_WEBHOOK_TOKEN", "POSTMARK_BROADCAST_WEBHOOK_TOKEN",
410 + "GIT_SSH_HOST", "MT_BASE_URL", "FAN_PLUS_STRIPE_PRICE_ID",
411 + "CREATOR_TIER_BASIC_PRICE_ID", "CREATOR_TIER_SMALL_FILES_PRICE_ID",
412 + "CREATOR_TIER_BIG_FILES_PRICE_ID", "CREATOR_TIER_STREAMING_PRICE_ID",
413 + "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN",
414 + "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN",
415 + "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN",
416 + ];
417 +
418 + /// RAII guard that snapshots config-related env vars on creation and restores
419 + /// them when dropped. Also holds the ENV_LOCK so tests run serially.
420 + struct EnvGuard {
421 + _lock: std::sync::MutexGuard<'static, ()>,
422 + snapshot: Vec<(&'static str, Option<String>)>,
423 + }
424 +
425 + impl EnvGuard {
426 + fn new() -> Self {
427 + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
428 + let snapshot = CONFIG_ENV_VARS
429 + .iter()
430 + .map(|&key| (key, std::env::var(key).ok()))
431 + .collect();
432 + Self { _lock: lock, snapshot }
433 + }
434 +
435 + /// Remove all config env vars so from_env() sees a clean slate.
436 + fn clear_all(&self) {
437 + for &key in CONFIG_ENV_VARS {
438 + // SAFETY: test-only, serialized by mutex
439 + unsafe { std::env::remove_var(key); }
440 + }
441 + }
442 + }
443 +
444 + impl Drop for EnvGuard {
445 + fn drop(&mut self) {
446 + for (key, val) in &self.snapshot {
447 + match val {
448 + // SAFETY: test-only, serialized by mutex
449 + Some(v) => unsafe { std::env::set_var(key, v) },
450 + None => unsafe { std::env::remove_var(key) },
451 + }
452 + }
453 + }
454 + }
455 +
456 + // ---- existing tests (unchanged) ----
393 457
394 458 #[test]
395 459 fn socket_addr_combines_host_and_port() {
@@ -432,4 +496,275 @@ mod tests {
432 496 assert!(ConfigError::MissingDatabaseUrl.to_string().contains("DATABASE_URL"));
433 497 }
434 498
499 + // ---- from_env validation tests ----
500 +
501 + #[test]
502 + fn from_env_succeeds_with_required_vars() {
503 + let guard = EnvGuard::new();
504 + guard.clear_all();
505 +
506 + // SAFETY: test-only, serialized by EnvGuard mutex
507 + unsafe {
508 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
509 + }
510 +
511 + let config = Config::from_env().expect("should succeed with DATABASE_URL set");
512 + assert_eq!(config.database_url, "postgres://localhost/test_db");
513 + // Defaults: host=127.0.0.1, port=3000
514 + assert_eq!(config.host.to_string(), "127.0.0.1");
515 + assert_eq!(config.port, 3000);
516 + // Signing secret should be a random UUID in dev mode
517 + assert!(!config.signing_secret.is_empty());
518 + drop(guard);
519 + }
520 +
521 + #[test]
522 + fn from_env_fails_without_database_url() {
523 + let guard = EnvGuard::new();
524 + guard.clear_all();
525 +
526 + let err = Config::from_env().unwrap_err();
527 + assert!(
528 + matches!(err, ConfigError::MissingDatabaseUrl),
529 + "expected MissingDatabaseUrl, got: {err}"
530 + );
531 + drop(guard);
532 + }
533 +
534 + #[test]
535 + fn from_env_fails_in_production_without_signing_secret() {
536 + let guard = EnvGuard::new();
537 + guard.clear_all();
538 +
539 + // SAFETY: test-only, serialized by EnvGuard mutex
540 + unsafe {
541 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
542 + std::env::set_var("HOST", "0.0.0.0"); // production indicator
543 + }
544 +
545 + let err = Config::from_env().unwrap_err();
546 + assert!(
547 + matches!(err, ConfigError::MissingSigningSecret),
548 + "expected MissingSigningSecret, got: {err}"
549 + );
550 + drop(guard);
551 + }
552 +
553 + #[test]
554 + fn from_env_fails_with_https_host_url_without_signing_secret() {
555 + let guard = EnvGuard::new();
556 + guard.clear_all();
557 +
558 + // SAFETY: test-only, serialized by EnvGuard mutex
559 + unsafe {
560 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
561 + std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator
562 + }
563 +
564 + let err = Config::from_env().unwrap_err();
565 + assert!(
566 + matches!(err, ConfigError::MissingSigningSecret),
567 + "expected MissingSigningSecret, got: {err}"
568 + );
569 + drop(guard);
570 + }
571 +
572 + #[test]
573 + fn from_env_uses_random_dev_secret_when_not_production() {
574 + let guard = EnvGuard::new();
575 + guard.clear_all();
576 +
577 + // SAFETY: test-only, serialized by EnvGuard mutex
578 + unsafe {
579 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
580 + // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET
581 + }
582 +
583 + let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET");
584 + // Should be a valid UUID v4
585 + assert!(
586 + uuid::Uuid::parse_str(&config.signing_secret).is_ok(),
587 + "expected random UUID signing secret, got: {}",
588 + config.signing_secret
589 + );
590 + drop(guard);
591 + }
592 +
593 + #[test]
594 + fn from_env_storage_none_when_partially_set() {
595 + let guard = EnvGuard::new();
596 + guard.clear_all();
597 +
598 + // SAFETY: test-only, serialized by EnvGuard mutex
599 + unsafe {
600 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
601 + // Set only some S3 vars — missing S3_SECRET_KEY and S3_ACCESS_KEY
602 + std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
603 + std::env::set_var("S3_BUCKET", "test-bucket");
604 + }
605 +
606 + let config = Config::from_env().expect("should succeed");
607 + assert!(
608 + config.storage.is_none(),
609 + "storage should be None when S3 vars are only partially set"
610 + );
611 + drop(guard);
612 + }
613 +
614 + #[test]
615 + fn from_env_storage_some_when_fully_set() {
616 + let guard = EnvGuard::new();
617 + guard.clear_all();
618 +
619 + // SAFETY: test-only, serialized by EnvGuard mutex
620 + unsafe {
621 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
622 + std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
623 + std::env::set_var("S3_BUCKET", "test-bucket");
624 + std::env::set_var("S3_ACCESS_KEY", "ak");
625 + std::env::set_var("S3_SECRET_KEY", "sk");
626 + }
627 +
628 + let config = Config::from_env().expect("should succeed");
629 + let storage = config.storage.expect("storage should be Some when all S3 vars set");
630 + assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com");
631 + assert_eq!(storage.bucket, "test-bucket");
632 + assert_eq!(storage.region, "us-east-1"); // default region
633 + drop(guard);
634 + }
635 +
636 + #[test]
637 + fn from_env_stripe_none_when_secret_key_missing() {
638 + let guard = EnvGuard::new();
639 + guard.clear_all();
640 +
641 + // SAFETY: test-only, serialized by EnvGuard mutex
642 + unsafe {
643 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
644 + // Set webhook secret but not secret key
645 + std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
646 + }
647 +
648 + let config = Config::from_env().expect("should succeed");
649 + assert!(
650 + config.stripe.is_none(),
651 + "stripe should be None when STRIPE_SECRET_KEY is missing"
652 + );
653 + drop(guard);
654 + }
655 +
656 + #[test]
657 + fn from_env_stripe_none_when_webhook_secret_missing() {
658 + let guard = EnvGuard::new();
659 + guard.clear_all();
660 +
661 + // SAFETY: test-only, serialized by EnvGuard mutex
662 + unsafe {
663 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
664 + // Set secret key but not webhook secret
665 + std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
666 + }
667 +
668 + let config = Config::from_env().expect("should succeed");
669 + assert!(
670 + config.stripe.is_none(),
671 + "stripe should be None when STRIPE_WEBHOOK_SECRET is missing"
672 + );
673 + drop(guard);
674 + }
675 +
676 + #[test]
677 + fn from_env_stripe_some_when_fully_set() {
678 + let guard = EnvGuard::new();
679 + guard.clear_all();
680 +
681 + // SAFETY: test-only, serialized by EnvGuard mutex
682 + unsafe {
683 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
684 + std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
685 + std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
686 + }
687 +
688 + let config = Config::from_env().expect("should succeed");
689 + let stripe = config.stripe.expect("stripe should be Some when fully configured");
690 + assert_eq!(stripe.secret_key, "sk_test_abc");
691 + assert_eq!(stripe.webhook_secret, "whsec_test");
692 + assert!(stripe.webhook_secret_v2.is_none());
693 + drop(guard);
694 + }
695 +
696 + #[test]
697 + fn from_env_invalid_host_rejected() {
698 + let guard = EnvGuard::new();
699 + guard.clear_all();
700 +
701 + // SAFETY: test-only, serialized by EnvGuard mutex
702 + unsafe {
703 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
704 + std::env::set_var("HOST", "not-an-ip");
705 + }
706 +
707 + let err = Config::from_env().unwrap_err();
708 + assert!(
709 + matches!(err, ConfigError::InvalidHost),
710 + "expected InvalidHost, got: {err}"
711 + );
712 + drop(guard);
713 + }
714 +
715 + #[test]
716 + fn from_env_invalid_port_rejected() {
717 + let guard = EnvGuard::new();
718 + guard.clear_all();
719 +
720 + // SAFETY: test-only, serialized by EnvGuard mutex
721 + unsafe {
722 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
723 + std::env::set_var("PORT", "not-a-number");
724 + }
725 +
726 + let err = Config::from_env().unwrap_err();
727 + assert!(
728 + matches!(err, ConfigError::InvalidPort),
729 + "expected InvalidPort, got: {err}"
730 + );
731 + drop(guard);
732 + }
733 +
734 + #[test]
735 + fn from_env_scan_disabled_when_explicitly_off() {
736 + let guard = EnvGuard::new();
737 + guard.clear_all();
738 +
739 + // SAFETY: test-only, serialized by EnvGuard mutex
740 + unsafe {
741 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
742 + std::env::set_var("SCAN_ENABLED", "false");
743 + }
744 +
745 + let config = Config::from_env().expect("should succeed");
746 + assert!(
747 + config.scan.is_none(),
748 + "scan should be None when SCAN_ENABLED=false"
749 + );
750 + drop(guard);
751 + }
752 +
753 + #[test]
754 + fn from_env_scan_enabled_by_default() {
755 + let guard = EnvGuard::new();
756 + guard.clear_all();
757 +
758 + // SAFETY: test-only, serialized by EnvGuard mutex
759 + unsafe {
760 + std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
761 + }
762 +
763 + let config = Config::from_env().expect("should succeed");
764 + assert!(
765 + config.scan.is_some(),
766 + "scan should be Some by default (enabled unless explicitly disabled)"
767 + );
768 + drop(guard);
769 + }
435 770 }