From d99eb16dabf39d357711cb0f4ea3bb931e9448ff Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 23:57:55 +0000 Subject: [PATCH] grpc: full account-authority write API (Register through UngroupNick) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trusted caller can now do everything a user does through NickServ commands — Register, Authenticate, SetPassword, SetEmail, Confirm, Drop, ForceLogout, GroupNick, UngroupNick — over gRPC instead of IRC. Each mirrors its NickServ command's exact behavior (Drop reuses the same cleanup a peer's gossiped drop already triggers; SetPassword/Register derive credentials off the shared engine lock, same as the IRC path) but is privileged: the bearer token is the authorization, so most calls skip the account's own password check the IRC command requires — the same trust model as an admin-level JSON-RPC integration. A write commits locally and gossips to every other node exactly like an IRC-originated one, no new propagation path needed. Verified end-to-end over the real wire, not just unit tests: registered an account via gRPC, changed its password via gRPC, then logged into a live IRC session with that exact password. --- README.md | 13 ++ proto/fedserv.proto | 55 +++++++ src/engine/mod.rs | 138 ++++++++++++++++ src/grpc.rs | 377 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 554 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f233e51..ef15779 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,19 @@ SCRAM verifiers, cert fingerprints) and the finer channel-ops-list events authenticated, optional server TLS. Omit `[grpc]` in config.toml to run without it. +The same port also serves **`Accounts`**, the write side: `Register`, +`Authenticate`, `SetPassword`, `SetEmail`, `Confirm`, `Drop`, `ForceLogout`, +`GroupNick`, `UngroupNick` — a trusted caller (e.g. a website's own backend, +already having done its own login/session check) managing accounts the same +way an IRC user does through NickServ, minus the command syntax. The bearer +token *is* the authorization: unlike the NickServ commands these mirror, most +calls do not re-check the account's own password (an admin-level override, the +same trust model a JSON-RPC integration to another services package would +use) — `Register` and `Authenticate` are the two exceptions, since the +password is the actual input there. A write committed this way replicates to +every other fedserv node exactly like an IRC-originated one does — gossip +doesn't know or care where it came from. + ## Config ```toml diff --git a/proto/fedserv.proto b/proto/fedserv.proto index a811b01..febd970 100644 --- a/proto/fedserv.proto +++ b/proto/fedserv.proto @@ -83,3 +83,58 @@ message ChannelRegistered { string name = 1; string founder = 2; uint64 register message ChannelDropped { string name = 1; } message ChannelFounderSet { string name = 1; string founder = 2; } message ChannelDescSet { string name = 1; string description = 2; } + +// Account authority API: the write side, for a trusted caller (e.g. a website's +// own backend, already having done its own login/session check) to manage +// accounts the same way an IRC user does through NickServ — registration, +// credentials, grouping, and forced logout. The bearer token IS the +// authorization: unlike the NickServ commands these mirror, most calls here do +// NOT re-check the account's own password (the caller is trusted, the same +// model as an admin-level override) — Register and Authenticate are the two +// exceptions, since the password is the actual input/question there. +// A write committed here replicates to every other fedserv node exactly like +// an IRC-originated one does — gossip doesn't know or care where it came from. +service Accounts { + rpc Register(RegisterRequest) returns (AccountReply); + rpc Authenticate(AuthenticateRequest) returns (AuthenticateReply); + rpc SetPassword(SetPasswordRequest) returns (AccountReply); + rpc SetEmail(SetEmailRequest) returns (AccountReply); + rpc Confirm(ConfirmRequest) returns (AccountReply); + rpc Drop(DropRequest) returns (AccountReply); + rpc ForceLogout(ForceLogoutRequest) returns (ForceLogoutReply); + rpc GroupNick(GroupNickRequest) returns (AccountReply); + rpc UngroupNick(UngroupNickRequest) returns (AccountReply); +} + +enum Status { + OK = 0; + ALREADY_EXISTS = 1; + NOT_FOUND = 2; + RATE_LIMITED = 3; + INVALID = 4; // bad password/code/input, or a name-shaped invariant violation + INTERNAL = 5; +} + +message AccountReply { + Status status = 1; + string message = 2; // human-readable detail, safe to show a user +} + +message RegisterRequest { string name = 1; string password = 2; string email = 3; } + +message AuthenticateRequest { string name = 1; string password = 2; } +message AuthenticateReply { + Status status = 1; + string account = 2; // canonical account name (resolves a grouped-nick alias) +} + +message SetPasswordRequest { string account = 1; string password = 2; } +message SetEmailRequest { string account = 1; string email = 2; } // empty = clear +message ConfirmRequest { string account = 1; string code = 2; } +message DropRequest { string account = 1; } + +message ForceLogoutRequest { string account = 1; } +message ForceLogoutReply { Status status = 1; uint32 sessions_cleared = 2; } + +message GroupNickRequest { string nick = 1; string account = 2; } +message UngroupNickRequest { string nick = 1; } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index bec3a4d..bc40aa3 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -68,6 +68,18 @@ enum ScramStep { }, } +// Outcome of an authority-originated account operation (see grpc.rs and the +// `authority_*` methods below). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorityStatus { + Ok, + AlreadyExists, + NotFound, + RateLimited, + Invalid, + Internal, +} + pub struct Engine { services: Vec>, network: Network, @@ -127,6 +139,125 @@ impl Engine { (self.db.accounts().cloned().collect(), self.db.channels().cloned().collect()) } + // ── Account authority (see grpc.rs) ───────────────────────────────────── + // A trusted caller (e.g. a website backend that already did its own login + // check) managing accounts the same way an IRC user does through NickServ, + // minus the command syntax. The bearer token on the gRPC side IS the + // authorization: unlike the mirrored NickServ commands, these do NOT + // re-check the account's own password — Register and Authenticate are the + // two exceptions, since the password is the actual input there. + + pub fn authority_pre_check(&mut self, name: &str) -> Result<(), AuthorityStatus> { + if self.db.exists(name) { + return Err(AuthorityStatus::AlreadyExists); + } + if !self.reg_limiter.allow() { + return Err(AuthorityStatus::RateLimited); + } + Ok(()) + } + + pub fn authority_register(&mut self, name: &str, creds: Option, email: Option) -> AuthorityStatus { + let Some(creds) = creds else { return AuthorityStatus::Internal }; + let addr = email.clone(); + let status = match self.db.register_prepared(name, creds, email) { + Ok(()) => AuthorityStatus::Ok, + Err(RegError::Exists) => AuthorityStatus::AlreadyExists, + Err(RegError::Internal) => AuthorityStatus::Internal, + }; + if status == AuthorityStatus::Ok && !self.db.is_verified(name) { + if let Some(addr) = addr { + let code = self.db.issue_code(name, db::CodeKind::Confirm); + let mail = crate::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code); + self.emit_irc(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); + } + } + status + } + + pub fn authority_authenticate(&self, name: &str, password: &str) -> Option { + self.db.authenticate(name, password).map(str::to_string) + } + + pub fn authority_set_password(&mut self, account: &str, creds: Option) -> AuthorityStatus { + let Some(creds) = creds else { return AuthorityStatus::Internal }; + // set_credentials's only Err is Internal, which here always means "no such account". + match self.db.set_credentials(account, creds) { + Ok(()) => AuthorityStatus::Ok, + Err(_) => AuthorityStatus::NotFound, + } + } + + pub fn authority_set_email(&mut self, account: &str, email: Option) -> AuthorityStatus { + match self.db.set_email(account, email) { + Ok(()) => AuthorityStatus::Ok, + Err(_) => AuthorityStatus::NotFound, + } + } + + pub fn authority_confirm(&mut self, account: &str, code: &str) -> AuthorityStatus { + if !self.db.exists(account) { + return AuthorityStatus::NotFound; + } + if self.db.is_verified(account) { + return AuthorityStatus::Ok; // already confirmed — idempotent, not an error + } + if !self.db.take_code(account, db::CodeKind::Confirm, code) { + return AuthorityStatus::Invalid; + } + match self.db.verify_account(account) { + Ok(()) => AuthorityStatus::Ok, + Err(_) => AuthorityStatus::Internal, + } + } + + // Drop reuses the exact cleanup a peer's gossiped drop already triggers + // locally (channel release + session logout) — see `handle_account_gone`. + pub fn authority_drop(&mut self, account: &str) -> AuthorityStatus { + match self.db.drop_account(account) { + Ok(true) => { + self.handle_account_gone(account, "was dropped"); + AuthorityStatus::Ok + } + Ok(false) => AuthorityStatus::NotFound, + Err(_) => AuthorityStatus::Internal, + } + } + + // Unlike Drop, the account itself is untouched — only its active IRC + // sessions are logged out (no channel cleanup, this isn't "account gone"). + pub fn authority_force_logout(&mut self, account: &str) -> u32 { + let victims = self.network.uids_logged_into(account); + let n = victims.len() as u32; + let ns = self.nick_service.clone(); + for uid in victims { + self.network.clear_account(&uid); + self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() }); + if let Some(ns) = &ns { + self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You have been logged out of \x02{account}\x02.") }); + } + } + n + } + + pub fn authority_group_nick(&mut self, nick: &str, account: &str) -> AuthorityStatus { + if self.db.account(nick).is_some() { + return AuthorityStatus::Invalid; // nick is itself a registered account + } + match self.db.group_nick(nick, account) { + Ok(()) => AuthorityStatus::Ok, + Err(_) => AuthorityStatus::NotFound, // group_nick's only Err means the account doesn't exist + } + } + + pub fn authority_ungroup_nick(&mut self, nick: &str) -> AuthorityStatus { + match self.db.ungroup_nick(nick) { + Ok(true) => AuthorityStatus::Ok, + Ok(false) => AuthorityStatus::NotFound, + Err(_) => AuthorityStatus::Internal, + } + } + pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> { // If ingesting a peer's entry removed an account a local session relied on // (lost a conflict, or dropped elsewhere), clean up after it. @@ -190,6 +321,13 @@ impl Engine { self.db.register(name, "pw", None).unwrap(); } + // Simulate a uid being logged into `account`, for tests that need + // `authority_force_logout` to have a live session to clear. + #[cfg(test)] + pub(crate) fn test_login(&mut self, uid: &str, account: &str) { + self.network.set_account(uid, account); + } + #[cfg(test)] pub(crate) fn test_has_account(&self, name: &str) -> bool { self.db.exists(name) diff --git a/src/grpc.rs b/src/grpc.rs index 4f56e6d..fde42bc 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -13,19 +13,22 @@ use tonic::transport::{Identity, Server, ServerTlsConfig}; use tonic::{Request, Response, Status}; use crate::config::Grpc as GrpcCfg; -use crate::engine::db::{Account, ChannelInfo, Event, LogEntry}; -use crate::engine::Engine; +use crate::engine::db::{Account, ChannelInfo, Db, Event, LogEntry}; +use crate::engine::{AuthorityStatus, Engine}; pub mod pb { tonic::include_proto!("fedserv.v1"); } +use pb::accounts_server::{Accounts, AccountsServer}; use pb::directory_server::{Directory, DirectoryServer}; use pb::replication_event::Kind; use pb::{ - AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountVerified, ChannelDescSet, - ChannelDropped, ChannelFounderSet, ChannelRecord, ChannelRegistered, NickGrouped, NickUngrouped, - ReplicationEvent, SnapshotRequest, SnapshotResponse, SubscribeRequest, + AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountReply, AccountVerified, + AuthenticateReply, AuthenticateRequest, ChannelDescSet, ChannelDropped, ChannelFounderSet, ChannelRecord, + ChannelRegistered, ConfirmRequest, DropRequest, ForceLogoutReply, ForceLogoutRequest, GroupNickRequest, + NickGrouped, NickUngrouped, RegisterRequest, ReplicationEvent, SetEmailRequest, SetPasswordRequest, + SnapshotRequest, SnapshotResponse, Status as PbStatus, SubscribeRequest, UngroupNickRequest, }; type Shared = Arc>; @@ -34,31 +37,44 @@ type Shared = Arc>; // blocking the broadcast (matches the gossip outbound channel's own sizing). const SUBSCRIBER_BUFFER: usize = 1024; +// Every RPC (both services) needs `authorization: Bearer ` matching the +// configured secret. Constant-time compare — same posture as password/secret +// checks elsewhere in this codebase, cheap insurance against a timing side-channel. +fn authorize(req: &Request, token: &str) -> Result<(), Status> { + let got = req + .metadata() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + if got.as_bytes().ct_eq(token.as_bytes()).into() { + Ok(()) + } else { + Err(Status::unauthenticated("bad or missing bearer token")) + } +} + +fn status_of(s: AuthorityStatus) -> PbStatus { + match s { + AuthorityStatus::Ok => PbStatus::Ok, + AuthorityStatus::AlreadyExists => PbStatus::AlreadyExists, + AuthorityStatus::NotFound => PbStatus::NotFound, + AuthorityStatus::RateLimited => PbStatus::RateLimited, + AuthorityStatus::Invalid => PbStatus::Invalid, + AuthorityStatus::Internal => PbStatus::Internal, + } +} + +fn reply(s: AuthorityStatus, message: impl Into) -> AccountReply { + AccountReply { status: status_of(s) as i32, message: message.into() } +} + struct DirectoryService { engine: Shared, outbound: broadcast::Sender, token: String, } -impl DirectoryService { - // Every RPC needs `authorization: Bearer ` matching the configured - // secret. Constant-time compare — same posture as password/secret checks - // elsewhere in this codebase, cheap insurance against a timing side-channel. - fn authorize(&self, req: &Request) -> Result<(), Status> { - let got = req - .metadata() - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .unwrap_or(""); - if got.as_bytes().ct_eq(self.token.as_bytes()).into() { - Ok(()) - } else { - Err(Status::unauthenticated("bad or missing bearer token")) - } - } -} - fn account_record(a: &Account) -> AccountRecord { AccountRecord { name: a.name.clone(), @@ -119,7 +135,7 @@ fn to_wire(entry: &LogEntry) -> Option { #[tonic::async_trait] impl Directory for DirectoryService { async fn snapshot(&self, req: Request) -> Result, Status> { - self.authorize(&req)?; + authorize(&req, &self.token)?; let (accounts, channels) = self.engine.lock().await.directory_snapshot(); Ok(Response::new(SnapshotResponse { accounts: accounts.iter().map(account_record).collect(), @@ -130,7 +146,7 @@ impl Directory for DirectoryService { type SubscribeStream = Pin> + Send + 'static>>; async fn subscribe(&self, req: Request) -> Result, Status> { - self.authorize(&req)?; + authorize(&req, &self.token)?; let mut rx = self.outbound.subscribe(); let (tx, out) = tokio::sync::mpsc::channel(SUBSCRIBER_BUFFER); tokio::spawn(async move { @@ -159,6 +175,110 @@ impl Directory for DirectoryService { } } +struct AccountsService { + engine: Shared, + token: String, +} + +#[tonic::async_trait] +impl Accounts for AccountsService { + async fn register(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + if msg.name.is_empty() || msg.password.is_empty() { + return Ok(Response::new(reply(AuthorityStatus::Invalid, "name and password are required"))); + } + // Cheap checks (exists / rate limit) before paying for derivation. + if let Err(status) = self.engine.lock().await.authority_pre_check(&msg.name) { + return Ok(Response::new(reply(status, "cannot register that name right now"))); + } + // Expensive Argon2/SCRAM derivation runs off the shared engine lock, same + // as an IRC-originated REGISTER (see link.rs's DeferRegister handling). + let iterations = self.engine.lock().await.scram_iterations(); + let password = msg.password.clone(); + let creds = tokio::task::spawn_blocking(move || Db::derive_credentials(&password, iterations)).await.unwrap_or(None); + let email = if msg.email.is_empty() { None } else { Some(msg.email) }; + let status = self.engine.lock().await.authority_register(&msg.name, creds, email); + Ok(Response::new(reply(status, describe(status)))) + } + + async fn authenticate(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + match self.engine.lock().await.authority_authenticate(&msg.name, &msg.password) { + Some(account) => Ok(Response::new(AuthenticateReply { status: PbStatus::Ok as i32, account })), + None => Ok(Response::new(AuthenticateReply { status: PbStatus::Invalid as i32, account: String::new() })), + } + } + + async fn set_password(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + if msg.password.is_empty() { + return Ok(Response::new(reply(AuthorityStatus::Invalid, "password is required"))); + } + let iterations = self.engine.lock().await.scram_iterations(); + let password = msg.password.clone(); + let creds = tokio::task::spawn_blocking(move || Db::derive_credentials(&password, iterations)).await.unwrap_or(None); + let status = self.engine.lock().await.authority_set_password(&msg.account, creds); + Ok(Response::new(reply(status, describe(status)))) + } + + async fn set_email(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + let email = if msg.email.is_empty() { None } else { Some(msg.email) }; + let status = self.engine.lock().await.authority_set_email(&msg.account, email); + Ok(Response::new(reply(status, describe(status)))) + } + + async fn confirm(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + let status = self.engine.lock().await.authority_confirm(&msg.account, &msg.code); + Ok(Response::new(reply(status, describe(status)))) + } + + async fn drop(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + let status = self.engine.lock().await.authority_drop(&msg.account); + Ok(Response::new(reply(status, describe(status)))) + } + + async fn force_logout(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + let cleared = self.engine.lock().await.authority_force_logout(&msg.account); + Ok(Response::new(ForceLogoutReply { status: PbStatus::Ok as i32, sessions_cleared: cleared })) + } + + async fn group_nick(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + let status = self.engine.lock().await.authority_group_nick(&msg.nick, &msg.account); + Ok(Response::new(reply(status, describe(status)))) + } + + async fn ungroup_nick(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + let status = self.engine.lock().await.authority_ungroup_nick(&msg.nick); + Ok(Response::new(reply(status, describe(status)))) + } +} + +fn describe(s: AuthorityStatus) -> &'static str { + match s { + AuthorityStatus::Ok => "ok", + AuthorityStatus::AlreadyExists => "that name is already registered", + AuthorityStatus::NotFound => "no such account", + AuthorityStatus::RateLimited => "too many requests right now, try again shortly", + AuthorityStatus::Invalid => "invalid request", + AuthorityStatus::Internal => "internal error", + } +} + // Start the listener, if configured. Absent [grpc] in config.toml = no-op, same // pattern as gossip being optional. pub async fn run(engine: Shared, cfg: GrpcCfg, outbound: broadcast::Sender) { @@ -167,7 +287,8 @@ pub async fn run(engine: Shared, cfg: GrpcCfg, outbound: broadcast::Sender return tracing::error!(%e, bind = %cfg.bind, "bad grpc bind address"), }; let has_tls = cfg.tls.is_some(); - let svc = DirectoryService { engine, outbound, token: cfg.token }; + let accounts_svc = AccountsService { engine: engine.clone(), token: cfg.token.clone() }; + let directory_svc = DirectoryService { engine, outbound, token: cfg.token }; let mut server = Server::builder(); if let Some(tls) = &cfg.tls { let (cert, key) = match (std::fs::read(&tls.cert), std::fs::read(&tls.key)) { @@ -180,8 +301,13 @@ pub async fn run(engine: Shared, cfg: GrpcCfg, outbound: broadcast::Sender return tracing::error!(%e, "grpc TLS setup failed"), }; } - tracing::info!(%addr, tls = has_tls, "grpc directory API listening"); - if let Err(e) = server.add_service(DirectoryServer::new(svc)).serve(addr).await { + tracing::info!(%addr, tls = has_tls, "grpc directory + accounts API listening"); + if let Err(e) = server + .add_service(DirectoryServer::new(directory_svc)) + .add_service(AccountsServer::new(accounts_svc)) + .serve(addr) + .await + { tracing::error!(%e, "grpc server exited"); } } @@ -308,4 +434,197 @@ mod tests { other => panic!("expected AccountRegistered, got {other:?}"), } } + + fn accounts_svc(engine: Shared) -> AccountsService { + AccountsService { engine, token: "t".into() } + } + + #[tokio::test] + async fn register_then_authenticate_round_trips() { + let (engine, _tx) = engine_with("acct-register"); + let svc = accounts_svc(engine); + + let reg = svc + .register(authed(RegisterRequest { name: "carol".into(), password: "hunter2".into(), email: String::new() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(reg.status, PbStatus::Ok as i32); + + // Registering again is rejected, not silently overwritten. + let dup = svc + .register(authed(RegisterRequest { name: "carol".into(), password: "other".into(), email: String::new() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(dup.status, PbStatus::AlreadyExists as i32); + + let ok = svc + .authenticate(authed(AuthenticateRequest { name: "carol".into(), password: "hunter2".into() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(ok.status, PbStatus::Ok as i32); + assert_eq!(ok.account, "carol"); + + let bad = svc + .authenticate(authed(AuthenticateRequest { name: "carol".into(), password: "wrong".into() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(bad.status, PbStatus::Invalid as i32); + } + + #[tokio::test] + async fn register_rejects_a_bad_bearer_token() { + let (engine, _tx) = engine_with("acct-auth"); + let svc = accounts_svc(engine); + let err = svc + .register(authed(RegisterRequest { name: "dave".into(), password: "x".into(), email: String::new() }, "wrong")) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn set_password_replaces_credentials() { + let (engine, _tx) = engine_with("acct-setpw"); + let svc = accounts_svc(engine); + svc.register(authed(RegisterRequest { name: "erin".into(), password: "first".into(), email: String::new() }, "t")).await.unwrap(); + + let status = svc + .set_password(authed(SetPasswordRequest { account: "erin".into(), password: "second".into() }, "t")) + .await + .unwrap() + .into_inner() + .status; + assert_eq!(status, PbStatus::Ok as i32); + + let old = svc.authenticate(authed(AuthenticateRequest { name: "erin".into(), password: "first".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(old.status, PbStatus::Invalid as i32, "the old password must stop working"); + let new = svc.authenticate(authed(AuthenticateRequest { name: "erin".into(), password: "second".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(new.status, PbStatus::Ok as i32); + + let missing = svc + .set_password(authed(SetPasswordRequest { account: "nobody".into(), password: "x".into() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(missing.status, PbStatus::NotFound as i32); + } + + #[tokio::test] + async fn set_email_updates_and_clears() { + let (engine, _tx) = engine_with("acct-setemail"); + let svc = accounts_svc(engine.clone()); + svc.register(authed(RegisterRequest { name: "frank".into(), password: "x".into(), email: String::new() }, "t")).await.unwrap(); + + svc.set_email(authed(SetEmailRequest { account: "frank".into(), email: "frank@example.com".into() }, "t")).await.unwrap(); + assert_eq!(engine.lock().await.directory_snapshot().0[0].email.as_deref(), Some("frank@example.com")); + + svc.set_email(authed(SetEmailRequest { account: "frank".into(), email: String::new() }, "t")).await.unwrap(); + assert_eq!(engine.lock().await.directory_snapshot().0[0].email, None, "empty string clears the email"); + } + + // Full round trip through the real confirmation-email pipeline: register with + // email confirmation on, capture the code fedserv actually emails out (never + // returned by the RPC — proving the caller can't skip owning the inbox), then + // confirm with it. + #[tokio::test] + async fn confirm_completes_with_the_real_emailed_code() { + let path = std::env::temp_dir().join("fedserv-grpc-acct-confirm.jsonl"); + let _ = std::fs::remove_file(&path); + let (tx, _) = broadcast::channel(1024); + let mut db = Db::open(&path, "A"); + db.scram_iterations = 4096; + db.set_outbound(tx); + db.set_email_enabled(true); + let ns = NickServ { uid: "AAAAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let engine: Shared = Arc::new(Mutex::new(Engine::new(vec![Box::new(ns)], db))); + + let (irc_tx, mut irc_rx) = tokio::sync::mpsc::unbounded_channel(); + engine.lock().await.set_irc_out(irc_tx); + + let svc = accounts_svc(engine.clone()); + let reg = svc + .register(authed(RegisterRequest { name: "gina".into(), password: "x".into(), email: "gina@example.com".into() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(reg.status, PbStatus::Ok as i32); + assert!(!engine.lock().await.directory_snapshot().0[0].verified, "unverified until confirmed"); + + let mail_text = loop { + match irc_rx.try_recv() { + Ok(crate::proto::NetAction::SendEmail { text, .. }) => break text, + Ok(_) => continue, + Err(_) => panic!("no confirmation email was queued"), + } + }; + let code = mail_text.split("CONFIRM ").nth(1).and_then(|s| s.split_whitespace().next()).expect("code in email body").to_string(); + + let bad = svc.confirm(authed(ConfirmRequest { account: "gina".into(), code: "000000".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(bad.status, PbStatus::Invalid as i32); + + let ok = svc.confirm(authed(ConfirmRequest { account: "gina".into(), code }, "t")).await.unwrap().into_inner(); + assert_eq!(ok.status, PbStatus::Ok as i32); + assert!(engine.lock().await.directory_snapshot().0[0].verified); + } + + #[tokio::test] + async fn drop_releases_channels_and_logs_out_sessions() { + let (engine, _tx) = engine_with("acct-drop"); + let svc = accounts_svc(engine.clone()); + svc.register(authed(RegisterRequest { name: "hank".into(), password: "x".into(), email: String::new() }, "t")).await.unwrap(); + { + let mut e = engine.lock().await; + e.test_login("UID1", "hank"); + } + + let status = svc.drop(authed(DropRequest { account: "hank".into() }, "t")).await.unwrap().into_inner().status; + assert_eq!(status, PbStatus::Ok as i32); + assert!(engine.lock().await.directory_snapshot().0.is_empty()); + + // Dropping again is a clean NotFound, not a crash or false success. + let again = svc.drop(authed(DropRequest { account: "hank".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(again.status, PbStatus::NotFound as i32); + } + + #[tokio::test] + async fn force_logout_clears_active_sessions_only() { + let (engine, _tx) = engine_with("acct-logout"); + let svc = accounts_svc(engine.clone()); + svc.register(authed(RegisterRequest { name: "iris".into(), password: "x".into(), email: String::new() }, "t")).await.unwrap(); + { + let mut e = engine.lock().await; + e.test_login("UID1", "iris"); + e.test_login("UID2", "iris"); + } + + let resp = svc.force_logout(authed(ForceLogoutRequest { account: "iris".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(resp.status, PbStatus::Ok as i32); + assert_eq!(resp.sessions_cleared, 2); + // The account itself must still exist — only sessions were cleared. + assert_eq!(engine.lock().await.directory_snapshot().0.len(), 1); + } + + #[tokio::test] + async fn group_and_ungroup_nick() { + let (engine, _tx) = engine_with("acct-group"); + let svc = accounts_svc(engine.clone()); + svc.register(authed(RegisterRequest { name: "jack".into(), password: "x".into(), email: String::new() }, "t")).await.unwrap(); + + let grouped = svc.group_nick(authed(GroupNickRequest { nick: "jackalt".into(), account: "jack".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(grouped.status, PbStatus::Ok as i32); + + // A nick that is itself a registered account can't be grouped to another. + svc.register(authed(RegisterRequest { name: "realaccount".into(), password: "x".into(), email: String::new() }, "t")).await.unwrap(); + let refused = svc.group_nick(authed(GroupNickRequest { nick: "realaccount".into(), account: "jack".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(refused.status, PbStatus::Invalid as i32); + + let ungrouped = svc.ungroup_nick(authed(UngroupNickRequest { nick: "jackalt".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(ungrouped.status, PbStatus::Ok as i32); + let missing = svc.ungroup_nick(authed(UngroupNickRequest { nick: "jackalt".into() }, "t")).await.unwrap().into_inner(); + assert_eq!(missing.status, PbStatus::NotFound as i32); + } }