use axum::{
Extension,
extract::{Json, Path, State},
};
use crate::authz;
use crate::db::queries::members;
use crate::db::{DbPool, models::*};
use crate::error::LificError;
use crate::realtime::{RealtimeEvent, RealtimeHub};
use super::with_read;
pub(super) async fn list_project_members(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(project_id): Path<i64>,
) -> Result<Json<Vec<MemberWithUser>>, LificError> {
authz::require_role(&db, &identity, project_id, Role::Viewer)?;
with_read(&db, |conn| {
members::list_members_with_users(conn, project_id)
})
.map(Json)
}
pub(super) async fn my_project_role(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(project_id): Path<i64>,
) -> Result<Json<serde_json::Value>, LificError> {
authz::require_role(&db, &identity, project_id, Role::Viewer)?;
let enforced = authz::authz_enforced(&db)?;
let (role, is_admin) = with_read(&db, |conn| {
let auth_user = identity.as_ref().map(|i| i.user.clone());
let effective = authz::effective_user(conn, &auth_user);
let is_admin = matches!(&effective, Some(u) if u.is_admin);
let role = match &effective {
Some(u) => members::get_member_role(conn, project_id, u.id)?,
None => None,
};
Ok((role, is_admin))
})?;
Ok(Json(serde_json::json!({
"role": role.map(|r| r.as_str()),
"enforced": enforced,
"is_admin": is_admin,
})))
}
fn parse_role(raw: &str) -> Result<Role, LificError> {
match raw.trim().to_ascii_lowercase().as_str() {
"viewer" => Ok(Role::Viewer),
"maintainer" => Ok(Role::Maintainer),
"lead" => Ok(Role::Lead),
other => Err(LificError::BadRequest(format!("unknown role '{other}'"))),
}
}
pub(super) async fn add_project_member(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(project_id): Path<i64>,
headers: axum::http::HeaderMap,
Json(input): Json<AddMember>,
) -> Result<Json<ProjectMember>, LificError> {
authz::require_role(&db, &identity, project_id, Role::Lead)?;
let granter = super::require_user(&identity)?;
let session_token = crate::auth::recent_session_token(&headers)?;
let role = input.role.as_deref().unwrap_or("viewer").to_string();
let member = db.transaction(|tx| {
let fresh = crate::auth::revalidate_recent_session(tx, &session_token, granter.id)?;
let fresh_identity = Some(crate::auth::fresh_identity(
&fresh,
crate::actor::Transport::Web,
));
authz::require_role_conn(tx, &fresh_identity, project_id, Role::Lead)?;
members::add_member(tx, project_id, input.user_id, &role)
})?;
realtime.send(RealtimeEvent::ProjectUpdated { project_id });
Ok(Json(member))
}
pub(super) async fn update_project_member(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path((project_id, user_id)): Path<(i64, i64)>,
headers: axum::http::HeaderMap,
Json(input): Json<ChangeMemberRole>,
) -> Result<Json<ProjectMember>, LificError> {
authz::require_role(&db, &identity, project_id, Role::Lead)?;
let requested = parse_role(&input.role)?;
let session_token = crate::auth::recent_session_token(&headers).ok();
let granter = super::require_user(&identity).ok();
let member = db.transaction(|tx| {
let current = members::get_member_role(tx, project_id, user_id)?;
let is_increase = current.is_none_or(|role| requested > role);
if is_increase {
let (Some(token), Some(user)) = (&session_token, &granter) else {
return Err(LificError::Forbidden(
"recent authentication required".into(),
));
};
let fresh = crate::auth::revalidate_recent_session(tx, token, user.id)?;
let fresh_identity = Some(crate::auth::fresh_identity(
&fresh,
crate::actor::Transport::Web,
));
authz::require_role_conn(tx, &fresh_identity, project_id, Role::Lead)?;
} else {
let caller = super::require_user(&identity)?;
let fresh = crate::auth::fresh_caller(tx, caller.id)?;
let fresh_identity = Some(crate::auth::fresh_identity(
&fresh,
crate::actor::Transport::Web,
));
authz::require_role_conn(tx, &fresh_identity, project_id, Role::Lead)?;
}
members::change_role(tx, project_id, user_id, &input.role)
})?;
realtime.send(RealtimeEvent::ProjectUpdated { project_id });
Ok(Json(member))
}
pub(super) async fn remove_project_member(
State(db): State<DbPool>,
Extension(realtime): Extension<RealtimeHub>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path((project_id, user_id)): Path<(i64, i64)>,
) -> Result<Json<serde_json::Value>, LificError> {
authz::require_role(&db, &identity, project_id, Role::Lead)?;
let caller = super::require_user(&identity)?;
db.transaction(|tx| {
let fresh = crate::auth::fresh_caller(tx, caller.id)?;
let fresh_identity = Some(crate::auth::fresh_identity(
&fresh,
crate::actor::Transport::Web,
));
authz::require_role_conn(tx, &fresh_identity, project_id, Role::Lead)?;
members::remove_member_guarded(tx, project_id, user_id)
})?;
realtime.send(RealtimeEvent::ProjectUpdated { project_id });
Ok(Json(serde_json::json!({"deleted": true})))
}
#[cfg(test)]
mod tests {
use crate::api::test_helpers::*;
use crate::db::models::{CreateUser, Role};
use axum::http::StatusCode;
#[tokio::test]
async fn lead_can_add_change_and_remove_a_member() {
let (db, _admin, lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db.clone(), &lead);
let resp = json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": non_member.id }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let added = parse_json(resp).await;
assert_eq!(added["user_id"], non_member.id);
assert_eq!(added["role"], "viewer");
assert_eq!(added["project_id"], project_id);
let resp = json_patch(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", non_member.id),
serde_json::json!({ "role": "maintainer" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["role"], "maintainer");
let resp = json_delete(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", non_member.id),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let list =
parse_json(json_get(&lead_app, &format!("/api/projects/{project_id}/members")).await)
.await;
assert!(
list.as_array()
.unwrap()
.iter()
.all(|m| m["user_id"] != non_member.id),
"removed member must not appear in the list: {list:#?}"
);
}
#[tokio::test]
async fn added_member_with_explicit_role_is_honored() {
let (db, _admin, lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db, &lead);
let resp = json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": non_member.id, "role": "lead" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(parse_json(resp).await["role"], "lead");
}
#[tokio::test]
async fn maintainer_viewer_and_non_member_denied_on_add() {
let (db, _admin, _lead, maintainer, viewer, non_member, project_id) =
setup_membership_test();
let target = non_member.id;
for actor in [&maintainer, &viewer, &non_member] {
let app = app_as_user(db.clone(), actor);
let resp = json_post(
&app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": target }),
)
.await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"{} must be denied on add",
actor.username
);
}
}
#[tokio::test]
async fn maintainer_viewer_and_non_member_denied_on_patch_and_delete() {
let (db, _admin, lead, maintainer, viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db.clone(), &lead);
for actor in [&maintainer, &viewer, &non_member] {
let app = app_as_user(db.clone(), actor);
let resp = json_patch(
&app,
&format!("/api/projects/{project_id}/members/{}", maintainer.id),
serde_json::json!({ "role": "lead" }),
)
.await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"{} must be denied on patch",
actor.username
);
let resp = json_delete(
&app,
&format!("/api/projects/{project_id}/members/{}", viewer.id),
)
.await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"{} must be denied on delete",
actor.username
);
}
let resp = json_patch(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", viewer.id),
serde_json::json!({ "role": "maintainer" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn members_list_visible_to_viewer_denied_to_non_member() {
let (db, _admin, _lead, _maintainer, viewer, non_member, project_id) =
setup_membership_test();
let viewer_app = app_as_user(db.clone(), &viewer);
let resp = json_get(&viewer_app, &format!("/api/projects/{project_id}/members")).await;
assert_eq!(resp.status(), StatusCode::OK);
let list = parse_json(resp).await;
let arr = list.as_array().unwrap();
assert!(arr.iter().any(|m| m["username"] == "lead"));
assert!(arr.iter().any(|m| m["username"] == "maintainer"));
assert!(arr.iter().any(|m| m["username"] == "viewer"));
let non_member_app = app_as_user(db, &non_member);
assert_eq!(
json_get(
&non_member_app,
&format!("/api/projects/{project_id}/members")
)
.await
.status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn demoting_or_removing_the_sole_lead_is_rejected_until_a_second_lead_exists() {
let (db, _admin, lead, _maintainer, _viewer, _non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db.clone(), &lead);
let resp = json_patch(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", lead.id),
serde_json::json!({ "role": "maintainer" }),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let resp = json_delete(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", lead.id),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let resp = json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": _non_member.id, "role": "lead" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = json_patch(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", lead.id),
serde_json::json!({ "role": "maintainer" }),
)
.await;
assert_eq!(
resp.status(),
StatusCode::OK,
"demotion allowed once a second lead exists"
);
}
#[tokio::test]
async fn post_duplicate_member_is_409() {
let (db, _admin, lead, maintainer, _viewer, _non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db, &lead);
let resp = json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": maintainer.id }),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn post_unknown_user_is_404() {
let (db, _admin, lead, _maintainer, _viewer, _non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db, &lead);
let resp = json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": 999999 }),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn post_bad_role_is_400() {
let (db, _admin, lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db, &lead);
let resp = json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": non_member.id, "role": "owner" }),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_can_manage_members_of_a_project_they_are_not_in() {
let (db, admin, _lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let admin_app = app_as_user(db, &admin);
let resp = json_post(
&admin_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": non_member.id, "role": "maintainer" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = json_patch(
&admin_app,
&format!("/api/projects/{project_id}/members/{}", non_member.id),
serde_json::json!({ "role": "viewer" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = json_delete(
&admin_app,
&format!("/api/projects/{project_id}/members/{}", non_member.id),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn membership_changes_appear_in_project_activity_with_acting_user() {
let (db, _admin, lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db, &lead);
let lead_id = lead.id;
crate::actor::scope(
crate::actor::ActorCtx {
user_id: Some(lead_id),
transport: crate::actor::Transport::Web,
},
async {
json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": non_member.id, "role": "viewer" }),
)
.await;
json_patch(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", non_member.id),
serde_json::json!({ "role": "maintainer" }),
)
.await;
json_delete(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", non_member.id),
)
.await;
},
)
.await;
let feed = parse_json(
json_get(
&lead_app,
&format!("/api/projects/{project_id}/activity?limit=100"),
)
.await,
)
.await;
let items = feed["items"].as_array().unwrap();
let member_rows: Vec<&serde_json::Value> = items
.iter()
.filter(|a| a["entity_type"] == "member" && a["entity_id"] == non_member.id)
.collect();
assert!(
member_rows
.iter()
.any(|a| a["action"] == "create" && a["new_value"] == "viewer"),
"expected a member create row: {member_rows:#?}"
);
assert!(
member_rows.iter().any(|a| a["action"] == "update"
&& a["field"] == "role"
&& a["old_value"] == "viewer"
&& a["new_value"] == "maintainer"),
"expected a member role-change row: {member_rows:#?}"
);
assert!(
member_rows
.iter()
.any(|a| a["action"] == "delete" && a["old_value"] == "maintainer"),
"expected a member delete row: {member_rows:#?}"
);
assert!(
member_rows.iter().all(|a| a["actor_username"] == "lead"),
"every membership change must be attributed to the acting user: {member_rows:#?}"
);
}
#[tokio::test]
async fn my_role_reports_each_members_own_role_when_enforced() {
let (db, _admin, lead, maintainer, viewer, _non_member, project_id) =
setup_membership_test();
for (user, expected) in [
(&lead, "lead"),
(&maintainer, "maintainer"),
(&viewer, "viewer"),
] {
let app = app_as_user(db.clone(), user);
let resp = json_get(&app, &format!("/api/projects/{project_id}/my-role")).await;
assert_eq!(resp.status(), StatusCode::OK, "{} my-role", user.username);
let body = parse_json(resp).await;
assert_eq!(body["role"], expected, "{} role", user.username);
assert_eq!(body["enforced"], true);
assert_eq!(body["is_admin"], false);
}
}
#[tokio::test]
async fn my_role_denies_non_member_when_enforced() {
let (db, _admin, _lead, _maintainer, _viewer, non_member, project_id) =
setup_membership_test();
let app = app_as_user(db, &non_member);
let resp = json_get(&app, &format!("/api/projects/{project_id}/my-role")).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn my_role_reports_admin_bypass_when_enforced() {
let (db, admin, _lead, _maintainer, _viewer, _non_member, project_id) =
setup_membership_test();
let app = app_as_user(db, &admin);
let resp = json_get(&app, &format!("/api/projects/{project_id}/my-role")).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = parse_json(resp).await;
assert_eq!(body["is_admin"], true);
assert_eq!(body["enforced"], true);
assert!(body["role"].is_null());
}
#[tokio::test]
async fn my_role_reports_enforced_false_in_legacy_mode() {
let (db, _admin, _lead, regular, project_id) = setup_lead_test();
let app = app_as_user(db, ®ular);
let resp = json_get(&app, &format!("/api/projects/{project_id}/my-role")).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = parse_json(resp).await;
assert_eq!(body["enforced"], false);
assert_eq!(body["is_admin"], false);
assert!(body["role"].is_null());
}
#[tokio::test]
async fn flag_off_writes_stay_lead_gated_list_stays_open() {
let (db, admin, lead, regular, project_id) = setup_lead_test();
let regular_app = app_as_user(db.clone(), ®ular);
let resp = json_post(
®ular_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": admin.id }),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let resp = json_get(®ular_app, &format!("/api/projects/{project_id}/members")).await;
assert_eq!(resp.status(), StatusCode::OK);
let lead_app = app_as_user(db.clone(), &lead);
let resp = json_post(
&lead_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": regular.id }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = json_patch(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", regular.id),
serde_json::json!({ "role": "maintainer" }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = json_delete(
&lead_app,
&format!("/api/projects/{project_id}/members/{}", regular.id),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let admin_app = app_as_user(db, &admin);
let resp = json_post(
&admin_app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": regular.id }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn a_stale_admin_snapshot_cannot_grant_project_access() {
let (db, admin, lead, _maintainer, viewer, non_member, project_id) =
setup_membership_test();
let app = app_as_user(db.clone(), &admin);
{
let conn = db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "spare-admin".into(),
email: "spare@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
crate::db::queries::users::set_admin_guarded(&conn, admin.id, false).unwrap();
}
let add = json_post(
&app,
&format!("/api/projects/{project_id}/members"),
serde_json::json!({ "user_id": non_member.id, "role": "viewer" }),
)
.await;
assert_eq!(add.status(), StatusCode::FORBIDDEN, "add");
let raise = json_patch(
&app,
&format!("/api/projects/{project_id}/members/{}", viewer.id),
serde_json::json!({ "role": "maintainer" }),
)
.await;
assert_eq!(raise.status(), StatusCode::FORBIDDEN, "role increase");
let name_lead = json_put(
&app,
&format!("/api/projects/{project_id}"),
serde_json::json!({ "lead_user_id": non_member.id }),
)
.await;
assert_eq!(name_lead.status(), StatusCode::FORBIDDEN, "naming a lead");
let conn = db.read().unwrap();
assert_eq!(
crate::db::queries::members::get_member_role(&conn, project_id, non_member.id).unwrap(),
None,
"nobody was added"
);
assert_eq!(
crate::db::queries::members::get_member_role(&conn, project_id, viewer.id).unwrap(),
Some(Role::Viewer),
"no role was raised"
);
let _ = lead;
}
mod recent_auth {
use crate::api::test_helpers::*;
use crate::db::DbPool;
use crate::db::models::*;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
struct Fixture {
db: DbPool,
app: axum::Router,
project_id: i64,
lead: User,
viewer: User,
outsider: User,
session: String,
lead_key: String,
lead_oauth: String,
}
fn fixture() -> Fixture {
let (db, _admin, lead, _maintainer, viewer, outsider, project_id) =
setup_membership_test();
let manager = crate::auth::create_key_manager().unwrap();
let session = {
let conn = db.write().unwrap();
crate::db::queries::users::create_session(&conn, lead.id, None)
.unwrap()
.token
};
let lead_key =
crate::auth::create_api_key(&db, &manager, "lead-key", Some(lead.id)).unwrap();
let lead_oauth = {
let token = "lific_at_lead".to_string();
let hash = crate::auth::sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris)
VALUES ('c', 'Test', '[\"http://localhost\"]')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id)
VALUES (?1, 'c', ?2, 'mcp', ?3)",
rusqlite::params![hash, expires, lead.id],
)
.unwrap();
token
};
let auth_state = crate::auth::AuthState {
db: db.clone(),
manager,
public_url: "https://example.com".into(),
required: true,
};
let app = crate::api::router(db.clone(), &[])
.layer(axum::Extension(crate::realtime::RealtimeHub::new()))
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::middleware::from_fn_with_state(
auth_state,
crate::auth::require_api_key,
));
Fixture {
db,
app,
project_id,
lead,
viewer,
outsider,
session,
lead_key,
lead_oauth,
}
}
async fn send(
f: &Fixture,
method: &str,
uri: &str,
token: &str,
body: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value) {
let builder = Request::builder()
.method(method)
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json");
let request = match &body {
Some(value) => builder
.body(axum::body::Body::from(serde_json::to_vec(value).unwrap()))
.unwrap(),
None => builder.body(axum::body::Body::empty()).unwrap(),
};
let response = f.app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
)
}
fn role_of(f: &Fixture, user_id: i64) -> Option<Role> {
crate::db::queries::members::get_member_role(
&f.db.read().unwrap(),
f.project_id,
user_id,
)
.unwrap()
}
fn age_sessions(f: &Fixture) {
f.db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
}
async fn add_outsider(f: &Fixture, token: &str) -> StatusCode {
send(
f,
"POST",
&format!("/api/projects/{}/members", f.project_id),
token,
Some(serde_json::json!({ "user_id": f.outsider.id, "role": "viewer" })),
)
.await
.0
}
async fn set_role(f: &Fixture, token: &str, user_id: i64, role: &str) -> StatusCode {
send(
f,
"PATCH",
&format!("/api/projects/{}/members/{}", f.project_id, user_id),
token,
Some(serde_json::json!({ "role": role })),
)
.await
.0
}
#[tokio::test]
async fn a_recent_session_may_add_a_member_and_raise_a_role() {
let f = fixture();
assert_eq!(add_outsider(&f, &f.session).await, StatusCode::OK);
assert_eq!(role_of(&f, f.outsider.id), Some(Role::Viewer));
assert_eq!(
set_role(&f, &f.session, f.viewer.id, "maintainer").await,
StatusCode::OK
);
assert_eq!(role_of(&f, f.viewer.id), Some(Role::Maintainer));
}
#[tokio::test]
async fn an_aged_session_may_not_add_a_member_or_raise_a_role() {
let f = fixture();
age_sessions(&f);
assert_eq!(add_outsider(&f, &f.session).await, StatusCode::FORBIDDEN);
assert_eq!(role_of(&f, f.outsider.id), None, "nobody was added");
assert_eq!(
set_role(&f, &f.session, f.viewer.id, "maintainer").await,
StatusCode::FORBIDDEN
);
assert_eq!(role_of(&f, f.viewer.id), Some(Role::Viewer), "unchanged");
}
#[tokio::test]
async fn a_lead_api_key_may_not_add_a_member_or_raise_a_role() {
let f = fixture();
let (status, _) = send(
&f,
"GET",
&format!("/api/projects/{}/members", f.project_id),
&f.lead_key,
None,
)
.await;
assert_eq!(status, StatusCode::OK, "the key does lead this project");
assert_eq!(add_outsider(&f, &f.lead_key).await, StatusCode::FORBIDDEN);
assert_eq!(role_of(&f, f.outsider.id), None);
assert_eq!(
set_role(&f, &f.lead_key, f.viewer.id, "maintainer").await,
StatusCode::FORBIDDEN
);
assert_eq!(role_of(&f, f.viewer.id), Some(Role::Viewer));
}
#[tokio::test]
async fn an_oauth_token_may_not_add_a_member_or_raise_a_role() {
let f = fixture();
assert_eq!(add_outsider(&f, &f.lead_oauth).await, StatusCode::FORBIDDEN);
assert_eq!(role_of(&f, f.outsider.id), None);
assert_eq!(
set_role(&f, &f.lead_oauth, f.viewer.id, "maintainer").await,
StatusCode::FORBIDDEN
);
assert_eq!(role_of(&f, f.viewer.id), Some(Role::Viewer));
}
#[tokio::test]
async fn reductions_need_no_recent_authentication() {
let f = fixture();
age_sessions(&f);
{
let conn = f.db.write().unwrap();
crate::db::queries::members::change_role(
&conn,
f.project_id,
f.viewer.id,
"maintainer",
)
.unwrap();
}
assert_eq!(
set_role(&f, &f.lead_key, f.viewer.id, "viewer").await,
StatusCode::OK,
"a downgrade is a reduction"
);
assert_eq!(role_of(&f, f.viewer.id), Some(Role::Viewer));
assert_eq!(
set_role(&f, &f.lead_key, f.viewer.id, "viewer").await,
StatusCode::OK
);
let (status, _) = send(
&f,
"DELETE",
&format!("/api/projects/{}/members/{}", f.project_id, f.viewer.id),
&f.lead_key,
None,
)
.await;
assert_eq!(status, StatusCode::OK, "removal is a reduction");
assert_eq!(role_of(&f, f.viewer.id), None);
}
#[tokio::test]
async fn naming_a_project_lead_is_a_grant_and_needs_a_recent_session() {
let f = fixture();
let uri = format!("/api/projects/{}", f.project_id);
age_sessions(&f);
let (status, _) = send(
&f,
"PUT",
&uri,
&f.session,
Some(serde_json::json!({ "name": "Renamed" })),
)
.await;
assert_eq!(status, StatusCode::OK, "a rename grants nothing");
for token in [&f.session, &f.lead_key, &f.lead_oauth] {
let (status, _) = send(
&f,
"PUT",
&uri,
token,
Some(serde_json::json!({ "lead_user_id": f.outsider.id })),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(role_of(&f, f.outsider.id), None, "no membership was minted");
}
let (status, body) = send(
&f,
"PUT",
&uri,
&f.lead_key,
Some(serde_json::json!({ "lead_user_id": null })),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
}
#[tokio::test]
async fn a_recent_session_may_name_a_project_lead() {
let f = fixture();
let (status, body) = send(
&f,
"PUT",
&format!("/api/projects/{}", f.project_id),
&f.session,
Some(serde_json::json!({ "lead_user_id": f.outsider.id })),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(role_of(&f, f.outsider.id), Some(Role::Lead));
}
async fn create_project_led_by(
f: &Fixture,
token: &str,
identifier: &str,
lead: Option<i64>,
) -> (StatusCode, serde_json::Value) {
let mut body = serde_json::json!({
"name": format!("Project {identifier}"),
"identifier": identifier,
});
if let Some(lead) = lead {
body["lead_user_id"] = serde_json::json!(lead);
}
send(f, "POST", "/api/projects", token, Some(body)).await
}
fn lead_of(f: &Fixture, project_id: i64) -> Option<i64> {
f.db.read()
.unwrap()
.query_row(
"SELECT lead_user_id FROM projects WHERE id = ?1",
rusqlite::params![project_id],
|r| r.get(0),
)
.unwrap()
}
#[tokio::test]
async fn an_api_key_may_still_create_a_project_it_leads() {
let f = fixture();
age_sessions(&f);
let (status, body) = create_project_led_by(&f, &f.lead_key, "OWN", None).await;
assert_eq!(status, StatusCode::OK, "{body}");
let created = body["id"].as_i64().unwrap();
assert_eq!(
lead_of(&f, created),
Some(f.lead.id),
"the creator leads it by default"
);
let (status, body) =
create_project_led_by(&f, &f.lead_key, "SELF", Some(f.lead.id)).await;
assert_eq!(status, StatusCode::OK, "{body}");
}
#[tokio::test]
async fn a_key_or_token_may_not_create_a_project_led_by_someone_else() {
let f = fixture();
for token in [&f.lead_key, &f.lead_oauth] {
let (status, body) =
create_project_led_by(&f, token, "OTHER", Some(f.outsider.id)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
}
let conn = f.db.read().unwrap();
let projects: i64 = conn
.query_row(
"SELECT COUNT(*) FROM projects WHERE identifier = 'OTHER'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(projects, 0, "no project, and so no membership, was created");
let memberships: i64 = conn
.query_row(
"SELECT COUNT(*) FROM project_members WHERE user_id = ?1",
rusqlite::params![f.outsider.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(memberships, 0);
}
#[tokio::test]
async fn an_aged_session_may_not_create_a_project_led_by_someone_else() {
let f = fixture();
age_sessions(&f);
let (status, _) =
create_project_led_by(&f, &f.session, "AGED", Some(f.outsider.id)).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn a_recent_session_may_create_a_project_led_by_someone_else() {
let f = fixture();
let (status, body) =
create_project_led_by(&f, &f.session, "GIFT", Some(f.outsider.id)).await;
assert_eq!(status, StatusCode::OK, "{body}");
let created = body["id"].as_i64().unwrap();
assert_eq!(lead_of(&f, created), Some(f.outsider.id));
assert_eq!(
crate::db::queries::members::get_member_role(
&f.db.read().unwrap(),
created,
f.outsider.id
)
.unwrap(),
Some(Role::Lead),
);
}
#[tokio::test]
async fn a_stale_admin_snapshot_cannot_perform_reductions() {
let (db, admin, _lead, _maintainer, viewer, _non_member, project_id) =
setup_membership_test();
let app = app_as_user(db.clone(), &admin);
{
let conn = db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&CreateUser {
username: "spare-admin".into(),
email: "spare2@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
crate::db::queries::members::change_role(
&conn,
project_id,
viewer.id,
"maintainer",
)
.unwrap();
crate::db::queries::users::set_admin_guarded(&conn, admin.id, false).unwrap();
}
let downgrade = json_patch(
&app,
&format!("/api/projects/{project_id}/members/{}", viewer.id),
serde_json::json!({ "role": "viewer" }),
)
.await;
assert_eq!(downgrade.status(), StatusCode::FORBIDDEN, "downgrade");
let remove = json_delete(
&app,
&format!("/api/projects/{project_id}/members/{}", viewer.id),
)
.await;
assert_eq!(remove.status(), StatusCode::FORBIDDEN, "remove");
let clear_lead = json_put(
&app,
&format!("/api/projects/{project_id}"),
serde_json::json!({ "lead_user_id": null }),
)
.await;
assert_eq!(clear_lead.status(), StatusCode::FORBIDDEN, "clear the lead");
let rename = json_put(
&app,
&format!("/api/projects/{project_id}"),
serde_json::json!({ "name": "Renamed" }),
)
.await;
assert_eq!(rename.status(), StatusCode::FORBIDDEN, "rename");
let delete = json_delete(&app, &format!("/api/projects/{project_id}")).await;
assert_eq!(delete.status(), StatusCode::FORBIDDEN, "delete the project");
let conn = db.read().unwrap();
assert_eq!(
crate::db::queries::members::get_member_role(&conn, project_id, viewer.id).unwrap(),
Some(Role::Maintainer),
);
let still_there: i64 = conn
.query_row(
"SELECT COUNT(*) FROM projects WHERE id = ?1",
rusqlite::params![project_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(still_there, 1, "the project survived");
}
#[tokio::test]
async fn losing_lead_between_requests_stops_the_next_grant() {
let f = fixture();
assert_eq!(add_outsider(&f, &f.session).await, StatusCode::OK);
{
let conn = f.db.write().unwrap();
crate::db::queries::members::change_role(
&conn,
f.project_id,
f.outsider.id,
"lead",
)
.unwrap();
crate::db::queries::members::change_role(&conn, f.project_id, f.lead.id, "viewer")
.unwrap();
}
assert_eq!(
set_role(&f, &f.session, f.viewer.id, "maintainer").await,
StatusCode::FORBIDDEN,
"a former lead may not raise anyone"
);
assert_eq!(role_of(&f, f.viewer.id), Some(Role::Viewer), "unchanged");
}
}
}