Add an authenticated POST /api/v1/invites endpoint that mints a single-use, 7-day registration link after the first-user bootstrap. Expose it via a new "Invite" section on the admin page and a `brewlog invite create` CLI command. The registration_tokens table, repository, and NewRegistrationToken already supported additional tokens; this adds the authenticated surfaces (session cookie or bearer token) to trigger creation.
441 lines
12 KiB
Rust
441 lines
12 KiB
Rust
use reqwest::{Client, StatusCode};
|
|
use serde_json::json;
|
|
|
|
use crate::helpers::{spawn_app, spawn_app_with_auth};
|
|
|
|
#[tokio::test]
|
|
async fn test_create_token_requires_authentication() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
|
|
// Token creation now requires session or bearer token auth
|
|
let response = client
|
|
.post(&app.api_url("/tokens"))
|
|
.json(&json!({ "name": "test-token" }))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_create_token_with_bearer_auth() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
// Create a token using bearer auth
|
|
let response = client
|
|
.post(&app.api_url("/tokens"))
|
|
.bearer_auth(auth_token)
|
|
.json(&json!({ "name": "new-token" }))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
|
|
assert!(body.get("id").is_some());
|
|
assert_eq!(body.get("name").unwrap(), "new-token");
|
|
assert!(body.get("token").is_some());
|
|
|
|
// Token should be a non-empty string
|
|
let token = body.get("token").unwrap().as_str().unwrap();
|
|
assert!(!token.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_tokens_requires_authentication() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
|
|
// Try to list tokens without authentication
|
|
let response = client
|
|
.get(&app.api_url("/tokens"))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_tokens_with_authentication() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
// List tokens with authentication
|
|
let response = client
|
|
.get(&app.api_url("/tokens"))
|
|
.bearer_auth(auth_token)
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let tokens: Vec<serde_json::Value> = response.json().await.expect("Failed to parse response");
|
|
// We expect at least 1 token (the one created by spawn_app_with_auth)
|
|
assert!(
|
|
!tokens.is_empty(),
|
|
"Should have at least one token from test setup"
|
|
);
|
|
let test_token = tokens
|
|
.iter()
|
|
.find(|t| t.get("name").unwrap() == "test-token")
|
|
.expect("Could not find test-token");
|
|
assert_eq!(test_token.get("name").unwrap(), "test-token");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_revoke_token() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
// Create a new token to revoke
|
|
let create_response = client
|
|
.post(&app.api_url("/tokens"))
|
|
.bearer_auth(auth_token)
|
|
.json(&json!({ "name": "token-to-revoke" }))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
let create_body: serde_json::Value = create_response
|
|
.json()
|
|
.await
|
|
.expect("Failed to parse response");
|
|
let token_id = create_body.get("id").unwrap().as_i64().unwrap();
|
|
|
|
// Revoke the token
|
|
let response = client
|
|
.post(&app.api_url(&format!("/tokens/{}/revoke", token_id)))
|
|
.bearer_auth(auth_token)
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
|
|
assert!(body.get("revoked_at").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_revoked_token_cannot_be_used() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
// Create a new token
|
|
let create_response = client
|
|
.post(&app.api_url("/tokens"))
|
|
.bearer_auth(auth_token)
|
|
.json(&json!({ "name": "will-be-revoked" }))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
let create_body: serde_json::Value = create_response
|
|
.json()
|
|
.await
|
|
.expect("Failed to parse response");
|
|
let new_token = create_body.get("token").unwrap().as_str().unwrap();
|
|
let token_id = create_body.get("id").unwrap().as_i64().unwrap();
|
|
|
|
// Revoke it using the original auth token
|
|
client
|
|
.post(&app.api_url(&format!("/tokens/{}/revoke", token_id)))
|
|
.bearer_auth(auth_token)
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
// Try to use the revoked token
|
|
let response = client
|
|
.get(&app.api_url("/tokens"))
|
|
.header("Authorization", format!("Bearer {}", new_token))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_protected_endpoints_require_authentication() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
|
|
// Try to create a roaster without authentication
|
|
let response = client
|
|
.post(&app.api_url("/roasters"))
|
|
.json(&json!({
|
|
"name": "Test Roaster",
|
|
"country": "UK"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_protected_endpoints_work_with_authentication() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
// Create a roaster with authentication
|
|
let response = client
|
|
.post(&app.api_url("/roasters"))
|
|
.bearer_auth(auth_token)
|
|
.json(&json!({
|
|
"name": "Test Roaster",
|
|
"country": "UK"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::CREATED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_invalid_session_cookie_fails() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = reqwest::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap();
|
|
|
|
// Try to create a roaster with no session (unauthenticated)
|
|
let response = client
|
|
.post(&app.api_url("/roasters"))
|
|
.json(&json!({
|
|
"name": "Invalid Session Roaster",
|
|
"country": "UK"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Request without valid session should fail"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_fake_session_cookie_fails() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = reqwest::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap();
|
|
|
|
// Try to use a fake/forged session cookie
|
|
let response = client
|
|
.post(&app.api_url("/roasters"))
|
|
.header("Cookie", "brewlog_session=fake_session_token_12345")
|
|
.json(&json!({
|
|
"name": "Fake Session Roaster",
|
|
"country": "IT"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Fake session cookie should not authenticate"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_read_endpoints_dont_require_authentication() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
|
|
// List roasters without authentication should work
|
|
let response = client
|
|
.get(&app.api_url("/roasters"))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
}
|
|
|
|
// --- Invite endpoint tests ---
|
|
|
|
#[tokio::test]
|
|
async fn create_invite_requires_authentication() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
|
|
let response = client
|
|
.post(&app.api_url("/invites"))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_invite_with_bearer_auth_returns_link() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
let response = client
|
|
.post(&app.api_url("/invites"))
|
|
.bearer_auth(auth_token)
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
|
|
|
|
let url = body.get("url").unwrap().as_str().unwrap();
|
|
assert!(
|
|
url.contains("/register/"),
|
|
"invite url should point at the register page, got {url}"
|
|
);
|
|
|
|
let token = body.get("token").unwrap().as_str().unwrap();
|
|
assert!(!token.is_empty(), "invite token should be non-empty");
|
|
assert!(
|
|
url.ends_with(token),
|
|
"invite url should end with the raw token"
|
|
);
|
|
|
|
assert!(
|
|
body.get("expires_at").is_some(),
|
|
"invite response should include an expiry"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn created_invite_link_opens_register_page() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
let create: serde_json::Value = client
|
|
.post(&app.api_url("/invites"))
|
|
.bearer_auth(auth_token)
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request")
|
|
.json()
|
|
.await
|
|
.expect("Failed to parse response");
|
|
|
|
let token = create.get("token").unwrap().as_str().unwrap();
|
|
|
|
// The freshly minted token must be redeemable at the register page.
|
|
let response = client
|
|
.get(&app.page_url(&format!("/register/{token}")))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_invite_datastar_returns_signals() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
let auth_token = app.auth_token.as_ref().unwrap();
|
|
|
|
let response = client
|
|
.post(&app.api_url("/invites"))
|
|
.bearer_auth(auth_token)
|
|
.header("datastar-request", "true")
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let body = response.text().await.expect("Failed to read response");
|
|
assert!(
|
|
body.contains("_inviteUrl") && body.contains("_inviteCreated"),
|
|
"datastar response should patch invite signals, got {body}"
|
|
);
|
|
}
|
|
|
|
// --- Admin passkey endpoint tests ---
|
|
|
|
#[tokio::test]
|
|
async fn list_passkeys_requires_auth() {
|
|
let app = spawn_app().await;
|
|
let client = Client::new();
|
|
|
|
let response = client
|
|
.get(&app.api_url("/passkeys"))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_passkeys_returns_empty_for_user_without_passkeys() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
|
|
let response = client
|
|
.get(&app.api_url("/passkeys"))
|
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let passkeys: Vec<serde_json::Value> = response.json().await.expect("Failed to parse response");
|
|
assert!(passkeys.is_empty(), "expected empty passkeys list");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_passkey_nonexistent_returns_404() {
|
|
let app = spawn_app_with_auth().await;
|
|
let client = Client::new();
|
|
|
|
let response = client
|
|
.delete(&app.api_url("/passkeys/99999"))
|
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_passkey_requires_auth() {
|
|
let app = spawn_app().await;
|
|
let client = Client::new();
|
|
|
|
let response = client
|
|
.delete(&app.api_url("/passkeys/1"))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|