From 742784424fcb79b7db451c28017158172e89cbdd Mon Sep 17 00:00:00 2001 From: ujeenet Date: Sat, 23 May 2026 09:39:38 -0300 Subject: [PATCH] =?UTF-8?q?feat(showcase):=20accounts=20app=20=E2=80=94=20?= =?UTF-8?q?register/login/JWT/me=20(Phase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds auth-flow coverage. Exercises: - rustango::passwords::{hash, verify} round-trip via register + login - rustango::jwt::{encode, decode} round-trip via login → /me handshake - Bearer-token header parsing on the protected route - unique constraint on both username and email - 8-char minimum password validation - auto_now_add timestamp on user creation (via existing re-fetch-after-insert pattern from blog/shop) ## Routes POST /accounts/register {username, email, password} → 201 user POST /accounts/login {username, password} → 200 {token, user} GET /accounts/me Bearer → 200 user Error shapes: - 400 on short password - 401 on bad credentials, unknown user, missing header, malformed header, tampered token - 409 on duplicate username/email ## JWT secret SHOWCASE_JWT_SECRET env var; playwright config sets it for deterministic test runs. Falls back to a fixed dev secret string. ## Playwright 10 new tests under e2e/tests/accounts/. Suite is now 23/23 passing locally (2 smoke + 5 blog + 6 shop + 10 accounts). Adds @types/node to the e2e package so the playwright config's `process.env` reads typecheck cleanly. --- examples/showcase/e2e/package-lock.json | 20 +- examples/showcase/e2e/package.json | 3 +- examples/showcase/e2e/playwright.config.ts | 2 + .../showcase/e2e/tests/accounts/auth.spec.ts | 123 +++++++++ .../showcase/migrations/0004_accounts.json | 257 ++++++++++++++++++ examples/showcase/src/apps/accounts/mod.rs | 5 + examples/showcase/src/apps/accounts/models.rs | 26 ++ examples/showcase/src/apps/accounts/urls.rs | 213 +++++++++++++++ examples/showcase/src/apps/mod.rs | 4 +- 9 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 examples/showcase/e2e/tests/accounts/auth.spec.ts create mode 100644 examples/showcase/migrations/0004_accounts.json create mode 100644 examples/showcase/src/apps/accounts/mod.rs create mode 100644 examples/showcase/src/apps/accounts/models.rs create mode 100644 examples/showcase/src/apps/accounts/urls.rs diff --git a/examples/showcase/e2e/package-lock.json b/examples/showcase/e2e/package-lock.json index 3389517..029b97c 100644 --- a/examples/showcase/e2e/package-lock.json +++ b/examples/showcase/e2e/package-lock.json @@ -8,7 +8,8 @@ "name": "rustango-showcase-e2e", "version": "0.0.0", "devDependencies": { - "@playwright/test": "^1.55.0" + "@playwright/test": "^1.55.0", + "@types/node": "^20.11.0" } }, "node_modules/@playwright/test": { @@ -27,6 +28,16 @@ "node": ">=18" } }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -73,6 +84,13 @@ "engines": { "node": ">=18" } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/examples/showcase/e2e/package.json b/examples/showcase/e2e/package.json index 2d5d95f..4bed389 100644 --- a/examples/showcase/e2e/package.json +++ b/examples/showcase/e2e/package.json @@ -9,6 +9,7 @@ "install:browsers": "playwright install --with-deps chromium" }, "devDependencies": { - "@playwright/test": "^1.55.0" + "@playwright/test": "^1.55.0", + "@types/node": "^20.11.0" } } diff --git a/examples/showcase/e2e/playwright.config.ts b/examples/showcase/e2e/playwright.config.ts index 8c787bb..f418b21 100644 --- a/examples/showcase/e2e/playwright.config.ts +++ b/examples/showcase/e2e/playwright.config.ts @@ -55,6 +55,8 @@ export default defineConfig({ env: { DATABASE_URL, RUSTANGO_BIND: `127.0.0.1:${PORT}`, + SHOWCASE_JWT_SECRET: + process.env.SHOWCASE_JWT_SECRET ?? 'showcase-e2e-jwt-secret-not-for-production', }, }, }); diff --git a/examples/showcase/e2e/tests/accounts/auth.spec.ts b/examples/showcase/e2e/tests/accounts/auth.spec.ts new file mode 100644 index 0000000..0391586 --- /dev/null +++ b/examples/showcase/e2e/tests/accounts/auth.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; + +/** + * Phase 4 — accounts auth flow. Exercises: + * + * - `rustango::passwords::{hash, verify}` round-trip via the + * register/login pair + * - `rustango::jwt::{encode, decode}` round-trip via the login → + * /me handshake + * - Bearer-token header parsing on the protected route + * - `unique` constraint on username (#[rustango(unique)]) + * + * Each test creates a fresh user with a unique suffix so the shared + * server suite doesn't trip the unique constraint across reruns. + */ + +const tag = () => `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +async function register(request, suffix: string, password = 'strong-pw-12345') { + const username = `u-${suffix}`; + const email = `u-${suffix}@example.com`; + const resp = await request.post('/accounts/register', { + data: { username, email, password }, + }); + return { resp, username, email, password }; +} + +test.describe('accounts auth', () => { + test('register returns 201 with public profile (no password_hash)', async ({ request }) => { + const { resp, username, email } = await register(request, tag()); + expect(resp.status()).toBe(201); + const body = await resp.json(); + expect(body.id).toBeGreaterThan(0); + expect(body.username).toBe(username); + expect(body.email).toBe(email); + expect(body).not.toHaveProperty('password'); + expect(body).not.toHaveProperty('password_hash'); + }); + + test('register rejects short passwords', async ({ request }) => { + const resp = await request.post('/accounts/register', { + data: { username: `short-${tag()}`, email: `s${tag()}@x.com`, password: 'short' }, + }); + expect(resp.status()).toBe(400); + }); + + test('duplicate username rejected by unique constraint', async ({ request }) => { + const { username } = await register(request, tag()); + const dup = await request.post('/accounts/register', { + data: { username, email: `other-${tag()}@example.com`, password: 'another-pw-12345' }, + }); + expect(dup.status()).toBe(409); + }); + + test('login with correct password returns JWT + user', async ({ request }) => { + const { username, password } = await register(request, tag()); + + const resp = await request.post('/accounts/login', { + data: { username, password }, + }); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(body.token).toMatch(/^eyJ/); // JWT base64-url header prefix + expect(body.user.username).toBe(username); + }); + + test('login with wrong password returns 401', async ({ request }) => { + const { username } = await register(request, tag()); + const resp = await request.post('/accounts/login', { + data: { username, password: 'definitely-wrong' }, + }); + expect(resp.status()).toBe(401); + }); + + test('login with unknown username returns 401', async ({ request }) => { + const resp = await request.post('/accounts/login', { + data: { username: `nobody-${tag()}`, password: 'pw-pw-pw-pw' }, + }); + expect(resp.status()).toBe(401); + }); + + test('GET /accounts/me without token is 401', async ({ request }) => { + const resp = await request.get('/accounts/me'); + expect(resp.status()).toBe(401); + }); + + test('GET /accounts/me with malformed header is 401', async ({ request }) => { + const resp = await request.get('/accounts/me', { + headers: { Authorization: 'Token notbearer' }, + }); + expect(resp.status()).toBe(401); + }); + + test('GET /accounts/me with valid Bearer token returns user', async ({ request }) => { + const { username, password } = await register(request, tag()); + const login = await request.post('/accounts/login', { + data: { username, password }, + }); + const { token, user } = await login.json(); + + const meResp = await request.get('/accounts/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(meResp.status()).toBe(200); + const me = await meResp.json(); + expect(me).toEqual(user); + }); + + test('GET /accounts/me with tampered token is 401', async ({ request }) => { + const { username, password } = await register(request, tag()); + const login = await request.post('/accounts/login', { data: { username, password } }); + const { token } = await login.json(); + + // Flip a byte in the middle of the payload. + const idx = Math.floor(token.length / 2); + const tampered = token.slice(0, idx) + (token[idx] === 'A' ? 'B' : 'A') + token.slice(idx + 1); + + const resp = await request.get('/accounts/me', { + headers: { Authorization: `Bearer ${tampered}` }, + }); + expect(resp.status()).toBe(401); + }); +}); diff --git a/examples/showcase/migrations/0004_accounts.json b/examples/showcase/migrations/0004_accounts.json new file mode 100644 index 0000000..2ac21cc --- /dev/null +++ b/examples/showcase/migrations/0004_accounts.json @@ -0,0 +1,257 @@ +{ + "name": "0004_accounts", + "created_at": "2026-05-23T12:37:39.262805+00:00", + "prev": "0003_shop", + "atomic": true, + "snapshot": { + "tables": [ + { + "name": "rustango_admin_users", + "model": "AdminUser", + "fields": [ + { + "name": "active", + "column": "active", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "true" + }, + { + "name": "created_at", + "column": "created_at", + "ty": "datetime", + "nullable": false, + "primary_key": false + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "is_superuser", + "column": "is_superuser", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "false" + }, + { + "name": "password_hash", + "column": "password_hash", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 200 + }, + { + "name": "username", + "column": "username", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 150, + "unique": true + } + ] + }, + { + "name": "rustango_content_types", + "model": "ContentType", + "fields": [ + { + "name": "app_label", + "column": "app_label", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 100 + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "model_name", + "column": "model_name", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 100 + }, + { + "name": "table", + "column": "table", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 100 + } + ] + }, + { + "name": "showcase_accounts_user", + "model": "User", + "fields": [ + { + "name": "created_at", + "column": "created_at", + "ty": "datetime", + "nullable": false, + "primary_key": false, + "default": "now()", + "auto": true + }, + { + "name": "email", + "column": "email", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 254, + "unique": true + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "password_hash", + "column": "password_hash", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 200 + }, + { + "name": "username", + "column": "username", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 64, + "unique": true + } + ] + }, + { + "name": "showcase_blog_post", + "model": "Post", + "fields": [ + { + "name": "body", + "column": "body", + "ty": "string", + "nullable": true, + "primary_key": false + }, + { + "name": "created_at", + "column": "created_at", + "ty": "datetime", + "nullable": false, + "primary_key": false, + "default": "now()", + "auto": true + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "published", + "column": "published", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "false" + }, + { + "name": "title", + "column": "title", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 200 + } + ] + }, + { + "name": "showcase_shop_product", + "model": "Product", + "fields": [ + { + "name": "active", + "column": "active", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "true" + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "name", + "column": "name", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 120 + }, + { + "name": "price_cents", + "column": "price_cents", + "ty": "i64", + "nullable": false, + "primary_key": false + }, + { + "name": "sku", + "column": "sku", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 32, + "unique": true + }, + { + "name": "stock", + "column": "stock", + "ty": "i64", + "nullable": true, + "primary_key": false + } + ] + } + ] + }, + "forward": [ + { + "schema": { + "CreateTable": "showcase_accounts_user" + } + } + ] +} \ No newline at end of file diff --git a/examples/showcase/src/apps/accounts/mod.rs b/examples/showcase/src/apps/accounts/mod.rs new file mode 100644 index 0000000..d07aae9 --- /dev/null +++ b/examples/showcase/src/apps/accounts/mod.rs @@ -0,0 +1,5 @@ +//! `accounts` sub-app — register → login → /me JWT round-trip. +//! Phase 4 of the E2E plan. + +pub mod models; +pub mod urls; diff --git a/examples/showcase/src/apps/accounts/models.rs b/examples/showcase/src/apps/accounts/models.rs new file mode 100644 index 0000000..eadaf50 --- /dev/null +++ b/examples/showcase/src/apps/accounts/models.rs @@ -0,0 +1,26 @@ +//! User model for the showcase accounts app. Standalone — does NOT +//! piggy-back on `tenancy`'s built-in User table, since the showcase +//! runs non-tenancy. + +use rustango::sql::Auto; +use rustango::Model; + +#[derive(Model, Debug, Clone)] +#[rustango(table = "showcase_accounts_user", display = "username")] +pub struct User { + #[rustango(primary_key)] + pub id: Auto, + + #[rustango(max_length = 64, unique)] + pub username: String, + + #[rustango(max_length = 254, unique)] + pub email: String, + + /// Argon2id phc-string. Never echoed in API responses. + #[rustango(max_length = 200)] + pub password_hash: String, + + #[rustango(auto_now_add)] + pub created_at: Auto>, +} diff --git a/examples/showcase/src/apps/accounts/urls.rs b/examples/showcase/src/apps/accounts/urls.rs new file mode 100644 index 0000000..bf77017 --- /dev/null +++ b/examples/showcase/src/apps/accounts/urls.rs @@ -0,0 +1,213 @@ +//! HTTP routes for the accounts app. +//! +//! * POST /accounts/register — create user, return public profile +//! * POST /accounts/login — verify password, mint HS256 JWT +//! * GET /accounts/me — Bearer-token-authenticated user lookup +//! +//! Exercises `rustango::passwords::{hash, verify}` + `rustango::jwt::{encode, decode}` +//! end-to-end. The JWT secret is read from `SHOWCASE_JWT_SECRET` so +//! the playwright suite can pre-set it; falls back to a fixed +//! per-process random secret if unset (rejects any token after restart). + +use std::time::Duration; + +use axum::extract::Extension; +use axum::http::{header, StatusCode}; +use axum::response::Json; +use axum::routing::{get, post}; +use axum::Router; +use rustango::core::Op; +use rustango::jwt::{decode, encode, Claims}; +use rustango::passwords; +use rustango::sql::{Auto, FetcherPool as _, Pool}; + +use super::models::User; + +#[cfg(feature = "postgres")] +type AttachedPool = sqlx::PgPool; +#[cfg(not(feature = "postgres"))] +type AttachedPool = Pool; + +fn into_pool(p: &AttachedPool) -> Pool { + #[cfg(feature = "postgres")] + { + Pool::from(p.clone()) + } + #[cfg(not(feature = "postgres"))] + { + p.clone() + } +} + +#[must_use] +pub fn api() -> Router { + Router::new() + .route("/accounts/register", post(register)) + .route("/accounts/login", post(login)) + .route("/accounts/me", get(me)) +} + +fn jwt_secret() -> Vec { + std::env::var("SHOWCASE_JWT_SECRET") + .unwrap_or_else(|_| "showcase-dev-only-jwt-secret-not-for-production".to_owned()) + .into_bytes() +} + +#[derive(serde::Deserialize)] +struct RegisterIn { + username: String, + email: String, + password: String, +} + +#[derive(serde::Serialize)] +struct UserOut { + id: i64, + username: String, + email: String, +} + +impl From<&User> for UserOut { + fn from(u: &User) -> Self { + Self { + id: match u.id { + Auto::Set(n) => n, + Auto::Unset => 0, + }, + username: u.username.clone(), + email: u.email.clone(), + } + } +} + +async fn register( + Extension(pool): Extension, + Json(input): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let pool = into_pool(&pool); + + if input.password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + "Password must be at least 8 characters.".into(), + )); + } + let password_hash = passwords::hash(&input.password) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut u = User { + id: Auto::Unset, + username: input.username, + email: input.email, + password_hash, + created_at: Auto::Unset, + }; + u.insert_pool(&pool) + .await + .map_err(|e| (StatusCode::CONFLICT, e.to_string()))?; + + let id = match u.id { + Auto::Set(n) => n, + Auto::Unset => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "insert_pool didn't populate PK".into(), + )); + } + }; + let mut rows: Vec = User::objects() + .filter_op("id", Op::Eq, id) + .fetch_pool(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let stored = rows.pop().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "could not re-fetch user".into(), + ))?; + Ok((StatusCode::CREATED, Json(UserOut::from(&stored)))) +} + +#[derive(serde::Deserialize)] +struct LoginIn { + username: String, + password: String, +} + +#[derive(serde::Serialize)] +struct LoginOut { + token: String, + user: UserOut, +} + +async fn login( + Extension(pool): Extension, + Json(input): Json, +) -> Result, (StatusCode, String)> { + let pool = into_pool(&pool); + let mut rows: Vec = User::objects() + .filter_op("username", Op::Eq, input.username.clone()) + .fetch_pool(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let user = rows + .pop() + .ok_or((StatusCode::UNAUTHORIZED, "invalid credentials".into()))?; + + let ok = passwords::verify(&input.password, &user.password_hash) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if !ok { + return Err((StatusCode::UNAUTHORIZED, "invalid credentials".into())); + } + + let id = match user.id { + Auto::Set(n) => n, + Auto::Unset => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "user has no PK".into(), + )); + } + }; + let claims = Claims::new(id.to_string()) + .issuer("rustango-showcase") + .ttl(Duration::from_secs(3600)); + let token = encode(&claims, &jwt_secret()) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(LoginOut { + token, + user: UserOut::from(&user), + })) +} + +async fn me( + Extension(pool): Extension, + headers: axum::http::HeaderMap, +) -> Result, (StatusCode, String)> { + let pool = into_pool(&pool); + + let token = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .ok_or(( + StatusCode::UNAUTHORIZED, + "missing or malformed Authorization header".into(), + ))?; + + let claims = decode(token, &jwt_secret()).map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + let id: i64 = claims + .subject() + .and_then(|s| s.parse().ok()) + .ok_or((StatusCode::UNAUTHORIZED, "no sub claim".into()))?; + + let mut rows: Vec = User::objects() + .filter_op("id", Op::Eq, id) + .fetch_pool(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let user = rows + .pop() + .ok_or((StatusCode::UNAUTHORIZED, "user no longer exists".into()))?; + Ok(Json(UserOut::from(&user))) +} diff --git a/examples/showcase/src/apps/mod.rs b/examples/showcase/src/apps/mod.rs index fc39724..2817b91 100644 --- a/examples/showcase/src/apps/mod.rs +++ b/examples/showcase/src/apps/mod.rs @@ -2,6 +2,7 @@ //! `views.rs`, `admin.rs`, `mod.rs`) and the E2E suite has a matching //! `e2e/tests//` folder. +pub mod accounts; pub mod blog; pub mod shop; @@ -18,6 +19,7 @@ pub fn api() -> Router { .route("/__showcase__/info", get(info)) .merge(blog::urls::api()) .merge(shop::urls::api()) + .merge(accounts::urls::api()) } /// Smoke endpoint — used by the E2E playwright suite's readiness @@ -36,6 +38,6 @@ async fn info() -> Json { "framework": "rustango", "version": env!("CARGO_PKG_VERSION"), "backend": backend, - "apps": ["blog", "shop"], + "apps": ["accounts", "blog", "shop"], })) }