use crate::Role;
use crate::Workspace;
use crate::WorkspaceStatus;
use crate::db::{self, TxGuard};
use crate::git::commands::run_git_output;
use anyhow::Result;
use serde::Serialize;
use std::path::{Path, PathBuf};
use tracing::warn;
pub(crate) const TELEGRAM_UNKNOWN_SENTINEL: &str = "unknown";
fn normalize_telegram_handle(handle: &str) -> anyhow::Result<String> {
let handle = handle.trim();
let handle = handle.strip_prefix('@').unwrap_or(handle).trim();
if handle.is_empty() {
anyhow::bail!("Telegram handle is empty");
}
if handle == TELEGRAM_UNKNOWN_SENTINEL {
anyhow::bail!("'unknown' is a reserved Telegram handle and cannot be bound");
}
Ok(handle.to_string())
}
crate::define_store! {
pub static USER_STORE: UserStore,
post_open = ensure_admin_user,
expect = "USER_STORE not initialized — call init_all_stores() first",
}
crate::columns! {
USERS_COLUMNS [USERS] {
NAME => "name",
PERMISSIONS => "permissions",
SELECTED_WORKSPACE => "selected_workspace",
SELECTED_ROLE => "selected_role",
}
}
crate::columns! {
USER_CHANNEL_COLUMNS [UC] {
CHANNEL => "channel",
IDENTIFIER => "identifier",
REPLY_TARGET => "reply_target",
}
}
impl UserStore {
pub(crate) async fn ensure_admin_user(&self) -> Result<()> {
let rows = self
.conn
.query("SELECT 1 FROM users WHERE name = 'admin'", db::params![])
.await?;
if rows.is_empty() {
self.add_user("admin", Some("full"), Role::Support).await?;
}
Ok(())
}
pub async fn add_user(
&self,
name: &str,
permissions: Option<&str>,
default_role: Role,
) -> Result<()> {
let inserted = self
.conn
.execute(
"INSERT OR IGNORE INTO users (name, permissions) \
VALUES (?1, ?2)",
db::params![name, permissions],
)
.await?;
let tx = self.conn.begin_tx().await?;
if inserted > 0 {
tx.execute(
"UPDATE users SET selected_role = ?1 WHERE name = ?2",
db::params![default_role.as_str(), name],
)
.await?;
}
tx.commit().await?;
ensure_personal_workspace(name).await;
Ok(())
}
pub async fn delete_user(&self, name: &str) -> Result<()> {
let tx = self.conn.begin_tx().await?;
tx.execute(
"DELETE FROM user_channels WHERE user_name = ?1",
db::params![name],
)
.await?;
tx.execute("DELETE FROM users WHERE name = ?1", db::params![name])
.await?;
tx.commit().await?;
Ok(())
}
async fn user_column(&self, column: &str, user_name: &str) -> Result<Option<String>> {
self.conn
.query_optional(
&format!("SELECT {column} FROM users WHERE name = ?1"),
db::params![user_name],
|row| row.get::<Option<String>>(0),
)
.await
.map(Option::flatten)
}
pub async fn get_selected_workspace_name(&self, user_name: &str) -> Result<Option<String>> {
self.user_column("selected_workspace", user_name).await
}
pub async fn get_active_role(&self, user_name: &str) -> Result<Option<String>> {
self.user_column("selected_role", user_name).await
}
pub async fn get_permissions(&self, user_name: &str) -> Result<Option<String>> {
self.user_column("permissions", user_name).await
}
pub async fn user_exists(&self, name: &str) -> Result<bool> {
let rows = self
.conn
.query("SELECT 1 FROM users WHERE name = ?1", db::params![name])
.await?;
Ok(!rows.is_empty())
}
pub async fn bind_channel(
&self,
user_name: &str,
channel: &str,
identifier: &str,
) -> Result<()> {
self.conn
.execute(
"INSERT OR REPLACE INTO user_channels (user_name, channel, identifier) \
VALUES (?1, ?2, ?3)",
db::params![user_name, channel, identifier],
)
.await?;
Ok(())
}
pub async fn validate_telegram_bind(&self, user_name: &str, handle: &str) -> Result<String> {
let handle = normalize_telegram_handle(handle)?;
if let Some(existing) = self.resolve_user_by_channel("telegram", &handle).await?
&& existing != user_name
{
anyhow::bail!("@{handle} is already bound to user '{existing}'");
}
Ok(handle)
}
pub async fn unbind_channel(
&self,
user_name: &str,
channel: &str,
identifier: &str,
) -> Result<()> {
self.conn
.execute(
"DELETE FROM user_channels WHERE user_name = ?1 AND channel = ?2 AND identifier = ?3",
db::params![user_name, channel, identifier],
)
.await?;
Ok(())
}
pub async fn update_channel_contact(
&self,
channel: &str,
identifier: &str,
reply_target: &str,
) -> Result<()> {
self.conn
.execute(
"UPDATE user_channels SET reply_target = ?1 \
WHERE channel = ?2 AND identifier = ?3",
db::params![reply_target, channel, identifier],
)
.await?;
Ok(())
}
pub async fn resolve_user_by_channel(
&self,
channel: &str,
identifier: &str,
) -> Result<Option<String>> {
self.conn
.query_optional(
"SELECT user_name FROM user_channels WHERE channel = ?1 AND identifier = ?2",
db::params![channel, identifier],
|row| row.get::<String>(0),
)
.await
}
pub async fn get_user_channels(&self, user_name: &str) -> Result<Vec<ChannelBinding>> {
self.conn
.query_map_strict(
&format!("SELECT {USER_CHANNEL_COLUMNS} FROM user_channels WHERE user_name = ?1"),
db::params![user_name],
|row| {
Ok::<_, ::turso::Error>(ChannelBinding {
channel: row.get::<String>(COL_UC_CHANNEL)?,
identifier: row.get::<String>(COL_UC_IDENTIFIER)?,
reply_target: row.get::<Option<String>>(COL_UC_REPLY_TARGET)?,
})
},
)
.await
}
async fn user_record_from_row(&self, row: &db::Row) -> Result<UserRecord> {
let name: String = row.get(COL_USERS_NAME)?;
let permissions = row.get::<Option<String>>(COL_USERS_PERMISSIONS)?;
let roles = role_pool_for_permissions(permissions.as_deref());
Ok(UserRecord {
name: name.clone(),
permissions: permissions.clone(),
selected_workspace: row.get::<Option<String>>(COL_USERS_SELECTED_WORKSPACE)?,
selected_role: row.get::<Option<String>>(COL_USERS_SELECTED_ROLE)?,
roles: roles.iter().map(|r| r.as_str().to_string()).collect(),
channels: self.get_user_channels(&name).await.unwrap_or_default(),
})
}
async fn list_users_where(
&self,
suffix: &str,
params: impl db::IntoParams + Send + 'static,
) -> Result<Vec<UserRecord>> {
let sql = format!("SELECT {USERS_COLUMNS} FROM users {suffix}");
let rows = self.conn.query(&sql, params).await?;
let mut users = Vec::with_capacity(rows.len());
for row in rows {
users.push(self.user_record_from_row(&row).await?);
}
Ok(users)
}
pub async fn find_by_workspace(&self, workspace_name: &str) -> Result<Vec<UserRecord>> {
self.list_users_where("WHERE selected_workspace = ?1", db::params![workspace_name])
.await
}
pub async fn find_by_name(&self, user_name: &str) -> Result<Option<UserRecord>> {
let rows = self
.conn
.query(
&format!("SELECT {USERS_COLUMNS} FROM users WHERE name = ?1"),
db::params![user_name],
)
.await?;
match rows.into_iter().next() {
Some(row) => Ok(Some(self.user_record_from_row(&row).await?)),
None => Ok(None),
}
}
pub async fn list_users(&self) -> Result<Vec<UserRecord>> {
self.list_users_where("", db::params![]).await
}
pub async fn find_admin(&self) -> Result<Option<UserRecord>> {
self.list_users_where("WHERE permissions = ?1", db::params!["full"])
.await
.map(|users| users.into_iter().next())
}
pub async fn update_user(
&self,
name: &str,
role_name: FieldUpdate<'_>,
workspace_name: FieldUpdate<'_>,
permissions: FieldUpdate<'_>,
) -> Result<()> {
let tx = self.conn.begin_tx().await?;
upsert_user_column(&tx, name, "selected_role", role_name).await?;
upsert_user_column(&tx, name, "selected_workspace", workspace_name).await?;
upsert_user_column(&tx, name, "permissions", permissions).await?;
tx.commit().await?;
Ok(())
}
pub async fn set_image_gen_model(&self, name: &str, model: &str) -> Result<()> {
let tx = self.conn.begin_tx().await?;
upsert_user_column(&tx, name, "image_gen_model", FieldUpdate::Set(model)).await?;
tx.commit().await?;
Ok(())
}
pub async fn set_video_model(&self, name: &str, model: &str) -> Result<()> {
let tx = self.conn.begin_tx().await?;
upsert_user_column(&tx, name, "video_model", FieldUpdate::Set(model)).await?;
tx.commit().await?;
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub enum FieldUpdate<'a> {
Unchanged,
Clear,
Set(&'a str),
}
async fn upsert_user_column(
tx: &TxGuard<'_>,
name: &str,
field: &str,
value: FieldUpdate<'_>,
) -> Result<()> {
let val: Option<&str> = match value {
FieldUpdate::Unchanged => return Ok(()),
FieldUpdate::Clear => None,
FieldUpdate::Set(v) => Some(v),
};
let sql = format!(
"INSERT INTO users (name, {field}) VALUES (?1, ?2) \
ON CONFLICT(name) DO UPDATE SET {field} = excluded.{field}"
);
tx.execute(&sql, db::params![name, val]).await?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
pub struct UserRecord {
pub name: String,
pub permissions: Option<String>,
pub selected_workspace: Option<String>,
pub selected_role: Option<String>,
pub roles: Vec<String>,
pub channels: Vec<ChannelBinding>,
}
impl UserRecord {
#[must_use]
pub fn is_admin(&self) -> bool {
is_admin_permissions(self.permissions.as_deref())
}
}
#[must_use]
fn is_admin_permissions(permissions: Option<&str>) -> bool {
permissions == Some("full")
}
#[must_use]
fn role_pool_for_permissions(permissions: Option<&str>) -> Vec<Role> {
if is_admin_permissions(permissions) {
vec![Role::Support, Role::Assistant, Role::Manager, Role::Artist]
} else {
vec![Role::Assistant, Role::Artist]
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ChannelBinding {
pub channel: String,
pub identifier: String,
pub reply_target: Option<String>,
}
#[must_use]
pub(crate) fn userspaces_root() -> PathBuf {
let storage_root = crate::config::CONFIG
.try_storage_root()
.unwrap_or_else(fallback_storage_root);
storage_root.join("userspaces")
}
#[cfg(test)]
fn fallback_storage_root() -> PathBuf {
crate::util::test::test_root().clone()
}
#[cfg(not(test))]
fn fallback_storage_root() -> PathBuf {
crate::config::default_config_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("mahbot_userspaces"))
}
#[must_use]
pub fn personal_workspace_path(user_name: &str) -> PathBuf {
userspaces_root().join(user_name)
}
#[must_use]
pub fn personal_workspace_name(user_name: &str) -> String {
format!("personal:{user_name}")
}
pub(crate) async fn ensure_personal_workspace(name: &str) {
let path = personal_workspace_path(name);
if let Err(e) = tokio::fs::create_dir_all(&path).await {
warn!(
path = %path.display(),
error = %e,
"Failed to create personal workspace directory"
);
}
if path.join(".git").exists() {
return;
}
match run_git_output(&path, &["init", "-q"]).await {
Ok(o) if o.status.success() => {}
Ok(_) => warn!(
path = %path.display(),
"git init failed for personal workspace (git may not be installed)"
),
Err(e) => warn!(
path = %path.display(),
error = %e,
"git init failed for personal workspace"
),
}
}
pub async fn get_raw_selected_workspace(user_name: &str) -> Result<Option<String>> {
store().get_selected_workspace_name(user_name).await
}
pub async fn resolve_image_gen_model(user_name: &str) -> String {
resolve_user_model_column(user_name, "image_gen_model")
.await
.unwrap_or_else(|| crate::config::DEFAULT_IMAGE_GEN_MODEL.to_string())
}
pub async fn resolve_video_model(user_name: &str) -> String {
resolve_user_model_column(user_name, "video_model")
.await
.unwrap_or_else(|| crate::config::DEFAULT_VIDEO_MODEL.to_string())
}
async fn resolve_user_model_column(user_name: &str, column: &str) -> Option<String> {
match store().user_column(column, user_name).await {
Ok(value) => value
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty()),
Err(e) => {
tracing::warn!(user_name, column, error = %e, "user model lookup failed; using default");
None
}
}
}
async fn get_workspace(user_name: &str) -> Result<Option<Workspace>> {
let s = store();
let selected = s.get_selected_workspace_name(user_name).await?;
if let Some(ws_name) = selected {
crate::workspace::get_by_name(&ws_name).await
} else {
let path = personal_workspace_path(user_name);
Ok(Some(personal_workspace_struct(user_name, &path)))
}
}
pub async fn resolve_workspace(workspace_name: &str) -> Result<Option<Workspace>> {
if let Some(ws) = crate::workspace::get_by_name(workspace_name).await? {
if is_personal_workspace(&ws.name) {
warn!(
workspace_name = %ws.name,
"Shared workspace name shadows the personal workspace key 'personal:{{user}}'"
);
}
Ok(Some(ws))
} else if is_personal_workspace(workspace_name) {
let user_name = personal_user_name(workspace_name)
.expect("invariant: is_personal_workspace checked the prefix");
let path = personal_workspace_path(user_name);
Ok(Some(personal_workspace_struct(user_name, &path)))
} else {
Ok(None)
}
}
#[must_use]
fn personal_workspace_struct(user_name: &str, path: &Path) -> Workspace {
let mut ws = Workspace::from_path(path);
ws.name = personal_workspace_name(user_name);
ws.status = WorkspaceStatus::Ready;
ws
}
pub async fn resolve_workspace_for_user_name(user_name: &str) -> Workspace {
match get_workspace(user_name).await {
Ok(Some(ws)) => ws,
Ok(None) => {
warn!(
user_name = %user_name,
"workspace resolution: selected_workspace points to non-existent workspace; \
falling back to personal workspace",
);
let path = personal_workspace_path(user_name);
personal_workspace_struct(user_name, &path)
}
Err(e) => {
warn!(
user_name = %user_name,
error = %e,
"workspace resolution: database error; falling back to personal workspace",
);
let path = personal_workspace_path(user_name);
personal_workspace_struct(user_name, &path)
}
}
}
pub async fn role_pool_status(user_name: &str) -> (Vec<Role>, bool) {
match store().get_permissions(user_name).await {
Ok(perms) => (role_pool_for_permissions(perms.as_deref()), false),
Err(e) => {
tracing::warn!(error = %e, user_name, "Failed to read role pool");
(Vec::new(), true)
}
}
}
pub async fn role_pool(user_name: &str) -> Vec<Role> {
role_pool_status(user_name).await.0
}
pub async fn switch_active_role(user_name: &str, role: Role) -> Result<()> {
store()
.update_user(
user_name,
FieldUpdate::Set(role.as_str()),
FieldUpdate::Unchanged,
FieldUpdate::Unchanged,
)
.await
}
pub async fn resolve_active_role(user_name: &str) -> Option<Role> {
let pool = role_pool(user_name).await;
resolve_active_role_from_pool(user_name, &pool).await
}
pub async fn resolve_active_role_from_pool(user_name: &str, pool: &[Role]) -> Option<Role> {
if pool.is_empty() {
return None;
}
let selected = match store().get_active_role(user_name).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, user_name, "Failed to read selected role");
return None;
}
};
match selected {
Some(name) => name
.parse::<Role>()
.ok()
.filter(|r| pool.contains(r))
.or_else(|| pool.first().copied()),
None => pool.first().copied(),
}
}
#[must_use]
fn resolve_effective_role(role: Role, ws_name: &str, pool: &[Role]) -> Role {
if role == Role::Manager && is_personal_workspace(ws_name) {
if pool.contains(&Role::Assistant) {
Role::Assistant
} else {
role
}
} else {
role
}
}
#[must_use]
fn pins_to_personal(role: Role, ws_name: &str, user_name: &str) -> bool {
!user_name.is_empty()
&& (role == Role::Assistant || role == Role::Artist || role == Role::Support)
&& !is_personal_workspace(ws_name)
}
#[must_use]
pub(crate) fn effective_workspace_for_role(
role: Role,
ws: Workspace,
user_name: &str,
) -> Workspace {
if pins_to_personal(role, &ws.name, user_name) {
let path = personal_workspace_path(user_name);
personal_workspace_struct(user_name, &path)
} else {
ws
}
}
#[must_use]
pub fn effective_role_and_workspace(
role: Role,
ws: Workspace,
user_name: &str,
pool: &[Role],
) -> (Role, Workspace) {
let role = resolve_effective_role(role, &ws.name, pool);
let ws = effective_workspace_for_role(role, ws, user_name);
(role, ws)
}
pub async fn resolve_session_target(user_name: &str) -> (Role, Workspace) {
let (ws, pool) = tokio::join!(
resolve_workspace_for_user_name(user_name),
role_pool(user_name),
);
let role = resolve_active_role_from_pool(user_name, &pool)
.await
.unwrap_or(Role::Assistant);
effective_role_and_workspace(role, ws, user_name, &pool)
}
pub async fn resolve_user_by_channel(channel: &str, identifier: &str) -> Option<String> {
let store = USER_STORE.get()?;
store
.resolve_user_by_channel(channel, identifier)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, ?channel, ?identifier, "Failed to resolve user by channel");
None
})
}
pub async fn resolve_user_by_reply_target(channel: &str, target: &str) -> Option<String> {
let store = USER_STORE.get()?;
store
.conn
.query(
"SELECT user_name, reply_target FROM user_channels WHERE channel = ?1",
db::params![channel],
)
.await
.and_then(|rows| {
let thread_prefix = format!("{target}:");
for row in rows {
let user_name: String = row.get(0)?;
let reply_target: Option<String> = row.get(1)?;
if let Some(t) = reply_target
&& (t == target || t.starts_with(&thread_prefix))
{
return Ok(Some(user_name));
}
}
Ok(None)
})
.unwrap_or_else(|e| {
tracing::warn!(error = %e, ?channel, ?target, "Failed to resolve user by reply target");
None
})
}
pub async fn is_admin(user_name: &str) -> bool {
match USER_STORE.get() {
Some(store) => match store.get_permissions(user_name).await {
Ok(perms) => is_admin_permissions(perms.as_deref()),
Err(e) => {
tracing::warn!(error = %e, user_name, "Failed to read permissions");
false
}
},
None => false,
}
}
pub async fn update_channel_contact(
channel: &str,
identifier: &str,
reply_target: &str,
) -> Result<()> {
store()
.update_channel_contact(channel, identifier, reply_target)
.await
}
#[must_use]
pub fn personal_user_name(workspace_name: &str) -> Option<&str> {
workspace_name.strip_prefix("personal:")
}
#[must_use]
pub fn is_personal_workspace(workspace_name: &str) -> bool {
personal_user_name(workspace_name).is_some()
}
#[cfg(test)]
pub(crate) mod test_util {
use super::*;
pub(crate) async fn init_test_store() {
crate::util::test::init_test_stores().await;
if let Some(store) = USER_STORE.get() {
store
.add_user("alice", Some("full"), Role::Support)
.await
.expect("failed to add alice to test USER_STORE");
store
.add_user("bob", None, Role::Assistant)
.await
.expect("failed to add bob to test USER_STORE");
store
.bind_channel("alice", "telegram", "alice")
.await
.expect("failed to bind alice telegram");
store
.bind_channel("bob", "telegram", "bob")
.await
.expect("failed to bind bob telegram");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_pool_for_permissions_is_permission_derived() {
assert_eq!(
role_pool_for_permissions(Some("full")),
vec![Role::Support, Role::Assistant, Role::Manager, Role::Artist]
);
assert_eq!(
role_pool_for_permissions(None),
vec![Role::Assistant, Role::Artist]
);
}
#[tokio::test]
async fn role_pool_lifecycle() {
crate::util::test::init_test_stores().await;
let store = store();
store
.add_user("pool_user", None, Role::Assistant)
.await
.unwrap();
assert_eq!(
role_pool("pool_user").await,
vec![Role::Assistant, Role::Artist]
);
assert_eq!(
resolve_active_role("pool_user").await,
Some(Role::Assistant)
);
store
.update_user(
"pool_user",
FieldUpdate::Set("engineer"),
FieldUpdate::Unchanged,
FieldUpdate::Unchanged,
)
.await
.unwrap();
assert_eq!(
resolve_active_role("pool_user").await,
Some(Role::Assistant)
);
}
#[tokio::test]
async fn delete_user_removes_channel_rows() {
crate::util::test::init_test_stores().await;
let store = store();
store
.add_user("doomed", None, Role::Assistant)
.await
.unwrap();
store.delete_user("doomed").await.unwrap();
assert!(
store
.conn
.query(
"SELECT 1 FROM user_channels WHERE user_name = 'doomed'",
crate::db::params![],
)
.await
.unwrap()
.is_empty(),
"user_channels rows must be deleted with the user"
);
assert!(
store.find_by_name("doomed").await.unwrap().is_none(),
"the user row must be deleted"
);
}
#[test]
fn normalize_telegram_handle_rules() {
assert_eq!(normalize_telegram_handle("alice").unwrap(), "alice");
assert_eq!(normalize_telegram_handle(" alice ").unwrap(), "alice");
assert_eq!(normalize_telegram_handle("@alice").unwrap(), "alice");
assert_eq!(normalize_telegram_handle(" @ alice ").unwrap(), "alice");
assert!(normalize_telegram_handle("unknown").is_err());
assert!(normalize_telegram_handle(" ").is_err());
assert!(normalize_telegram_handle("@").is_err());
assert_eq!(normalize_telegram_handle("Unknown").unwrap(), "Unknown");
}
#[tokio::test]
async fn validate_telegram_bind_guards() {
crate::util::test::init_test_stores().await;
let store = store();
store
.add_user("bind_guard_owner", None, Role::Assistant)
.await
.unwrap();
store
.bind_channel("bind_guard_owner", "telegram", "guard_handle")
.await
.unwrap();
assert_eq!(
store
.validate_telegram_bind("bind_guard_owner", "@guard_handle")
.await
.unwrap(),
"guard_handle"
);
let err = store
.validate_telegram_bind("bind_guard_other", "guard_handle")
.await
.unwrap_err();
assert!(
err.to_string().contains("bind_guard_owner"),
"error must name the current owner: {err}"
);
let err = store
.validate_telegram_bind("bind_guard_owner", "unknown")
.await
.unwrap_err();
assert!(
err.to_string().contains("reserved"),
"error must mention 'reserved': {err}"
);
}
#[test]
fn pinning_helpers_pin_assistant_artist_support_to_personal() {
assert!(pins_to_personal(Role::Assistant, "ws1", "alice"));
assert!(pins_to_personal(Role::Artist, "ws1", "alice"));
assert!(pins_to_personal(Role::Support, "ws1", "alice"));
assert!(!pins_to_personal(
Role::Assistant,
"personal:alice",
"alice"
));
assert!(!pins_to_personal(Role::Manager, "ws1", "alice"));
assert!(!pins_to_personal(Role::Assistant, "ws1", ""));
let project = Workspace {
name: "ws1".to_string(),
..Default::default()
};
let personal = effective_workspace_for_role(Role::Assistant, project.clone(), "alice");
assert_eq!(personal.name, "personal:alice");
assert!(
personal.path.ends_with("userspaces/alice"),
"the personal workspace must use the userspaces path, got: {}",
personal.path
);
let kept = effective_workspace_for_role(Role::Manager, project.clone(), "alice");
assert_eq!(kept.name, "ws1");
let already = effective_workspace_for_role(Role::Artist, personal.clone(), "alice");
assert_eq!(already.name, "personal:alice");
let (role, ws) = effective_role_and_workspace(
Role::Manager,
Workspace {
name: "personal:alice".to_string(),
..Default::default()
},
"alice",
&[Role::Assistant],
);
assert_eq!(role, Role::Assistant);
assert_eq!(ws.name, "personal:alice");
let (role, _ws) = effective_role_and_workspace(
Role::Manager,
Workspace {
name: "personal:alice".to_string(),
..Default::default()
},
"alice",
&[Role::Analyst],
);
assert_eq!(role, Role::Manager);
}
#[tokio::test]
async fn resolve_session_target_matches_routing() {
crate::util::test::init_test_stores().await;
let user = "home_clear_target";
let store = store();
store
.add_user(user, Some("full"), Role::Manager)
.await
.unwrap();
crate::util::test::create_test_workspace(
"/tmp/home_clear_target_ws",
"ws_home_clear_target",
)
.await;
store
.update_user(
user,
FieldUpdate::Set("manager"),
FieldUpdate::Set("ws_home_clear_target"),
FieldUpdate::Unchanged,
)
.await
.unwrap();
let (role, ws) = resolve_session_target(user).await;
assert_eq!(role, Role::Manager);
assert_eq!(ws.name, "ws_home_clear_target");
store
.update_user(
user,
FieldUpdate::Set("assistant"),
FieldUpdate::Unchanged,
FieldUpdate::Unchanged,
)
.await
.unwrap();
let (role, ws) = resolve_session_target(user).await;
assert_eq!(role, Role::Assistant);
assert_eq!(ws.name, "personal:home_clear_target");
store
.update_user(
user,
FieldUpdate::Set("manager"),
FieldUpdate::Clear,
FieldUpdate::Unchanged,
)
.await
.unwrap();
let (role, ws) = resolve_session_target(user).await;
assert_eq!(role, Role::Assistant);
assert_eq!(ws.name, "personal:home_clear_target");
}
}