#![allow(
clippy::expect_used,
reason = "Fixture setup and response assertions require successful fallible construction."
)]
use super::*;
use crate::plugins::organization::{OrganizationPlugin, types::*};
use crate::plugins::test_helpers;
use better_auth_core::{
CreateMember, CreateOrganization, CreateSession, CreateUser, FieldMap, FieldValue, HttpMethod,
SchemaValue,
config::{FieldTransforms, UserFieldConfig, UserFieldTransform},
id::IdGeneration,
plugin::AuthPlugin,
session::NativeSessionData,
store::{EphemeralStore, StatelessSchema},
};
use chrono::{Duration, Utc};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
pub(super) async fn context(
config: better_auth_core::AuthConfig,
organization: &OrganizationConfig,
) -> AuthContext<StatelessSchema> {
let config = Arc::new(config);
test_helpers::initialize_test_context(
config.clone(),
Arc::new(EphemeralStore::new(config)),
&[&OrganizationPlugin::with_config(organization.clone())],
)
.await
.unwrap()
}
pub(super) async fn session(
ctx: &AuthContext<StatelessSchema>,
user: &better_auth_core::wire::UserView,
) -> NativeSessionData {
let session = ctx
.database
.create_session(CreateSession {
inherited_fields: Default::default(),
additional_fields: Default::default(),
user_id: user.id.clone(),
expires_at: (Utc::now() + Duration::hours(1)).into(),
ip_address: None,
user_agent: None,
impersonated_by: None,
active_organization_id: None,
})
.await
.unwrap();
NativeSessionData::new(session, FieldMap::from(user.clone()).into())
}
pub(super) fn assert_coded_error(error: AuthError, status: u16, expected: &[u8]) {
assert!(error.is_api_error().unwrap());
assert_eq!(error.status_code().unwrap(), status);
let response = error.to_auth_response().expect("fixture response succeeds");
assert_eq!(
response.api_error_status().unwrap(),
better_auth_core::SchemaValue::Typed(status)
);
assert_eq!(
better_auth_core::ResponseBody::Native(response.api_error_body().unwrap())
.bytes()
.unwrap()
.as_ref(),
expected
);
}
#[tokio::test]
async fn full_organization_keeps_native_dates_undefined_and_response_identity() {
let config = OrganizationConfig::default();
let ctx = context(test_helpers::create_test_config(), &config).await;
let user = ctx
.database
.create_user(CreateUser::new().with_email("full-native@example.test"))
.await
.unwrap();
let data = session(&ctx, &user).await;
let organization = ctx
.database
.create_organization(CreateOrganization::new("Native", "full-native"))
.await
.unwrap();
let _ = ctx
.database
.create_member(CreateMember::new(
organization.id.typed().unwrap(),
user.id.typed().unwrap(),
"owner",
))
.await
.unwrap();
let _ = ctx
.database
.update_session_active_organization_by_token_value(
&data.session_property("token").unwrap(),
Some(&organization.id.field_value()),
)
.await
.unwrap();
let request = test_helpers::create_auth_json_request_no_query(
HttpMethod::Get,
"/organization/get-full-organization",
Some(data.session_view().unwrap().token.typed().unwrap()),
None,
);
let mut response = super::org::handle_get_full_organization(&request, &ctx, &config)
.await
.unwrap();
assert!(matches!(
response.body,
better_auth_core::ResponseBody::Native(_)
));
let original = response.body.field_value().unwrap();
assert!(matches!(
original.model_property("createdAt").unwrap(),
FieldValue::Date(_)
));
assert!(original.enumerable_fields().unwrap().contains_key("teams"));
assert!(original.model_property("teams").unwrap().is_undefined());
OrganizationPlugin::with_config(config)
.after_request(&request, &mut response, &ctx)
.await
.unwrap();
assert!(
response
.body
.field_value()
.unwrap()
.strict_equals(&original)
);
let json: serde_json::Value =
serde_json::from_slice(&response.into_http_response().unwrap().body.bytes().unwrap())
.unwrap();
assert!(json.get("teams").is_none());
assert!(json.get("createdAt").unwrap().is_string());
}
#[tokio::test]
async fn native_read_routes_preserve_property_access_order_and_failed_membership_storage() {
let config = OrganizationConfig::default();
let ctx = context(test_helpers::create_test_config(), &config).await;
let user = ctx
.database
.create_user(CreateUser::new().with_email("native-org@example.test"))
.await
.unwrap();
let mut data = session(&ctx, &user).await;
let organization = ctx
.database
.create_organization(CreateOrganization::new("Native", "native"))
.await
.unwrap();
data.field_value()
.set_model_property("user", FieldValue::Null)
.unwrap();
assert_coded_error(
get_active_member_core(&data, &ctx).await.unwrap_err(),
400,
br#"{"message":"No active organization","code":"NO_ACTIVE_ORGANIZATION"}"#,
);
assert!(
get_full_organization_core(&GetFullOrganizationQuery::default(), &data, &config, &ctx)
.await
.unwrap()
.is_none()
);
assert_coded_error(
get_full_organization_core(
&GetFullOrganizationQuery {
organization_id: Some("missing".into()),
..Default::default()
},
&data,
&config,
&ctx,
)
.await
.unwrap_err(),
400,
br#"{"message":"Organization not found","code":"ORGANIZATION_NOT_FOUND"}"#,
);
let active = organization.id.typed().unwrap();
data = data
.with_session(
ctx.database
.update_session_active_organization_by_token_value(
&data.session_property("token").unwrap(),
Some(&organization.id.field_value()),
)
.await
.unwrap(),
)
.unwrap();
assert!(matches!(
get_active_member_core(&data, &ctx).await,
Err(AuthError::TypeError(message)) if message == "Cannot read properties of null (reading 'id')"
));
assert_eq!(
ctx.database
.get_session_by_token_value(&data.session_property("token").unwrap())
.await
.unwrap()
.unwrap()
.active_organization_id
.field_value(),
FieldValue::from(active.clone())
);
for primitive in [
FieldValue::Bool(false),
3.0.into(),
Vec::<FieldValue>::new().into(),
] {
data.field_value()
.set_model_property("user", primitive)
.unwrap();
assert!(
list_organizations_core(&data, &ctx)
.await
.unwrap()
.is_empty()
);
assert_coded_error(
get_active_member_core(&data, &ctx).await.unwrap_err(),
400,
br#"{"message":"Member not found","code":"MEMBER_NOT_FOUND"}"#,
);
assert_coded_error(
get_full_organization_core(
&GetFullOrganizationQuery::default(), &data, &config, &ctx
).await.unwrap_err(),
403,
br#"{"message":"User is not a member of the organization","code":"USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION"}"#,
);
assert_eq!(
ctx.database
.get_session_by_token_value(&data.session_property("token").unwrap())
.await
.unwrap()
.unwrap()
.active_organization_id
.field_value(),
FieldValue::Null
);
}
}
pub(super) fn numeric_output(enabled: Arc<AtomicBool>) -> UserFieldConfig {
UserFieldConfig {
transform: Some(FieldTransforms {
input: None,
output: Some(UserFieldTransform::new(move |value| {
if !enabled.load(Ordering::SeqCst) {
return Ok(value);
}
if value.is_number() {
return Ok(value);
}
let number = value.as_str().unwrap().parse::<f64>().unwrap();
Ok(FieldValue::Number(number))
})),
}),
..Default::default()
}
}
#[tokio::test]
async fn native_member_page_preserves_projected_values_and_strict_join_identity() {
let numeric_member_ids = Arc::new(AtomicBool::new(false));
let mut auth = test_helpers::create_test_config();
auth.session.disable_session_refresh = Some(true);
auth.advanced.database.generate_id = Some(IdGeneration::Serial);
auth.advanced.database.joins = Some(true);
let _ = auth.user.fields_mut().insert(
"image".into(),
numeric_output(Arc::new(AtomicBool::new(true))),
);
let mut config = OrganizationConfig::default();
let _ = config.schema.member.fields_mut().insert(
"userId".into(),
UserFieldConfig {
references: Some(better_auth_core::config::UserFieldReference {
model: "user".into(),
field: "name".into(),
..Default::default()
}),
..numeric_output(numeric_member_ids.clone())
},
);
let ctx = context(auth, &config).await;
let organization = ctx
.database
.create_organization(CreateOrganization::new("Native", "native"))
.await
.unwrap();
let mut users = Vec::new();
let mut members = Vec::new();
for index in 1..=2 {
let mut input = CreateUser::new()
.with_email(format!("native-{index}@example.test"))
.with_name(index.to_string());
input.image = Some(index.to_string()).into();
let user = ctx.database.create_user(input).await.unwrap();
assert_eq!(user.id.field_value(), FieldValue::from(index.to_string()));
assert_eq!(
user.image.field_value(),
FieldValue::Number(f64::from(index))
);
members.push(
ctx.database
.create_member(CreateMember {
additional_fields: Default::default(),
organization_id: organization.id.clone(),
user_id: user.id.clone(),
role: "owner".into(),
})
.await
.unwrap(),
);
users.push(user);
}
let mut data = session(&ctx, &users[0]).await;
data = data
.with_session(
ctx.database
.update_session_active_organization_by_token_value(
&data.session_property("token").unwrap(),
Some(&organization.id.field_value()),
)
.await
.unwrap(),
)
.unwrap();
let stored_session = ctx
.database
.get_session_by_token_value(&data.session_property("token").unwrap())
.await
.unwrap();
let request = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/organization/list-members",
Some(data.session_view().unwrap().token.typed().unwrap()),
None,
);
let response = handle_list_members(&request, &config, &ctx).await.unwrap();
let expected = serde_json::json!({
"members": members.iter().zip(&users).map(|(member, user)| {
serde_json::json!({
"id": member.id, "organizationId": member.organization_id,
"userId": member.user_id, "role": member.role, "createdAt": member.created_at,
"user": {"id": user.id, "name": user.name, "email": user.email, "image": user.image}
})
}).collect::<Vec<_>>(),
"total": 2,
});
assert_eq!(response.body.json().unwrap(), Some(expected));
let organizations = list_organizations_core(&data, &ctx).await.unwrap();
assert_eq!(
FieldValue::from(organizations).json().unwrap(),
Some(serde_json::to_value([&organization]).unwrap())
);
numeric_member_ids.store(true, Ordering::SeqCst);
let projected = ctx
.database
.list_organization_members_value(&organization.id.field_value())
.await
.unwrap();
let mut expected_projected = members.clone();
for (member, id) in expected_projected.iter_mut().zip([1.0, 2.0]) {
member.user_id = SchemaValue::from_field(FieldValue::Number(id));
}
assert_eq!(projected, expected_projected);
assert_eq!(
ctx.database
.list_users_by_id_values(&[1.0.into(), 2.0.into()], 2.0)
.await
.unwrap(),
users
);
let error = list_members_core(&ListMembersQuery::default(), &config, &data, &ctx)
.await
.unwrap_err();
assert_eq!(
error.raw_value().unwrap().model_property("name").unwrap(),
FieldValue::from("BetterAuthError")
);
assert_eq!(
error.message_value().unwrap(),
FieldValue::from("Unexpected error: User not found for member")
);
assert!(!error.is_api_error().unwrap());
assert_eq!(
ctx.database
.count_organization_members_value(&organization.id.field_value())
.await
.unwrap(),
2
);
numeric_member_ids.store(false, Ordering::SeqCst);
assert_eq!(
ctx.database
.list_organization_members_value(&organization.id.field_value())
.await
.unwrap(),
members
);
assert_eq!(
ctx.database
.list_users_by_id_values(&[1.0.into(), 2.0.into()], 2.0)
.await
.unwrap(),
users
);
assert_eq!(
ctx.database
.get_organization_by_id_value(&organization.id.field_value())
.await
.unwrap(),
Some(organization)
);
assert_eq!(
ctx.database
.get_session_by_token_value(&data.session_property("token").unwrap())
.await
.unwrap(),
stored_session
);
}
#[tokio::test]
async fn active_member_role_returns_native_values_and_empty_target_uses_requester() {
for role in [
FieldValue::Undefined,
FieldValue::Null,
7.0.into(),
vec![FieldValue::from("owner")].into(),
] {
let mut config = OrganizationConfig::default();
let output = role.clone();
let _ = config.schema.member.fields_mut().insert(
"role".into(),
UserFieldConfig {
transform: Some(FieldTransforms {
input: None,
output: Some(UserFieldTransform::new(move |_| Ok(output.clone()))),
}),
..Default::default()
},
);
let ctx = context(test_helpers::create_test_config(), &config).await;
let user = ctx
.database
.create_user(CreateUser::new().with_email("role@example.test"))
.await
.unwrap();
let organization = ctx
.database
.create_organization(CreateOrganization::new("Role", "role"))
.await
.unwrap();
ctx.database
.create_member(CreateMember::new(
organization.id.typed().unwrap(),
user.id.typed().unwrap(),
"owner",
))
.await
.unwrap();
let data = session(&ctx, &user).await;
data.session_value()
.unwrap()
.set_model_property("activeOrganizationId", organization.id.field_value())
.unwrap();
let response = get_active_member_role_core(
&GetActiveMemberRoleQuery {
user_id: Some(String::new()),
organization_slug: Some(String::new()),
..Default::default()
},
&data,
&ctx,
)
.await
.unwrap();
assert!(response.role.field_value().strict_equals(&role));
let expected = if role.is_undefined() {
serde_json::json!({})
} else {
serde_json::json!({"role": SchemaValue::<String>::from_field(role)})
};
assert_eq!(serde_json::to_value(response).unwrap(), expected);
}
}
#[tokio::test]
async fn native_invitation_reads_preserve_email_fallback_and_verification_order() {
for teams_enabled in [false, true] {
invitation_read_case(teams_enabled).await;
}
}
async fn invitation_read_case(teams_enabled: bool) {
let mut config = OrganizationConfig::default();
config.teams.enabled = teams_enabled;
let mut auth = test_helpers::create_test_config();
auth.session.disable_session_refresh = Some(true);
let ctx = context(auth, &config).await;
let user = ctx
.database
.create_user(CreateUser {
email_verified: Some(true),
..CreateUser::new().with_email("recipient@example.test")
})
.await
.unwrap();
let data = session(&ctx, &user).await;
let stored_session = ctx
.database
.get_session_by_token_value(&data.session_property("token").unwrap())
.await
.unwrap();
let organization = ctx
.database
.create_organization(CreateOrganization::new("Invites", "invites"))
.await
.unwrap();
let invitation = ctx
.database
.create_invitation(better_auth_core::CreateInvitation::new(
organization.id.typed().unwrap(),
"recipient@example.test",
"member",
user.id.typed().unwrap(),
(Utc::now() + Duration::hours(1)).into(),
))
.await
.unwrap();
data.field_value()
.set_model_property("user", FieldValue::Null)
.unwrap();
assert!(
get_invitation_core(
&GetInvitationQuery {
id: "missing".into()
},
&data,
&config,
&ctx
)
.await
.unwrap()
.is_none()
);
assert!(matches!(
get_invitation_core(&GetInvitationQuery { id: invitation.id.typed().unwrap().clone() }, &data, &config, &ctx).await,
Err(AuthError::TypeError(message)) if message == "Cannot read properties of null (reading 'email')"
));
assert!(matches!(
list_user_invitations_core(Some(&data), Some("recipient@example.test"), &ctx).await,
Err(AuthError::TypeError(message)) if message == "Cannot read properties of null (reading 'emailVerified')"
));
data.field_value()
.set_model_property("user", false.into())
.unwrap();
assert_coded_error(list_user_invitations_core(Some(&data), Some("recipient@example.test"), &ctx).await.unwrap_err(), 403, br#"{"message":"Email verification required to view or list invitations for the session email","code":"EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION"}"#);
assert_coded_error(
list_user_invitations_core(None, None, &ctx)
.await
.unwrap_err(),
400,
br#"{"message":"Missing session headers, or email query parameter."}"#,
);
let mut expected = serde_json::json!([{
"id": invitation.id, "organizationId": invitation.organization_id,
"email": invitation.email, "role": invitation.role, "status": invitation.status,
"inviterId": invitation.inviter_id, "expiresAt": invitation.expires_at,
"createdAt": invitation.created_at, "organizationName": organization.name,
}]);
if teams_enabled {
expected[0]["teamId"] = serde_json::Value::Null;
}
let mut expected_core = expected.clone();
expected_core[0]["teamId"] = serde_json::Value::Null;
let assert_response =
|rows: &[UserInvitationResponse<better_auth_core::wire::InvitationView>]| {
let mut response = AuthResponse::json(200, &rows).unwrap();
assert_eq!(response.body.json().unwrap(), Some(expected_core.clone()));
super::super::shape_invitation_output(
"/organization/list-user-invitations",
&mut response,
&config,
)
.unwrap();
assert_eq!(response.body.json().unwrap(), Some(expected.clone()));
};
for email in [FieldValue::Null, "".into(), false.into()] {
data.field_value()
.set_model_property(
"user",
FieldMap::from([
("email".into(), email),
("emailVerified".into(), Vec::<FieldValue>::new().into()),
])
.into(),
)
.unwrap();
let rows = list_user_invitations_core(Some(&data), Some("RECIPIENT@example.test"), &ctx)
.await
.unwrap();
assert_response(&rows);
}
let rows = list_user_invitations_core(None, Some("RECIPIENT@example.test"), &ctx)
.await
.unwrap();
assert_response(&rows);
data.field_value()
.set_model_property(
"user",
FieldMap::from([
("email".into(), 17.0.into()),
("emailVerified".into(), true.into()),
])
.into(),
)
.unwrap();
assert!(matches!(
list_user_invitations_core(Some(&data), None, &ctx).await,
Err(AuthError::TypeError(message)) if message == "user.email.toLowerCase is not a function"
));
let mut request = test_helpers::create_auth_request_no_query(
HttpMethod::Get,
"/organization/list-user-invitations",
Some(data.session_view().unwrap().token.typed().unwrap()),
None,
);
request.query = Some(serde_json::json!({"email": "recipient@example.test"}));
assert_coded_error(
handle_list_user_invitations(&request, &ctx)
.await
.unwrap_err(),
400,
br#"{"message":"User email cannot be passed for client side API calls."}"#,
);
request.query = None;
let response = OrganizationPlugin::with_config(config)
.on_request(&request, &ctx)
.await
.unwrap()
.unwrap();
assert_eq!(response.body.json().unwrap(), Some(expected));
assert_eq!(
ctx.database
.list_organization_invitations_value(&organization.id.field_value())
.await
.unwrap(),
vec![invitation]
);
assert_eq!(
ctx.database
.get_user_by_id_value(&user.id.field_value())
.await
.unwrap(),
Some(user)
);
assert_eq!(
ctx.database
.get_organization_by_id_value(&organization.id.field_value())
.await
.unwrap(),
Some(organization)
);
assert_eq!(
ctx.database
.get_session_by_token_value(&data.session_property("token").unwrap())
.await
.unwrap(),
stored_session
);
}