diff --git a/.env.example b/.env.example index bd560a1..fb32c3d 100644 --- a/.env.example +++ b/.env.example @@ -24,12 +24,13 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/waveflow # Upper bound on the sqlx pool. Default 20. WAVEFLOW_DB_MAX_CONNECTIONS=20 -# Phase 1.b auth shim — set to "1" to unlock the `X-User-Id` header -# auth on `/api/v1/users` and `/api/v1/profiles/*`. Default OFF -# (`/api/v1/*` returns 503). NEVER set this in production: the header -# is trivial to forge and would let an attacker pretend to be any -# user. Phase 1.d replaces this with JWT verification. -WAVEFLOW_DEV_AUTH=1 +# Bearer-JWT auth (required) — set the full triple or boot fails. +# Point at the Better Auth instance (`waveflow-web`) issuing tokens +# for this server. Locally: the `/api/auth/jwks` endpoint exposed by +# the `jwt()` plugin + the matching `iss` / `aud` claims. +WAVEFLOW_JWT_JWKS_URL=http://localhost:3000/api/auth/jwks +WAVEFLOW_JWT_ISSUER=http://localhost:3000 +WAVEFLOW_JWT_AUDIENCE=waveflow-server # Logging. # RUST_LOG syntax: `info,waveflow_server=debug,tower_http=debug`. diff --git a/CLAUDE.md b/CLAUDE.md index 73cd5c9..ec24c49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,8 +39,8 @@ CI runs the full suite only on Linux (service container); the Windows leg is a c - **`AppState`** (`src/lib.rs`) holds the shared singletons threaded through every handler — currently just the `PgPool` (cheap to clone, `Arc`-backed). Add new singletons here. - **API is one file per resource** under `src/api/`, each exposing a `router()` merged in `src/api/mod.rs`. `/health` (liveness, no DB) and `/ready` (DB-aware readiness) are unversioned infra probes; every real resource mounts under `/api/v1/`. - **No SQL in handlers.** SQL lives in the DB layer (`src/db.rs`) or in a `waveflow-core::repository::postgres::*` method; handlers stay pure HTTP orchestration. `db::ping` (`/ready`'s `SELECT 1`) and `db::users::create` are the in-tree pattern; everything tenant-scoped goes through `PostgresProfileRepository::*_for_user`. This mirrors the desktop's Tauri-command ↔ `waveflow-core` boundary. -- **Tenancy is enforced at the storage layer.** Server handlers under `/api/v1/*` extract `UserId` from the `require_user_id` middleware and call `*_for_user` methods only — never the single-tenant trait surface. `PostgresProfileRepository` deliberately does NOT implement `ProfileRepository`, so the compiler stops a careless `list_all()` from leaking another tenant's rows. Apply the same pattern when adding library / track / playlist repositories. -- **Auth: JWT + dev shim, transitioning.** `middleware::authenticate` runs JWT-first when [`AppState::jwt_verifier`] is configured: verify the Bearer, then `db::users::find_or_provision_by_external_id(state.db, &sub, now_ms)` to lazy-onboard the user on first request (Phase 1.c.3a — a valid signature is the authoritative onboarding signal, so no separate `POST /api/v1/users` is needed after Better Auth signup). Falls back to the legacy `X-User-Id` shim when `dev_auth_enabled`; returns 503 when neither path is configured (production gate). Phase 1.d.2 deletes the shim branch entirely. +- **Tenancy is enforced at the storage layer.** Server handlers under `/api/v1/*` extract `UserId` from the `middleware::authenticate` middleware and call `*_for_user` methods only — never the single-tenant trait surface. `PostgresProfileRepository` deliberately does NOT implement `ProfileRepository`, so the compiler stops a careless `list_all()` from leaking another tenant's rows. Apply the same pattern when adding library / track / playlist repositories. +- **Auth: JWT-only (Phase 1.d.2).** `middleware::authenticate` requires a Bearer JWT signed by the upstream Better Auth issuer. The middleware verifies the token via [`AppState::jwt_verifier`], then `db::users::find_or_provision_by_external_id(state.db, &sub, now_ms)` lazy-onboards the user on first request — a valid signature is the authoritative onboarding signal, so no separate `POST /api/v1/users` exists. Boot requires the full `WAVEFLOW_JWT_*` triple (`_JWKS_URL` / `_ISSUER` / `_AUDIENCE`); the legacy `X-User-Id` dev shim retired alongside `WAVEFLOW_DEV_AUTH`. - **Don't leak DB errors to unauthenticated probes.** `/ready` logs the sqlx error via `tracing::warn!` but returns a fixed sentinel body (`{status, db}`) so a load balancer never sees the connection-URL host or credentials. Apply the same discipline to any other unauthenticated endpoint. - **Migrations are immutable once merged.** They're embedded at compile time via `sqlx::migrate!("./migrations")` (`db::MIGRATOR`); the `_sqlx_migrations` table stores each file's checksum, so editing an applied migration makes the server refuse to start. Schema changes = a new dated migration file (`YYYYMMDDHHMMSS_name.sql`). Boot applies pending migrations *before* opening the listener, which is what makes `/ready` trustworthy. - **Schema parity with the desktop SQLite migrations.** Postgres tables mirror the shapes in the desktop repo's `src-tauri/migrations/app/` so `PostgresProfileRepository` and `SqliteProfileRepository` (in `waveflow-core`) satisfy the same trait against identical rows. Keep types compatible (e.g. `BIGSERIAL` ↔ SQLite `INTEGER PK`, epoch-millis `BIGINT` for timestamps). diff --git a/README.md b/README.md index d37f1a8..f8cf1a3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Self-hosted backend for [WaveFlow](https://github.com/InstaZDLL/WaveFlow). Powers multi-device library sync, browser playback, public shareable playlists, and (later) the mobile app. -> **Status:** Phase 1.b.4 — tenant-scoped profile CRUD landed (`POST /api/v1/users`, full `/api/v1/profiles/*` with the dev `X-User-Id` header shim). Phase 1.d will swap the shim for JWT verification against Better Auth's JWKS. Track progress against the Phase 1 milestone on the main repo. +> **Status:** Phase 1.d.2 — JWT auth is the only path. Bearer tokens issued by [`waveflow-web`](https://github.com/InstaZDLL/waveflow-web)'s Better Auth instance are verified against its JWKS endpoint, and the first authenticated request from a fresh signup lazy-provisions the `users` row. Track progress against the Phase 1 milestone on the main repo. ## Architecture @@ -33,10 +33,10 @@ cargo run - `GET /ready` — readiness, `200 {status: "ready", db: "ok"}` when `SELECT 1` round-trips, `503 {status: "not_ready", db: "unavailable"}` otherwise. The sqlx error detail stays in the `tracing::warn!` log so an unauthenticated probe (e.g. a load balancer) doesn't see the connection-URL host or credentials. - `GET /openapi.json` — OpenAPI 3.1 spec built from the handlers that carry both a `#[utoipa::path(...)]` annotation and a `routes!()` registration on the per-module `OpenApiRouter`. A plain `Router::route()` would mount the handler but leave it absent from the spec, so make sure new endpoints follow the same `routes!()` pattern as `/health` and `/ready`. - `GET /reference` — [Scalar](https://github.com/scalar/scalar) API reference UI. Modern, dark-mode-native, integrated search. The OpenAPI spec it renders is the same one served at `/openapi.json`. -- `POST /api/v1/users` — mint a user row, returns `{id}`. Gated by the dev-auth shim (see below). -- `/api/v1/profiles/*` — full CRUD scoped to the calling user via the `X-User-Id` header. Tenant isolation enforced at the storage layer (`PostgresProfileRepository::*_for_user`), not just at the handler. `DELETE` refuses 409 if it would leave the user with zero profiles — same invariant the desktop's selector enforces client-side. +- `/api/v1/profiles/*` — full CRUD scoped to the calling user via the `Authorization: Bearer ` header. Tenant isolation enforced at the storage layer (`PostgresProfileRepository::*_for_user`), not just at the handler. `DELETE` refuses 409 if it would leave the user with zero profiles — same invariant the desktop's selector enforces client-side. +- `/api/v1/profiles/{profile_id}/libraries/*`, `/.../tracks/*`, `/api/v1/profiles/{profile_id}/playlists/*` — same auth + tenant-scoping pattern, nested per the resource tree. -> ⚠️ **Dev auth shim — production-off by default.** `/api/v1/*` returns `503 Service Unavailable` until `WAVEFLOW_DEV_AUTH=1` is set explicitly. With the gate on, every data route reads its tenant id from a forgeable `X-User-Id` request header — fine for local dev against a private Postgres, **never safe to expose on the public internet**. Phase 1.d retires both the flag and the shim by replacing the middleware with JWT verification against Better Auth's JWKS endpoint. +> 🔒 **Auth: JWT-only.** Every `/api/v1/*` request must carry an `Authorization: Bearer ` header signed by the configured Better Auth issuer. Boot requires the full `WAVEFLOW_JWT_JWKS_URL` / `WAVEFLOW_JWT_ISSUER` / `WAVEFLOW_JWT_AUDIENCE` triple — a missing knob fails fast at startup. The first authenticated request from a fresh `sub` lazy-provisions the `users` row, so there is no separate onboarding endpoint to hit. ### Running the tests diff --git a/migrations/20260531000000_users_external_id_not_null.sql b/migrations/20260531000000_users_external_id_not_null.sql new file mode 100644 index 0000000..48fc35f --- /dev/null +++ b/migrations/20260531000000_users_external_id_not_null.sql @@ -0,0 +1,23 @@ +-- Phase 1.d.2: Better Auth is now the only configured auth path, +-- which means every `users` row exists because a Better Auth JWT +-- minted it (lazy-provisioned by `find_or_provision_by_external_id` +-- in the middleware). A NULL `external_id` is therefore a dangling +-- row — no JWT can ever authenticate against it — so the column +-- gets the NOT NULL constraint the lookup invariant always wanted. +-- +-- The previous migration (20260530000005_users_external_id) added +-- the column as nullable so the Phase 1.b `X-User-Id` shim could +-- mint users via `POST /api/v1/users` without an upstream account. +-- The shim retires with this PR, so the nullable variant is +-- unreachable from production code. +-- +-- Backfill: there is no install where this server runs in +-- production yet (1.c hasn't deployed), so the only rows with NULL +-- `external_id` are dev-time / test artifacts that the next test +-- run would have wiped anyway. Delete them outright rather than +-- inventing a synthetic external_id that no JWT could ever +-- resolve to. + +DELETE FROM users WHERE external_id IS NULL; + +ALTER TABLE users ALTER COLUMN external_id SET NOT NULL; diff --git a/src/api/libraries.rs b/src/api/libraries.rs index 153b2c9..8919fda 100644 --- a/src/api/libraries.rs +++ b/src/api/libraries.rs @@ -3,7 +3,7 @@ //! //! Same design as [`super::profiles`]: every handler reads //! [`UserId`] from the request extension that -//! `middleware::require_user_id` attached, threads the path's +//! `middleware::authenticate` attached, threads the path's //! `profile_id` straight through, and calls a `*_for_profile` method on //! [`PostgresLibraryRepository`]. The repository SQL validates the //! `library → profile → user` chain inline, so a request that targets @@ -134,12 +134,12 @@ pub fn router(state: AppState) -> OpenApiRouter { path = "/api/v1/profiles/{profile_id}/libraries", tag = "libraries", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ), responses( (status = 200, description = "Libraries under the profile, most-recently-updated first", body = Vec), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), )] @@ -172,14 +172,14 @@ async fn list_libraries( path = "/api/v1/profiles/{profile_id}/libraries", tag = "libraries", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ), request_body = CreateLibraryRequest, responses( (status = 201, description = "Library created", body = LibraryResponse), (status = 400, description = "Empty or whitespace-only `name` after trim"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "Profile not owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -234,13 +234,13 @@ async fn create_library( path = "/api/v1/profiles/{profile_id}/libraries/{id}", tag = "libraries", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("id" = i64, Path, description = "Library id"), ), responses( (status = 200, description = "Library found", body = LibraryResponse), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No library with that id under the profile owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -272,7 +272,7 @@ async fn get_library( path = "/api/v1/profiles/{profile_id}/libraries/{id}", tag = "libraries", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("id" = i64, Path, description = "Library id"), ), @@ -280,7 +280,7 @@ async fn get_library( responses( (status = 200, description = "Library updated", body = LibraryResponse), (status = 400, description = "`name` was supplied but is empty / whitespace-only after trim"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No library with that id under the profile owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -337,13 +337,13 @@ async fn update_library( path = "/api/v1/profiles/{profile_id}/libraries/{id}", tag = "libraries", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("id" = i64, Path, description = "Library id"), ), responses( (status = 204, description = "Library deleted"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No library with that id under the profile owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), diff --git a/src/api/mod.rs b/src/api/mod.rs index c233e51..8344378 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,34 +2,32 @@ //! //! The router is split per-resource so new endpoints land in their own //! file: `health.rs` covers liveness, `ready.rs` covers DB-aware -//! readiness, `users.rs` mints dev-shim users, `profiles.rs` covers -//! tenant-scoped profile CRUD, `libraries.rs` covers tenant-scoped -//! library CRUD nested under a profile, `tracks.rs` covers -//! tenant-scoped track CRUD nested under a library, `playlists.rs` -//! covers tenant-scoped playlist CRUD nested under a profile (same -//! depth as library). Future modules will cover `auth`, `sync`, -//! `stream` (per RFC-001 §6 / §7). +//! readiness, `profiles.rs` covers tenant-scoped profile CRUD, +//! `libraries.rs` covers tenant-scoped library CRUD nested under a +//! profile, `tracks.rs` covers tenant-scoped track CRUD nested under +//! a library, `playlists.rs` covers tenant-scoped playlist CRUD +//! nested under a profile. Future modules will cover `sync`, `stream` +//! (per RFC-001 §6 / §7). //! //! Versioning policy: every resource module mounts under `/api/v1/` -//! (except `/health` and `/ready`, which are unversioned by convention -//! — they're infrastructure probes, not part of the public API contract). +//! (except `/health` and `/ready`, which are unversioned by +//! convention — they're infrastructure probes, not part of the +//! public API contract). //! //! Each module returns a `utoipa_axum::OpenApiRouter` so endpoints //! tagged with `#[utoipa::path]` show up in the generated OpenAPI spec //! automatically — no parallel `paths(...)` list to keep in sync. //! -//! Auth: every `/api/v1/profiles/*` (and its nested resources) -//! rides behind [`crate::middleware::authenticate`], which tries -//! JWT verification first and falls back to the dev `X-User-Id` -//! shim. Phase 1.d.2 retires the shim once Better Auth is the only -//! configured auth path. `/api/v1/users` stays open when the dev -//! shim is enabled (it's the test/bootstrap user-mint path) and -//! 503's otherwise. +//! Auth: every `/api/v1/*` data route rides behind +//! [`crate::middleware::authenticate`] which requires a valid Bearer +//! JWT (Phase 1.d.2 retired the dev `X-User-Id` shim). User rows are +//! lazy-provisioned on first authenticated request via the JWT path, +//! so there's no separate user-creation endpoint to gate. -use axum::{extract::Request, http::StatusCode, middleware, middleware::Next, response::Response}; +use axum::middleware; use utoipa_axum::router::OpenApiRouter; -use crate::{middleware as auth_middleware, AppState, Config}; +use crate::{middleware as auth_middleware, AppState}; mod health; mod libraries; @@ -37,37 +35,17 @@ mod playlists; mod profiles; mod ready; mod tracks; -mod users; /// Combined router for every API module. Mounted at the root by /// [`crate::app`]; sub-routers prefix their own paths and contribute /// their `#[utoipa::path]` declarations to the merged OpenAPI spec. /// -/// `/api/v1/profiles/*`, its nested resources, and -/// `/api/v1/profiles/{profile_id}/playlists/*` all ride behind the -/// unified [`crate::middleware::authenticate`] layer. That layer -/// short-circuits to **503** when neither auth path is configured — -/// see [`Config::auth_disabled_at_boot`]. Without that gate a forged -/// `X-User-Id` header on a publicly-exposed instance would walk -/// straight into another tenant's data. -/// -/// `/api/v1/users` stays open when [`Config::dev_auth_enabled`] is -/// true (it's the test/bootstrap user-mint path) and answers **503** -/// otherwise. The JWT path doesn't gate it because production -/// onboarding happens at Better Auth, not at this endpoint — -/// Phase 1.d.2 will retire it alongside the shim. -pub fn router(state: AppState, config: &Config) -> OpenApiRouter { - let users_router = if config.dev_auth_enabled { - users::router(state.clone()) - } else { - users::router(state.clone()).layer(middleware::from_fn(reject_dev_auth_disabled)) - }; - - // Single auth layer shared across every tenant-scoped resource - // — replaces the per-resource fork between `require_user_id` - // and `reject_dev_auth_disabled` that pre-PR3 mod.rs carried. - // The middleware reads `state.jwt_verifier` + `dev_auth_enabled` - // and decides per request. +/// `/api/v1/*` rides behind the unified +/// [`crate::middleware::authenticate`] layer — the only auth path +/// after Phase 1.d.2. Boot requires the `WAVEFLOW_JWT_*` triple, so +/// reaching this function with a non-functional verifier is +/// impossible. +pub fn router(state: AppState) -> OpenApiRouter { let auth_layer = middleware::from_fn_with_state(state.clone(), auth_middleware::authenticate); let profiles_router = profiles::router(state.clone()).layer(auth_layer.clone()); @@ -79,18 +57,8 @@ pub fn router(state: AppState, config: &Config) -> OpenApiRouter { // Probes — no auth, no gate. .merge(health::router()) .merge(ready::router(state)) - .merge(users_router) .merge(profiles_router) .merge(libraries_router) .merge(tracks_router) .merge(playlists_router) } - -/// Reject every request with **503 Service Unavailable**. Mounted on -/// `/api/v1/*` when `WAVEFLOW_DEV_AUTH` isn't `"1"` — see the rationale -/// in [`crate::Config::dev_auth_enabled`]. The 503 (vs. 401) intentionally -/// hides that the endpoints exist: a production probe can't tell -/// whether the dev shim is even compiled in. -async fn reject_dev_auth_disabled(_req: Request, _next: Next) -> Result { - Err(StatusCode::SERVICE_UNAVAILABLE) -} diff --git a/src/api/playlists.rs b/src/api/playlists.rs index ffdd629..04c2914 100644 --- a/src/api/playlists.rs +++ b/src/api/playlists.rs @@ -148,12 +148,12 @@ pub fn router(state: AppState) -> OpenApiRouter { path = "/api/v1/profiles/{profile_id}/playlists", tag = "playlists", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ), responses( (status = 200, description = "Playlists under the profile, in sidebar order", body = Vec), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), )] @@ -187,14 +187,14 @@ async fn list_playlists( path = "/api/v1/profiles/{profile_id}/playlists", tag = "playlists", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ), request_body = CreatePlaylistRequest, responses( (status = 201, description = "Playlist created", body = PlaylistResponse), (status = 400, description = "Empty / whitespace-only `name` after trim"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "Profile not owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -243,13 +243,13 @@ async fn create_playlist( path = "/api/v1/profiles/{profile_id}/playlists/{id}", tag = "playlists", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("id" = i64, Path, description = "Playlist id"), ), responses( (status = 200, description = "Playlist found", body = PlaylistResponse), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No playlist with that id under the profile owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -279,7 +279,7 @@ async fn get_playlist( path = "/api/v1/profiles/{profile_id}/playlists/{id}", tag = "playlists", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("id" = i64, Path, description = "Playlist id"), ), @@ -287,7 +287,7 @@ async fn get_playlist( responses( (status = 200, description = "Playlist updated", body = PlaylistResponse), (status = 400, description = "`name` was supplied but is empty / whitespace-only after trim"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No playlist with that id under the profile owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -340,13 +340,13 @@ async fn update_playlist( path = "/api/v1/profiles/{profile_id}/playlists/{id}", tag = "playlists", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("id" = i64, Path, description = "Playlist id"), ), responses( (status = 204, description = "Playlist deleted"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No playlist with that id under the profile owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), diff --git a/src/api/profiles.rs b/src/api/profiles.rs index 748c514..32b5dd0 100644 --- a/src/api/profiles.rs +++ b/src/api/profiles.rs @@ -1,7 +1,7 @@ //! `/api/v1/profiles/*` — tenant-scoped CRUD over the `profile` table. //! //! Every handler reads the owning user id from the [`UserId`] -//! extension that `middleware::require_user_id` attached to the +//! extension that `middleware::authenticate` attached to the //! request, and dispatches to a `*_for_user` method on //! [`PostgresProfileRepository`]. The trait surface from //! `waveflow-core` is *not* used here — it has no notion of tenancy @@ -87,11 +87,11 @@ pub fn router(state: AppState) -> OpenApiRouter { path = "/api/v1/profiles", tag = "profiles", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ), responses( (status = 200, description = "Owned profiles, most-recently-used first", body = Vec), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), )] @@ -112,21 +112,24 @@ async fn list_profiles( } } -/// Create a profile owned by the calling user. Returns 409 when the -/// FK rejects — the request carried a user id that no longer exists. +/// Create a profile owned by the calling user. Returns 409 if the +/// `profile.user_id` FK rejects — race between the middleware's +/// lazy-provision and a concurrent users-row delete. Vanishingly +/// unlikely with the current schema (users rows are never deleted), +/// but kept defensive in case the lifecycle gains a delete path. #[utoipa::path( post, path = "/api/v1/profiles", tag = "profiles", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ), request_body = CreateProfileRequest, responses( (status = 201, description = "Profile created", body = ProfileResponse), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), - (status = 409, description = "X-User-Id does not match an existing users row"), + (status = 409, description = "Authenticated user id no longer matches an existing users row (race)"), ), )] async fn create_profile( @@ -146,16 +149,19 @@ async fn create_profile( Ok(id) => id, Err(err) => { // sqlx::Error::Database with code 23503 is the FK - // violation — the X-User-Id header carried a user that - // doesn't exist (or was deleted between the middleware - // check and the insert). Surface that distinctly so the - // client can re-bootstrap a user. + // violation — the authenticated user id resolved by the + // middleware no longer matches an existing users row (a + // concurrent delete raced us). Surface it distinctly so + // the client can re-auth from scratch. if matches!( &err, waveflow_core::error::CoreError::Database(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("23503"), ) { - return (StatusCode::CONFLICT, "X-User-Id has no matching user row") + return ( + StatusCode::CONFLICT, + "authenticated user id has no matching users row", + ) .into_response(); } tracing::error!(error = %err, user_id, "create profile failed"); @@ -200,12 +206,12 @@ async fn create_profile( path = "/api/v1/profiles/{id}", tag = "profiles", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("id" = i64, Path, description = "Profile id"), ), responses( (status = 200, description = "Profile found", body = ProfileResponse), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), (status = 404, description = "No profile with that id owned by the calling user"), ), @@ -233,13 +239,13 @@ async fn get_profile( path = "/api/v1/profiles/{id}", tag = "profiles", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("id" = i64, Path, description = "Profile id"), ), request_body = UpdateProfileRequest, responses( (status = 200, description = "Profile renamed", body = ProfileResponse), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), (status = 404, description = "No profile with that id owned by the calling user"), ), @@ -274,12 +280,12 @@ async fn update_profile( path = "/api/v1/profiles/{id}", tag = "profiles", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("id" = i64, Path, description = "Profile id"), ), responses( (status = 204, description = "Profile deleted"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), (status = 404, description = "No profile with that id owned by the calling user"), (status = 409, description = "Refused — would leave the user with zero profiles"), diff --git a/src/api/tracks.rs b/src/api/tracks.rs index 976202b..2ada64a 100644 --- a/src/api/tracks.rs +++ b/src/api/tracks.rs @@ -148,13 +148,13 @@ pub fn router(state: AppState) -> OpenApiRouter { path = "/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks", tag = "tracks", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("library_id" = i64, Path, description = "Owning library id"), ), responses( (status = 200, description = "Tracks under the library, most-recently-added first", body = Vec), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), )] @@ -191,7 +191,7 @@ async fn list_tracks( path = "/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks", tag = "tracks", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("library_id" = i64, Path, description = "Owning library id"), ), @@ -199,7 +199,7 @@ async fn list_tracks( responses( (status = 201, description = "Track created", body = TrackResponse), (status = 400, description = "Empty / whitespace-only `title` or `file_path` after trim"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "Library / profile not owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -273,14 +273,14 @@ async fn create_track( path = "/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}", tag = "tracks", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("library_id" = i64, Path, description = "Owning library id"), ("id" = i64, Path, description = "Track id"), ), responses( (status = 200, description = "Track found", body = TrackResponse), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No track with that id under the (library, profile) owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -320,7 +320,7 @@ async fn get_track( path = "/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}", tag = "tracks", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("library_id" = i64, Path, description = "Owning library id"), ("id" = i64, Path, description = "Track id"), @@ -329,7 +329,7 @@ async fn get_track( responses( (status = 200, description = "Track updated", body = TrackResponse), (status = 400, description = "`title` was supplied but is empty / whitespace-only after trim"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No track with that id under the (library, profile) owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), @@ -392,14 +392,14 @@ async fn update_track( path = "/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}", tag = "tracks", params( - ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), ("profile_id" = i64, Path, description = "Owning profile id"), ("library_id" = i64, Path, description = "Owning library id"), ("id" = i64, Path, description = "Track id"), ), responses( (status = 204, description = "Track deleted"), - (status = 401, description = "Missing or invalid X-User-Id"), + (status = 401, description = "Missing or invalid bearer token"), (status = 404, description = "No track with that id under the (library, profile) owned by the calling user"), (status = 500, description = "Database or internal failure (body is a plain-text reason)"), ), diff --git a/src/api/users.rs b/src/api/users.rs deleted file mode 100644 index 7631c28..0000000 --- a/src/api/users.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! POST /api/v1/users — dev-only user creation. -//! -//! Phase 1.b ships an `X-User-Id`-header auth shim, which is only -//! useful once the caller has *a* user id to send. This endpoint is -//! the boot-strap: anyone can hit it (no auth) to mint a fresh row -//! in the `users` table and get the assigned id back. -//! -//! Phase 1.d retires this entirely — Better Auth owns user creation -//! once JWT verification lands. The endpoint stays usable in dev -//! against a local Postgres without standing up the full auth stack. - -use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use utoipa_axum::{router::OpenApiRouter, routes}; - -use crate::{db, AppState}; - -#[derive(Debug, Default, Deserialize, ToSchema)] -pub struct CreateUserRequest { - /// Stable identifier from the upstream auth provider — `sub` - /// claim of a Better Auth-issued JWT in 1.d. Trimmed and - /// validated server-side (empty / whitespace-only is rejected - /// 400). Optional in 1.d.1 so the dev `X-User-Id` shim path - /// can still mint users without an upstream account; the JWT - /// middleware (1.d.1-PR2) refuses to authenticate a row whose - /// `external_id` is NULL. - pub external_id: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct CreateUserResponse { - /// `BIGSERIAL` row id of the new user. Stash this client-side and - /// send it in the `X-User-Id` header on subsequent calls — Better - /// Auth-issued JWTs replace this in Phase 1.d. - #[schema(example = 1)] - pub id: i64, -} - -pub fn router(state: AppState) -> OpenApiRouter { - OpenApiRouter::new() - .routes(routes!(create_user)) - .with_state(state) -} - -/// Mint a new user row and return its id. Phase 1.d.1 widens the -/// payload to accept an optional `external_id` — the seed for the -/// upcoming JWT auth path. The dev `X-User-Id` shim can still POST -/// with an empty body and get a usable id; once Better Auth lands -/// (1.d.2) the `external_id` becomes mandatory and this endpoint -/// retires alongside the shim. -#[utoipa::path( - post, - path = "/api/v1/users", - tag = "users", - // Handler extractor is `Option>` so a - // POST with no body / missing Content-Type still mints a user. - // The `Option<…>` wrapper makes utoipa emit `requestBody.required: - // false` in the OpenAPI spec; the bare `request_body = - // CreateUserRequest` shorthand would generate `required: true` and - // misrepresent the contract. utoipa-axum 0.2 doesn't accept an - // explicit `required = false` attribute (only `content`, - // `description`, `content_type`, `example`, `examples`, - // `extensions`), so the type-level wrap is the only way to express - // optionality. - request_body = Option, - responses( - (status = 201, description = "User created", body = CreateUserResponse), - (status = 400, description = "`external_id` supplied but empty / whitespace-only after trim"), - (status = 409, description = "`external_id` collides with an existing users row"), - (status = 500, description = "Database or internal failure (body is a plain-text reason)"), - ), -)] -async fn create_user( - State(state): State, - body: Option>, -) -> impl IntoResponse { - // axum's `Option>` covers both "no body" (legacy callers - // hitting this endpoint without Content-Type) and "empty JSON" - // — either way the resulting `CreateUserRequest` is fine since - // every field is optional. - let req = body.map(|Json(r)| r).unwrap_or_default(); - - // Trim + validate the optional external_id at the boundary so a - // whitespace-only payload can't slip past the UNIQUE index and - // sit in the DB as a non-NULL-but-blank string. - let external_id = match req.external_id { - Some(s) => { - let trimmed = s.trim(); - if trimmed.is_empty() { - return (StatusCode::BAD_REQUEST, "external_id must not be blank").into_response(); - } - Some(trimmed.to_string()) - } - None => None, - }; - - let now = Utc::now().timestamp_millis(); - match db::users::create(&state.db, now, external_id.as_deref()).await { - Ok(id) => (StatusCode::CREATED, Json(CreateUserResponse { id })).into_response(), - Err(err) => { - // Postgres unique-violation (SQLSTATE 23505) on - // `external_id` is the "you already minted this" case - // — distinct from a transient 500. Surface as 409 so - // the caller can re-bootstrap rather than retry. - if matches!( - &err, - sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505"), - ) { - return ( - StatusCode::CONFLICT, - "external_id already taken by another user", - ) - .into_response(); - } - tracing::error!(error = %err, "user insert failed"); - (StatusCode::INTERNAL_SERVER_ERROR, "failed to create user").into_response() - } - } -} diff --git a/src/config.rs b/src/config.rs index 27fe283..f9e01f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,55 +42,20 @@ pub struct Config { /// server behind a pooler that demands a smaller pool here. pub db_max_connections: u32, - /// `WAVEFLOW_DEV_AUTH=1` — opt in to the Phase 1.b `X-User-Id` - /// header auth shim on `/api/v1/profiles/*`. Default: `false`. - /// - /// The shim is intentionally trivial to forge (any caller can - /// send any `i64`), so it must NEVER be on in production. Phase - /// 1.d.1-PR3 widens the auth surface to accept either the shim - /// OR a JWT-verified `Authorization: Bearer` header, and the - /// production gate is now "both auth paths off" — see - /// [`Self::auth_disabled_at_boot`]. Keeping the shim behind an - /// opt-in env var means a stray container on a public LAN can't - /// accidentally expose tenant data to anyone who guesses an - /// integer id. - pub dev_auth_enabled: bool, - - /// `WAVEFLOW_JWT_JWKS_URL` — URL of the upstream JWKS document. - /// When set together with `jwt_issuer` and `jwt_audience`, the - /// boot path constructs a [`crate::auth::JwtVerifier`] and the - /// middleware accepts `Authorization: Bearer …` tokens. - /// `None` leaves the JWT path off. - pub jwt_jwks_url: Option, + /// `WAVEFLOW_JWT_JWKS_URL` — URL of the upstream JWKS document + /// (e.g. `https://auth.waveflow.app/api/auth/jwks`). Required at + /// boot — the legacy `X-User-Id` dev shim retired in Phase 1.d.2, + /// so JWT verification is the only auth path the server offers. + pub jwt_jwks_url: String, /// `WAVEFLOW_JWT_ISSUER` — expected `iss` claim on verified - /// tokens. Paired with [`Self::jwt_jwks_url`]; both must be - /// `Some` for the JWT auth path to activate. - pub jwt_issuer: Option, + /// tokens. Must match Better Auth's `BETTER_AUTH_URL`. Required. + pub jwt_issuer: String, /// `WAVEFLOW_JWT_AUDIENCE` — expected `aud` claim on verified - /// tokens. Paired with [`Self::jwt_jwks_url`]; both must be - /// `Some` for the JWT auth path to activate. - pub jwt_audience: Option, -} - -impl Config { - /// True when neither auth path is configured — every `/api/v1/*` - /// request must short-circuit to 503. The production-default - /// state on a fresh binary: the operator hasn't yet pointed at - /// a JWKS, hasn't yet enabled the dev shim, and we'd rather - /// fail closed than ship an open server. - pub fn auth_disabled_at_boot(&self) -> bool { - !self.dev_auth_enabled && !self.has_jwt_config() - } - - /// True when every JWT env var that the verifier needs is set. - /// Boot uses this to decide whether to build a verifier; the - /// middleware uses [`crate::AppState::jwt_verifier`] which is - /// `Some` exactly when this holds at boot. - pub fn has_jwt_config(&self) -> bool { - self.jwt_jwks_url.is_some() && self.jwt_issuer.is_some() && self.jwt_audience.is_some() - } + /// tokens. Must match `WAVEFLOW_JWT_AUDIENCE` on the auth server + /// side (defaults there to `"waveflow-server"`). Required. + pub jwt_audience: String, } impl Config { @@ -128,40 +93,24 @@ impl Config { anyhow::bail!("invalid WAVEFLOW_DB_MAX_CONNECTIONS: must be > 0"); } - // Strict equality on "1" — `true`, `yes`, `on` etc. don't - // count. Footgun-resistant: the only way to enable the - // forgeable-header shim is to send the exact string the - // README documents. - let dev_auth_enabled = std::env::var("WAVEFLOW_DEV_AUTH").as_deref() == Ok("1"); - - // JWT auth knobs. All three must land together — a partial - // config would build a verifier with wrong / missing - // claims-validation parameters, which fails closed but - // confusingly (every token rejected with InvalidClaims). - // Boot fails fast instead. - let jwt_jwks_url = std::env::var("WAVEFLOW_JWT_JWKS_URL").ok(); - let jwt_issuer = std::env::var("WAVEFLOW_JWT_ISSUER").ok(); - let jwt_audience = std::env::var("WAVEFLOW_JWT_AUDIENCE").ok(); - let jwt_partial = [ - jwt_jwks_url.is_some(), - jwt_issuer.is_some(), - jwt_audience.is_some(), - ]; - let some_count = jwt_partial.iter().filter(|x| **x).count(); - if some_count != 0 && some_count != 3 { - anyhow::bail!( - "JWT auth requires WAVEFLOW_JWT_JWKS_URL, WAVEFLOW_JWT_ISSUER and \ - WAVEFLOW_JWT_AUDIENCE to all be set, or all unset. Currently {} of 3 are set.", - some_count - ); - } + // JWT triple — all three are required for the server to + // boot. The dev `X-User-Id` shim retired in Phase 1.d.2, so + // there's no longer a "boot without JWT" mode to fall back + // to. Failing at boot (rather than silently 503-ing every + // request) tells the operator immediately that the + // deployment is misconfigured. + let jwt_jwks_url = std::env::var("WAVEFLOW_JWT_JWKS_URL") + .map_err(|_| anyhow::anyhow!("WAVEFLOW_JWT_JWKS_URL is required"))?; + let jwt_issuer = std::env::var("WAVEFLOW_JWT_ISSUER") + .map_err(|_| anyhow::anyhow!("WAVEFLOW_JWT_ISSUER is required"))?; + let jwt_audience = std::env::var("WAVEFLOW_JWT_AUDIENCE") + .map_err(|_| anyhow::anyhow!("WAVEFLOW_JWT_AUDIENCE is required"))?; Ok(Self { bind_addr, request_timeout_secs, database_url, db_max_connections, - dev_auth_enabled, jwt_jwks_url, jwt_issuer, jwt_audience, diff --git a/src/db.rs b/src/db.rs index a23dfc0..47b4bba 100644 --- a/src/db.rs +++ b/src/db.rs @@ -76,31 +76,10 @@ pub async fn ping(pool: &PgPool) -> Result<(), sqlx::Error> { /// User-table helpers. Keeps the raw SQL out of handlers — same /// boundary the project's no-SQL-in-handlers rule enforces for the -/// `/ready` probe. Returns the new user id so the dev caller can -/// stash it for the subsequent `X-User-Id` header. +/// `/ready` probe. pub mod users { use sqlx::PgPool; - /// Insert a new user row and return its id. `BIGSERIAL` means - /// we never pass the id in — Postgres allocates from its - /// sequence. `external_id` is the seed for Phase 1.d's JWT - /// auth: it gets matched against the verified `sub` claim of - /// inbound Bearer tokens. Pass `None` from the dev `X-User-Id` - /// shim path; pass `Some(sub)` once Better Auth lands. - pub async fn create( - pool: &PgPool, - created_at: i64, - external_id: Option<&str>, - ) -> Result { - sqlx::query_scalar::<_, i64>( - "INSERT INTO users (created_at, external_id) VALUES ($1, $2) RETURNING id", - ) - .bind(created_at) - .bind(external_id) - .fetch_one(pool) - .await - } - /// Resolve a JWT `sub` to an internal `users.id`, inserting a /// row if the sub is unknown. Used by the JWT middleware /// (Phase 1.c.3a) so a fresh Better Auth signup doesn't require diff --git a/src/lib.rs b/src/lib.rs index fd56179..b5ee9e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,15 +63,12 @@ pub struct ApiDoc; #[derive(Clone)] pub struct AppState { pub db: PgPool, - /// Built when the boot config carries a full JWT triple - /// (`WAVEFLOW_JWT_JWKS_URL` + `_ISSUER` + `_AUDIENCE`). `None` - /// keeps the JWT path off — the middleware then falls back to - /// the `X-User-Id` shim or to 503 depending on the config. - pub jwt_verifier: Option>, - /// Mirror of `Config::dev_auth_enabled`. The middleware reads - /// this via state instead of threading the whole `Config` so - /// the per-request hot path stays a single field load. - pub dev_auth_enabled: bool, + /// Verifier built at boot from the required `WAVEFLOW_JWT_*` + /// triple. Phase 1.d.2 made this non-optional — the dev + /// `X-User-Id` shim retired, so the JWT path is the only auth + /// channel and a missing verifier means the server shouldn't + /// have booted in the first place. + pub jwt_verifier: std::sync::Arc, } /// Header used for inbound + propagated request IDs. UUIDv4 by default @@ -137,7 +134,7 @@ pub fn app(config: Config, state: AppState) -> Router { // `/reference`; both stay outside the `/api/v1/*` namespace so a // future Better-Auth middleware (1.d) gates only the data routes. let (api_router, openapi) = utoipa_axum::router::OpenApiRouter::with_openapi(ApiDoc::openapi()) - .merge(api::router(state, &config)) + .merge(api::router(state)) .split_for_parts(); Router::new() diff --git a/src/main.rs b/src/main.rs index 90ed457..363897d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,49 +44,20 @@ async fn main() -> anyhow::Result<()> { // (the operator sees the error immediately, not on the first // request). The verifier itself doesn't fetch the JWKS until a // token actually needs verifying. - let jwt_verifier = if config.has_jwt_config() { - let verifier = JwtVerifier::new(JwtVerifierConfig { - jwks_url: config - .jwt_jwks_url - .clone() - .expect("has_jwt_config checked above"), - issuer: config - .jwt_issuer - .clone() - .expect("has_jwt_config checked above"), - audience: config - .jwt_audience - .clone() - .expect("has_jwt_config checked above"), - }) - .map_err(|err| anyhow::anyhow!("JWT verifier init failed: {err}"))?; - info!("JWT auth path enabled"); - Some(Arc::new(verifier)) - } else { - None - }; - - if config.auth_disabled_at_boot() { - // The server still boots — `/health` and `/ready` stay up - // so a deploy in this state can be probed — but every - // `/api/v1/*` request will short-circuit to 503. Warn loudly - // so an operator who flipped a wrong env var sees it without - // having to read the body of a request. - tracing::warn!( - "no auth configured: every /api/v1/* request will return 503. \ - Set WAVEFLOW_DEV_AUTH=1 (dev only) or the WAVEFLOW_JWT_* triple." - ); - } + let verifier = JwtVerifier::new(JwtVerifierConfig { + jwks_url: config.jwt_jwks_url.clone(), + issuer: config.jwt_issuer.clone(), + audience: config.jwt_audience.clone(), + }) + .map_err(|err| anyhow::anyhow!("JWT verifier init failed: {err}"))?; + let jwt_verifier = Arc::new(verifier); + info!(jwks_url = %config.jwt_jwks_url, "JWT auth path enabled"); let listener = tokio::net::TcpListener::bind(config.bind_addr).await?; let local = listener.local_addr()?; info!(addr = %local, "waveflow-server listening"); - let state = AppState { - db, - jwt_verifier, - dev_auth_enabled: config.dev_auth_enabled, - }; + let state = AppState { db, jwt_verifier }; axum::serve( listener, app(config, state).into_make_service_with_connect_info::(), diff --git a/src/middleware.rs b/src/middleware.rs index a61d4e8..e1b4efd 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -1,17 +1,14 @@ //! HTTP middleware. //! -//! Two authentication paths live here: the dev-only `X-User-Id` -//! header shim (Phase 1.b) and the Bearer-JWT verifier (Phase -//! 1.d.1-PR3). Both feed the same [`UserId`] extension shape so the -//! handlers downstream can't tell them apart. -//! -//! Phase 1.d.2 retires the shim entirely once Better Auth is deployed -//! as the only auth path — this module shrinks to just the JWT -//! middleware then. +//! Single auth path: every `/api/v1/*` request must carry a +//! Bearer JWT signed by the upstream Better Auth instance. The dev +//! `X-User-Id` header shim retired in Phase 1.d.2 along with +//! `POST /api/v1/users`; lazy auto-provisioning via the JWT path is +//! the only way to land a row in `users`. use axum::{ extract::{Request, State}, - http::{HeaderMap, HeaderValue, StatusCode}, + http::{HeaderValue, StatusCode}, middleware::Next, response::Response, }; @@ -28,108 +25,37 @@ use crate::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UserId(pub i64); -/// Header name used by the dev shim. **NOT** present in 1.d once -/// Better Auth lands — the JWT bearer header replaces it. Keeping -/// the constant here so a future grep for "x-user-id" surfaces every -/// touch point in one shot. -pub const X_USER_ID_HEADER: &str = "x-user-id"; - -/// Middleware: pull `X-User-Id` off the request, parse it as an -/// `i64`, and attach a [`UserId`] extension. Reject 401 if missing or -/// malformed so a handler can never run against an unscoped query by -/// accident. +/// Bearer-JWT auth middleware for `/api/v1/*`. Per-request flow: /// -/// The middleware does NOT verify the id exists in the `users` table -/// — that's enforced at the storage layer by `profile.user_id`'s FK, -/// which rejects an insert with a dangling user. The trade-off keeps -/// the middleware free of a DB round-trip per request; the loud -/// failure mode (insert) is acceptable in a dev shim. -pub async fn require_user_id(mut request: Request, next: Next) -> Result { - // Delegates to the shared [`parse_x_user_id_header`] helper — - // the `i64 > 0` invariant + the missing/malformed → 401 mapping - // live there. Phase 1.d.1-PR3's `authenticate` middleware uses - // the same helper, so there's exactly one place to audit when - // the shim's contract needs revisiting. 1.d.2 will delete both - // this function AND the helper. - let user_id = parse_x_user_id_header(request.headers())?; - request.extensions_mut().insert(UserId(user_id)); - Ok(next.run(request).await) -} - -/// Unified auth middleware for `/api/v1/*`. Tries Bearer JWT first -/// (when [`AppState::jwt_verifier`] is `Some`), falls back to the -/// dev `X-User-Id` shim (when [`AppState::dev_auth_enabled`]) and -/// short-circuits to **503** when neither path is configured — the -/// production gate documented in [`crate::Config::auth_disabled_at_boot`]. -/// -/// Per-request flow: -/// -/// 1. **No auth configured at all** → 503. The state matches a -/// fresh boot where the operator hasn't pointed at a JWKS and -/// hasn't flipped the shim on; failing closed is the only safe -/// default. -/// 2. **JWT configured, `Authorization` present** → verify the token, -/// resolve `sub` → `users.id`, attach [`UserId`]. +/// 1. **No `Authorization` header** → 401. The server is configured +/// (boot would have failed otherwise), so the request, not the +/// server, is at fault. +/// 2. **Verify the token** via [`AppState::jwt_verifier`]. /// - Bad signature / claims / `kid` → 401 (no body detail, the /// reason lands in `tracing::warn!`). /// - JWKS fetch failure → 503. Routes around the instance while /// the upstream is unreachable, lets the load balancer pick a /// healthy peer. -/// - `sub` has no `users` row yet → lazy-provision it via -/// [`db::users::find_or_provision_by_external_id`] and attach -/// the freshly-minted [`UserId`]. The verified JWT is the -/// authoritative onboarding signal; no separate `POST /users` -/// is needed after a Better Auth signup (Phase 1.c.3a). -/// - DB error while provisioning → 500. The signature was -/// valid, so this is server-side fault, not a client problem. -/// 3. **JWT configured, NO `Authorization`** → fall through to the -/// shim path if it's enabled; otherwise 401. -/// 4. **Shim path** — same parse as the legacy `require_user_id`: -/// `X-User-Id` must be an `i64 > 0`. Reject 401 if missing or -/// malformed. -/// -/// Order matters: JWT before shim means a request that carries both -/// headers gets authenticated by the cryptographically-trusted side, -/// not the forgeable one. Phase 1.d.2 deletes the shim branch -/// entirely. +/// 3. **Resolve `sub` → `users.id`** via +/// [`db::users::find_or_provision_by_external_id`]. +/// - Hit → attach the existing [`UserId`]. +/// - Miss → lazy-provision a fresh row (a valid JWT from the +/// configured issuer IS the authoritative onboarding signal, +/// so a separate `POST /users` isn't needed). +/// - DB error → 500. Signature was valid, so this is server-side +/// fault, not a client problem. pub async fn authenticate( State(state): State, mut request: Request, next: Next, ) -> Result { - // Production gate: no auth configured at boot → every request - // 503. Matches the legacy `reject_dev_auth_disabled` behaviour - // for the case where the shim was off, generalised to the JWT - // path being off too. - if state.jwt_verifier.is_none() && !state.dev_auth_enabled { - return Err(StatusCode::SERVICE_UNAVAILABLE); - } - - // JWT path — try first whenever the verifier is configured AND - // the client supplied an `Authorization` header. Missing header - // falls through to the shim path so an existing dev client that - // only knows `X-User-Id` keeps working during the transition. - if let Some(verifier) = state.jwt_verifier.as_ref() { - if let Some(auth_header) = request.headers().get(axum::http::header::AUTHORIZATION) { - let user_id = resolve_bearer(verifier, &state, auth_header).await?; - request.extensions_mut().insert(UserId(user_id)); - return Ok(next.run(request).await); - } - } - - // Shim path — only reachable when `dev_auth_enabled`. The legacy - // `X-User-Id` parse stays bit-for-bit identical so the existing - // 1.b.5 test suite keeps passing. - if state.dev_auth_enabled { - let user_id = parse_x_user_id_header(request.headers())?; - request.extensions_mut().insert(UserId(user_id)); - return Ok(next.run(request).await); - } + let Some(auth_header) = request.headers().get(axum::http::header::AUTHORIZATION) else { + return Err(StatusCode::UNAUTHORIZED); + }; - // JWT configured but the client didn't carry `Authorization`, - // and the shim is off — surface 401 rather than 503 since the - // server itself IS configured (just the request wasn't). - Err(StatusCode::UNAUTHORIZED) + let user_id = resolve_bearer(&state.jwt_verifier, &state, auth_header).await?; + request.extensions_mut().insert(UserId(user_id)); + Ok(next.run(request).await) } /// Verify a `Authorization` header against the configured JWT @@ -172,13 +98,7 @@ async fn resolve_bearer( // Resolve sub → users.id, lazy-provisioning on cache miss. The // verified JWT is the authoritative statement that the sub is a // real user (Better Auth signed it), so a missing row means - // "first request from a fresh signup", not "intruder". Inserting - // here keeps the auth flow single-round-trip: no separate - // /users POST is needed after Better Auth's signUp.email. - // - // The UPSERT is idempotent on the UNIQUE(external_id) index — - // a concurrent first request from the same user collapses - // cleanly to one row. + // "first request from a fresh signup", not "intruder". let created_at_ms = chrono::Utc::now().timestamp_millis(); let user_id = db::users::find_or_provision_by_external_id(&state.db, &claims.sub, created_at_ms) @@ -190,25 +110,3 @@ async fn resolve_bearer( Ok(user_id) } - -/// Legacy `X-User-Id` parse, factored out so [`require_user_id`] -/// (still used by the existing test surface) and [`authenticate`] -/// share one implementation. Phase 1.d.2 deletes this alongside the -/// `require_user_id` middleware itself. -fn parse_x_user_id_header(headers: &HeaderMap) -> Result { - let value = headers - .get(X_USER_ID_HEADER) - .ok_or(StatusCode::UNAUTHORIZED)?; - - let user_id: i64 = value - .to_str() - .ok() - .and_then(|s| s.parse().ok()) - .ok_or(StatusCode::UNAUTHORIZED)?; - - if user_id <= 0 { - return Err(StatusCode::UNAUTHORIZED); - } - - Ok(user_id) -} diff --git a/tests/jwt_middleware.rs b/tests/jwt_middleware.rs index cda4e23..84d24a8 100644 --- a/tests/jwt_middleware.rs +++ b/tests/jwt_middleware.rs @@ -10,51 +10,28 @@ //! Why profiles: it's the smallest CRUD surface that requires //! authentication. A 401 vs 200 there is unambiguous proof of the //! middleware's decision. +//! +//! Phase 1.d.2 collapsed the auth surface to JWT-only; the `*_with_shim` +//! variants of these tests retired alongside the shim itself. -mod jwks_harness; mod support; use jsonwebtoken::Header; -use jwks_harness::{good_claims, header_with_kid, JwksHarness, TEST_KID}; use reqwest::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; -use support::{spawn_app_with_jwt, spawn_app_with_jwt_and_shim}; - -/// Bootstrap helper — mint a user with the supplied `external_id`, -/// return its id. JWT-only test mode can't hit `POST /api/v1/users` -/// (it's gated by the dev shim), so the shim-and-JWT mode is the -/// transition shape these tests need. -async fn mint_user_with_external_id(base: &str, external_id: &str) -> i64 { - let body: Value = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .json(&json!({ "external_id": external_id })) - .send() - .await - .expect("user create failed") - .error_for_status() - .expect("non-2xx on user create") - .json() - .await - .expect("user create body"); - body["id"].as_i64().expect("user id missing from response") -} +use support::{ + good_claims, header_with_kid, spawn_app_with_jwt, spawn_authenticated, JwksHarness, TEST_KID, +}; #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn valid_bearer_authenticates_request(pool: PgPool) { - let harness = JwksHarness::spawn().await; - let base = spawn_app_with_jwt_and_shim(pool, harness.verifier_arc()).await; - - let external_id = "auth-user-jwt-happy"; - let user_id = mint_user_with_external_id(&base, external_id).await; +async fn valid_bearer_authenticates_and_provisions_user(pool: PgPool) { + let auth = spawn_authenticated(pool, "auth-user-jwt-happy").await; - let token = harness.mint(&good_claims(external_id), &header_with_kid(TEST_KID)); - - // Hit the protected endpoint with Bearer — should authenticate - // and let the request through to the empty-list happy path. + // List → empty (the user owns no profiles yet). let list: Vec = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .bearer_auth(&token) + .get(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -65,12 +42,11 @@ async fn valid_bearer_authenticates_request(pool: PgPool) { .unwrap(); assert!(list.is_empty()); - // And the round-trip — create a profile via Bearer, then GET via - // Bearer — proves the UserId extension threads through to the - // tenant-scoped query. + // Create + re-list — proves the UserId extension threads through + // to the tenant-scoped storage call. let created: Value = reqwest::Client::new() - .post(format!("{base}/api/v1/profiles")) - .bearer_auth(&token) + .post(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .json(&json!({ "name": "via-JWT", "color_id": "emerald" })) .send() .await @@ -83,8 +59,8 @@ async fn valid_bearer_authenticates_request(pool: PgPool) { let profile_id = created["id"].as_i64().unwrap(); let list: Vec = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .bearer_auth(&token) + .get(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -93,27 +69,11 @@ async fn valid_bearer_authenticates_request(pool: PgPool) { .unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0]["id"].as_i64().unwrap(), profile_id); - - // And the same user_id underlies both auth paths — the X-User-Id - // shim and the Bearer JWT both surface the same row. - let list_via_shim: Vec = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(list_via_shim.len(), 1); - assert_eq!(list_via_shim[0]["id"].as_i64().unwrap(), profile_id); } #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn missing_bearer_with_jwt_only_returns_401(pool: PgPool) { +async fn missing_bearer_returns_401(pool: PgPool) { let harness = JwksHarness::spawn().await; - // JWT-only mode (no shim). We can't mint a user from the - // endpoint, so this test only exercises the rejection path. let base = spawn_app_with_jwt(pool, harness.verifier_arc()).await; let resp = reqwest::Client::new() @@ -127,13 +87,8 @@ async fn missing_bearer_with_jwt_only_returns_401(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn bearer_with_unknown_sub_lazy_provisions_user(pool: PgPool) { let harness = JwksHarness::spawn().await; - let base = spawn_app_with_jwt_and_shim(pool.clone(), harness.verifier_arc()).await; + let base = spawn_app_with_jwt(pool.clone(), harness.verifier_arc()).await; - // No `mint_user_with_external_id` call — the sub in the token - // has no matching row in `users` at request time. Phase 1.c.3a - // says: a valid JWT IS the authoritative onboarding signal, so - // the middleware inserts the row and lets the request through. - // // Fire two requests concurrently with the same fresh token so // both racing tasks hit the SELECT-miss → UPSERT path together. // Idempotence is the property we're proving: both must succeed @@ -156,8 +111,7 @@ async fn bearer_with_unknown_sub_lazy_provisions_user(pool: PgPool) { let resp_a = resp_a.unwrap(); let resp_b = resp_b.unwrap(); - // Both requests authenticated AND tenant-scoped to the new user - // (an unscoped query would have leaked another tenant's rows). + // Both requests authenticated AND tenant-scoped to the new user. assert_eq!(resp_a.status(), StatusCode::OK); assert_eq!(resp_b.status(), StatusCode::OK); let profiles_a: Value = resp_a.json().await.unwrap(); @@ -182,11 +136,7 @@ async fn bearer_with_bad_signature_returns_401(pool: PgPool) { // server's JWKS — exactly the wrong-key scenario. let signing_harness = JwksHarness::spawn().await; let server_harness = JwksHarness::spawn().await; - let base = spawn_app_with_jwt_and_shim(pool, server_harness.verifier_arc()).await; - - // Mint a user via the shim so the sub *could* resolve — proving - // the 401 isn't just a "no user" miss but a signature failure. - mint_user_with_external_id(&base, "auth-user-bad-sig").await; + let base = spawn_app_with_jwt(pool, server_harness.verifier_arc()).await; let token = signing_harness.mint( &good_claims("auth-user-bad-sig"), @@ -205,12 +155,11 @@ async fn bearer_with_bad_signature_returns_401(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn bearer_with_no_kid_returns_401(pool: PgPool) { let harness = JwksHarness::spawn().await; - let base = spawn_app_with_jwt_and_shim(pool, harness.verifier_arc()).await; - mint_user_with_external_id(&base, "auth-user-no-kid").await; + let base = spawn_app_with_jwt(pool, harness.verifier_arc()).await; - let header = Header::new(jsonwebtoken::Algorithm::RS256); // header.kid stays None — verifier rejects with MalformedToken // → 401. + let header = Header::new(jsonwebtoken::Algorithm::RS256); let token = harness.mint(&good_claims("auth-user-no-kid"), &header); let resp = reqwest::Client::new() @@ -225,7 +174,7 @@ async fn bearer_with_no_kid_returns_401(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn bearer_with_wrong_scheme_returns_401(pool: PgPool) { let harness = JwksHarness::spawn().await; - let base = spawn_app_with_jwt_and_shim(pool, harness.verifier_arc()).await; + let base = spawn_app_with_jwt(pool, harness.verifier_arc()).await; let token = harness.mint(&good_claims("anyone"), &header_with_kid(TEST_KID)); let resp = reqwest::Client::new() @@ -237,49 +186,3 @@ async fn bearer_with_wrong_scheme_returns_401(pool: PgPool) { .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } - -/// With JWT configured AND the shim enabled, the JWT path takes -/// precedence when both headers are present. A request that carries -/// `Authorization: Bearer ` AND `X-User-Id: 1` MUST 401 — -/// the forgeable header can't override a failed JWT check, otherwise -/// an attacker could downgrade auth by sending a bogus Bearer -/// alongside a forged user id. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn invalid_bearer_does_not_downgrade_to_shim(pool: PgPool) { - let harness = JwksHarness::spawn().await; - let base = spawn_app_with_jwt_and_shim(pool, harness.verifier_arc()).await; - let user_id = mint_user_with_external_id(&base, "real-user").await; - - let resp = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header(reqwest::header::AUTHORIZATION, "Bearer obviously.not.a.jwt") - .header("x-user-id", user_id.to_string()) - .send() - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::UNAUTHORIZED, - "an invalid Bearer must not silently fall back to the shim — \ - that would let an attacker downgrade auth" - ); -} - -/// With no auth configured at all, every `/api/v1/*` route 503s — -/// same prod-gate behaviour as the legacy `reject_dev_auth_disabled` -/// branch from pre-PR3 mod.rs, now generalised to "neither path -/// configured". -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn no_auth_configured_returns_503(pool: PgPool) { - let base = support::spawn_app_prod_gate(pool).await; - - let resp = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - // Header is irrelevant — the gate is at the auth layer - // before any parsing. - .header("x-user-id", "42") - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); -} diff --git a/tests/libraries.rs b/tests/libraries.rs index b98e154..5f84dfe 100644 --- a/tests/libraries.rs +++ b/tests/libraries.rs @@ -12,34 +12,15 @@ mod support; use reqwest::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; -use support::spawn_app; - -/// Mint a user via `POST /api/v1/users`. Same helper signature as -/// `tests/profiles.rs::mint_user` — duplicated here on purpose to keep -/// each integration test file self-contained (Cargo compiles each -/// `tests/*.rs` as its own crate, so factoring this out would mean a -/// `support` helper that not every file needs). -async fn mint_user(base: &str) -> i64 { - let body: Value = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .send() - .await - .expect("user create failed") - .error_for_status() - .expect("non-2xx on user create") - .json() - .await - .expect("user create body"); - body["id"].as_i64().expect("user id missing from response") -} +use support::{spawn_authenticated, spawn_two_authenticated}; -/// Mint a profile under `user_id` and return its id. Every library +/// Mint a profile under the authenticated caller and return its id. Every library /// test needs at least one profile because `library.profile_id` is a /// non-null FK. -async fn mint_profile(base: &str, user_id: i64, name: &str) -> i64 { +async fn mint_profile(base: &str, token: &str, name: &str) -> i64 { let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(token) .json(&json!({ "name": name, "color_id": "emerald" })) .send() .await @@ -52,44 +33,17 @@ async fn mint_profile(base: &str, user_id: i64, name: &str) -> i64 { created["id"].as_i64().expect("profile id missing") } -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn libraries_require_x_user_id(pool: PgPool) { - let base = spawn_app(pool).await; - - // No header — every CRUD verb on the nested resource should bounce 401. - for (method, path) in [ - ("GET", "/api/v1/profiles/1/libraries"), - ("POST", "/api/v1/profiles/1/libraries"), - ("GET", "/api/v1/profiles/1/libraries/1"), - ("PATCH", "/api/v1/profiles/1/libraries/1"), - ("DELETE", "/api/v1/profiles/1/libraries/1"), - ] { - let req = match method { - "GET" => reqwest::Client::new().get(format!("{base}{path}")), - "POST" => reqwest::Client::new() - .post(format!("{base}{path}")) - .json(&json!({ "name": "x" })), - "PATCH" => reqwest::Client::new() - .patch(format!("{base}{path}")) - .json(&json!({ "name": "x" })), - "DELETE" => reqwest::Client::new().delete(format!("{base}{path}")), - _ => unreachable!(), - }; - let resp = req.send().await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{method} {path}"); - } -} - #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_then_list_then_get_under_profile(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; // Empty to start. let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -101,7 +55,7 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { // Create — minimal body (color/icon fall back to defaults). let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Bandes-son" })) .send() .await @@ -124,7 +78,7 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { // List now sees it. let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -139,7 +93,7 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -152,13 +106,14 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_with_explicit_color_and_icon(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Live", "description": "Live recordings 2024-2026", @@ -183,14 +138,15 @@ async fn create_with_explicit_color_and_icon(pool: PgPool) { /// client bug can't ship blank-shelf rows into the DB. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_rejects_empty_name(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; for blank in ["", " ", "\t\n "] { let resp = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": blank })) .send() .await @@ -205,7 +161,7 @@ async fn create_rejects_empty_name(pool: PgPool) { // And nothing got persisted. let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -219,15 +175,18 @@ async fn create_rejects_empty_name(pool: PgPool) { /// the profile exists at all on the box. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_under_foreign_profile_returns_404(pool: PgPool) { - let base = spawn_app(pool).await; - let user_a = mint_user(&base).await; - let user_b = mint_user(&base).await; - let profile_a = mint_profile(&base, user_a, "A's profile").await; + let two = spawn_two_authenticated(pool, "test-user-a", "test-user-b").await; + let base = two.base.clone(); + let a = &two.a; + let b = &two.b; + let _user_a = a.user_id; + let _user_b = b.user_id; + let profile_a = mint_profile(&base, &a.token, "A's profile").await; // User B tries to create a library under user A's profile. let resp = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_a}/libraries")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .json(&json!({ "name": "stolen" })) .send() .await @@ -238,7 +197,7 @@ async fn create_under_foreign_profile_returns_404(pool: PgPool) { // confirming nothing was written by the foreign call. let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_a}/libraries")) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .send() .await .unwrap() @@ -253,16 +212,19 @@ async fn create_under_foreign_profile_returns_404(pool: PgPool) { /// profile id. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn tenants_are_isolated(pool: PgPool) { - let base = spawn_app(pool).await; - let user_a = mint_user(&base).await; - let user_b = mint_user(&base).await; - let profile_a = mint_profile(&base, user_a, "A's profile").await; - let profile_b = mint_profile(&base, user_b, "B's profile").await; + let two = spawn_two_authenticated(pool, "test-user-a", "test-user-b").await; + let base = two.base.clone(); + let a = &two.a; + let b = &two.b; + let _user_a = a.user_id; + let _user_b = b.user_id; + let profile_a = mint_profile(&base, &a.token, "A's profile").await; + let profile_b = mint_profile(&base, &b.token, "B's profile").await; // User A creates a library. let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_a}/libraries")) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .json(&json!({ "name": "A's lib" })) .send() .await @@ -277,7 +239,7 @@ async fn tenants_are_isolated(pool: PgPool) { // User B's list under their own profile is empty. let list_b: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_b}/libraries")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap() @@ -289,7 +251,7 @@ async fn tenants_are_isolated(pool: PgPool) { // User B's list under user A's profile is also empty (no leak). let list_b_under_a: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_a}/libraries")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap() @@ -308,7 +270,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{proxy_profile}/libraries/{lib_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -324,7 +286,7 @@ async fn tenants_are_isolated(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{lib_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .json(&json!({ "name": "hijacked" })) .send() .await @@ -336,7 +298,7 @@ async fn tenants_are_isolated(pool: PgPool) { .delete(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{lib_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -347,7 +309,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{lib_id}" )) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .send() .await .unwrap() @@ -363,13 +325,14 @@ async fn tenants_are_isolated(pool: PgPool) { /// (verifying the `UPDATE … RETURNING …` path, not a stale read-back). #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_renames_in_place(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Old name", "color_id": "emerald" })) .send() .await @@ -386,7 +349,7 @@ async fn update_renames_in_place(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "New name", "color_id": "crimson" })) .send() .await @@ -406,13 +369,14 @@ async fn update_renames_in_place(pool: PgPool) { /// client bug can't silently blank an existing shelf label. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_rejects_empty_name(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Keep me" })) .send() .await @@ -430,7 +394,7 @@ async fn update_rejects_empty_name(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": blank })) .send() .await @@ -448,7 +412,7 @@ async fn update_rejects_empty_name(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -464,13 +428,14 @@ async fn update_rejects_empty_name(pool: PgPool) { /// on the storage layer). #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_preserves_omitted_fields(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Keep me", "color_id": "ocean", @@ -491,7 +456,7 @@ async fn update_preserves_omitted_fields(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "color_id": "sunset" })) .send() .await @@ -508,13 +473,14 @@ async fn update_preserves_omitted_fields(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn delete_returns_204_then_404(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "doomed" })) .send() .await @@ -531,7 +497,7 @@ async fn delete_returns_204_then_404(pool: PgPool) { .delete(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -542,7 +508,7 @@ async fn delete_returns_204_then_404(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -555,20 +521,21 @@ async fn delete_returns_204_then_404(pool: PgPool) { /// CASCADE keyword" regression. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn profile_delete_cascades_to_libraries(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; // Two profiles so the delete-last-profile guard doesn't block us. // p2 stays alive so we can use it as a proxy GET path after p1 // is deleted — a route through a *still-owned* profile is the // only way to prove the library row itself is gone (rather than // the request failing because the path's profile no longer exists). - let p1 = mint_profile(&base, user_id, "one").await; - let p2 = mint_profile(&base, user_id, "two").await; + let p1 = mint_profile(&auth.base, &auth.token, "one").await; + let p2 = mint_profile(&auth.base, &auth.token, "two").await; let lib_id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{p1}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "doomed lib" })) .send() .await @@ -584,7 +551,7 @@ async fn profile_delete_cascades_to_libraries(pool: PgPool) { // Delete the profile. let resp = reqwest::Client::new() .delete(format!("{base}/api/v1/profiles/{p1}")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -596,7 +563,7 @@ async fn profile_delete_cascades_to_libraries(pool: PgPool) { // profile is gone. let resp = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{p1}/libraries/{lib_id}")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -609,27 +576,9 @@ async fn profile_delete_cascades_to_libraries(pool: PgPool) { // would surface here as a leaked 200. Worth the extra request. let resp = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{p2}/libraries/{lib_id}")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } - -/// Production-gate sanity: when `WAVEFLOW_DEV_AUTH` is off, the -/// nested route is gated by 503 just like every other `/api/v1/*` -/// endpoint. Mirrors `dev_auth_gate_returns_503_when_disabled` in -/// `tests/profiles.rs` so a future refactor that adds a router but -/// forgets the gate is caught here. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn dev_auth_gate_returns_503_for_libraries_when_disabled(pool: PgPool) { - let base = support::spawn_app_prod_gate(pool).await; - - let resp = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles/1/libraries")) - .header("x-user-id", "42") - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); -} diff --git a/tests/openapi.rs b/tests/openapi.rs index 193dbea..cbe6c6c 100644 --- a/tests/openapi.rs +++ b/tests/openapi.rs @@ -63,28 +63,12 @@ async fn openapi_doc_lists_every_handler(pool: PgPool) { "missing playlists item path in spec" ); - // `POST /api/v1/users` accepts `Option>` - // so the OpenAPI requestBody must signal `required: false`. The - // bare `request_body = CreateUserRequest` shorthand would emit - // `required: true` and silently misdocument the contract — lock - // in the actual shape so a future refactor that drops the - // `Option<…>` wrapper trips here instead of in production. + // `POST /api/v1/users` retired in Phase 1.d.2 — the JWT path's + // lazy auto-provisioning is the only way to land a `users` row, + // so the spec must NOT advertise the legacy bootstrap endpoint. assert!( - paths.contains_key("/api/v1/users"), - "missing /api/v1/users in spec" - ); - let users_post = &paths["/api/v1/users"]["post"]; - let request_body = users_post - .get("requestBody") - .expect("POST /api/v1/users must declare a requestBody"); - // OpenAPI 3.x defaults `required` to false when absent, so either - // explicitly `false` or missing is fine — what we must NOT see is - // `true`. - let required = request_body.get("required").and_then(Value::as_bool); - assert_ne!( - required, - Some(true), - "POST /api/v1/users requestBody must not be `required: true` — handler is Option>" + !paths.contains_key("/api/v1/users"), + "spec still advertises /api/v1/users — endpoint retired in 1.d.2" ); // The /ready operation must declare both the 200 and the 503 diff --git a/tests/playlists.rs b/tests/playlists.rs index 6d6a32a..43e36f2 100644 --- a/tests/playlists.rs +++ b/tests/playlists.rs @@ -18,26 +18,12 @@ mod support; use reqwest::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; -use support::spawn_app; +use support::{spawn_authenticated, spawn_two_authenticated}; -async fn mint_user(base: &str) -> i64 { - let body: Value = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .send() - .await - .expect("user create failed") - .error_for_status() - .expect("non-2xx on user create") - .json() - .await - .expect("user create body"); - body["id"].as_i64().expect("user id missing from response") -} - -async fn mint_profile(base: &str, user_id: i64, name: &str) -> i64 { +async fn mint_profile(base: &str, token: &str, name: &str) -> i64 { let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(token) .json(&json!({ "name": name, "color_id": "emerald" })) .send() .await @@ -50,43 +36,17 @@ async fn mint_profile(base: &str, user_id: i64, name: &str) -> i64 { created["id"].as_i64().expect("profile id missing") } -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn playlists_require_x_user_id(pool: PgPool) { - let base = spawn_app(pool).await; - - for (method, path) in [ - ("GET", "/api/v1/profiles/1/playlists"), - ("POST", "/api/v1/profiles/1/playlists"), - ("GET", "/api/v1/profiles/1/playlists/1"), - ("PATCH", "/api/v1/profiles/1/playlists/1"), - ("DELETE", "/api/v1/profiles/1/playlists/1"), - ] { - let req = match method { - "GET" => reqwest::Client::new().get(format!("{base}{path}")), - "POST" => reqwest::Client::new() - .post(format!("{base}{path}")) - .json(&json!({ "name": "x" })), - "PATCH" => reqwest::Client::new() - .patch(format!("{base}{path}")) - .json(&json!({ "name": "x" })), - "DELETE" => reqwest::Client::new().delete(format!("{base}{path}")), - _ => unreachable!(), - }; - let resp = req.send().await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{method} {path}"); - } -} - #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_then_list_then_get_under_profile(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; // Empty list initially. let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -98,7 +58,7 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { // Create — minimal body (color/icon fall back to defaults). let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Soirée" })) .send() .await @@ -147,7 +107,7 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { // List sees it. let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -162,7 +122,7 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/playlists/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -175,13 +135,14 @@ async fn create_then_list_then_get_under_profile(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_with_explicit_color_and_icon(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Focus", "description": "Lo-fi pour bosser", @@ -203,14 +164,15 @@ async fn create_with_explicit_color_and_icon(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_rejects_empty_name(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; for blank in ["", " ", "\t\n "] { let resp = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": blank })) .send() .await @@ -224,7 +186,7 @@ async fn create_rejects_empty_name(pool: PgPool) { let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -237,14 +199,17 @@ async fn create_rejects_empty_name(pool: PgPool) { /// Foreign profile id under the calling user must 404. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_under_foreign_profile_returns_404(pool: PgPool) { - let base = spawn_app(pool).await; - let user_a = mint_user(&base).await; - let user_b = mint_user(&base).await; - let profile_a = mint_profile(&base, user_a, "A's profile").await; + let two = spawn_two_authenticated(pool, "test-user-a", "test-user-b").await; + let base = two.base.clone(); + let a = &two.a; + let b = &two.b; + let _user_a = a.user_id; + let _user_b = b.user_id; + let profile_a = mint_profile(&base, &a.token, "A's profile").await; let resp = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_a}/playlists")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .json(&json!({ "name": "stolen" })) .send() .await @@ -253,7 +218,7 @@ async fn create_under_foreign_profile_returns_404(pool: PgPool) { let list: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_a}/playlists")) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .send() .await .unwrap() @@ -268,15 +233,18 @@ async fn create_under_foreign_profile_returns_404(pool: PgPool) { /// nor through user A's profile. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn tenants_are_isolated(pool: PgPool) { - let base = spawn_app(pool).await; - let user_a = mint_user(&base).await; - let user_b = mint_user(&base).await; - let profile_a = mint_profile(&base, user_a, "A").await; - let profile_b = mint_profile(&base, user_b, "B").await; + let two = spawn_two_authenticated(pool, "test-user-a", "test-user-b").await; + let base = two.base.clone(); + let a = &two.a; + let b = &two.b; + let _user_a = a.user_id; + let _user_b = b.user_id; + let profile_a = mint_profile(&base, &a.token, "A").await; + let profile_b = mint_profile(&base, &b.token, "B").await; let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_a}/playlists")) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .json(&json!({ "name": "A's playlist" })) .send() .await @@ -291,7 +259,7 @@ async fn tenants_are_isolated(pool: PgPool) { // User B's list under their own profile is empty. let list_b: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_b}/playlists")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap() @@ -303,7 +271,7 @@ async fn tenants_are_isolated(pool: PgPool) { // User B can't list user A's playlists via user A's profile id. let list_b_proxy: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{profile_a}/playlists")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap() @@ -321,7 +289,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{proxy_profile}/playlists/{playlist_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -337,7 +305,7 @@ async fn tenants_are_isolated(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_a}/playlists/{playlist_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .json(&json!({ "name": "hijacked" })) .send() .await @@ -349,7 +317,7 @@ async fn tenants_are_isolated(pool: PgPool) { .delete(format!( "{base}/api/v1/profiles/{profile_a}/playlists/{playlist_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -360,7 +328,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_a}/playlists/{playlist_id}" )) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .send() .await .unwrap() @@ -375,13 +343,14 @@ async fn tenants_are_isolated(pool: PgPool) { /// PATCH round-trips and the response carries the new value. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_renames_in_place(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Old name" })) .send() .await @@ -398,7 +367,7 @@ async fn update_renames_in_place(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/playlists/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "New name", "color_id": "crimson" })) .send() .await @@ -416,13 +385,14 @@ async fn update_renames_in_place(pool: PgPool) { /// Partial PATCH preserves omitted fields (the `COALESCE` path). #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_preserves_omitted_fields(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Keep me", "color_id": "ocean", @@ -442,7 +412,7 @@ async fn update_preserves_omitted_fields(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/playlists/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "color_id": "sunset" })) .send() .await @@ -460,13 +430,14 @@ async fn update_preserves_omitted_fields(pool: PgPool) { /// PATCH blank name must 400 — same boundary check as create. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_rejects_empty_name(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "Keep me" })) .send() .await @@ -484,7 +455,7 @@ async fn update_rejects_empty_name(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/playlists/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": blank })) .send() .await @@ -501,7 +472,7 @@ async fn update_rejects_empty_name(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/playlists/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -515,13 +486,14 @@ async fn update_rejects_empty_name(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn delete_returns_204_then_404(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; let id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "doomed" })) .send() .await @@ -538,7 +510,7 @@ async fn delete_returns_204_then_404(pool: PgPool) { .delete(format!( "{base}/api/v1/profiles/{profile_id}/playlists/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -548,7 +520,7 @@ async fn delete_returns_204_then_404(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/playlists/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -558,16 +530,17 @@ async fn delete_returns_204_then_404(pool: PgPool) { /// Deleting a profile must cascade through to its playlists. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn profile_delete_cascades_to_playlists(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; // Two profiles so the delete-last-profile guard doesn't block us. - let p1 = mint_profile(&base, user_id, "to delete").await; - let p2 = mint_profile(&base, user_id, "to keep").await; + let p1 = mint_profile(&auth.base, &auth.token, "to delete").await; + let p2 = mint_profile(&auth.base, &auth.token, "to keep").await; let playlist_id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{p1}/playlists")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "name": "doomed playlist" })) .send() .await @@ -582,7 +555,7 @@ async fn profile_delete_cascades_to_playlists(pool: PgPool) { let resp = reqwest::Client::new() .delete(format!("{base}/api/v1/profiles/{p1}")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -593,7 +566,7 @@ async fn profile_delete_cascades_to_playlists(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{p1}/playlists/{playlist_id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -605,23 +578,9 @@ async fn profile_delete_cascades_to_playlists(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{p2}/playlists/{playlist_id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } - -/// Production-gate sanity. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn dev_auth_gate_returns_503_for_playlists_when_disabled(pool: PgPool) { - let base = support::spawn_app_prod_gate(pool).await; - - let resp = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles/1/playlists")) - .header("x-user-id", "42") - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); -} diff --git a/tests/profiles.rs b/tests/profiles.rs index d52df4b..28c31e9 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -1,138 +1,26 @@ -//! End-to-end tests for `/api/v1/users` + `/api/v1/profiles`. +//! End-to-end tests for `/api/v1/profiles`. //! //! Every test boots the real router (no axum-test mocks) against a -//! per-test Postgres database from `#[sqlx::test]`, mints a user via -//! `POST /api/v1/users`, then exercises the CRUD surface with that -//! user id in the `X-User-Id` header. The shared `mint_user` helper -//! does the bootstrap and returns the id so each test focuses on -//! its scenario. +//! per-test Postgres database from `#[sqlx::test]`, calls +//! `spawn_authenticated` to provision a user via the lazy-provision +//! JWT path, then exercises the CRUD surface with the resulting +//! Bearer token. Phase 1.d.2 retired the `X-User-Id` shim + the +//! bootstrap `POST /api/v1/users` endpoint, so JWT is the only path +//! these tests exercise. mod support; use reqwest::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; -use support::spawn_app; - -/// Bootstrap: mint a user, return its id. Every test under -/// `/api/v1/profiles` needs one because the FK on `profile.user_id` -/// rejects orphaned writes. -async fn mint_user(base: &str) -> i64 { - let body: Value = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .send() - .await - .expect("user create failed") - .error_for_status() - .expect("non-2xx on user create") - .json() - .await - .expect("user create body"); - body["id"].as_i64().expect("user id missing from response") -} +use support::{spawn_app_with_jwt, spawn_authenticated, spawn_two_authenticated, JwksHarness}; #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn create_user_returns_201_with_id(pool: PgPool) { - let base = spawn_app(pool).await; - - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .send() - .await - .expect("request failed"); - assert_eq!(resp.status(), StatusCode::CREATED); - let body: Value = resp.json().await.unwrap(); - assert!(body["id"].as_i64().unwrap() > 0); -} - -/// Phase 1.d.1 seed: `external_id` accepted, persisted, returned via -/// `id`. The actual round-trip back through a query lives in the -/// JWT middleware tests (1.d.1-PR2) — here we just exercise the -/// handler accepting the payload without 500'ing. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn create_user_accepts_external_id(pool: PgPool) { - let base = spawn_app(pool).await; - - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .json(&json!({ "external_id": "auth-provider-uuid-abc-123" })) - .send() - .await - .expect("request failed"); - assert_eq!(resp.status(), StatusCode::CREATED); - let body: Value = resp.json().await.unwrap(); - assert!(body["id"].as_i64().unwrap() > 0); -} - -/// Blank `external_id` (empty or whitespace-only after trim) must -/// 400 — otherwise it would slip past the UNIQUE constraint and sit -/// in the DB as a non-NULL-but-blank row that no JWT could ever -/// match. Same boundary-validation rule as the rest of 1.b.5. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn create_user_rejects_blank_external_id(pool: PgPool) { - let base = spawn_app(pool).await; - - for blank in ["", " ", "\t\n "] { - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .json(&json!({ "external_id": blank })) - .send() - .await - .expect("request failed"); - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "external_id = {blank:?} should 400" - ); - } -} - -/// Two POSTs with the same `external_id` must collide on the UNIQUE -/// constraint — the second one gets 409, not a transient 500. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn create_user_rejects_duplicate_external_id(pool: PgPool) { - let base = spawn_app(pool).await; - - let body = json!({ "external_id": "duplicate-sub" }); - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .json(&body) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .json(&body) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CONFLICT); -} - -/// An explicit `null` for `external_id` is equivalent to omitting it -/// — same behaviour as the no-body case. Locks in the contract so a -/// future serde change doesn't silently flip "explicit null" into a -/// validation error. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn create_user_accepts_explicit_null_external_id(pool: PgPool) { - let base = spawn_app(pool).await; - - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .json(&json!({ "external_id": null })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); -} +async fn missing_bearer_returns_401(pool: PgPool) { + let harness = JwksHarness::spawn().await; + let base = spawn_app_with_jwt(pool, harness.verifier_arc()).await; -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn profiles_require_x_user_id(pool: PgPool) { - let base = spawn_app(pool).await; - - // No header — every CRUD verb should bounce 401. + // No Authorization — every CRUD verb bounces 401. for (method, path) in [ ("GET", "/api/v1/profiles"), ("POST", "/api/v1/profiles"), @@ -156,34 +44,14 @@ async fn profiles_require_x_user_id(pool: PgPool) { } } -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn malformed_x_user_id_rejected(pool: PgPool) { - let base = spawn_app(pool).await; - - for header in ["", "abc", "0", "-1"] { - let resp = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", header) - .send() - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::UNAUTHORIZED, - "X-User-Id = {header:?} should 401" - ); - } -} - #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_then_list_then_get(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; + let auth = spawn_authenticated(pool, "profiles-create-list-get").await; // Empty to start. let body: Value = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .get(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -194,8 +62,8 @@ async fn create_then_list_then_get(pool: PgPool) { // Create. let created: Value = reqwest::Client::new() - .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .post(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .json(&json!({ "name": "Alice", "color_id": "emerald" })) .send() .await @@ -215,8 +83,8 @@ async fn create_then_list_then_get(pool: PgPool) { // List now sees it. let list: Vec = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .get(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -228,8 +96,8 @@ async fn create_then_list_then_get(pool: PgPool) { // Get by id round-trips the same shape. let one: Value = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles/{id}")) - .header("x-user-id", user_id.to_string()) + .get(format!("{}/api/v1/profiles/{id}", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -241,14 +109,15 @@ async fn create_then_list_then_get(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn tenants_are_isolated(pool: PgPool) { - let base = spawn_app(pool).await; - let user_a = mint_user(&base).await; - let user_b = mint_user(&base).await; + let two = spawn_two_authenticated(pool, "profiles-tenant-a", "profiles-tenant-b").await; + let base = two.base.clone(); + let a = &two.a; + let b = &two.b; - // User A creates a profile. + // User A creates a profile (on A's app instance). let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .json(&json!({ "name": "A's profile", "color_id": "emerald" })) .send() .await @@ -260,10 +129,11 @@ async fn tenants_are_isolated(pool: PgPool) { .unwrap(); let id = created["id"].as_i64().unwrap(); - // User B's list is empty. + // User B's list is empty (B sees the same DB but the WHERE + // user_id = $1 filter excludes A's row). let list_b: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap() @@ -276,7 +146,7 @@ async fn tenants_are_isolated(pool: PgPool) { // (no data leak), not 403 (no existence leak). let resp = reqwest::Client::new() .get(format!("{base}/api/v1/profiles/{id}")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -285,7 +155,7 @@ async fn tenants_are_isolated(pool: PgPool) { // And user B can't delete A's profile. let resp = reqwest::Client::new() .delete(format!("{base}/api/v1/profiles/{id}")) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -294,7 +164,7 @@ async fn tenants_are_isolated(pool: PgPool) { // User A still sees their profile. let list_a: Vec = reqwest::Client::new() .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .send() .await .unwrap() @@ -306,12 +176,11 @@ async fn tenants_are_isolated(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_renames_in_place(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; + let auth = spawn_authenticated(pool, "profiles-rename").await; let id = reqwest::Client::new() - .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .post(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .json(&json!({ "name": "Old name", "color_id": "emerald" })) .send() .await @@ -325,8 +194,8 @@ async fn update_renames_in_place(pool: PgPool) { .unwrap(); let renamed: Value = reqwest::Client::new() - .patch(format!("{base}/api/v1/profiles/{id}")) - .header("x-user-id", user_id.to_string()) + .patch(format!("{}/api/v1/profiles/{id}", auth.base)) + .bearer_auth(&auth.token) .json(&json!({ "name": "New name" })) .send() .await @@ -342,12 +211,11 @@ async fn update_renames_in_place(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn delete_blocks_last_profile(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; + let auth = spawn_authenticated(pool, "profiles-delete-last").await; let id = reqwest::Client::new() - .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .post(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .json(&json!({ "name": "only one", "color_id": "emerald" })) .send() .await @@ -363,8 +231,8 @@ async fn delete_blocks_last_profile(pool: PgPool) { // Deleting the last profile must 409 — the storage invariant // refuses to leave the user with zero profiles. let resp = reqwest::Client::new() - .delete(format!("{base}/api/v1/profiles/{id}")) - .header("x-user-id", user_id.to_string()) + .delete(format!("{}/api/v1/profiles/{id}", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -372,8 +240,8 @@ async fn delete_blocks_last_profile(pool: PgPool) { // Profile still there. let list: Vec = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .get(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -385,15 +253,14 @@ async fn delete_blocks_last_profile(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn delete_succeeds_when_more_than_one(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; + let auth = spawn_authenticated(pool, "profiles-delete-more").await; // Two profiles → deleting one leaves one → 204. let mut ids = Vec::new(); for name in ["one", "two"] { let id = reqwest::Client::new() - .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .post(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .json(&json!({ "name": name, "color_id": "emerald" })) .send() .await @@ -409,16 +276,16 @@ async fn delete_succeeds_when_more_than_one(pool: PgPool) { } let resp = reqwest::Client::new() - .delete(format!("{base}/api/v1/profiles/{}", ids[0])) - .header("x-user-id", user_id.to_string()) + .delete(format!("{}/api/v1/profiles/{}", auth.base, ids[0])) + .bearer_auth(&auth.token) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let list: Vec = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .get(format!("{}/api/v1/profiles", auth.base)) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -428,55 +295,3 @@ async fn delete_succeeds_when_more_than_one(pool: PgPool) { assert_eq!(list.len(), 1); assert_eq!(list[0]["id"].as_i64().unwrap(), ids[1]); } - -/// With `WAVEFLOW_DEV_AUTH` unset (production default), every -/// `/api/v1/*` request must short-circuit to 503 — even a "valid" -/// X-User-Id header. The probe routes (`/health`, `/ready`, -/// `/openapi.json`, `/reference`) stay reachable because they don't -/// carry tenant data. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn dev_auth_gate_returns_503_when_disabled(pool: PgPool) { - let base = support::spawn_app_prod_gate(pool).await; - - // Health stays up. - let resp = reqwest::Client::new() - .get(format!("{base}/health")) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - // POST /api/v1/users — gated. - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); - - // GET /api/v1/profiles with a header — still gated; the 503 - // wins over the auth shim so an attacker can't tell the shim - // exists. - let resp = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles")) - .header("x-user-id", "42") - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); -} - -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn create_with_unknown_user_id_returns_409(pool: PgPool) { - let base = spawn_app(pool).await; - - // Skip mint_user — use a hard-coded id no users row will have. - let resp = reqwest::Client::new() - .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", "99999") - .json(&json!({ "name": "x", "color_id": "emerald" })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CONFLICT); -} diff --git a/tests/ready.rs b/tests/ready.rs index e30505f..d6454af 100644 --- a/tests/ready.rs +++ b/tests/ready.rs @@ -127,22 +127,35 @@ async fn migration_adds_users_external_id_column(pool: PgPool) { assert!(exists, "users.external_id column missing after migrations"); } -/// Defense-in-depth probe on the `users_external_id_non_blank` -/// CHECK constraint. Exercises a direct INSERT (bypassing the -/// handler's trim-and-reject path) so a future regression that -/// drops the constraint surfaces here rather than silently allowing -/// blank rows the JWT middleware could never match. +/// Defense-in-depth probe on the `users.external_id` column shape. +/// Direct INSERT (bypassing the lazy-provision middleware) so a +/// future regression that loosens the constraints surfaces here. +/// +/// Phase 1.d.2 made the column NOT NULL — JWT auth is the only path +/// and every row must carry the `sub` claim it was provisioned from. +/// Blank values still trip the `users_external_id_non_blank` CHECK +/// (`23514`), distinct from the unique-violation case (`23505`) the +/// upsert in `find_or_provision_by_external_id` handles. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn users_external_id_check_rejects_blank(pool: PgPool) { - // NULL is allowed (the dev shim mints users without external_id). - sqlx::query("INSERT INTO users (created_at, external_id) VALUES (1, NULL)") +async fn users_external_id_rejects_null_and_blank(pool: PgPool) { + // NULL is now forbidden — the 1.d.2 migration set the column + // NOT NULL. SQLSTATE 23502 = not_null_violation. + let err = sqlx::query("INSERT INTO users (created_at, external_id) VALUES (1, NULL)") .execute(&pool) .await - .expect("NULL external_id should be allowed by the CHECK"); + .expect_err("NULL external_id should violate NOT NULL"); + let code = match &err { + sqlx::Error::Database(db_err) => db_err.code().map(|c| c.into_owned()), + other => panic!("expected Database error, got {other:?}"), + }; + assert_eq!( + code.as_deref(), + Some("23502"), + "NULL external_id should fail with not_null_violation (23502), got {code:?}" + ); // Empty string and whitespace-only must trip the CHECK with - // SQLSTATE 23514 (check_violation), distinct from the - // 23505 unique_violation case the handler maps to 409. + // SQLSTATE 23514 (check_violation). for blank in ["", " ", "\t\n "] { let err = sqlx::query("INSERT INTO users (created_at, external_id) VALUES (1, $1)") .bind(blank) diff --git a/tests/support.rs b/tests/support.rs index dd233f6..4328383 100644 --- a/tests/support.rs +++ b/tests/support.rs @@ -1,31 +1,51 @@ //! Shared integration-test harness: spawn the real axum app on a -//! kernel-assigned port against a caller-provided Postgres pool, and -//! hand back the URL the test should hit. +//! kernel-assigned port against a caller-provided Postgres pool, mint +//! a JWT off a per-test JWKS harness, and hand back the URL + a token +//! the test should authenticate with. //! //! Each test that uses this gets its pool from `#[sqlx::test(...)]`, -//! which creates a fresh per-test database, runs migrations, and -//! drops the database when the test exits — no fixtures to clean up +//! which creates a fresh per-test database, runs migrations, and drops +//! the database when the test exits — no fixtures to clean up //! manually. +//! +//! Phase 1.d.2 collapsed every spawn variant down to the JWT-only +//! flow. The legacy `spawn_app` (shim) + `spawn_app_with_jwt_and_shim` +//! variants are gone alongside the `X-User-Id` header itself. + +#![allow(dead_code)] + +// Cargo compiles each `tests/*.rs` as its own crate, so each test +// file does `mod support; mod jwks_harness;` separately. We need +// `jwks_harness` accessible from inside `support.rs` too — but the +// default module resolution from here would look for +// `tests/support/jwks_harness.rs`. The `#[path]` attribute tells +// rustc to read the sibling file directly. +#[path = "jwks_harness.rs"] +mod jwks_harness; use std::{net::SocketAddr, sync::Arc}; use sqlx::PgPool; use waveflow_server::{app, auth::JwtVerifier, AppState, Config}; -/// Boot knobs for the shared `spawn_app_…` family. Keeps the per-test -/// configuration surface in one place — every new "spawn variant" -/// adds an entry here, the wrappers below stay one-liners. -#[derive(Default)] -pub struct SpawnOptions { - pub dev_auth_enabled: bool, - pub jwt_verifier: Option>, +pub use jwks_harness::{good_claims, header_with_kid, JwksHarness, TEST_KID}; + +/// Spawn the app with a fresh JwksHarness verifier and return the +/// base URL — for tests that hit unauthenticated routes (`/health`, +/// `/ready`, `/openapi.json`, `/reference`) and don't care about +/// the auth surface. Use [`spawn_app_with_jwt`] when the test needs +/// to wire its own harness, or [`spawn_authenticated`] when it needs +/// a token + provisioned user id. +pub async fn spawn_app(pool: PgPool) -> String { + let harness = JwksHarness::spawn().await; + spawn_app_with_jwt(pool, harness.verifier_arc()).await } -/// Boot the app with an arbitrary configuration. The convenience -/// wrappers below are the only spellings tests should use; the -/// builder stays here so a future "exhaustively test both branches" -/// style ever gets a single point to extend. -async fn spawn_app_with(pool: PgPool, opts: SpawnOptions) -> String { +/// Spawn the app with a caller-supplied verifier (pointed at a mock +/// JWKS via `JwksHarness`) and return the base URL. Use +/// [`spawn_authenticated`] for the common case where the test also +/// needs a token + pre-provisioned user id. +pub async fn spawn_app_with_jwt(pool: PgPool, verifier: Arc) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -38,19 +58,17 @@ async fn spawn_app_with(pool: PgPool, opts: SpawnOptions) -> String { // is fine inside the test. database_url: "".into(), db_max_connections: 1, - dev_auth_enabled: opts.dev_auth_enabled, // The Config copies of the JWT triple are only read at boot // (to construct the verifier). The integration tests inject - // a pre-built verifier instead, so these stay `None`. - jwt_jwks_url: None, - jwt_issuer: None, - jwt_audience: None, + // a pre-built verifier instead, so these stay empty. + jwt_jwks_url: String::new(), + jwt_issuer: String::new(), + jwt_audience: String::new(), }; let state = AppState { db: pool, - jwt_verifier: opts.jwt_verifier, - dev_auth_enabled: opts.dev_auth_enabled, + jwt_verifier: verifier, }; tokio::spawn(async move { axum::serve( @@ -64,64 +82,137 @@ async fn spawn_app_with(pool: PgPool, opts: SpawnOptions) -> String { format!("http://{addr}") } -/// Spawn the app with the dev `X-User-Id` shim **enabled** and no -/// JWT verifier — what the existing 1.b.5 integration tests want. -/// Use [`spawn_app_prod_gate`] for the 503 path, or -/// [`spawn_app_with_jwt`] for the new JWT-middleware tests. -#[allow(dead_code)] // jwt_middleware.rs only uses the JWT-enabled variants -pub async fn spawn_app(pool: PgPool) -> String { - spawn_app_with( +/// One-stop bootstrap for tenant-scoped integration tests: +/// - spins up a JwksHarness + the app wired to it, +/// - mints a token with the supplied `external_id`, +/// - fires a no-op authenticated request so the lazy-provision +/// middleware lands the `users` row and we know its id, +/// - returns everything the test needs to keep going. +/// +/// Use this whenever a test needs a "the user is signed in" baseline. +/// Tests that exercise the auth surface itself (bad signature, no kid, +/// etc.) should bypass this and drive [`JwksHarness::mint`] directly. +pub struct Authenticated { + pub base: String, + pub token: String, + pub user_id: i64, + pub external_id: String, + pub harness: Arc, + pub pool: PgPool, +} + +/// Mint an authenticated test caller with the supplied external_id. +/// Tests that need a second caller call this again with a distinct +/// external_id — each spawns its own harness so the verifier keys +/// don't collide. +pub async fn spawn_authenticated(pool: PgPool, external_id: &str) -> Authenticated { + let harness = Arc::new(JwksHarness::spawn().await); + let base = spawn_app_with_jwt(pool.clone(), harness.verifier_arc()).await; + let token = harness.mint(&good_claims(external_id), &header_with_kid(TEST_KID)); + + // Fire a no-op authenticated request so the middleware lazy- + // provisions the users row. We then SELECT the row to learn its + // id (BIGSERIAL means the assignment is opaque to the client). + let resp = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles")) + .bearer_auth(&token) + .send() + .await + .expect("warm-up request failed"); + assert!( + resp.status().is_success(), + "warm-up request returned {} (body: {:?})", + resp.status(), + resp.text().await.ok(), + ); + + let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE external_id = $1") + .bind(external_id) + .fetch_one(&pool) + .await + .expect("lazy-provision did not land the users row"); + + Authenticated { + base, + token, + user_id, + external_id: external_id.to_string(), + harness, pool, - SpawnOptions { - dev_auth_enabled: true, - ..SpawnOptions::default() - }, - ) - .await + } } -/// Spawn the app with the production-default config — dev auth -/// **disabled**, no JWT verifier — so every `/api/v1/*` request -/// short-circuits to 503. Used by the various -/// `dev_auth_gate_returns_503_when_disabled` tests. -#[allow(dead_code)] // some test files don't use this helper -pub async fn spawn_app_prod_gate(pool: PgPool) -> String { - spawn_app_with(pool, SpawnOptions::default()).await +/// Convenience for tests that need TWO authenticated callers +/// sharing the same base URL — typical pattern for tenant-isolation +/// assertions. Both tokens are minted from the same JWKS harness, +/// so a single app instance verifies both: that mirrors the real +/// deployment where every caller hits the same waveflow-server + +/// the same Better Auth issuer. +pub struct TwoAuthenticated { + /// Base URL of the single app both callers hit. + pub base: String, + pub a: AuthenticatedCaller, + pub b: AuthenticatedCaller, + pub harness: Arc, + pub pool: PgPool, } -/// Spawn the app with the JWT path enabled (a caller-supplied -/// verifier pointed at a mock JWKS) and the dev shim **off**. The -/// `tests/jwt_middleware.rs` battery uses this to exercise the -/// production-shape auth path end-to-end. Note: `POST /api/v1/users` -/// is the test bootstrap entry — it needs the shim to mint a user -/// with an `external_id`, so callers that need to seed users should -/// flip to [`spawn_app_with_jwt_and_shim`] instead. -#[allow(dead_code)] -pub async fn spawn_app_with_jwt(pool: PgPool, verifier: Arc) -> String { - spawn_app_with( - pool, - SpawnOptions { - dev_auth_enabled: false, - jwt_verifier: Some(verifier), - }, - ) - .await +pub struct AuthenticatedCaller { + pub token: String, + pub user_id: i64, + pub external_id: String, } -/// Spawn the app with both auth paths enabled. The middleware -/// gives JWT precedence so a request that carries both headers -/// goes through the cryptographically-trusted path. Used by the -/// transition-shape tests that need to mint users via the open -/// `POST /api/v1/users` (shim path) and then authenticate -/// follow-on requests via Bearer. -#[allow(dead_code)] -pub async fn spawn_app_with_jwt_and_shim(pool: PgPool, verifier: Arc) -> String { - spawn_app_with( - pool, - SpawnOptions { - dev_auth_enabled: true, - jwt_verifier: Some(verifier), +pub async fn spawn_two_authenticated( + pool: PgPool, + external_a: &str, + external_b: &str, +) -> TwoAuthenticated { + let harness = Arc::new(JwksHarness::spawn().await); + let base = spawn_app_with_jwt(pool.clone(), harness.verifier_arc()).await; + + let token_a = harness.mint(&good_claims(external_a), &header_with_kid(TEST_KID)); + let token_b = harness.mint(&good_claims(external_b), &header_with_kid(TEST_KID)); + + // Warm both users in via the lazy-provision middleware. + for token in [&token_a, &token_b] { + let resp = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles")) + .bearer_auth(token) + .send() + .await + .expect("warm-up request failed"); + assert!( + resp.status().is_success(), + "warm-up failed: {}", + resp.status() + ); + } + + let user_a: i64 = sqlx::query_scalar("SELECT id FROM users WHERE external_id = $1") + .bind(external_a) + .fetch_one(&pool) + .await + .expect("user A row missing after warm-up"); + let user_b: i64 = sqlx::query_scalar("SELECT id FROM users WHERE external_id = $1") + .bind(external_b) + .fetch_one(&pool) + .await + .expect("user B row missing after warm-up"); + + TwoAuthenticated { + base, + a: AuthenticatedCaller { + token: token_a, + user_id: user_a, + external_id: external_a.to_string(), + }, + b: AuthenticatedCaller { + token: token_b, + user_id: user_b, + external_id: external_b.to_string(), }, - ) - .await + harness, + pool, + } } diff --git a/tests/tracks.rs b/tests/tracks.rs index 5280485..792e43a 100644 --- a/tests/tracks.rs +++ b/tests/tracks.rs @@ -14,29 +14,12 @@ mod support; use reqwest::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; -use support::spawn_app; +use support::{spawn_authenticated, spawn_two_authenticated}; -/// Mint a user via `POST /api/v1/users`. Duplicated across test -/// files on purpose — each `tests/*.rs` is a separate Cargo crate, -/// so a `support`-side helper would force every file to import it. -async fn mint_user(base: &str) -> i64 { - let body: Value = reqwest::Client::new() - .post(format!("{base}/api/v1/users")) - .send() - .await - .expect("user create failed") - .error_for_status() - .expect("non-2xx on user create") - .json() - .await - .expect("user create body"); - body["id"].as_i64().expect("user id missing from response") -} - -async fn mint_profile(base: &str, user_id: i64, name: &str) -> i64 { +async fn mint_profile(base: &str, token: &str, name: &str) -> i64 { let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(token) .json(&json!({ "name": name, "color_id": "emerald" })) .send() .await @@ -49,10 +32,10 @@ async fn mint_profile(base: &str, user_id: i64, name: &str) -> i64 { created["id"].as_i64().expect("profile id missing") } -async fn mint_library(base: &str, user_id: i64, profile_id: i64, name: &str) -> i64 { +async fn mint_library(base: &str, token: &str, profile_id: i64, name: &str) -> i64 { let created: Value = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{profile_id}/libraries")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(token) .json(&json!({ "name": name })) .send() .await @@ -87,46 +70,20 @@ fn track_body(title: &str, file_path: &str) -> Value { }) } -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn tracks_require_x_user_id(pool: PgPool) { - let base = spawn_app(pool).await; - - for (method, path) in [ - ("GET", "/api/v1/profiles/1/libraries/1/tracks"), - ("POST", "/api/v1/profiles/1/libraries/1/tracks"), - ("GET", "/api/v1/profiles/1/libraries/1/tracks/1"), - ("PATCH", "/api/v1/profiles/1/libraries/1/tracks/1"), - ("DELETE", "/api/v1/profiles/1/libraries/1/tracks/1"), - ] { - let req = match method { - "GET" => reqwest::Client::new().get(format!("{base}{path}")), - "POST" => reqwest::Client::new() - .post(format!("{base}{path}")) - .json(&track_body("x", "/x.flac")), - "PATCH" => reqwest::Client::new() - .patch(format!("{base}{path}")) - .json(&json!({ "title": "x" })), - "DELETE" => reqwest::Client::new().delete(format!("{base}{path}")), - _ => unreachable!(), - }; - let resp = req.send().await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{method} {path}"); - } -} - #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_then_list_then_get_under_library(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Bandes-son").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Bandes-son").await; // Empty list initially. let list: Vec = reqwest::Client::new() .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -140,7 +97,7 @@ async fn create_then_list_then_get_under_library(pool: PgPool) { .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("Cosmic Dust", "/music/cosmic_dust.flac")) .send() .await @@ -164,7 +121,7 @@ async fn create_then_list_then_get_under_library(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -179,7 +136,7 @@ async fn create_then_list_then_get_under_library(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -192,10 +149,11 @@ async fn create_then_list_then_get_under_library(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_rejects_empty_title(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Lib").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Lib").await; for blank in ["", " ", "\t\n "] { let mut body = track_body(blank, "/x.flac"); @@ -204,7 +162,7 @@ async fn create_rejects_empty_title(pool: PgPool) { .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&body) .send() .await @@ -220,7 +178,7 @@ async fn create_rejects_empty_title(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -236,16 +194,17 @@ async fn create_rejects_empty_title(pool: PgPool) { /// before storage. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_rejects_empty_file_path(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Lib").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Lib").await; let resp = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("Title", " ")) .send() .await @@ -259,16 +218,17 @@ async fn create_rejects_empty_file_path(pool: PgPool) { /// parsing) — we accept either as long as it's a client error. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_rejects_out_of_range_rating(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Lib").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Lib").await; let id = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("Track", "/track.flac")) .send() .await @@ -285,7 +245,7 @@ async fn update_rejects_out_of_range_rating(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "rating": 256 })) .send() .await @@ -300,12 +260,15 @@ async fn update_rejects_out_of_range_rating(pool: PgPool) { /// Foreign library id under the calling user must 404. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn create_under_foreign_library_returns_404(pool: PgPool) { - let base = spawn_app(pool).await; - let user_a = mint_user(&base).await; - let user_b = mint_user(&base).await; - let profile_a = mint_profile(&base, user_a, "A").await; - let library_a = mint_library(&base, user_a, profile_a, "A's lib").await; - let profile_b = mint_profile(&base, user_b, "B").await; + let two = spawn_two_authenticated(pool, "test-user-a", "test-user-b").await; + let base = two.base.clone(); + let a = &two.a; + let b = &two.b; + let _user_a = a.user_id; + let _user_b = b.user_id; + let profile_a = mint_profile(&base, &a.token, "A").await; + let library_a = mint_library(&base, &a.token, profile_a, "A's lib").await; + let profile_b = mint_profile(&base, &b.token, "B").await; // User B tries to drop a track into user A's library, proxying // through their own profile. @@ -313,7 +276,7 @@ async fn create_under_foreign_library_returns_404(pool: PgPool) { .post(format!( "{base}/api/v1/profiles/{profile_b}/libraries/{library_a}/tracks" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .json(&track_body("stolen", "/stolen.flac")) .send() .await @@ -326,7 +289,7 @@ async fn create_under_foreign_library_returns_404(pool: PgPool) { .post(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{library_a}/tracks" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .json(&track_body("stolen", "/stolen2.flac")) .send() .await @@ -338,7 +301,7 @@ async fn create_under_foreign_library_returns_404(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{library_a}/tracks" )) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .send() .await .unwrap() @@ -353,20 +316,23 @@ async fn create_under_foreign_library_returns_404(pool: PgPool) { /// (profile, library) they try. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn tenants_are_isolated(pool: PgPool) { - let base = spawn_app(pool).await; - let user_a = mint_user(&base).await; - let user_b = mint_user(&base).await; - let profile_a = mint_profile(&base, user_a, "A").await; - let library_a = mint_library(&base, user_a, profile_a, "A's lib").await; - let profile_b = mint_profile(&base, user_b, "B").await; - let library_b = mint_library(&base, user_b, profile_b, "B's lib").await; + let two = spawn_two_authenticated(pool, "test-user-a", "test-user-b").await; + let base = two.base.clone(); + let a = &two.a; + let b = &two.b; + let _user_a = a.user_id; + let _user_b = b.user_id; + let profile_a = mint_profile(&base, &a.token, "A").await; + let library_a = mint_library(&base, &a.token, profile_a, "A's lib").await; + let profile_b = mint_profile(&base, &b.token, "B").await; + let library_b = mint_library(&base, &b.token, profile_b, "B's lib").await; // User A creates a track. let created: Value = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{library_a}/tracks" )) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .json(&track_body("A's track", "/a.flac")) .send() .await @@ -383,7 +349,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_b}/libraries/{library_b}/tracks" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap() @@ -403,7 +369,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{proxy_profile}/libraries/{proxy_library}/tracks" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap() @@ -419,7 +385,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{proxy_profile}/libraries/{proxy_library}/tracks/{track_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -436,7 +402,7 @@ async fn tenants_are_isolated(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{library_a}/tracks/{track_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .json(&json!({ "title": "hijacked" })) .send() .await @@ -447,7 +413,7 @@ async fn tenants_are_isolated(pool: PgPool) { .delete(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{library_a}/tracks/{track_id}" )) - .header("x-user-id", user_b.to_string()) + .bearer_auth(&b.token) .send() .await .unwrap(); @@ -458,7 +424,7 @@ async fn tenants_are_isolated(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_a}/libraries/{library_a}/tracks/{track_id}" )) - .header("x-user-id", user_a.to_string()) + .bearer_auth(&a.token) .send() .await .unwrap() @@ -474,16 +440,17 @@ async fn tenants_are_isolated(pool: PgPool) { /// `UPDATE … RETURNING …` path, not a stale read-back). #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_round_trips(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Lib").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Lib").await; let id = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("Original", "/original.flac")) .send() .await @@ -500,7 +467,7 @@ async fn update_round_trips(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "title": "Renamed", "rating": 200 })) .send() .await @@ -521,16 +488,17 @@ async fn update_round_trips(pool: PgPool) { /// `None` stays legitimate. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn update_rejects_empty_title(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Lib").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Lib").await; let id = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("Keep me", "/keep.flac")) .send() .await @@ -548,7 +516,7 @@ async fn update_rejects_empty_title(pool: PgPool) { .patch(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&json!({ "title": blank })) .send() .await @@ -565,7 +533,7 @@ async fn update_rejects_empty_title(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap() @@ -579,16 +547,17 @@ async fn update_rejects_empty_title(pool: PgPool) { #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn delete_returns_204_then_404(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Lib").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Lib").await; let id = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("doomed", "/doomed.flac")) .send() .await @@ -605,7 +574,7 @@ async fn delete_returns_204_then_404(pool: PgPool) { .delete(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -615,7 +584,7 @@ async fn delete_returns_204_then_404(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -628,20 +597,21 @@ async fn delete_returns_204_then_404(pool: PgPool) { /// CASCADE keyword" regression. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn library_delete_cascades_to_tracks(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; // Two libraries — one to delete with its tracks, one to keep // as a still-owned proxy for the post-delete probe. - let l1 = mint_library(&base, user_id, profile_id, "to delete").await; - let l2 = mint_library(&base, user_id, profile_id, "to keep").await; + let l1 = mint_library(&auth.base, &auth.token, profile_id, "to delete").await; + let l2 = mint_library(&auth.base, &auth.token, profile_id, "to keep").await; let track_id = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{l1}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("doomed track", "/doomed.flac")) .send() .await @@ -659,7 +629,7 @@ async fn library_delete_cascades_to_tracks(pool: PgPool) { .delete(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{l1}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -670,7 +640,7 @@ async fn library_delete_cascades_to_tracks(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{l1}/tracks/{track_id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -685,7 +655,7 @@ async fn library_delete_cascades_to_tracks(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{l2}/tracks/{track_id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -697,19 +667,20 @@ async fn library_delete_cascades_to_tracks(pool: PgPool) { /// surfaces here. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn profile_delete_cascades_to_tracks(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; // Two profiles so the delete-last-profile guard doesn't block // us; one to delete, one to keep as the post-delete probe path. - let p1 = mint_profile(&base, user_id, "to delete").await; - let p2 = mint_profile(&base, user_id, "to keep").await; - let l1 = mint_library(&base, user_id, p1, "lib").await; - let l2 = mint_library(&base, user_id, p2, "kept lib").await; + let p1 = mint_profile(&auth.base, &auth.token, "to delete").await; + let p2 = mint_profile(&auth.base, &auth.token, "to keep").await; + let l1 = mint_library(&auth.base, &auth.token, p1, "lib").await; + let l2 = mint_library(&auth.base, &auth.token, p2, "kept lib").await; let track_id = reqwest::Client::new() .post(format!("{base}/api/v1/profiles/{p1}/libraries/{l1}/tracks")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&track_body("doomed track", "/doomed.flac")) .send() .await @@ -725,7 +696,7 @@ async fn profile_delete_cascades_to_tracks(pool: PgPool) { // Delete the profile — should cascade through library to track. let resp = reqwest::Client::new() .delete(format!("{base}/api/v1/profiles/{p1}")) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -738,7 +709,7 @@ async fn profile_delete_cascades_to_tracks(pool: PgPool) { .get(format!( "{base}/api/v1/profiles/{p2}/libraries/{l2}/tracks/{track_id}" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .send() .await .unwrap(); @@ -752,17 +723,18 @@ async fn profile_delete_cascades_to_tracks(pool: PgPool) { /// rather than an accidental drift. #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn duplicate_file_path_under_same_library_fails(pool: PgPool) { - let base = spawn_app(pool).await; - let user_id = mint_user(&base).await; - let profile_id = mint_profile(&base, user_id, "Alice").await; - let library_id = mint_library(&base, user_id, profile_id, "Lib").await; + let auth = spawn_authenticated(pool, "test-user").await; + let base = auth.base.clone(); + let _user_id = auth.user_id; + let profile_id = mint_profile(&auth.base, &auth.token, "Alice").await; + let library_id = mint_library(&auth.base, &auth.token, profile_id, "Lib").await; let body = track_body("first", "/dup.flac"); let resp = reqwest::Client::new() .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&body) .send() .await @@ -773,7 +745,7 @@ async fn duplicate_file_path_under_same_library_fails(pool: PgPool) { .post(format!( "{base}/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks" )) - .header("x-user-id", user_id.to_string()) + .bearer_auth(&auth.token) .json(&body) .send() .await @@ -784,19 +756,3 @@ async fn duplicate_file_path_under_same_library_fails(pool: PgPool) { "duplicate file_path under the same library should currently 5xx" ); } - -/// Production-gate sanity: when `WAVEFLOW_DEV_AUTH` is off, the -/// nested route is gated by 503 just like every other `/api/v1/*` -/// endpoint. -#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] -async fn dev_auth_gate_returns_503_for_tracks_when_disabled(pool: PgPool) { - let base = support::spawn_app_prod_gate(pool).await; - - let resp = reqwest::Client::new() - .get(format!("{base}/api/v1/profiles/1/libraries/1/tracks")) - .header("x-user-id", "42") - .send() - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); -}