687 lines
22 KiB
Rust
687 lines
22 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use crate::*;
|
|
use shared_contracts::admin::{ADMIN_ACTION_PERMISSIONS, ADMIN_TAB_PERMISSIONS};
|
|
|
|
const ADMIN_ACCOUNT_MAX_ID_CHARS: usize = 128;
|
|
const ADMIN_ACCOUNT_MAX_USERNAME_CHARS: usize = 64;
|
|
const ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS: usize = 64;
|
|
const ADMIN_ACCOUNT_MAX_ACTOR_CHARS: usize = 128;
|
|
const ADMIN_ACCOUNT_MAX_PASSWORD_HASH_CHARS: usize = 512;
|
|
|
|
#[spacetimedb::table(accessor = admin_account)]
|
|
#[derive(Clone)]
|
|
pub struct AdminAccount {
|
|
#[primary_key]
|
|
pub account_id: String,
|
|
#[unique]
|
|
pub username: String,
|
|
pub display_name: String,
|
|
pub password_hash: String,
|
|
pub tab_permissions_json: String,
|
|
pub enabled: bool,
|
|
pub token_version: u64,
|
|
pub created_by: String,
|
|
pub updated_by: String,
|
|
pub created_at: Timestamp,
|
|
pub updated_at: Timestamp,
|
|
#[default(None::<String>)]
|
|
pub action_permissions_json: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountGetByUsernameInput {
|
|
pub username: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountGetByIdInput {
|
|
pub account_id: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountCreateInput {
|
|
pub account_id: String,
|
|
pub username: String,
|
|
pub display_name: String,
|
|
pub password_hash: String,
|
|
pub tab_permissions_json: String,
|
|
pub enabled: bool,
|
|
pub created_by: String,
|
|
pub action_permissions_json: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountUpdateInput {
|
|
pub account_id: String,
|
|
pub display_name: String,
|
|
pub password_hash: Option<String>,
|
|
pub tab_permissions_json: String,
|
|
pub enabled: bool,
|
|
pub updated_by: String,
|
|
pub action_permissions_json: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountSnapshot {
|
|
pub account_id: String,
|
|
pub username: String,
|
|
pub display_name: String,
|
|
pub tab_permissions_json: String,
|
|
pub enabled: bool,
|
|
pub token_version: u64,
|
|
pub created_by: String,
|
|
pub updated_by: String,
|
|
pub created_at_micros: i64,
|
|
pub updated_at_micros: i64,
|
|
pub action_permissions_json: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountCredentialSnapshot {
|
|
pub account: AdminAccountSnapshot,
|
|
pub password_hash: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountProcedureResult {
|
|
pub ok: bool,
|
|
pub account: Option<AdminAccountSnapshot>,
|
|
pub accounts: Vec<AdminAccountSnapshot>,
|
|
pub error_message: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
|
pub struct AdminAccountCredentialProcedureResult {
|
|
pub ok: bool,
|
|
pub account: Option<AdminAccountCredentialSnapshot>,
|
|
pub error_message: Option<String>,
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_admin_account_by_username_and_return(
|
|
ctx: &mut ProcedureContext,
|
|
input: AdminAccountGetByUsernameInput,
|
|
) -> AdminAccountCredentialProcedureResult {
|
|
let caller = ctx.sender();
|
|
match ctx.try_with_tx(|tx| {
|
|
require_admin_account_service_identity(tx, caller)?;
|
|
get_admin_account_by_username(tx, input.clone())
|
|
}) {
|
|
Ok(account) => admin_account_credential_ok(account),
|
|
Err(message) => admin_account_credential_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_admin_account_by_id_and_return(
|
|
ctx: &mut ProcedureContext,
|
|
input: AdminAccountGetByIdInput,
|
|
) -> AdminAccountProcedureResult {
|
|
let caller = ctx.sender();
|
|
match ctx.try_with_tx(|tx| {
|
|
require_admin_account_service_identity(tx, caller)?;
|
|
get_admin_account_by_id(tx, input.clone())
|
|
}) {
|
|
Ok(account) => admin_account_single_ok(account),
|
|
Err(message) => admin_account_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn list_admin_accounts_and_return(ctx: &mut ProcedureContext) -> AdminAccountProcedureResult {
|
|
let caller = ctx.sender();
|
|
match ctx.try_with_tx(|tx| {
|
|
require_admin_account_service_identity(tx, caller)?;
|
|
Ok(list_admin_accounts(tx))
|
|
}) {
|
|
Ok(accounts) => admin_account_list_ok(accounts),
|
|
Err(message) => admin_account_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn create_admin_account_and_return(
|
|
ctx: &mut ProcedureContext,
|
|
input: AdminAccountCreateInput,
|
|
) -> AdminAccountProcedureResult {
|
|
let caller = ctx.sender();
|
|
match ctx.try_with_tx(|tx| {
|
|
require_admin_account_service_identity(tx, caller)?;
|
|
create_admin_account(tx, input.clone())
|
|
}) {
|
|
Ok(account) => admin_account_single_ok(account),
|
|
Err(message) => admin_account_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn update_admin_account_and_return(
|
|
ctx: &mut ProcedureContext,
|
|
input: AdminAccountUpdateInput,
|
|
) -> AdminAccountProcedureResult {
|
|
let caller = ctx.sender();
|
|
match ctx.try_with_tx(|tx| {
|
|
require_admin_account_service_identity(tx, caller)?;
|
|
update_admin_account(tx, input.clone())
|
|
}) {
|
|
Ok(account) => admin_account_single_ok(account),
|
|
Err(message) => admin_account_error(message),
|
|
}
|
|
}
|
|
|
|
fn require_admin_account_service_identity(
|
|
ctx: &ReducerContext,
|
|
caller: Identity,
|
|
) -> Result<(), String> {
|
|
crate::editor_project_storage::require_editor_generation_runtime_service_identity(ctx, caller)
|
|
}
|
|
|
|
fn get_admin_account_by_username(
|
|
ctx: &ReducerContext,
|
|
input: AdminAccountGetByUsernameInput,
|
|
) -> Result<AdminAccountCredentialSnapshot, String> {
|
|
let username = normalize_username(&input.username)?;
|
|
ctx.db
|
|
.admin_account()
|
|
.username()
|
|
.find(&username)
|
|
.map(admin_account_credential_snapshot_from_row)
|
|
.ok_or_else(|| "后台账号不存在".to_string())
|
|
}
|
|
|
|
fn get_admin_account_by_id(
|
|
ctx: &ReducerContext,
|
|
input: AdminAccountGetByIdInput,
|
|
) -> Result<AdminAccountSnapshot, String> {
|
|
let account_id = normalize_required(
|
|
&input.account_id,
|
|
"admin_account.account_id",
|
|
ADMIN_ACCOUNT_MAX_ID_CHARS,
|
|
)?;
|
|
ctx.db
|
|
.admin_account()
|
|
.account_id()
|
|
.find(&account_id)
|
|
.map(admin_account_snapshot_from_row)
|
|
.ok_or_else(|| "后台账号不存在".to_string())
|
|
}
|
|
|
|
fn list_admin_accounts(ctx: &ReducerContext) -> Vec<AdminAccountSnapshot> {
|
|
let mut accounts = ctx
|
|
.db
|
|
.admin_account()
|
|
.iter()
|
|
.map(admin_account_snapshot_from_row)
|
|
.collect::<Vec<_>>();
|
|
accounts.sort_by(|left, right| {
|
|
left.username
|
|
.cmp(&right.username)
|
|
.then_with(|| left.account_id.cmp(&right.account_id))
|
|
});
|
|
accounts
|
|
}
|
|
|
|
fn create_admin_account(
|
|
ctx: &ReducerContext,
|
|
input: AdminAccountCreateInput,
|
|
) -> Result<AdminAccountSnapshot, String> {
|
|
let account_id = normalize_required(
|
|
&input.account_id,
|
|
"admin_account.account_id",
|
|
ADMIN_ACCOUNT_MAX_ID_CHARS,
|
|
)?;
|
|
let username = normalize_username(&input.username)?;
|
|
ensure_admin_account_available(
|
|
ctx.db
|
|
.admin_account()
|
|
.account_id()
|
|
.find(&account_id)
|
|
.is_some(),
|
|
ctx.db.admin_account().username().find(&username).is_some(),
|
|
)?;
|
|
let display_name = normalize_required(
|
|
&input.display_name,
|
|
"admin_account.display_name",
|
|
ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS,
|
|
)?;
|
|
let password_hash = normalize_password_hash(&input.password_hash)?;
|
|
let tab_permissions_json = normalize_tab_permissions_json(&input.tab_permissions_json)?;
|
|
let action_permissions_json =
|
|
normalize_action_permissions_json(&input.action_permissions_json)?;
|
|
let created_by = normalize_required(
|
|
&input.created_by,
|
|
"admin_account.created_by",
|
|
ADMIN_ACCOUNT_MAX_ACTOR_CHARS,
|
|
)?;
|
|
|
|
ctx.db.admin_account().try_insert(AdminAccount {
|
|
account_id: account_id.clone(),
|
|
username,
|
|
display_name,
|
|
password_hash,
|
|
tab_permissions_json,
|
|
enabled: input.enabled,
|
|
token_version: 1,
|
|
created_by: created_by.clone(),
|
|
updated_by: created_by,
|
|
created_at: ctx.timestamp,
|
|
updated_at: ctx.timestamp,
|
|
action_permissions_json: Some(action_permissions_json),
|
|
})?;
|
|
|
|
ctx.db
|
|
.admin_account()
|
|
.account_id()
|
|
.find(&account_id)
|
|
.map(admin_account_snapshot_from_row)
|
|
.ok_or_else(|| "后台账号创建失败".to_string())
|
|
}
|
|
|
|
fn update_admin_account(
|
|
ctx: &ReducerContext,
|
|
input: AdminAccountUpdateInput,
|
|
) -> Result<AdminAccountSnapshot, String> {
|
|
let account_id = normalize_required(
|
|
&input.account_id,
|
|
"admin_account.account_id",
|
|
ADMIN_ACCOUNT_MAX_ID_CHARS,
|
|
)?;
|
|
let row = ctx
|
|
.db
|
|
.admin_account()
|
|
.account_id()
|
|
.find(&account_id)
|
|
.ok_or_else(|| "后台账号不存在".to_string())?;
|
|
let display_name = normalize_required(
|
|
&input.display_name,
|
|
"admin_account.display_name",
|
|
ADMIN_ACCOUNT_MAX_DISPLAY_NAME_CHARS,
|
|
)?;
|
|
let password_hash = input
|
|
.password_hash
|
|
.as_deref()
|
|
.map(normalize_password_hash)
|
|
.transpose()?;
|
|
let tab_permissions_json = normalize_tab_permissions_json(&input.tab_permissions_json)?;
|
|
let action_permissions_json =
|
|
normalize_action_permissions_json(&input.action_permissions_json)?;
|
|
let updated_by = normalize_required(
|
|
&input.updated_by,
|
|
"admin_account.updated_by",
|
|
ADMIN_ACCOUNT_MAX_ACTOR_CHARS,
|
|
)?;
|
|
let updated = build_updated_admin_account(
|
|
row,
|
|
display_name,
|
|
password_hash,
|
|
tab_permissions_json,
|
|
action_permissions_json,
|
|
input.enabled,
|
|
updated_by,
|
|
ctx.timestamp,
|
|
)?;
|
|
ctx.db.admin_account().account_id().update(updated);
|
|
|
|
ctx.db
|
|
.admin_account()
|
|
.account_id()
|
|
.find(&account_id)
|
|
.map(admin_account_snapshot_from_row)
|
|
.ok_or_else(|| "后台账号更新失败".to_string())
|
|
}
|
|
|
|
fn build_updated_admin_account(
|
|
row: AdminAccount,
|
|
display_name: String,
|
|
password_hash: Option<String>,
|
|
tab_permissions_json: String,
|
|
action_permissions_json: String,
|
|
enabled: bool,
|
|
updated_by: String,
|
|
updated_at: Timestamp,
|
|
) -> Result<AdminAccount, String> {
|
|
let invalidates_session = password_hash.is_some()
|
|
|| row.tab_permissions_json != tab_permissions_json
|
|
|| row.action_permissions_json.as_deref().unwrap_or("[]") != action_permissions_json
|
|
|| row.enabled != enabled;
|
|
let token_version = if invalidates_session {
|
|
row.token_version
|
|
.checked_add(1)
|
|
.ok_or_else(|| "后台账号 token_version 已达上限".to_string())?
|
|
} else {
|
|
row.token_version
|
|
};
|
|
Ok(AdminAccount {
|
|
display_name,
|
|
password_hash: password_hash.unwrap_or(row.password_hash),
|
|
tab_permissions_json,
|
|
action_permissions_json: Some(action_permissions_json),
|
|
enabled,
|
|
token_version,
|
|
updated_by,
|
|
updated_at,
|
|
..row
|
|
})
|
|
}
|
|
|
|
fn admin_account_snapshot_from_row(row: AdminAccount) -> AdminAccountSnapshot {
|
|
AdminAccountSnapshot {
|
|
account_id: row.account_id,
|
|
username: row.username,
|
|
display_name: row.display_name,
|
|
tab_permissions_json: row.tab_permissions_json,
|
|
enabled: row.enabled,
|
|
token_version: row.token_version,
|
|
created_by: row.created_by,
|
|
updated_by: row.updated_by,
|
|
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
|
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
|
action_permissions_json: row
|
|
.action_permissions_json
|
|
.unwrap_or_else(|| "[]".to_string()),
|
|
}
|
|
}
|
|
|
|
fn admin_account_credential_snapshot_from_row(row: AdminAccount) -> AdminAccountCredentialSnapshot {
|
|
AdminAccountCredentialSnapshot {
|
|
password_hash: row.password_hash.clone(),
|
|
account: admin_account_snapshot_from_row(row),
|
|
}
|
|
}
|
|
|
|
fn normalize_username(value: &str) -> Result<String, String> {
|
|
normalize_required(
|
|
value,
|
|
"admin_account.username",
|
|
ADMIN_ACCOUNT_MAX_USERNAME_CHARS,
|
|
)
|
|
.map(|value| value.to_ascii_lowercase())
|
|
}
|
|
|
|
fn normalize_password_hash(value: &str) -> Result<String, String> {
|
|
normalize_required(
|
|
value,
|
|
"admin_account.password_hash",
|
|
ADMIN_ACCOUNT_MAX_PASSWORD_HASH_CHARS,
|
|
)
|
|
}
|
|
|
|
fn normalize_required(value: &str, field: &str, max_chars: usize) -> Result<String, String> {
|
|
let normalized = value.trim();
|
|
if normalized.is_empty() {
|
|
return Err(format!("{field} 不能为空"));
|
|
}
|
|
if normalized.chars().count() > max_chars {
|
|
return Err(format!("{field} 长度不能超过 {max_chars} 个字符"));
|
|
}
|
|
Ok(normalized.to_string())
|
|
}
|
|
|
|
fn normalize_tab_permissions_json(value: &str) -> Result<String, String> {
|
|
normalize_permissions_json(
|
|
value,
|
|
"admin_account.tab_permissions_json",
|
|
"后台 Tab",
|
|
&ADMIN_TAB_PERMISSIONS,
|
|
)
|
|
}
|
|
|
|
fn normalize_action_permissions_json(value: &str) -> Result<String, String> {
|
|
normalize_permissions_json(
|
|
value,
|
|
"admin_account.action_permissions_json",
|
|
"后台操作",
|
|
&ADMIN_ACTION_PERMISSIONS,
|
|
)
|
|
}
|
|
|
|
fn normalize_permissions_json(
|
|
value: &str,
|
|
field_name: &str,
|
|
permission_kind: &str,
|
|
allowed_permissions: &[&str],
|
|
) -> Result<String, String> {
|
|
let permissions = serde_json::from_str::<Vec<String>>(value.trim())
|
|
.map_err(|_| format!("{field_name} 必须是字符串数组 JSON"))?;
|
|
let requested = permissions
|
|
.into_iter()
|
|
.map(|permission| permission.trim().to_string())
|
|
.collect::<HashSet<_>>();
|
|
if let Some(permission) = requested
|
|
.iter()
|
|
.find(|permission| !allowed_permissions.contains(&permission.as_str()))
|
|
{
|
|
return Err(format!("未知{permission_kind}权限: {permission}"));
|
|
}
|
|
let normalized = allowed_permissions
|
|
.iter()
|
|
.filter(|permission| requested.contains(**permission))
|
|
.copied()
|
|
.collect::<Vec<_>>();
|
|
serde_json::to_string(&normalized).map_err(|_| format!("{permission_kind}权限序列化失败"))
|
|
}
|
|
|
|
fn ensure_admin_account_available(
|
|
account_id_exists: bool,
|
|
username_exists: bool,
|
|
) -> Result<(), String> {
|
|
if account_id_exists {
|
|
return Err("后台账号 ID 已存在".to_string());
|
|
}
|
|
if username_exists {
|
|
return Err("后台账号用户名已存在".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn admin_account_single_ok(account: AdminAccountSnapshot) -> AdminAccountProcedureResult {
|
|
AdminAccountProcedureResult {
|
|
ok: true,
|
|
account: Some(account),
|
|
accounts: Vec::new(),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn admin_account_credential_ok(
|
|
account: AdminAccountCredentialSnapshot,
|
|
) -> AdminAccountCredentialProcedureResult {
|
|
AdminAccountCredentialProcedureResult {
|
|
ok: true,
|
|
account: Some(account),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn admin_account_list_ok(accounts: Vec<AdminAccountSnapshot>) -> AdminAccountProcedureResult {
|
|
AdminAccountProcedureResult {
|
|
ok: true,
|
|
account: None,
|
|
accounts,
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn admin_account_error(message: String) -> AdminAccountProcedureResult {
|
|
AdminAccountProcedureResult {
|
|
ok: false,
|
|
account: None,
|
|
accounts: Vec::new(),
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
fn admin_account_credential_error(message: String) -> AdminAccountCredentialProcedureResult {
|
|
AdminAccountCredentialProcedureResult {
|
|
ok: false,
|
|
account: None,
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tab_permissions_are_deduplicated_in_stable_route_order() {
|
|
let normalized = normalize_tab_permissions_json(
|
|
r#"["editor-assets","dashboard","dashboard","tracking"]"#,
|
|
)
|
|
.expect("permissions should normalize");
|
|
|
|
assert_eq!(normalized, r#"["dashboard","tracking","editor-assets"]"#);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_tab_permission_is_rejected() {
|
|
let error = normalize_tab_permissions_json(r#"["dashboard","accounts"]"#)
|
|
.expect_err("owner-only accounts permission must be rejected");
|
|
|
|
assert!(error.contains("accounts"));
|
|
}
|
|
|
|
#[test]
|
|
fn action_permissions_are_independent_from_tab_permissions() {
|
|
assert_eq!(
|
|
normalize_action_permissions_json(
|
|
r#"["profile-wallet-consumption-reconcile","profile-wallet-consumption-reconcile"]"#,
|
|
)
|
|
.expect("action permission should normalize"),
|
|
r#"["profile-wallet-consumption-reconcile"]"#
|
|
);
|
|
assert!(normalize_action_permissions_json(r#"["recharge-orders"]"#).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_username_is_rejected() {
|
|
let error = ensure_admin_account_available(false, true)
|
|
.expect_err("duplicate username must be rejected");
|
|
|
|
assert_eq!(error, "后台账号用户名已存在");
|
|
}
|
|
|
|
#[test]
|
|
fn update_state_increments_token_version_and_preserves_username() {
|
|
let row = AdminAccount {
|
|
account_id: "member-1".to_string(),
|
|
username: "operator".to_string(),
|
|
display_name: "运营".to_string(),
|
|
password_hash: "$argon2id$old".to_string(),
|
|
tab_permissions_json: r#"["dashboard"]"#.to_string(),
|
|
enabled: true,
|
|
token_version: 7,
|
|
created_by: "owner".to_string(),
|
|
updated_by: "owner".to_string(),
|
|
created_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
action_permissions_json: None,
|
|
};
|
|
let updated = build_updated_admin_account(
|
|
row,
|
|
"禁用运营".to_string(),
|
|
None,
|
|
r#"["dashboard"]"#.to_string(),
|
|
"[]".to_string(),
|
|
false,
|
|
"owner".to_string(),
|
|
Timestamp::from_micros_since_unix_epoch(20),
|
|
)
|
|
.expect("account should update");
|
|
|
|
assert_eq!(updated.username, "operator");
|
|
assert_eq!(updated.token_version, 8);
|
|
assert!(!updated.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn display_name_only_update_keeps_token_version() {
|
|
let row = AdminAccount {
|
|
account_id: "member-1".to_string(),
|
|
username: "operator".to_string(),
|
|
display_name: "运营".to_string(),
|
|
password_hash: "$argon2id$old".to_string(),
|
|
tab_permissions_json: r#"["dashboard"]"#.to_string(),
|
|
enabled: true,
|
|
token_version: 7,
|
|
created_by: "owner".to_string(),
|
|
updated_by: "owner".to_string(),
|
|
created_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
action_permissions_json: None,
|
|
};
|
|
let updated = build_updated_admin_account(
|
|
row,
|
|
"运营二组".to_string(),
|
|
None,
|
|
r#"["dashboard"]"#.to_string(),
|
|
"[]".to_string(),
|
|
true,
|
|
"owner".to_string(),
|
|
Timestamp::from_micros_since_unix_epoch(20),
|
|
)
|
|
.expect("display name should update");
|
|
|
|
assert_eq!(updated.display_name, "运营二组");
|
|
assert_eq!(updated.token_version, 7);
|
|
}
|
|
|
|
#[test]
|
|
fn action_permission_update_invalidates_existing_session() {
|
|
let row = AdminAccount {
|
|
account_id: "member-1".to_string(),
|
|
username: "operator".to_string(),
|
|
display_name: "运营".to_string(),
|
|
password_hash: "$argon2id$old".to_string(),
|
|
tab_permissions_json: r#"["dashboard"]"#.to_string(),
|
|
enabled: true,
|
|
token_version: 7,
|
|
created_by: "owner".to_string(),
|
|
updated_by: "owner".to_string(),
|
|
created_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
action_permissions_json: None,
|
|
};
|
|
let updated = build_updated_admin_account(
|
|
row,
|
|
"运营".to_string(),
|
|
None,
|
|
r#"["dashboard"]"#.to_string(),
|
|
r#"["profile-wallet-consumption-reconcile"]"#.to_string(),
|
|
true,
|
|
"owner".to_string(),
|
|
Timestamp::from_micros_since_unix_epoch(20),
|
|
)
|
|
.expect("action permission should update");
|
|
|
|
assert_eq!(updated.token_version, 8);
|
|
}
|
|
|
|
#[test]
|
|
fn public_and_credential_account_snapshots_are_type_separated() {
|
|
let row = AdminAccount {
|
|
account_id: "member-1".to_string(),
|
|
username: "operator".to_string(),
|
|
display_name: "运营".to_string(),
|
|
password_hash: "$argon2id$secret".to_string(),
|
|
tab_permissions_json: "[]".to_string(),
|
|
enabled: true,
|
|
token_version: 1,
|
|
created_by: "owner".to_string(),
|
|
updated_by: "owner".to_string(),
|
|
created_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(10),
|
|
action_permissions_json: Some(
|
|
r#"["profile-wallet-consumption-reconcile"]"#.to_string(),
|
|
),
|
|
};
|
|
let public_snapshot = admin_account_snapshot_from_row(row.clone());
|
|
let credential_snapshot = admin_account_credential_snapshot_from_row(row);
|
|
|
|
assert_eq!(public_snapshot.account_id, "member-1");
|
|
assert_eq!(credential_snapshot.account, public_snapshot);
|
|
assert_eq!(credential_snapshot.password_hash, "$argon2id$secret");
|
|
}
|
|
}
|