mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
447 lines
15 KiB
Rust
447 lines
15 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
#![recursion_limit = "256"]
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
body::{Body, to_bytes},
|
|
extract::State,
|
|
http::{Method, Request, StatusCode, Uri, header},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use fluxer_admin::{
|
|
build_router,
|
|
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
|
session,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::net::TcpListener;
|
|
use tower::ServiceExt;
|
|
|
|
const SECRET_KEY: &str = "password-reset-link-test-secret";
|
|
const ADMIN_ID: &str = "1500000000000000000";
|
|
const TARGET_ID: &str = "1500000000000000042";
|
|
const RESET_URL: &str = "https://chat.example.test/reset#token=one-time-reset-token";
|
|
|
|
#[derive(Clone)]
|
|
struct MockApi {
|
|
account_identity: &'static str,
|
|
admin_acls: Vec<&'static str>,
|
|
requests: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn creating_a_reset_link_shows_the_url_once_with_a_copy_button() {
|
|
let app = setup(true, "username", vec!["*"]).await;
|
|
let csrf_token = csrf_token(&app).await;
|
|
let (status, body) = post_form(
|
|
&app,
|
|
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
|
|
&format!("_csrf={csrf_token}"),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert!(app.saw(&format!(
|
|
"POST /admin/users/{TARGET_ID}/password-reset-link"
|
|
)));
|
|
assert!(body.contains("Copy this link now. It is shown only once."));
|
|
assert!(body.contains(&format!(r#"value="{RESET_URL}""#)));
|
|
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
|
|
assert!(body.contains("Copy Link"));
|
|
|
|
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Create Password Reset Link"));
|
|
assert!(!page.contains(RESET_URL));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_htmx_reset_link_request_gets_only_the_result_fragment() {
|
|
let app = setup(true, "username", vec!["*"]).await;
|
|
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains(r##"hx-target="#password-reset-link-result""##));
|
|
assert!(page.contains(r#"hx-push-url="false""#));
|
|
let csrf_token = csrf_token(&app).await;
|
|
let (status, body) = post_form_with_headers(
|
|
&app,
|
|
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
|
|
&format!("_csrf={csrf_token}"),
|
|
&[
|
|
("HX-Request", "true"),
|
|
("HX-Target", "password-reset-link-result"),
|
|
],
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert!(
|
|
body.starts_with(r#"<div id="password-reset-link-result""#),
|
|
"{body}"
|
|
);
|
|
assert!(body.contains(r#"hx-history="false""#));
|
|
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
|
|
assert!(!body.contains("<html"));
|
|
assert!(!body.contains("Create Password Reset Link"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn revoking_a_recovery_kit_calls_the_api() {
|
|
let app = setup(true, "username", vec!["*"]).await;
|
|
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Revoke Recovery Kit"));
|
|
let csrf_token = csrf_token(&app).await;
|
|
let (status, _) = post_form(
|
|
&app,
|
|
&format!("/users/{TARGET_ID}?action=revoke_recovery_kit&tab=account"),
|
|
&format!("_csrf={csrf_token}"),
|
|
)
|
|
.await;
|
|
assert!(status.is_redirection() || status.is_success(), "{status}");
|
|
assert!(app.saw(&format!("DELETE /admin/users/{TARGET_ID}/recovery-kit")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_revoke_recovery_kit_action_needs_its_acl_and_a_username_instance() {
|
|
let without_acl = setup(
|
|
true,
|
|
"username",
|
|
vec![
|
|
"admin:authenticate",
|
|
"user:lookup",
|
|
"user:create:password_reset_link",
|
|
],
|
|
)
|
|
.await;
|
|
let page = get(&without_acl, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Create Password Reset Link"));
|
|
assert!(!page.contains("Revoke Recovery Kit"));
|
|
|
|
let with_acl = setup(
|
|
true,
|
|
"username",
|
|
vec![
|
|
"admin:authenticate",
|
|
"user:lookup",
|
|
"user:delete:recovery_kit",
|
|
],
|
|
)
|
|
.await;
|
|
let page = get(&with_acl, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Revoke Recovery Kit"));
|
|
|
|
let email = setup(true, "email", vec!["*"]).await;
|
|
let page = get(&email, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(!page.contains("Revoke Recovery Kit"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn username_instances_hide_email_actions_on_the_account_tab() {
|
|
let app = setup(true, "username", vec!["*"]).await;
|
|
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Create Password Reset Link"));
|
|
assert!(!page.contains("Send Password Reset"));
|
|
assert!(!page.contains("Change Email"));
|
|
assert!(!page.contains("Verify Email"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_reset_link_action_needs_its_acl() {
|
|
let app = setup(true, "username", vec!["admin:authenticate", "user:lookup"]).await;
|
|
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Terminate All Sessions"));
|
|
assert!(!page.contains("Create Password Reset Link"));
|
|
assert!(!page.contains("Send Password Reset"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn email_instances_keep_the_email_actions() {
|
|
let app = setup(true, "email", vec!["*"]).await;
|
|
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Send Password Reset"));
|
|
assert!(page.contains("Change Email"));
|
|
assert!(page.contains("Verify Email"));
|
|
assert!(!page.contains("Create Password Reset Link"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_email_ban_notice_stays_after_a_ban_action_on_a_username_instance() {
|
|
let notice = "Accounts have no email address, so email bans have no effect.";
|
|
let username = setup(true, "username", vec!["*"]).await;
|
|
let username_csrf = csrf_token(&username).await;
|
|
let (status, body) = post_form(
|
|
&username,
|
|
"/email-bans?action=ban",
|
|
&format!("_csrf={username_csrf}&email="),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert!(body.contains("Value is required"));
|
|
assert!(body.contains(notice));
|
|
|
|
let email = setup(true, "email", vec!["*"]).await;
|
|
let email_csrf = csrf_token(&email).await;
|
|
let (_, body) = post_form(
|
|
&email,
|
|
"/email-bans?action=ban",
|
|
&format!("_csrf={email_csrf}&email="),
|
|
)
|
|
.await;
|
|
assert!(body.contains("Value is required"));
|
|
assert!(!body.contains(notice));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hosted_admin_never_asks_discovery_for_the_sign_in_method() {
|
|
let app = setup(false, "username", vec!["*"]).await;
|
|
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
|
assert!(page.contains("Send Password Reset"));
|
|
assert!(!page.contains("Create Password Reset Link"));
|
|
assert!(!app.saw("GET /.well-known/fluxer"));
|
|
}
|
|
|
|
struct TestApp {
|
|
router: Router,
|
|
session_cookie: String,
|
|
requests: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl TestApp {
|
|
fn saw(&self, route: &str) -> bool {
|
|
self.requests
|
|
.lock()
|
|
.expect("requests")
|
|
.iter()
|
|
.any(|seen| seen == route)
|
|
}
|
|
}
|
|
|
|
async fn setup(
|
|
self_hosted: bool,
|
|
account_identity: &'static str,
|
|
admin_acls: Vec<&'static str>,
|
|
) -> TestApp {
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let api_endpoint = spawn_mock_api(MockApi {
|
|
account_identity,
|
|
admin_acls,
|
|
requests: Arc::clone(&requests),
|
|
})
|
|
.await;
|
|
let router = build_router(test_config(api_endpoint, self_hosted));
|
|
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
|
|
TestApp {
|
|
router,
|
|
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
|
requests,
|
|
}
|
|
}
|
|
|
|
async fn get(app: &TestApp, uri: &str) -> String {
|
|
let response = app
|
|
.router
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri(uri)
|
|
.header(header::COOKIE, &app.session_cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK, "{uri}");
|
|
body_text(response).await
|
|
}
|
|
|
|
async fn csrf_token(app: &TestApp) -> String {
|
|
let response = app
|
|
.router
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri(format!("/users/{TARGET_ID}?tab=account"))
|
|
.header(header::COOKIE, &app.session_cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
response
|
|
.headers()
|
|
.get_all(header::SET_COOKIE)
|
|
.iter()
|
|
.filter_map(|value| value.to_str().ok())
|
|
.find_map(|value| {
|
|
let pair = value.split(';').next()?;
|
|
let token = pair
|
|
.strip_prefix("__Host-csrf_token=")
|
|
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
|
(!token.is_empty()).then(|| token.to_owned())
|
|
})
|
|
.expect("csrf_token cookie")
|
|
}
|
|
|
|
async fn post_form(app: &TestApp, uri: &str, body: &str) -> (StatusCode, String) {
|
|
post_form_with_headers(app, uri, body, &[]).await
|
|
}
|
|
|
|
async fn post_form_with_headers(
|
|
app: &TestApp,
|
|
uri: &str,
|
|
body: &str,
|
|
headers: &[(&str, &str)],
|
|
) -> (StatusCode, String) {
|
|
let csrf = body
|
|
.split('&')
|
|
.find_map(|pair| pair.strip_prefix("_csrf="))
|
|
.expect("form carries a csrf token");
|
|
let mut request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.header(
|
|
header::COOKIE,
|
|
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
|
|
);
|
|
for (name, value) in headers {
|
|
request = request.header(*name, *value);
|
|
}
|
|
let response = app
|
|
.router
|
|
.clone()
|
|
.oneshot(request.body(Body::from(body.to_owned())).unwrap())
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
(status, body_text(response).await)
|
|
}
|
|
|
|
async fn body_text(response: Response) -> String {
|
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
String::from_utf8(bytes.to_vec()).unwrap()
|
|
}
|
|
|
|
async fn spawn_mock_api(mock: MockApi) -> String {
|
|
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, Router::new().fallback(mock_api).with_state(mock))
|
|
.await
|
|
.unwrap();
|
|
});
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
|
|
let path = uri.path().to_owned();
|
|
mock.requests
|
|
.lock()
|
|
.expect("requests")
|
|
.push(format!("{method} {path}"));
|
|
let target_user = format!("/admin/users/{TARGET_ID}");
|
|
let target_sessions = format!("{target_user}/sessions");
|
|
let target_credentials = format!("{target_user}/webauthn-credentials");
|
|
let target_reset_link = format!("{target_user}/password-reset-link");
|
|
let target_recovery_kit = format!("{target_user}/recovery-kit");
|
|
match (method, path.as_str()) {
|
|
(Method::GET, "/admin/users/@me") => Json(json!({
|
|
"user": user(ADMIN_ID, "AdminUser", &mock.admin_acls)
|
|
}))
|
|
.into_response(),
|
|
(Method::GET, "/.well-known/fluxer") => Json(json!({
|
|
"features": {
|
|
"premium_enabled": false,
|
|
"account_identity": mock.account_identity
|
|
}
|
|
}))
|
|
.into_response(),
|
|
(Method::GET, p) if p == target_user => Json(json!({
|
|
"users": [user(TARGET_ID, "member", &[])]
|
|
}))
|
|
.into_response(),
|
|
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
|
|
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
|
|
(Method::POST, p) if p == target_reset_link => Json(json!({
|
|
"url": RESET_URL,
|
|
"expires_at": "2026-10-01T13:00:00.000Z"
|
|
}))
|
|
.into_response(),
|
|
(Method::DELETE, p) if p == target_recovery_kit => StatusCode::NO_CONTENT.into_response(),
|
|
_ => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({ "message": "not found" })),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
fn user(id: &str, username: &str, acls: &[&str]) -> Value {
|
|
json!({
|
|
"id": id,
|
|
"username": username,
|
|
"discriminator": 1,
|
|
"avatar": null,
|
|
"banner": null,
|
|
"email": null,
|
|
"email_verified": false,
|
|
"email_bounced": false,
|
|
"global_name": username,
|
|
"bio": null,
|
|
"pronouns": null,
|
|
"accent_color": null,
|
|
"date_of_birth": null,
|
|
"locale": "en-GB",
|
|
"acls": acls,
|
|
"traits": [],
|
|
"flags": "0",
|
|
"premium_flags": 0,
|
|
"bot": false,
|
|
"system": false,
|
|
"premium_type": null,
|
|
"premium_since": null,
|
|
"premium_until": null,
|
|
"premium_grace_ends_at": null,
|
|
"premium_lifetime_sequence": null,
|
|
"has_totp": false,
|
|
"authenticator_types": [],
|
|
"temp_banned_until": null,
|
|
"pending_deletion_at": null,
|
|
"pending_bulk_message_deletion_at": null,
|
|
"deletion_reason_code": null,
|
|
"deletion_public_reason": null,
|
|
"deletion_audit_log_reason": null,
|
|
"deletion_scheduled_by": null,
|
|
"deletion_scheduled_at": null,
|
|
"last_active_at": null,
|
|
"last_active_ip": null,
|
|
"last_active_ip_reverse": null,
|
|
"last_active_location": null
|
|
})
|
|
}
|
|
|
|
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
|
|
AdminConfig {
|
|
env: RuntimeEnv::Test,
|
|
host: "127.0.0.1".to_owned(),
|
|
port: 0,
|
|
secret_key_base: SECRET_KEY.to_owned(),
|
|
base_path: String::new(),
|
|
api_endpoint,
|
|
media_endpoint: "https://media.example.test".to_owned(),
|
|
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
|
admin_endpoint: "https://admin.example.test".to_owned(),
|
|
web_app_endpoint: "https://app.example.test".to_owned(),
|
|
oauth_client_id: "admin-client".to_owned(),
|
|
oauth_client_secret: "admin-secret".to_owned(),
|
|
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
|
build_version: "test".to_owned(),
|
|
self_hosted,
|
|
proxy: ProxyConfig {
|
|
trust_client_ip_header: false,
|
|
client_ip_header_name: "x-forwarded-for".to_owned(),
|
|
},
|
|
}
|
|
}
|