grpc: full account-authority write API (Register through UngroupNick)

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.
This commit is contained in:
Jean Chevronnet 2026-07-12 23:57:55 +00:00
parent e5a0d2acdf
commit d99eb16dab
No known key found for this signature in database
4 changed files with 554 additions and 29 deletions

View file

@ -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 authenticated, optional server TLS. Omit `[grpc]` in config.toml to run
without it. 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 ## Config
```toml ```toml

View file

@ -83,3 +83,58 @@ message ChannelRegistered { string name = 1; string founder = 2; uint64 register
message ChannelDropped { string name = 1; } message ChannelDropped { string name = 1; }
message ChannelFounderSet { string name = 1; string founder = 2; } message ChannelFounderSet { string name = 1; string founder = 2; }
message ChannelDescSet { string name = 1; string description = 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; }

View file

@ -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 { pub struct Engine {
services: Vec<Box<dyn Service>>, services: Vec<Box<dyn Service>>,
network: Network, network: Network,
@ -127,6 +139,125 @@ impl Engine {
(self.db.accounts().cloned().collect(), self.db.channels().cloned().collect()) (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<db::Credentials>, email: Option<String>) -> 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<String> {
self.db.authenticate(name, password).map(str::to_string)
}
pub fn authority_set_password(&mut self, account: &str, creds: Option<db::Credentials>) -> 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<String>) -> 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<()> { 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 // If ingesting a peer's entry removed an account a local session relied on
// (lost a conflict, or dropped elsewhere), clean up after it. // (lost a conflict, or dropped elsewhere), clean up after it.
@ -190,6 +321,13 @@ impl Engine {
self.db.register(name, "pw", None).unwrap(); 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)] #[cfg(test)]
pub(crate) fn test_has_account(&self, name: &str) -> bool { pub(crate) fn test_has_account(&self, name: &str) -> bool {
self.db.exists(name) self.db.exists(name)

View file

@ -13,19 +13,22 @@ use tonic::transport::{Identity, Server, ServerTlsConfig};
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use crate::config::Grpc as GrpcCfg; use crate::config::Grpc as GrpcCfg;
use crate::engine::db::{Account, ChannelInfo, Event, LogEntry}; use crate::engine::db::{Account, ChannelInfo, Db, Event, LogEntry};
use crate::engine::Engine; use crate::engine::{AuthorityStatus, Engine};
pub mod pb { pub mod pb {
tonic::include_proto!("fedserv.v1"); tonic::include_proto!("fedserv.v1");
} }
use pb::accounts_server::{Accounts, AccountsServer};
use pb::directory_server::{Directory, DirectoryServer}; use pb::directory_server::{Directory, DirectoryServer};
use pb::replication_event::Kind; use pb::replication_event::Kind;
use pb::{ use pb::{
AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountVerified, ChannelDescSet, AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountReply, AccountVerified,
ChannelDropped, ChannelFounderSet, ChannelRecord, ChannelRegistered, NickGrouped, NickUngrouped, AuthenticateReply, AuthenticateRequest, ChannelDescSet, ChannelDropped, ChannelFounderSet, ChannelRecord,
ReplicationEvent, SnapshotRequest, SnapshotResponse, SubscribeRequest, ChannelRegistered, ConfirmRequest, DropRequest, ForceLogoutReply, ForceLogoutRequest, GroupNickRequest,
NickGrouped, NickUngrouped, RegisterRequest, ReplicationEvent, SetEmailRequest, SetPasswordRequest,
SnapshotRequest, SnapshotResponse, Status as PbStatus, SubscribeRequest, UngroupNickRequest,
}; };
type Shared = Arc<Mutex<Engine>>; type Shared = Arc<Mutex<Engine>>;
@ -34,31 +37,44 @@ type Shared = Arc<Mutex<Engine>>;
// blocking the broadcast (matches the gossip outbound channel's own sizing). // blocking the broadcast (matches the gossip outbound channel's own sizing).
const SUBSCRIBER_BUFFER: usize = 1024; const SUBSCRIBER_BUFFER: usize = 1024;
// Every RPC (both services) needs `authorization: Bearer <token>` 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<T>(req: &Request<T>, 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<String>) -> AccountReply {
AccountReply { status: status_of(s) as i32, message: message.into() }
}
struct DirectoryService { struct DirectoryService {
engine: Shared, engine: Shared,
outbound: broadcast::Sender<LogEntry>, outbound: broadcast::Sender<LogEntry>,
token: String, token: String,
} }
impl DirectoryService {
// Every RPC needs `authorization: Bearer <token>` 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<T>(&self, req: &Request<T>) -> 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 { fn account_record(a: &Account) -> AccountRecord {
AccountRecord { AccountRecord {
name: a.name.clone(), name: a.name.clone(),
@ -119,7 +135,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
#[tonic::async_trait] #[tonic::async_trait]
impl Directory for DirectoryService { impl Directory for DirectoryService {
async fn snapshot(&self, req: Request<SnapshotRequest>) -> Result<Response<SnapshotResponse>, Status> { async fn snapshot(&self, req: Request<SnapshotRequest>) -> Result<Response<SnapshotResponse>, Status> {
self.authorize(&req)?; authorize(&req, &self.token)?;
let (accounts, channels) = self.engine.lock().await.directory_snapshot(); let (accounts, channels) = self.engine.lock().await.directory_snapshot();
Ok(Response::new(SnapshotResponse { Ok(Response::new(SnapshotResponse {
accounts: accounts.iter().map(account_record).collect(), accounts: accounts.iter().map(account_record).collect(),
@ -130,7 +146,7 @@ impl Directory for DirectoryService {
type SubscribeStream = Pin<Box<dyn Stream<Item = Result<ReplicationEvent, Status>> + Send + 'static>>; type SubscribeStream = Pin<Box<dyn Stream<Item = Result<ReplicationEvent, Status>> + Send + 'static>>;
async fn subscribe(&self, req: Request<SubscribeRequest>) -> Result<Response<Self::SubscribeStream>, Status> { async fn subscribe(&self, req: Request<SubscribeRequest>) -> Result<Response<Self::SubscribeStream>, Status> {
self.authorize(&req)?; authorize(&req, &self.token)?;
let mut rx = self.outbound.subscribe(); let mut rx = self.outbound.subscribe();
let (tx, out) = tokio::sync::mpsc::channel(SUBSCRIBER_BUFFER); let (tx, out) = tokio::sync::mpsc::channel(SUBSCRIBER_BUFFER);
tokio::spawn(async move { 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<RegisterRequest>) -> Result<Response<AccountReply>, 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<AuthenticateRequest>) -> Result<Response<AuthenticateReply>, 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<SetPasswordRequest>) -> Result<Response<AccountReply>, 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<SetEmailRequest>) -> Result<Response<AccountReply>, 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<ConfirmRequest>) -> Result<Response<AccountReply>, 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<DropRequest>) -> Result<Response<AccountReply>, 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<ForceLogoutRequest>) -> Result<Response<ForceLogoutReply>, 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<GroupNickRequest>) -> Result<Response<AccountReply>, 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<UngroupNickRequest>) -> Result<Response<AccountReply>, 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 // Start the listener, if configured. Absent [grpc] in config.toml = no-op, same
// pattern as gossip being optional. // pattern as gossip being optional.
pub async fn run(engine: Shared, cfg: GrpcCfg, outbound: broadcast::Sender<LogEntry>) { pub async fn run(engine: Shared, cfg: GrpcCfg, outbound: broadcast::Sender<LogEntry>) {
@ -167,7 +287,8 @@ pub async fn run(engine: Shared, cfg: GrpcCfg, outbound: broadcast::Sender<LogEn
Err(e) => return tracing::error!(%e, bind = %cfg.bind, "bad grpc bind address"), Err(e) => return tracing::error!(%e, bind = %cfg.bind, "bad grpc bind address"),
}; };
let has_tls = cfg.tls.is_some(); 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(); let mut server = Server::builder();
if let Some(tls) = &cfg.tls { if let Some(tls) = &cfg.tls {
let (cert, key) = match (std::fs::read(&tls.cert), std::fs::read(&tls.key)) { 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<LogEn
Err(e) => return tracing::error!(%e, "grpc TLS setup failed"), Err(e) => return tracing::error!(%e, "grpc TLS setup failed"),
}; };
} }
tracing::info!(%addr, tls = has_tls, "grpc directory API listening"); tracing::info!(%addr, tls = has_tls, "grpc directory + accounts API listening");
if let Err(e) = server.add_service(DirectoryServer::new(svc)).serve(addr).await { 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"); tracing::error!(%e, "grpc server exited");
} }
} }
@ -308,4 +434,197 @@ mod tests {
other => panic!("expected AccountRegistered, got {other:?}"), 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);
}
} }