max / makenotwork
12 files changed,
+315 insertions,
-3 deletions
| @@ -0,0 +1,166 @@ | |||
| 1 | + | //! Site-wide access gate (the `ACCESS_GATE=fan_plus_or_creator` mode). | |
| 2 | + | //! | |
| 3 | + | //! When enabled, the entire site is reachable only by logged-in users who hold | |
| 4 | + | //! a creator account or an active Fan+ subscription; everyone else is bounced | |
| 5 | + | //! to `/login` with a notice. This backs the testnot.work staging mirror, whose | |
| 6 | + | //! data is a daily restore of production — gating it to Fan+/creator accounts | |
| 7 | + | //! keeps that mirror off the open internet (the "available to anyone with a | |
| 8 | + | //! Fan+ or creator account" rule), matching the testnot Fan+ perk. | |
| 9 | + | //! | |
| 10 | + | //! It is a COARSE pre-filter: it reads the cached session flags only (no DB | |
| 11 | + | //! query, no session-tracking revalidation). The per-route `AuthUser` extractor | |
| 12 | + | //! still enforces full auth underneath, so the gate never relaxes real | |
| 13 | + | //! authorization — it only narrows who reaches the routes at all. Default-off, | |
| 14 | + | //! so production (`AccessGate::Open`) is completely unaffected. | |
| 15 | + | ||
| 16 | + | use axum::{ | |
| 17 | + | body::Body, | |
| 18 | + | extract::State, | |
| 19 | + | http::Request, | |
| 20 | + | middleware::Next, | |
| 21 | + | response::{IntoResponse, Redirect, Response}, | |
| 22 | + | }; | |
| 23 | + | use tower_sessions::Session; | |
| 24 | + | ||
| 25 | + | use crate::config::AccessGate; | |
| 26 | + | use crate::AppState; | |
| 27 | + | ||
| 28 | + | /// Paths that must stay reachable even when the gate is on, so an | |
| 29 | + | /// un-authenticated visitor can still log in and so infrastructure keeps | |
| 30 | + | /// working. Everything else requires a creator/Fan+ session. | |
| 31 | + | /// | |
| 32 | + | /// Matched by exact path or `<prefix>/` so `/login` and `/login/...` both pass | |
| 33 | + | /// but a hypothetical `/loginsomething` does not. | |
| 34 | + | fn path_is_exempt(path: &str) -> bool { | |
| 35 | + | /// Reachable as exactly `p` or as `p` followed by `/`. | |
| 36 | + | fn hit(path: &str, p: &str) -> bool { | |
| 37 | + | path == p || path.strip_prefix(p).is_some_and(|rest| rest.starts_with('/')) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Authentication surface — without these the gate would lock out its own | |
| 41 | + | // login page and the assets/endpoints the login flow needs. | |
| 42 | + | hit(path, "/login") | |
| 43 | + | || hit(path, "/logout") | |
| 44 | + | || hit(path, "/auth") // /auth/me, /auth/2fa, /auth/passkey/* | |
| 45 | + | || hit(path, "/oauth") // MNW-as-OAuth-provider authorize/token/userinfo | |
| 46 | + | // Static assets + browser chrome (the login page pulls CSS/JS/images). | |
| 47 | + | || hit(path, "/static") | |
| 48 | + | || hit(path, "/rustdoc") | |
| 49 | + | || path == "/favicon.ico" | |
| 50 | + | || path == "/robots.txt" | |
| 51 | + | // Operational endpoints: the deploy smoke check and machine callers. | |
| 52 | + | || path == "/health" | |
| 53 | + | || path == "/metrics" | |
| 54 | + | || hit(path, "/stripe/webhook") // inert on testnot (Stripe stubbed) but never gate a webhook | |
| 55 | + | || hit(path, "/postmark") // inbound/webhook callbacks | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Whether the gate permits this session through. Creators (`can_create_projects`) | |
| 59 | + | /// and active Fan+ members pass; anonymous and plain-fan sessions do not. | |
| 60 | + | fn session_is_allowed(user: Option<&crate::auth::SessionUser>) -> bool { | |
| 61 | + | user.is_some_and(|u| u.can_create_projects || u.is_fan_plus) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Axum middleware enforcing the site access gate. No-op when the gate is | |
| 65 | + | /// `Open` (production), so it adds a single enum comparison per request there. | |
| 66 | + | pub async fn access_gate_middleware( | |
| 67 | + | State(state): State<AppState>, | |
| 68 | + | request: Request<Body>, | |
| 69 | + | next: Next, | |
| 70 | + | ) -> Response { | |
| 71 | + | if state.config.access_gate == AccessGate::Open { | |
| 72 | + | return next.run(request).await; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | let path = request.uri().path(); | |
| 76 | + | if path_is_exempt(path) { | |
| 77 | + | return next.run(request).await; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Session is installed by the session layer (outer to this middleware). | |
| 81 | + | let user = match request.extensions().get::<Session>() { | |
| 82 | + | Some(session) => crate::auth::session_user(session).await, | |
| 83 | + | None => None, | |
| 84 | + | }; | |
| 85 | + | ||
| 86 | + | if session_is_allowed(user.as_ref()) { | |
| 87 | + | next.run(request).await | |
| 88 | + | } else { | |
| 89 | + | // Coarse redirect to login with a notice flag; the per-route auth still | |
| 90 | + | // governs anything the user reaches after authenticating. | |
| 91 | + | Redirect::to("/login?gate=fan_plus_or_creator").into_response() | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[cfg(test)] | |
| 96 | + | mod tests { | |
| 97 | + | use super::*; | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | fn exempts_auth_and_asset_paths() { | |
| 101 | + | for p in [ | |
| 102 | + | "/login", | |
| 103 | + | "/login?gate=fan_plus_or_creator", | |
| 104 | + | "/logout", | |
| 105 | + | "/auth/me", | |
| 106 | + | "/auth/passkey/start", | |
| 107 | + | "/oauth/authorize", | |
| 108 | + | "/static/style.css", | |
| 109 | + | "/static/images/favicon.ico", | |
| 110 | + | "/rustdoc/index.html", | |
| 111 | + | "/favicon.ico", | |
| 112 | + | "/robots.txt", | |
| 113 | + | "/health", | |
| 114 | + | "/metrics", | |
| 115 | + | "/stripe/webhook", | |
| 116 | + | "/postmark/inbound", | |
| 117 | + | ] { | |
| 118 | + | // path_is_exempt sees the path only (no query), mirroring uri().path(). | |
| 119 | + | let path = p.split('?').next().unwrap(); | |
| 120 | + | assert!(path_is_exempt(path), "expected exempt: {p}"); | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[test] | |
| 125 | + | fn gates_content_paths() { | |
| 126 | + | for p in [ | |
| 127 | + | "/", | |
| 128 | + | "/discover", | |
| 129 | + | "/u/someone", | |
| 130 | + | "/p/some-project", | |
| 131 | + | "/changelog", | |
| 132 | + | "/library", | |
| 133 | + | "/loginsomething", // prefix must not over-match | |
| 134 | + | "/authority", // ditto | |
| 135 | + | ] { | |
| 136 | + | assert!(!path_is_exempt(p), "expected gated: {p}"); | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn only_creator_or_fan_plus_passes() { | |
| 142 | + | use crate::auth::SessionUser; | |
| 143 | + | use crate::db::{UserId, Username}; | |
| 144 | + | ||
| 145 | + | fn user(can_create_projects: bool, is_fan_plus: bool) -> SessionUser { | |
| 146 | + | SessionUser { | |
| 147 | + | id: UserId::default(), | |
| 148 | + | username: Username::from_trusted("t".into()), | |
| 149 | + | email: "t@example.com".into(), | |
| 150 | + | display_name: None, | |
| 151 | + | can_create_projects, | |
| 152 | + | suspended: false, | |
| 153 | + | is_admin: false, | |
| 154 | + | is_fan_plus, | |
| 155 | + | creator_tier: None, | |
| 156 | + | deactivated: false, | |
| 157 | + | is_sandbox: false, | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | assert!(!session_is_allowed(None), "anonymous blocked"); | |
| 162 | + | assert!(!session_is_allowed(Some(&user(false, false))), "plain fan blocked"); | |
| 163 | + | assert!(session_is_allowed(Some(&user(true, false))), "creator allowed"); | |
| 164 | + | assert!(session_is_allowed(Some(&user(false, true))), "fan+ allowed"); | |
| 165 | + | } | |
| 166 | + | } |
| @@ -121,6 +121,16 @@ impl SessionUser { | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | /// Read the cached `SessionUser` from a session, if one is logged in. | |
| 125 | + | /// | |
| 126 | + | /// Coarse read: it does NOT revalidate the session-tracking row (the way the | |
| 127 | + | /// `AuthUser` extractor does). Intended for middleware-level pre-filters like | |
| 128 | + | /// the site access gate, where the per-route `AuthUser` extractor still | |
| 129 | + | /// enforces full validation downstream. Returns `None` for anonymous sessions. | |
| 130 | + | pub async fn session_user(session: &Session) -> Option<SessionUser> { | |
| 131 | + | session.get::<SessionUser>(USER_SESSION_KEY).await.ok().flatten() | |
| 132 | + | } | |
| 133 | + | ||
| 124 | 134 | /// Extractor for authenticated users - returns error if not logged in. | |
| 125 | 135 | /// | |
| 126 | 136 | /// Specialized to `AppState` (not generic `S`) to access the DB pool for | |
| @@ -666,6 +676,7 @@ mod tests { | |||
| 666 | 676 | internal_shared_secret: None, | |
| 667 | 677 | cli_service_token: None, | |
| 668 | 678 | wam_url: None, | |
| 679 | + | access_gate: crate::config::AccessGate::Open, | |
| 669 | 680 | }; | |
| 670 | 681 | assert!(require_admin(&user, &config).is_ok()); | |
| 671 | 682 | } | |
| @@ -733,6 +744,7 @@ mod tests { | |||
| 733 | 744 | internal_shared_secret: None, | |
| 734 | 745 | cli_service_token: None, | |
| 735 | 746 | wam_url: None, | |
| 747 | + | access_gate: crate::config::AccessGate::Open, | |
| 736 | 748 | }; | |
| 737 | 749 | assert!(require_admin(&user, &config).is_err()); | |
| 738 | 750 | } |
| @@ -89,6 +89,24 @@ pub struct Config { | |||
| 89 | 89 | /// Base URL of the WAM ticket manager (e.g., "http://100.x.x.x:7890"). | |
| 90 | 90 | /// When set, operational events create WAM tickets for human triage. | |
| 91 | 91 | pub wam_url: Option<String>, | |
| 92 | + | /// Site-wide access gate. `Open` (default) serves the public site as | |
| 93 | + | /// normal. `FanPlusOrCreator` restricts the whole site to logged-in users | |
| 94 | + | /// with a creator account or an active Fan+ subscription — used on the | |
| 95 | + | /// testnot.work staging mirror so it's reachable only by Fan+/creator | |
| 96 | + | /// accounts. Off in production. | |
| 97 | + | pub access_gate: AccessGate, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Site-wide access-gate mode (`ACCESS_GATE`). | |
| 101 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] | |
| 102 | + | pub enum AccessGate { | |
| 103 | + | /// No gate — the public site is served to everyone (production default). | |
| 104 | + | #[default] | |
| 105 | + | Open, | |
| 106 | + | /// Only logged-in creators or active Fan+ members may reach the site; | |
| 107 | + | /// everyone else is bounced to login. A coarse pre-filter — per-route auth | |
| 108 | + | /// still applies underneath. | |
| 109 | + | FanPlusOrCreator, | |
| 92 | 110 | } | |
| 93 | 111 | ||
| 94 | 112 | /// S3-compatible storage configuration (Hetzner Object Storage) | |
| @@ -276,6 +294,13 @@ impl Config { | |||
| 276 | 294 | // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890") | |
| 277 | 295 | let wam_url = std::env::var("WAM_URL").ok(); | |
| 278 | 296 | ||
| 297 | + | // Site-wide access gate. Only "fan_plus_or_creator" enables it; any | |
| 298 | + | // other value (or unset) leaves the site open. Staging-only knob. | |
| 299 | + | let access_gate = match std::env::var("ACCESS_GATE").as_deref() { | |
| 300 | + | Ok("fan_plus_or_creator") => AccessGate::FanPlusOrCreator, | |
| 301 | + | _ => AccessGate::Open, | |
| 302 | + | }; | |
| 303 | + | ||
| 279 | 304 | Ok(Config { | |
| 280 | 305 | host, | |
| 281 | 306 | port, | |
| @@ -307,6 +332,7 @@ impl Config { | |||
| 307 | 332 | internal_shared_secret, | |
| 308 | 333 | cli_service_token, | |
| 309 | 334 | wam_url, | |
| 335 | + | access_gate, | |
| 310 | 336 | }) | |
| 311 | 337 | } | |
| 312 | 338 | ||
| @@ -477,6 +503,7 @@ impl std::fmt::Debug for Config { | |||
| 477 | 503 | .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]")) | |
| 478 | 504 | .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]")) | |
| 479 | 505 | .field("wam_url", &self.wam_url) | |
| 506 | + | .field("access_gate", &self.access_gate) | |
| 480 | 507 | .finish() | |
| 481 | 508 | } | |
| 482 | 509 | } | |
| @@ -550,7 +577,7 @@ mod tests { | |||
| 550 | 577 | "CREATOR_FOUNDER_WINDOW_OPEN", | |
| 551 | 578 | "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN", | |
| 552 | 579 | "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN", | |
| 553 | - | "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", | |
| 580 | + | "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", "WAM_URL", "ACCESS_GATE", | |
| 554 | 581 | ]; | |
| 555 | 582 | ||
| 556 | 583 | /// RAII guard that snapshots config-related env vars on creation and restores | |
| @@ -626,6 +653,7 @@ mod tests { | |||
| 626 | 653 | internal_shared_secret: None, | |
| 627 | 654 | cli_service_token: None, | |
| 628 | 655 | wam_url: None, | |
| 656 | + | access_gate: AccessGate::Open, | |
| 629 | 657 | }; | |
| 630 | 658 | let addr = config.socket_addr(); | |
| 631 | 659 | assert_eq!(addr.port(), 8080); |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! MakeNotWork library — shared between the binary and integration tests. | |
| 2 | 2 | ||
| 3 | + | pub mod access_gate; | |
| 3 | 4 | pub mod auth; | |
| 4 | 5 | pub mod background; | |
| 5 | 6 | pub mod build_runner; | |
| @@ -222,7 +223,8 @@ pub fn build_app( | |||
| 222 | 223 | ); | |
| 223 | 224 | } | |
| 224 | 225 | ||
| 225 | - | app.layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware)) | |
| 226 | + | app.layer(middleware::from_fn_with_state(state.clone(), access_gate::access_gate_middleware)) | |
| 227 | + | .layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware)) | |
| 226 | 228 | .layer(middleware::from_fn(metrics::cache_control_middleware)) | |
| 227 | 229 | .layer(middleware::from_fn(metrics::metrics_middleware)) | |
| 228 | 230 | .layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware)) |
| @@ -118,6 +118,7 @@ async fn login_handler( | |||
| 118 | 118 | csrf_token: recall_csrf_token.clone(), | |
| 119 | 119 | prefill_login: submitted_login.clone(), | |
| 120 | 120 | error: Some(msg.to_string()), | |
| 121 | + | notice: None, | |
| 121 | 122 | }.into_response()) | |
| 122 | 123 | } | |
| 123 | 124 | }; |
| @@ -370,13 +370,32 @@ pub(super) async fn library_tab_communities( | |||
| 370 | 370 | .into_response()) | |
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | + | /// Query params for the login page. | |
| 374 | + | #[derive(Deserialize)] | |
| 375 | + | pub(crate) struct LoginQuery { | |
| 376 | + | /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why | |
| 377 | + | /// the visitor landed on login instead of the page they requested. | |
| 378 | + | pub gate: Option<String>, | |
| 379 | + | } | |
| 380 | + | ||
| 373 | 381 | /// Render the login page. | |
| 374 | 382 | #[tracing::instrument(skip_all, name = "landing::login_page")] | |
| 375 | - | pub(crate) async fn login_page(session: Session) -> impl IntoResponse { | |
| 383 | + | pub(crate) async fn login_page( | |
| 384 | + | session: Session, | |
| 385 | + | Query(query): Query<LoginQuery>, | |
| 386 | + | ) -> impl IntoResponse { | |
| 387 | + | let notice = match query.gate.as_deref() { | |
| 388 | + | Some("fan_plus_or_creator") => Some( | |
| 389 | + | "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue." | |
| 390 | + | .to_string(), | |
| 391 | + | ), | |
| 392 | + | _ => None, | |
| 393 | + | }; | |
| 376 | 394 | LoginTemplate { | |
| 377 | 395 | csrf_token: get_csrf_token(&session).await, | |
| 378 | 396 | prefill_login: String::new(), | |
| 379 | 397 | error: None, | |
| 398 | + | notice, | |
| 380 | 399 | } | |
| 381 | 400 | } | |
| 382 | 401 |
| @@ -117,6 +117,10 @@ pub struct LoginTemplate { | |||
| 117 | 117 | pub prefill_login: String, | |
| 118 | 118 | /// Shown inline above the form on a failed POST. None hides the banner. | |
| 119 | 119 | pub error: Option<String>, | |
| 120 | + | /// Neutral informational notice above the form (e.g. the access-gate prompt | |
| 121 | + | /// on the testnot staging mirror). None hides it. Separate from `error` so | |
| 122 | + | /// it doesn't render as a failure. | |
| 123 | + | pub notice: Option<String>, | |
| 120 | 124 | } | |
| 121 | 125 | ||
| 122 | 126 | // ============================================================================ |
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | {% block content %} | |
| 8 | 8 | <h1 class="brand-h1">Makenot<span class="dot">.</span>work</h1> | |
| 9 | 9 | <div class="login-container"> | |
| 10 | + | {% if let Some(note) = notice %}<div class="alert alert-note">{{ note }}</div>{% endif %} | |
| 10 | 11 | <div id="login-errors"> | |
| 11 | 12 | {% if let Some(msg) = error %}<div class="alert alert-error">{{ msg }}</div>{% endif %} | |
| 12 | 13 | </div> |
| @@ -76,6 +76,9 @@ pub struct BuildOptions { | |||
| 76 | 76 | pub cli_service_token: Option<String>, | |
| 77 | 77 | pub mock_email: Option<Arc<email::MockEmailTransport>>, | |
| 78 | 78 | pub cdn_base_url: Option<String>, | |
| 79 | + | /// Site access gate. Defaults to `Open`; set to `FanPlusOrCreator` to test | |
| 80 | + | /// the testnot-style gate. | |
| 81 | + | pub access_gate: makenotwork::config::AccessGate, | |
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | /// Full test harness: isolated database, in-process app, cookie-aware client. | |
| @@ -301,6 +304,7 @@ impl TestHarness { | |||
| 301 | 304 | internal_shared_secret: opts.internal_shared_secret.clone(), | |
| 302 | 305 | cli_service_token: opts.cli_service_token.clone(), | |
| 303 | 306 | wam_url: None, | |
| 307 | + | access_gate: opts.access_gate, | |
| 304 | 308 | }; | |
| 305 | 309 | ||
| 306 | 310 | let mock_email_ref = opts.mock_email.clone(); |
| @@ -80,6 +80,7 @@ pub async fn run(config: LoadConfig) { | |||
| 80 | 80 | internal_shared_secret: None, | |
| 81 | 81 | cli_service_token: None, | |
| 82 | 82 | wam_url: None, | |
| 83 | + | access_gate: makenotwork::config::AccessGate::Open, | |
| 83 | 84 | }; | |
| 84 | 85 | ||
| 85 | 86 | let email = EmailClient::new(EmailConfig { |
| @@ -0,0 +1,73 @@ | |||
| 1 | + | //! Site access gate (`ACCESS_GATE=fan_plus_or_creator`): the testnot-style | |
| 2 | + | //! gate that restricts the whole site to creators and Fan+ members. | |
| 3 | + | ||
| 4 | + | use crate::harness::{BuildOptions, TestHarness}; | |
| 5 | + | use makenotwork::config::AccessGate; | |
| 6 | + | use sqlx::PgPool; | |
| 7 | + | ||
| 8 | + | fn location(resp: &crate::harness::client::TestResponse) -> String { | |
| 9 | + | resp.headers | |
| 10 | + | .get("location") | |
| 11 | + | .and_then(|v| v.to_str().ok()) | |
| 12 | + | .unwrap_or("") | |
| 13 | + | .to_string() | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// Insert a verified user directly (the HTTP signup flow is itself gated on | |
| 17 | + | /// testnot, so the test seeds the DB and authenticates via the exempt /login). | |
| 18 | + | async fn seed_user(pool: &PgPool, username: &str, can_create_projects: bool) { | |
| 19 | + | let hash = makenotwork::auth::hash_password("password123").expect("hash"); | |
| 20 | + | sqlx::query( | |
| 21 | + | "INSERT INTO users (username, email, password_hash, email_verified, can_create_projects) | |
| 22 | + | VALUES ($1, $2, $3, true, $4)", | |
| 23 | + | ) | |
| 24 | + | .bind(username) | |
| 25 | + | .bind(format!("{username}@example.com")) | |
| 26 | + | .bind(&hash) | |
| 27 | + | .bind(can_create_projects) | |
| 28 | + | .execute(pool) | |
| 29 | + | .await | |
| 30 | + | .expect("seed user"); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[tokio::test] | |
| 34 | + | async fn access_gate_restricts_to_fan_plus_or_creator() { | |
| 35 | + | let mut h = TestHarness::build(BuildOptions { | |
| 36 | + | access_gate: AccessGate::FanPlusOrCreator, | |
| 37 | + | ..Default::default() | |
| 38 | + | }) | |
| 39 | + | .await; | |
| 40 | + | seed_user(&h.db, "gatecreator", true).await; | |
| 41 | + | seed_user(&h.db, "plainfan", false).await; | |
| 42 | + | ||
| 43 | + | // Anonymous visitor → bounced to login with the gate notice. | |
| 44 | + | let r = h.client.get("/").await; | |
| 45 | + | assert!(r.status.is_redirection(), "anon should be redirected, got {}", r.status); | |
| 46 | + | assert!(location(&r).starts_with("/login"), "anon should land on login, got {}", location(&r)); | |
| 47 | + | ||
| 48 | + | // Exempt paths stay reachable while the gate is on, or login is impossible. | |
| 49 | + | assert_eq!(h.client.get("/login").await.status.as_u16(), 200, "login page must be reachable"); | |
| 50 | + | assert_eq!(h.client.get("/health").await.status.as_u16(), 200, "health must be reachable"); | |
| 51 | + | ||
| 52 | + | // A logged-in plain fan (no creator, no Fan+) is still blocked — the gate | |
| 53 | + | // is stricter than ordinary auth. | |
| 54 | + | h.login("plainfan", "password123").await; | |
| 55 | + | let r = h.client.get("/library").await; | |
| 56 | + | assert!(r.status.is_redirection(), "plain fan should be gated, got {}", r.status); | |
| 57 | + | assert!(location(&r).starts_with("/login"), "plain fan should land on login"); | |
| 58 | + | ||
| 59 | + | // A creator passes the gate (library renders 200 for the logged-in owner). | |
| 60 | + | h.client.post_form("/logout", "").await; | |
| 61 | + | h.login("gatecreator", "password123").await; | |
| 62 | + | let r = h.client.get("/library").await; | |
| 63 | + | assert_eq!(r.status.as_u16(), 200, "creator should pass the gate: {} {}", r.status, r.text); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #[tokio::test] | |
| 67 | + | async fn access_gate_open_serves_public_site() { | |
| 68 | + | // Default (Open) — the public landing renders for an anonymous visitor, | |
| 69 | + | // proving the gate is off unless explicitly enabled. | |
| 70 | + | let mut h = TestHarness::new().await; | |
| 71 | + | let r = h.client.get("/").await; | |
| 72 | + | assert_eq!(r.status.as_u16(), 200, "open site should serve landing to anon: {}", r.status); | |
| 73 | + | } |
| @@ -1,3 +1,4 @@ | |||
| 1 | + | mod access_gate; | |
| 1 | 2 | mod auth; | |
| 2 | 3 | mod discover; | |
| 3 | 4 | mod embeds; |