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>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-07 13:28:43 +01:00
1 parent e37d583179
commit 4764f3545f
68 files changed
+7220 -964

No files matched your search

+65 -1
View File
@@ -2,14 +2,78 @@ const { chromium } = require('playwright');
function launchBrowser() {
const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome';
const executablePath = process.env.MEGURI_BROWSER_EXECUTABLE;
return chromium.launch({
headless: true,
...(channel === 'chromium' ? {} : { channel }),
...(executablePath ? { executablePath, args: ['--no-sandbox'] } : channel === 'chromium' ? {} : { channel }),
});
}
exports.launchBrowser = launchBrowser;
const baseUrl = () => process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
exports.baseUrl = baseUrl;
// The demo generates one password per server start and publishes it to the login screen.
let password;
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(session => {
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
return session.demo_lifecycle.password;
});
exports.signIn = async (page, username, secret) => {
await page.goto(baseUrl());
await page.getByLabel('Username', { exact: true }).fill(username);
await page.getByLabel('Password', { exact: true }).fill(secret ?? await exports.demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.waitForFunction(() => { const a = window.Alpine && Alpine.$data(document.body); return a && a.ready && a.user && !a.busy; });
};
// Minimal cookie-aware JSON client for test setup that must not go through the UI.
exports.apiLogin = async (username, secret) => {
const base = baseUrl();
const login = await fetch(base + '/api/login', { method: 'POST', headers: { Origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: secret ?? await exports.demoPassword() }) });
if (!login.ok) throw new Error(`Login as ${username} failed: ${login.status}`);
const cookie = login.headers.getSetCookie().map(c => c.split(';')[0]).find(c => c.startsWith('meguri_session='));
const { csrf } = await login.json();
const call = async (method, path, body) => {
const response = await fetch(base + path, { method, headers: { Origin: base, 'Content-Type': 'application/json', Cookie: cookie, 'X-CSRF-Token': csrf }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const data = await response.json().catch(() => ({}));
return { status: response.status, data };
};
const ok = async (method, path, body) => {
const result = await call(method, path, body);
if (result.status >= 300) throw new Error(`${method} ${path} -> ${result.status} ${JSON.stringify(result.data)}`);
return result.data;
};
return { call, ok, get: path => ok('GET', path), post: (path, body) => ok('POST', path, body), put: (path, body) => ok('PUT', path, body), csrf, cookie };
};
exports.adminApi = () => exports.apiLogin('admin');
// Mutating suites each own one empty sandbox workspace ("Sandbox N", demo started with
// MEGURI_DEMO_SANDBOXES) whose groups are sandbox-N-leads (administrators) and sandbox-N-staff (employees).
// Nobody is in a sandbox except accounts created here, so suites do not depend on each other or on order.
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display }) => {
const overview = await admin.get('/api/admin/overview');
const target = overview.workspaces.find(w => w.name === `Sandbox ${sandbox}` && !w.disabled_at);
if (!target) throw new Error(`Start the demo with MEGURI_DEMO_SANDBOXES>=${sandbox}: Sandbox ${sandbox} not found`);
const groups = role === 'admin' ? target.admin_groups : target.member_groups;
const username = `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toLowerCase();
const secret = 'local-browser-test-password';
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups });
return { username, password: secret, display, workspace: target };
};
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
exports.isolatedUser = async ({ sandbox, prefix, display, employees = [] }) => {
const admin = await exports.adminApi();
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display });
const staff = [];
for (const name of employees) staff.push(await exports.createUser(admin, { sandbox, role: 'member', prefix: name.split(' ')[0], display: name }));
return { admin, ...user, staff };
};
exports.openSetup = async (page, name) => {
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
+293
View File
@@ -0,0 +1,293 @@
//! A minimal OpenID provider for tests: discovery, JWKS, token and userinfo endpoints with
//! RS256 tokens. The private key is a throwaway test fixture and protects nothing.
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDI4xpMobGZ91uK
+uV7YFtGg0KorieswaP4ZhIqg3LkxIjpaMggyw0Fc4jliFPxn+uZAzKEF5lBrrdt
XBllxKayC6dLEHFBvrglZMwyzsj6fgssne/N1YRWsQsQ48d2KANzfGpkBB8/29g7
YhpNK6lWZo54yGFcFa2g68/yqbpQ6dSo191/CSqOnC+R2pdMe9NzI8kQBtZPkcft
hmQ05IQJoyNHgBgwUQO2vB7J23haATlvQUrZlv0ASFYgxJDKeLZOPvdLGEmezIvc
WZYdI5qEqiNDr9KRWLS/sDYxxOQkVxRP4OoWy5svru1ptJ3QEG50uUnPZkdpX/Ny
UaMFOieJAgMBAAECgf8UyqFa5XWp/ODExbcM7nE2ydLmiAuRJgvXmHfdLY72qqne
ajvq17OcvVVs3g3B3wE5PaKWPCAdhJdEtUCCB/HXTO25hOTOYAhTrdLuh6cvIUxF
wqlJGVNM7tM3ZS8mW8b7maLjMxQtep605CxYxjU5c+wD54yuIMkl58Q3bLENuM5G
Y5GeXwmAv4yrJXuFIe/h2F6A+p4IEQ64QF6VQ/rDeG8kniLXWNZjAndqGxPdLDjC
4YdXpSWeYM/dTGKUyt0SwE64vxytMNyoRWAO4i4v3JJbrST0SmAM2RwM803Ks0Ys
H9VvFoYt5kGhETOz4OKa3m6HrMwdUy7vnUYd870CgYEA59CLNpjU3Dl57RJRlHRw
2FAPM0uM9qBWiahDyhQAeHeypUFGeu1yZHZCH9Z5oOo28xRyAB27R8ozFX0JdPYl
HBuJH63boff4/DuzjmnAK92889m+cbyPcAd6BjOo545cMRiAXvu3b8cz2Mlks1bj
uWS5RIVbQvuiPqNEbHNHc5UCgYEA3diG58Ziug75V6k8U9wHyqWWRyvQdwdJQmey
FAB0LQdSsHUkPteGl2U6Ml7EL2AuqoZK5kVRK+8aGhBHyOlsjwj/sCfQfiTNDgve
MP7pWfUUjctO8ZvKHG3SG87BG2kLAW6l/vuhDfLwQe/PV8hC0yDegFfF1R4nQVYf
7XFv5yUCgYBftygEHw6k78Z8MLgmh/2gE05za5qEqqeQpz12TtPy6dmFinJDh49Y
E8wxwSjvXEXcEHERwzT063Q0RzCEbq3b8TKpNewd8HmKDEyTXO7QLfo65YlRBP5k
z3F+5g2zroA6dVdr4JtZN/Mca+t8cUAN6Jzmwflwqt3O5zL1ZJP4EQKBgQCt50Sp
f9x4mbBgRWvjGQKKmo9ZL0F1bSLCiWMRRuHLQohRhyJomK8LEvEx9xO3R6CNUER6
ii/9TquZbtW26zSMU7OLtUo8+EA+GJOOuOTX/cSk+pDh2QvwiS5BWiwKfqd/8ery
HPK9aoloRsK0v2mS85s35JZSYBbJoufYTCo6eQKBgGW1vIlLzHRMeboj82uW0yw2
Jkg6Ig45qtVhY89267E8cI7gdC92Y715nFR4v+0eiWo08k+FIaHyBsah0vklj1Q6
fj6ir9inIzkkrFmjs6u/g4h2GNGUXOJJwJzOU78C6oeB3G5OUM/pWFx0zypRGH/q
HpM1TJ5Fkyog20WBQS9+
-----END PRIVATE KEY-----";
const MODULUS: &str = "yOMaTKGxmfdbivrle2BbRoNCqK4nrMGj-GYSKoNy5MSI6WjIIMsNBXOI5YhT8Z_rmQMyhBeZQa63bVwZZcSmsgunSxBxQb64JWTMMs7I-n4LLJ3vzdWEVrELEOPHdigDc3xqZAQfP9vYO2IaTSupVmaOeMhhXBWtoOvP8qm6UOnUqNfdfwkqjpwvkdqXTHvTcyPJEAbWT5HH7YZkNOSECaMjR4AYMFEDtrweydt4WgE5b0FK2Zb9AEhWIMSQyni2Tj73SxhJnsyL3FmWHSOahKojQ6_SkVi0v7A2McTkJFcUT-DqFsubL67tabSd0BBudLlJz2ZHaV_zclGjBToniQ";
pub const CLIENT_ID: &str = "meguri-test";
pub const CLIENT_SECRET: &str = "test-client-secret";
/// What a single sign-in attempt looks like to the relying party.
#[derive(Clone)]
pub struct Scenario {
pub claims: Value,
pub userinfo: Option<Value>,
pub issuer: Option<String>,
pub audience: Option<Value>,
pub expires_in: i64,
pub nonce: Option<String>,
pub corrupt_signature: bool,
pub token_status: u16,
}
impl Scenario {
pub fn new(claims: Value) -> Self {
Self {
claims,
userinfo: None,
issuer: None,
audience: None,
expires_in: 300,
nonce: None,
corrupt_signature: false,
token_status: 200,
}
}
}
struct Grant {
scenario: Scenario,
nonce: String,
challenge: String,
}
#[derive(Default)]
pub struct State {
codes: HashMap<String, Grant>,
tokens: HashMap<String, Value>,
pub token_requests: usize,
pub userinfo_requests: usize,
}
pub struct Server {
pub base: String,
pub state: Arc<Mutex<State>>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Server {
fn drop(&mut self) {
self.task.abort();
}
}
impl Server {
pub fn issuer(&self) -> String {
format!("{}/realms/corp", self.base)
}
/// Registers an authorization the way a browser visit to the provider would.
pub fn authorize(&self, authorization_url: &str, scenario: Scenario) -> String {
let url = reqwest::Url::parse(authorization_url).unwrap();
let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query["response_type"], "code");
assert_eq!(query["client_id"], CLIENT_ID);
assert_eq!(query["code_challenge_method"], "S256");
assert!(query["scope"].split(' ').any(|s| s == "openid"));
let code = format!("code-{}", uuid::Uuid::new_v4().simple());
self.state.lock().unwrap().codes.insert(
code.clone(),
Grant {
scenario,
nonce: query["nonce"].clone(),
challenge: query["code_challenge"].clone(),
},
);
code
}
pub async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let state = Arc::new(Mutex::new(State::default()));
let (shared, root) = (state.clone(), base.clone());
let task = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let (shared, root) = (shared.clone(), root.clone());
tokio::spawn(async move {
let mut buffer = vec![];
let (head_end, length) = loop {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buffer[..end]);
let length = head
.lines()
.find_map(|l| {
l.to_lowercase()
.strip_prefix("content-length: ")
.and_then(|v| v.parse::<usize>().ok())
})
.unwrap_or(0);
break (end + 4, length);
}
};
while buffer.len() < head_end + length {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
}
let head = String::from_utf8_lossy(&buffer[..head_end]).to_string();
let body =
String::from_utf8_lossy(&buffer[head_end..head_end + length]).to_string();
let mut first = head.lines().next().unwrap().split_whitespace();
let method = first.next().unwrap().to_owned();
let path = first.next().unwrap().to_owned();
let (status, result) = handle(
&mut shared.lock().unwrap(),
&root,
&method,
&path,
&head,
&body,
);
let body = result.to_string();
let response = format!(
"HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
});
}
});
Self { base, state, task }
}
}
fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> {
head.lines().find_map(|l| {
let (key, value) = l.split_once(':')?;
key.eq_ignore_ascii_case(name).then(|| value.trim())
})
}
fn sign(claims: &Value, corrupt: bool) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key".into());
let mut token = jsonwebtoken::encode(
&header,
claims,
&EncodingKey::from_rsa_pem(PRIVATE_KEY.as_bytes()).unwrap(),
)
.unwrap();
if corrupt {
let last = token.pop().unwrap();
token.push(if last == 'A' { 'B' } else { 'A' });
}
token
}
fn handle(
s: &mut State,
root: &str,
method: &str,
path: &str,
head: &str,
body: &str,
) -> (u16, Value) {
let issuer = format!("{root}/realms/corp");
let path = path.split('?').next().unwrap();
match (method, path) {
("GET", "/realms/corp/.well-known/openid-configuration") => (
200,
json!({
"issuer":issuer,
"authorization_endpoint":format!("{issuer}/protocol/openid-connect/auth"),
"token_endpoint":format!("{issuer}/protocol/openid-connect/token"),
"jwks_uri":format!("{issuer}/protocol/openid-connect/certs"),
"userinfo_endpoint":format!("{issuer}/protocol/openid-connect/userinfo"),
}),
),
("GET", "/realms/corp/protocol/openid-connect/certs") => (
200,
json!({"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":MODULUS,"e":"AQAB"}]}),
),
("POST", "/realms/corp/protocol/openid-connect/token") => {
s.token_requests += 1;
let expected = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode(format!("{CLIENT_ID}:{CLIENT_SECRET}"))
);
if header(head, "authorization") != Some(expected.as_str()) {
return (401, json!({"error":"invalid_client"}));
}
let form: HashMap<_, _> = reqwest::Url::parse(&format!("http://x/?{body}"))
.unwrap()
.query_pairs()
.into_owned()
.collect();
let Some(grant) = form.get("code").and_then(|c| s.codes.remove(c)) else {
return (400, json!({"error":"invalid_grant"}));
};
let verifier = form.get("code_verifier").cloned().unwrap_or_default();
if form.get("grant_type").map(String::as_str) != Some("authorization_code")
|| URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) != grant.challenge
|| !form
.get("redirect_uri")
.is_some_and(|u| u.ends_with("/api/oidc/callback"))
{
return (400, json!({"error":"invalid_grant"}));
}
let scenario = grant.scenario;
if scenario.token_status != 200 {
return (scenario.token_status, json!({"error":"server_error"}));
}
let now = chrono::Utc::now().timestamp();
let mut claims = scenario.claims.clone();
claims["iss"] = json!(scenario.issuer.unwrap_or(issuer));
claims["aud"] = scenario.audience.unwrap_or(json!(CLIENT_ID));
claims["iat"] = json!(now);
claims["exp"] = json!(now + scenario.expires_in);
claims["nonce"] = json!(scenario.nonce.unwrap_or(grant.nonce));
let access = format!("access-{}", uuid::Uuid::new_v4().simple());
if let Some(info) = scenario.userinfo {
s.tokens.insert(access.clone(), info);
}
(
200,
json!({"id_token":sign(&claims, scenario.corrupt_signature),"access_token":access,"token_type":"Bearer"}),
)
}
("GET", "/realms/corp/protocol/openid-connect/userinfo") => {
s.userinfo_requests += 1;
let token = header(head, "authorization")
.and_then(|h| h.strip_prefix("Bearer "))
.unwrap_or_default();
match s.tokens.get(token) {
Some(info) => (200, info.clone()),
None => (401, json!({"error":"invalid_token"})),
}
}
_ => (404, json!({})),
}
}
+139 -42
View File
@@ -1,7 +1,11 @@
#![allow(dead_code)]
use chrono::{Duration, Utc};
use cot::db::Model;
use cot::db::query;
use meguri::{
access::{AuthConfig, Config, WorkspaceConfig},
admin::{self, NewUser},
domain::*,
identity,
models::UserRow,
service,
store::{Store, WorkspaceRecord},
@@ -14,54 +18,83 @@ pub struct Fixture {
pub team: WorkspaceRecord,
pub schedule: Uuid,
pub start: chrono::DateTime<Utc>,
pub config: Config,
}
pub fn config_with(workspaces: Vec<WorkspaceConfig>) -> Config {
Config {
auth: AuthConfig {
password_login: true,
bootstrap_admin: None,
},
oidc: None,
instance_admin_groups: vec![],
workspaces,
}
}
pub fn unique_workspace(name: &str) -> WorkspaceConfig {
let key = format!("t-{}", &Uuid::new_v4().simple().to_string()[..12]);
WorkspaceConfig {
admin_groups: vec![format!("{key}-leads")],
member_groups: vec![format!("{key}-staff")],
name: name.into(),
key,
}
}
pub async fn connect() -> Store {
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
Store::connect(&url).await.unwrap()
}
/// A password account whose groups are applied through the real access path.
pub async fn account(db: &Store, config: &Config, name: &str, groups: &[String]) -> User {
let created = admin::create_password_user(
db,
config,
None,
NewUser {
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
password: "test-only-password".into(),
email: None,
groups: groups.to_vec(),
},
)
.await
.unwrap();
serde_json::from_value(created["user"].clone()).unwrap()
}
impl Fixture {
pub async fn new() -> Self {
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
let db = Store::connect(&url).await.unwrap();
let db = connect().await;
let workspace = unique_workspace("Test team");
let config = config_with(vec![workspace.clone()]);
let ensured = identity::ensure_workspace(&db, &workspace).await.unwrap();
let mut people = vec![];
for name in ["Alice", "Bob", "Carol"] {
let user = User {
language: None,
id: Uuid::new_v4(),
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
country: "CY".into(),
timezone: None,
for (index, name) in ["Alice", "Bob", "Carol"].into_iter().enumerate() {
let groups = if index == 0 {
&workspace.admin_groups
} else {
&workspace.member_groups
};
UserRow {
language: None,
demo_deleted_at: None,
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone(),
timezone: None,
password_hash: "test-only-unusable-password".into(),
created_at: Utc::now(),
}
.insert(&db.db)
.await
.unwrap();
people.push(user);
people.push(account(&db, &config, name, groups).await);
}
let people: [User; 3] = people.try_into().unwrap();
let mut team = service::create_workspace(&db, people[0].id, "Test team".into())
.await
.unwrap();
for person in &people[1..] {
team = service::set_member(
&db,
people[0].id,
team.id,
team.revision,
person.username.clone(),
Permission::member(),
)
.await
.unwrap();
}
let mut team = db.workspace(ensured.id).await.unwrap();
// Employees keep the narrow pre-existing grant set that the scheduling regressions assume.
team = service::set_member_permissions(
&db,
people[0].id,
team.id,
team.revision,
Permission::member(),
)
.await
.unwrap();
let schedule = Uuid::new_v4();
let day = Utc::now().date_naive() + Duration::days(2);
let mut doc = team.document.clone();
@@ -86,8 +119,28 @@ impl Fixture {
team,
schedule,
start,
config,
}
}
/// A second workspace whose administrator group is granted to `admin` only.
pub async fn extra_workspace(&self, name: &str, admin: &User) -> WorkspaceRecord {
let extra = unique_workspace(name);
let mut config = self.config.clone();
config.workspaces.push(extra.clone());
let ensured = identity::ensure_workspace(&self.db, &extra).await.unwrap();
let id = admin.id.to_string();
let row = query!(UserRow,$id==id)
.get(&self.db.db)
.await
.unwrap()
.unwrap();
let mut groups = identity::stored_groups(&row).unwrap();
groups.extend(extra.admin_groups);
identity::apply_access(&self.db, &config, admin.id, Some(groups))
.await
.unwrap();
self.db.workspace(ensured.id).await.unwrap()
}
pub fn leg(&self) -> ReplacementLeg {
ReplacementLeg {
schedule_id: self.schedule,
@@ -133,3 +186,47 @@ impl Fixture {
.unwrap();
}
}
/// Demonstration seeding reads the run's generated password from the process-wide notice.
pub fn install_demo_notice() {
meguri::demo_runtime::install(meguri::demo_runtime::Notice {
isolated: false,
retention: meguri::demo_runtime::Retention::default(),
password: meguri::access::random_password(),
accounts: meguri::demo::accounts(),
});
}
/// A uniquely named database for tests whose subject is whole-instance state (configuration
/// sync disables every workspace that is not configured, so shared databases cannot be used).
pub struct IsolatedDatabase {
pub store: Store,
admin: cot::db::Database,
name: String,
}
impl IsolatedDatabase {
pub async fn new() -> Self {
let base = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
let admin = cot::db::Database::new(&base).await.unwrap();
let name = format!("meguri_access_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&base).unwrap();
url.set_path(&format!("/{name}"));
let store = Store::connect(url.as_str()).await.unwrap();
Self { store, admin, name }
}
pub async fn url(&self) -> String {
let base = std::env::var("MEGURI_TEST_DATABASE_URL").unwrap();
let mut url = reqwest::Url::parse(&base).unwrap();
url.set_path(&format!("/{}", self.name));
url.to_string()
}
pub async fn drop(self) {
drop(self.store);
self.admin
.raw(&format!("DROP DATABASE {} WITH (FORCE)", self.name))
.await
.unwrap();
}
}