max / makenotwork
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; |
| @@ -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 | } |