686 lines
23 KiB
Rust
686 lines
23 KiB
Rust
use sprimo_api::{ApiConfig, ApiServerError, ApiState};
|
|
use sprimo_config::{save, ApiTokenEntry, AppConfig, ConfigError};
|
|
use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, RwLock};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use thiserror::Error;
|
|
use tokio::runtime::Runtime;
|
|
use tokio::sync::{mpsc, Mutex};
|
|
use tracing::warn;
|
|
|
|
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN: u8 = 1;
|
|
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX: u8 = 200;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum RuntimeCoreError {
|
|
#[error("{0}")]
|
|
Config(#[from] ConfigError),
|
|
#[error("snapshot lock poisoned")]
|
|
SnapshotPoisoned,
|
|
#[error("config lock poisoned")]
|
|
ConfigPoisoned,
|
|
#[error("api token not found: {0}")]
|
|
ApiTokenNotFound(String),
|
|
#[error("cannot revoke the last API token")]
|
|
LastApiToken,
|
|
}
|
|
|
|
pub struct RuntimeCore {
|
|
config_path: PathBuf,
|
|
config: Arc<RwLock<AppConfig>>,
|
|
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
|
api_config: ApiConfig,
|
|
auth_store: Arc<RwLock<HashSet<String>>>,
|
|
command_tx: mpsc::Sender<CommandEnvelope>,
|
|
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
|
|
}
|
|
|
|
impl RuntimeCore {
|
|
pub fn new(app_name: &str, capabilities: CapabilityFlags) -> Result<Self, RuntimeCoreError> {
|
|
let (config_path, config_value) = sprimo_config::load_or_create(app_name)?;
|
|
Self::new_with_config(config_path, config_value, capabilities)
|
|
}
|
|
|
|
pub fn new_with_config(
|
|
config_path: PathBuf,
|
|
mut config_value: AppConfig,
|
|
capabilities: CapabilityFlags,
|
|
) -> Result<Self, RuntimeCoreError> {
|
|
let click_through_was_enabled = config_value.window.click_through;
|
|
config_value.window.click_through = false;
|
|
let tokens_changed = normalize_api_tokens(&mut config_value);
|
|
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
|
|
snapshot.x = config_value.window.x;
|
|
snapshot.y = config_value.window.y;
|
|
snapshot.scale = config_value.window.scale;
|
|
snapshot.flags.click_through = false;
|
|
snapshot.flags.always_on_top = config_value.window.always_on_top;
|
|
snapshot.flags.visible = config_value.window.visible;
|
|
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
|
|
|
|
let api_config = ApiConfig::default_with_tokens(
|
|
config_value
|
|
.api
|
|
.auth_tokens
|
|
.iter()
|
|
.map(|entry| entry.token.clone())
|
|
.collect(),
|
|
);
|
|
let (command_tx, command_rx) = mpsc::channel(1_024);
|
|
let auth_store = Arc::new(RwLock::new(
|
|
config_value
|
|
.api
|
|
.auth_tokens
|
|
.iter()
|
|
.map(|entry| entry.token.clone())
|
|
.collect(),
|
|
));
|
|
|
|
let core = Self {
|
|
config_path,
|
|
config: Arc::new(RwLock::new(config_value)),
|
|
snapshot: Arc::new(RwLock::new(snapshot)),
|
|
api_config,
|
|
auth_store,
|
|
command_tx,
|
|
command_rx: Arc::new(Mutex::new(command_rx)),
|
|
};
|
|
if click_through_was_enabled || tokens_changed {
|
|
core.persist_config()?;
|
|
}
|
|
Ok(core)
|
|
}
|
|
|
|
pub fn snapshot(&self) -> Arc<RwLock<FrontendStateSnapshot>> {
|
|
Arc::clone(&self.snapshot)
|
|
}
|
|
|
|
pub fn config(&self) -> Arc<RwLock<AppConfig>> {
|
|
Arc::clone(&self.config)
|
|
}
|
|
|
|
pub fn command_receiver(&self) -> Arc<Mutex<mpsc::Receiver<CommandEnvelope>>> {
|
|
Arc::clone(&self.command_rx)
|
|
}
|
|
|
|
pub fn command_sender(&self) -> mpsc::Sender<CommandEnvelope> {
|
|
self.command_tx.clone()
|
|
}
|
|
|
|
pub fn frontend_debug_overlay_visible(&self) -> Result<bool, RuntimeCoreError> {
|
|
let guard = self
|
|
.config
|
|
.read()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
Ok(guard.frontend.debug_overlay_visible)
|
|
}
|
|
|
|
pub fn set_frontend_debug_overlay_visible(
|
|
&self,
|
|
visible: bool,
|
|
) -> Result<(), RuntimeCoreError> {
|
|
{
|
|
let mut guard = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
guard.frontend.debug_overlay_visible = visible;
|
|
}
|
|
self.persist_config()
|
|
}
|
|
|
|
pub fn frontend_tauri_animation_slowdown_factor(&self) -> Result<u8, RuntimeCoreError> {
|
|
let guard = self
|
|
.config
|
|
.read()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
Ok(clamp_tauri_animation_slowdown_factor(
|
|
guard.frontend.tauri_animation_slowdown_factor,
|
|
))
|
|
}
|
|
|
|
pub fn set_frontend_tauri_animation_slowdown_factor(
|
|
&self,
|
|
value: u8,
|
|
) -> Result<u8, RuntimeCoreError> {
|
|
let clamped = clamp_tauri_animation_slowdown_factor(value);
|
|
{
|
|
let mut guard = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
guard.frontend.tauri_animation_slowdown_factor = clamped;
|
|
}
|
|
self.persist_config()?;
|
|
Ok(clamped)
|
|
}
|
|
|
|
pub fn api_config(&self) -> ApiConfig {
|
|
self.api_config.clone()
|
|
}
|
|
|
|
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenEntry>, RuntimeCoreError> {
|
|
let guard = self
|
|
.config
|
|
.read()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
Ok(guard.api.auth_tokens.clone())
|
|
}
|
|
|
|
pub fn create_api_token(
|
|
&self,
|
|
label: Option<String>,
|
|
) -> Result<ApiTokenEntry, RuntimeCoreError> {
|
|
let entry = ApiTokenEntry {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
label: normalize_token_label(label.as_deref().unwrap_or("")),
|
|
token: uuid::Uuid::new_v4().to_string(),
|
|
created_at_ms: now_unix_ms(),
|
|
};
|
|
{
|
|
let mut guard = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
guard.api.auth_tokens.push(entry.clone());
|
|
guard.api.auth_token = guard
|
|
.api
|
|
.auth_tokens
|
|
.first()
|
|
.map(|token| token.token.clone())
|
|
.unwrap_or_default();
|
|
}
|
|
self.persist_config()?;
|
|
self.refresh_auth_store_from_config()?;
|
|
Ok(entry)
|
|
}
|
|
|
|
pub fn rename_api_token(&self, id: &str, label: &str) -> Result<(), RuntimeCoreError> {
|
|
let mut found = false;
|
|
{
|
|
let mut guard = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
for entry in &mut guard.api.auth_tokens {
|
|
if entry.id == id {
|
|
entry.label = normalize_token_label(label);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
|
|
}
|
|
self.persist_config()?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn revoke_api_token(&self, id: &str) -> Result<(), RuntimeCoreError> {
|
|
let removed = {
|
|
let mut guard = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
if guard.api.auth_tokens.len() <= 1 {
|
|
return Err(RuntimeCoreError::LastApiToken);
|
|
}
|
|
let before = guard.api.auth_tokens.len();
|
|
guard.api.auth_tokens.retain(|entry| entry.id != id);
|
|
let after = guard.api.auth_tokens.len();
|
|
guard.api.auth_token = guard
|
|
.api
|
|
.auth_tokens
|
|
.first()
|
|
.map(|token| token.token.clone())
|
|
.unwrap_or_default();
|
|
before != after
|
|
};
|
|
if !removed {
|
|
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
|
|
}
|
|
self.persist_config()?;
|
|
self.refresh_auth_store_from_config()?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn spawn_api(&self, runtime: &Runtime) {
|
|
let mut cfg = self.api_config.clone();
|
|
if let Ok(guard) = self.config.read() {
|
|
cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into();
|
|
cfg.auth_tokens = guard
|
|
.api
|
|
.auth_tokens
|
|
.iter()
|
|
.map(|entry| entry.token.clone())
|
|
.collect();
|
|
}
|
|
let state = Arc::new(ApiState::new(
|
|
cfg.clone(),
|
|
Arc::clone(&self.snapshot),
|
|
self.command_tx.clone(),
|
|
Arc::clone(&self.auth_store),
|
|
));
|
|
runtime.spawn(async move {
|
|
if let Err(err) = sprimo_api::run_server(cfg, state).await {
|
|
log_api_error(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn apply_command(&self, command: &FrontendCommand) -> Result<(), RuntimeCoreError> {
|
|
match command {
|
|
FrontendCommand::SetState { state, .. } => {
|
|
let mut snapshot = self
|
|
.snapshot
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
|
snapshot.state = *state;
|
|
snapshot.current_animation = default_animation_for_state(*state).to_string();
|
|
snapshot.last_error = None;
|
|
}
|
|
FrontendCommand::PlayAnimation { name, .. } => {
|
|
let mut snapshot = self
|
|
.snapshot
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
|
snapshot.current_animation = name.clone();
|
|
snapshot.last_error = None;
|
|
}
|
|
FrontendCommand::SetSpritePack { pack_id_or_path } => {
|
|
{
|
|
let mut snapshot = self
|
|
.snapshot
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
|
snapshot.active_sprite_pack = pack_id_or_path.clone();
|
|
snapshot.last_error = None;
|
|
}
|
|
{
|
|
let mut config = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
config.sprite.selected_pack = pack_id_or_path.clone();
|
|
}
|
|
self.persist_config()?;
|
|
}
|
|
FrontendCommand::SetTransform { x, y, scale, .. } => {
|
|
{
|
|
let mut snapshot = self
|
|
.snapshot
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
|
if let Some(value) = x {
|
|
snapshot.x = *value;
|
|
}
|
|
if let Some(value) = y {
|
|
snapshot.y = *value;
|
|
}
|
|
if let Some(value) = scale {
|
|
snapshot.scale = *value;
|
|
}
|
|
snapshot.last_error = None;
|
|
}
|
|
{
|
|
let mut config = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
if let Some(value) = x {
|
|
config.window.x = *value;
|
|
}
|
|
if let Some(value) = y {
|
|
config.window.y = *value;
|
|
}
|
|
if let Some(value) = scale {
|
|
config.window.scale = *value;
|
|
}
|
|
}
|
|
self.persist_config()?;
|
|
}
|
|
FrontendCommand::SetFlags {
|
|
click_through: _click_through,
|
|
always_on_top,
|
|
visible,
|
|
} => {
|
|
{
|
|
let mut snapshot = self
|
|
.snapshot
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
|
snapshot.flags.click_through = false;
|
|
if let Some(value) = always_on_top {
|
|
snapshot.flags.always_on_top = *value;
|
|
}
|
|
if let Some(value) = visible {
|
|
snapshot.flags.visible = *value;
|
|
}
|
|
snapshot.last_error = None;
|
|
}
|
|
{
|
|
let mut config = self
|
|
.config
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
config.window.click_through = false;
|
|
if let Some(value) = always_on_top {
|
|
config.window.always_on_top = *value;
|
|
}
|
|
if let Some(value) = visible {
|
|
config.window.visible = *value;
|
|
}
|
|
}
|
|
self.persist_config()?;
|
|
}
|
|
FrontendCommand::Toast { .. } => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn persist_config(&self) -> Result<(), RuntimeCoreError> {
|
|
let guard = self
|
|
.config
|
|
.read()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
save(&self.config_path, &guard)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn refresh_auth_store_from_config(&self) -> Result<(), RuntimeCoreError> {
|
|
let tokens = {
|
|
let guard = self
|
|
.config
|
|
.read()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
guard
|
|
.api
|
|
.auth_tokens
|
|
.iter()
|
|
.map(|entry| entry.token.clone())
|
|
.collect::<HashSet<_>>()
|
|
};
|
|
let mut auth = self
|
|
.auth_store
|
|
.write()
|
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
|
*auth = tokens;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str {
|
|
match state {
|
|
sprimo_protocol::v1::FrontendState::Idle => "idle",
|
|
sprimo_protocol::v1::FrontendState::Active => "active",
|
|
sprimo_protocol::v1::FrontendState::Success => "success",
|
|
sprimo_protocol::v1::FrontendState::Error => "error",
|
|
sprimo_protocol::v1::FrontendState::Dragging => "dragging",
|
|
sprimo_protocol::v1::FrontendState::Hidden => "idle",
|
|
}
|
|
}
|
|
|
|
fn clamp_tauri_animation_slowdown_factor(value: u8) -> u8 {
|
|
value.clamp(
|
|
TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN,
|
|
TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX,
|
|
)
|
|
}
|
|
|
|
fn log_api_error(err: ApiServerError) {
|
|
warn!(%err, "runtime core api server exited");
|
|
}
|
|
|
|
fn normalize_api_tokens(config: &mut AppConfig) -> bool {
|
|
let mut changed = false;
|
|
let mut seen = HashSet::new();
|
|
let mut normalized = Vec::new();
|
|
|
|
let legacy = config.api.auth_token.trim().to_string();
|
|
if legacy != config.api.auth_token {
|
|
config.api.auth_token = legacy.clone();
|
|
changed = true;
|
|
}
|
|
|
|
for mut entry in config.api.auth_tokens.clone() {
|
|
let original_id = entry.id.clone();
|
|
let original_label = entry.label.clone();
|
|
let original_token = entry.token.clone();
|
|
let original_created = entry.created_at_ms;
|
|
|
|
entry.id = entry.id.trim().to_string();
|
|
if entry.id.is_empty() {
|
|
entry.id = uuid::Uuid::new_v4().to_string();
|
|
}
|
|
entry.label = normalize_token_label(&entry.label);
|
|
entry.token = entry.token.trim().to_string();
|
|
if entry.created_at_ms == 0 {
|
|
entry.created_at_ms = now_unix_ms();
|
|
}
|
|
|
|
let field_changed = entry.id != original_id
|
|
|| entry.label != original_label
|
|
|| entry.token != original_token
|
|
|| entry.created_at_ms != original_created;
|
|
if field_changed {
|
|
changed = true;
|
|
}
|
|
if entry.token.is_empty() {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
if !seen.insert(entry.token.clone()) {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
normalized.push(entry);
|
|
}
|
|
|
|
if normalized.is_empty() {
|
|
let token = if legacy.is_empty() {
|
|
uuid::Uuid::new_v4().to_string()
|
|
} else {
|
|
legacy
|
|
};
|
|
normalized.push(ApiTokenEntry {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
label: "default".to_string(),
|
|
token,
|
|
created_at_ms: now_unix_ms(),
|
|
});
|
|
changed = true;
|
|
}
|
|
|
|
let mirror = normalized
|
|
.first()
|
|
.map(|entry| entry.token.clone())
|
|
.unwrap_or_default();
|
|
if config.api.auth_token != mirror {
|
|
config.api.auth_token = mirror;
|
|
changed = true;
|
|
}
|
|
if config.api.auth_tokens != normalized {
|
|
config.api.auth_tokens = normalized;
|
|
changed = true;
|
|
}
|
|
|
|
changed
|
|
}
|
|
|
|
fn normalize_token_label(value: &str) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
"token".to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
fn now_unix_ms() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|v| v.as_millis() as u64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::RuntimeCore;
|
|
use sprimo_config::AppConfig;
|
|
use sprimo_protocol::v1::{CapabilityFlags, FrontendCommand, FrontendState};
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn state_command_updates_snapshot() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
|
.expect("core init");
|
|
core.apply_command(&FrontendCommand::SetState {
|
|
state: FrontendState::Active,
|
|
ttl_ms: None,
|
|
})
|
|
.expect("apply");
|
|
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
|
|
assert_eq!(snapshot.state, FrontendState::Active);
|
|
assert_eq!(snapshot.current_animation, "active");
|
|
}
|
|
|
|
#[test]
|
|
fn dragging_state_maps_to_dragging_animation() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
|
.expect("core init");
|
|
core.apply_command(&FrontendCommand::SetState {
|
|
state: FrontendState::Dragging,
|
|
ttl_ms: None,
|
|
})
|
|
.expect("apply");
|
|
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
|
|
assert_eq!(snapshot.state, FrontendState::Dragging);
|
|
assert_eq!(snapshot.current_animation, "dragging");
|
|
}
|
|
|
|
#[test]
|
|
fn click_through_flag_is_ignored_and_forced_false() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let mut config = AppConfig::default();
|
|
config.window.click_through = true;
|
|
let core = RuntimeCore::new_with_config(path, config, CapabilityFlags::default())
|
|
.expect("core init");
|
|
|
|
core.apply_command(&FrontendCommand::SetFlags {
|
|
click_through: Some(true),
|
|
always_on_top: None,
|
|
visible: None,
|
|
})
|
|
.expect("apply");
|
|
|
|
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
|
|
assert!(!snapshot.flags.click_through);
|
|
let config = core.config().read().expect("config lock").clone();
|
|
assert!(!config.window.click_through);
|
|
}
|
|
|
|
#[test]
|
|
fn frontend_debug_overlay_visibility_roundtrips() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
|
.expect("core init");
|
|
core.set_frontend_debug_overlay_visible(true).expect("set");
|
|
assert!(core.frontend_debug_overlay_visible().expect("get"));
|
|
}
|
|
|
|
#[test]
|
|
fn frontend_tauri_animation_slowdown_factor_roundtrips() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
|
.expect("core init");
|
|
let persisted = core
|
|
.set_frontend_tauri_animation_slowdown_factor(6)
|
|
.expect("set");
|
|
assert_eq!(persisted, 6);
|
|
assert_eq!(
|
|
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
|
6
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn frontend_tauri_animation_slowdown_factor_clamps() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
|
.expect("core init");
|
|
let persisted = core
|
|
.set_frontend_tauri_animation_slowdown_factor(0)
|
|
.expect("set");
|
|
assert_eq!(persisted, 1);
|
|
assert_eq!(
|
|
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
|
1
|
|
);
|
|
|
|
let upper = core
|
|
.set_frontend_tauri_animation_slowdown_factor(201)
|
|
.expect("set high");
|
|
assert_eq!(upper, 200);
|
|
assert_eq!(
|
|
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
|
200
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_token_create_rename_revoke_roundtrip() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
|
.expect("core init");
|
|
|
|
let initial_len = core.list_api_tokens().expect("tokens").len();
|
|
let created = core
|
|
.create_api_token(Some("backend-ci".to_string()))
|
|
.expect("create token");
|
|
let after_create = core.list_api_tokens().expect("tokens");
|
|
assert_eq!(after_create.len(), initial_len + 1);
|
|
assert!(after_create.iter().any(|entry| entry.id == created.id));
|
|
|
|
core.rename_api_token(&created.id, "automation")
|
|
.expect("rename token");
|
|
let after_rename = core.list_api_tokens().expect("tokens");
|
|
assert!(
|
|
after_rename
|
|
.iter()
|
|
.any(|entry| entry.id == created.id && entry.label == "automation")
|
|
);
|
|
|
|
core.revoke_api_token(&created.id).expect("revoke token");
|
|
let after_revoke = core.list_api_tokens().expect("tokens");
|
|
assert_eq!(after_revoke.len(), initial_len);
|
|
assert!(!after_revoke.iter().any(|entry| entry.id == created.id));
|
|
}
|
|
|
|
#[test]
|
|
fn cannot_revoke_last_api_token() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
|
.expect("core init");
|
|
let only = core.list_api_tokens().expect("tokens");
|
|
assert_eq!(only.len(), 1);
|
|
let err = core
|
|
.revoke_api_token(&only[0].id)
|
|
.expect_err("last token revoke must fail");
|
|
assert!(format!("{err}").contains("last API token"));
|
|
}
|
|
}
|