Files
meguri/tests/demo_lifecycle.rs
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00

327 lines
11 KiB
Rust

//! Demonstration lifecycle. Kept in its own test binary because installing the demonstration
//! notice is process-wide and disables real provider writes.
mod support;
use chrono::{Duration, Utc};
use cot::db::query;
use meguri::{domain::*, engine, models::SyncJobRow, service};
use uuid::Uuid;
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_bootstrap_protects_unmarked_and_normal_databases_and_resets_only_application_state() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let source_url = runtime::application_url(url.as_str(), false).unwrap();
let source = Store::connect(&source_url).await.unwrap();
support::install_demo_notice();
let owner = meguri::identity::new_user_row("protected-owner".into(), "Keep me".into(), None);
owner.clone().insert(&source.db).await.unwrap();
let tenant = meguri::identity::ensure_workspace(
&source,
&support::unique_workspace("Protected workspace"),
)
.await
.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
let alternate_name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
url.set_path(&format!("/{alternate_name}"));
let (selected, isolated) = runtime::select_database(&source_url, Some(url.as_str()))
.await
.unwrap();
assert!(isolated);
assert_ne!(
reqwest::Url::parse(&selected).unwrap().path(),
reqwest::Url::parse(&source_url).unwrap().path()
);
let demo = Store::connect(&selected).await.unwrap();
assert!(runtime::is_demo_database(&demo.db).await);
meguri::demo::seed(&demo).await.unwrap();
assert_eq!(UserRow::objects().all(&demo.db).await.unwrap().len(), 6);
assert_eq!(
WorkspaceRow::objects().all(&demo.db).await.unwrap().len(),
4
);
let old_user = query!(UserRow,$username=="alice")
.get(&demo.db)
.await
.unwrap()
.unwrap();
demo.db.raw("DO $$ BEGIN CREATE TABLE unrelated_sentinel(value text); INSERT INTO unrelated_sentinel VALUES('keep'); END $$").await.unwrap();
runtime::reset(&demo.db).await.unwrap();
assert!(UserRow::objects().all(&demo.db).await.unwrap().is_empty());
assert!(runtime::is_demo_database(&demo.db).await);
demo.db.raw("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM unrelated_sentinel WHERE value='keep') THEN RAISE EXCEPTION 'unrelated data lost'; END IF; END $$").await.unwrap();
let rerun = Store::connect(&selected).await.unwrap();
meguri::demo::seed(&rerun).await.unwrap();
assert_ne!(
query!(UserRow,$username=="alice")
.get(&rerun.db)
.await
.unwrap()
.unwrap()
.id,
old_user.id
);
runtime::mark_normal(&source.db).await.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
assert!(
query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
assert!(
runtime::cleanup(
&source,
Utc::now() + Duration::days(100),
&runtime::Retention::default()
)
.await
.is_err()
);
assert!(
query!(UserRow,$id==owner.id.clone())
.exists(&source.db)
.await
.unwrap()
);
// Names are generated above exclusively for this test; never drop the configured DB.
admin
.raw(&format!("DROP DATABASE {alternate_name} WITH (FORCE)"))
.await
.unwrap();
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_retention_soft_deletes_then_purges_by_creation_and_reseeds_examples() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let url = runtime::application_url(url.as_str(), true).unwrap();
let raw = Database::new(&url).await.unwrap();
runtime::claim_empty(&raw).await.unwrap();
let db = Store::connect(&url).await.unwrap();
support::install_demo_notice();
meguri::demo::seed(&db).await.unwrap();
let alice = query!(UserRow,$username=="alice")
.get(&db.db)
.await
.unwrap()
.unwrap();
let alice_id = Uuid::parse_str(&alice.id).unwrap();
// A temporary workspace and account next to the permanent examples, both granted through
// the ordinary group mapping.
let mut config = meguri::demo::config().unwrap();
let temporary = support::unique_workspace("Temporary team");
config.workspaces.push(temporary.clone());
meguri::identity::ensure_workspace(&db, &temporary)
.await
.unwrap();
let visitor =
support::account(&db, &config, "Temporary visitor", &temporary.admin_groups).await;
let mut alice_groups = meguri::identity::stored_groups(&alice).unwrap();
alice_groups.extend(temporary.member_groups.clone());
meguri::identity::apply_access(&db, &config, alice_id, Some(alice_groups))
.await
.unwrap();
let mut team = meguri::identity::find_workspace(&db, &temporary.key)
.await
.unwrap()
.unwrap();
let tenant = team.clone();
let original_revision = team.revision;
// Editing does not extend lifetime: created_at is the expiration anchor.
let mut doc = team.document.clone();
doc.name = "Edited temporary team".into();
team = service::update_document(&db, visitor.id, team.id, team.revision, doc)
.await
.unwrap();
assert!(team.revision > original_revision);
let now = query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.created_at;
let schedule = Uuid::new_v4();
let mut document = team.document.clone();
document.schedules.push(Schedule {
id: schedule,
name: "Temporary rotation".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![visitor.id],
anchor: now.date_naive(),
shift_days: 7,
},
});
team = service::update_document(&db, visitor.id, team.id, team.revision, document)
.await
.unwrap();
let start = engine::handover(&team.document, now.date_naive() + Duration::days(2)).unwrap();
team = service::request_replacement(
&db,
visitor.id,
team.id,
service::NewRequest {
remaining: false,
revision: team.revision,
reason: "Demo coverage".into(),
force: false,
legs: vec![ReplacementLeg {
schedule_id: schedule,
start,
end: start + Duration::hours(1),
from_user: visitor.id,
to_user: alice_id,
}],
},
)
.await
.unwrap();
let ttl = runtime::Retention {
workspaces: 120,
accounts: 300,
requests: 60,
events: 180,
purge_grace: 30,
};
runtime::cleanup(&db, now + Duration::seconds(61), &ttl)
.await
.unwrap();
let after = db.workspace(team.id).await.unwrap();
assert!(after.document.requests.is_empty());
assert_eq!(after.revision, team.revision + 1);
assert_eq!(
query!(SyncJobRow,$workspace_id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.requested_revision,
after.revision
);
runtime::cleanup(&db, now + Duration::seconds(119), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_ok());
runtime::cleanup(&db, now + Duration::seconds(121), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_err());
assert!(
query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.demo_deleted_at
.is_some()
);
assert!(
service::list_workspaces(&db, visitor.id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
service::update_document(
&db,
visitor.id,
team.id,
team.revision,
team.document.clone()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(152), &ttl)
.await
.unwrap();
assert!(
!query!(WorkspaceRow,$id==team.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
runtime::cleanup(&db, now + Duration::seconds(242), &ttl)
.await
.unwrap();
assert!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
runtime::cleanup(&db, now + Duration::seconds(301), &ttl)
.await
.unwrap();
assert!(
service::login(
&db,
true,
visitor.username.clone(),
"test-only-password".into()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(332), &ttl)
.await
.unwrap();
assert!(
!query!(UserRow,$id==visitor.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert!(
!query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert_eq!(UserRow::objects().all(&db.db).await.unwrap().len(), 6);
meguri::demo::seed(&db).await.unwrap();
assert_eq!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(WorkspaceRow::objects().all(&db.db).await.unwrap().len(), 4);
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}