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:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user