backend signup

This commit is contained in:
2025-10-30 01:15:26 +01:00
parent 4d14d5a3d4
commit 84f4cd05f4
8 changed files with 458 additions and 142 deletions
+61 -56
View File
@@ -7,10 +7,9 @@ use backend::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use backend::jobs::JOB_PROVISION_TENANT;
use backend::models::{Job, NewUserMembership, TenantStatus, UserPasskey};
use backend::models::{NewUserMembership, TenantStatus, UserPasskey};
use backend::openapi::schemas::PasskeySummary;
use backend::schema::{jobs, tenants, user_memberships, users};
use backend::schema::{tenants, user_memberships, users};
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize;
@@ -43,6 +42,12 @@ struct LoginResponse {
tenant: LoginTenant,
}
#[derive(Deserialize)]
struct SignupStartResponse {
signup_token: String,
challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize)]
struct TenantSelectionResponse {
access_token: String,
@@ -93,72 +98,40 @@ async fn login_rejects_unknown_user() -> Result<()> {
}
#[tokio::test]
async fn signup_creates_user_tenant_and_membership() -> Result<()> {
async fn signup_start_and_finish_require_valid_passkey() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({
"username": "signup-user",
"password": "super-secret",
});
let payload = json!({ "username": "signup-user" });
let response = app.post_json("/api/auth/signup", &payload, None).await?;
let response = app
.post_json("/api/auth/signup/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&body)?;
let start: SignupStartResponse = serde_json::from_slice(&body)?;
assert!(!start.signup_token.is_empty());
assert_ne!(start.challenge.challenge_id, Uuid::nil());
// session works immediately
let me = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(me.status(), StatusCode::OK);
app.with_conn(move |conn| {
let user: backend::models::User = users::table
.filter(users::username.eq("signup-user"))
.first(conn)?;
let tenant: backend::models::Tenant = tenants::table
.filter(tenants::name.eq("signup-user"))
.first(conn)?;
assert_eq!(tenant.status, TenantStatus::Creating);
assert_eq!(tenant.created_by, Some(user.id));
let membership_exists: bool = diesel::select(diesel::dsl::exists(
user_memberships::table
.filter(user_memberships::user_id.eq(user.id))
.filter(user_memberships::tenant_id.eq(tenant.id)),
app.with_conn(|conn| {
let exists: bool = diesel::select(diesel::dsl::exists(
users::table.filter(users::username.eq("signup-user")),
))
.get_result(conn)?;
assert!(
!membership_exists,
"membership should be enqueued, not created synchronously"
);
let job: Job = jobs::table
.filter(jobs::tenant_id.eq(tenant.id))
.filter(jobs::job_type.eq(JOB_PROVISION_TENANT))
.first(conn)
.context("provision job missing")?;
let members = job
.payload
.get("members")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
assert!(members.iter().any(|value| {
value
.as_str()
.and_then(|id| Uuid::parse_str(id).ok())
.map(|parsed| parsed == user.id)
.unwrap_or(false)
}));
assert!(!exists);
Ok(())
})
.await?;
let finish_payload = json!({
"signup_token": start.signup_token,
"credential": fake_register_credential(),
});
let finish_response = app
.post_json("/api/auth/signup/finish", &finish_payload, None)
.await?;
assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
@@ -329,6 +302,7 @@ async fn delete_passkey_soft_revokes() -> Result<()> {
let password = "secret";
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
app.insert_passkey(user_id, Some("Backup")).await?;
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
let response = app
@@ -355,6 +329,37 @@ async fn delete_passkey_soft_revokes() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn delete_passkey_prevents_last() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-guard", password, "admin").await?;
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
let response = app
.delete(
&format!("/api/profile/passkeys/{}", first_id),
Some(&session.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let block_response = app
.delete(
&format!("/api/profile/passkeys/{}", last_id),
Some(&session.access_token),
)
.await?;
assert_eq!(block_response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
fn fake_register_credential() -> RegisterPublicKeyCredential {
RegisterPublicKeyCredential {
id: "fake-passkey".to_string(),