use anyhow::{Context, Result};
use feather_reader::config::Config;
use feather_reader::{store, web, AppState, VERSION};
use tokio::sync::watch;
use tracing::info;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[path = "scheduler.rs"]
mod scheduler;
const RUNTIME_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
fn main() -> Result<()> {
feather_reader::block_on_then_shutdown(run(), RUNTIME_SHUTDOWN_TIMEOUT)
.context("building the tokio runtime")?
}
async fn run() -> Result<()> {
if wants_revoke_all(std::env::args()) {
let opts = feather_reader::oauth::revoke::PreflightOptions {
sweep: wants_sweep(std::env::args()),
accept_unreadable: wants_accept_unreadable(std::env::args()),
};
std::process::exit(run_revoke_all(opts).await);
}
let config = Config::from_env().context("loading configuration")?;
init_tracing();
info!(version = VERSION, bind = %config.bind, db = %config.db_path.display(), "starting featherreader");
let db = store::init(&config)
.await
.context("initializing the SQLite store")?;
if wants_vacuum_migration(std::env::args()) {
let outcome = run_vacuum_migration(&db, &config).await;
db.close().await;
return outcome;
}
check_watermark_vs_disk(&config);
match store::ensure_seed(&db, config.admin_seed_dids()).await {
Ok(new_seats) => {
if new_seats > 0 {
info!(new_seats, "seeded admin DIDs into beta_access");
}
}
Err(err) => return Err(err).context("seeding admin beta_access DIDs"),
}
let bind = config.bind;
let state = AppState::new(config, db).context("building application state")?;
state
.runtime_health
.set_started_at(chrono::Utc::now().timestamp());
let (shutdown_tx, shutdown_rx) = watch::channel(());
tokio::spawn(async move {
shutdown_signal().await;
let _ = shutdown_tx.send(());
});
let scheduler_handles = scheduler::spawn(state.clone(), shutdown_rx.clone());
let router = web::router(state);
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding to {bind}"))?;
info!(addr = %bind, "listening");
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx))
.await
.context("HTTP server error")?;
for h in scheduler_handles {
let _ = h.await;
}
info!("shutdown complete");
Ok(())
}
const MIGRATE_AUTO_VACUUM_FLAG: &str = "--migrate-auto-vacuum";
fn wants_vacuum_migration<I: IntoIterator<Item = String>>(args: I) -> bool {
args.into_iter()
.skip(1)
.any(|a| a == MIGRATE_AUTO_VACUUM_FLAG)
}
async fn run_vacuum_migration(db: &store::Pool, config: &Config) -> Result<()> {
info!(db = %config.db_path.display(), "auto_vacuum migration: starting");
info!(
"auto_vacuum migration: this rewrites the whole database file and holds an \
exclusive lock for minutes. Writes from a RUNNING instance will FAIL (not \
queue) for the duration, logins included. Stop the app first."
);
let available = available_disk_bytes(&config.db_path);
match store::migrate_to_incremental_vacuum(db, available).await? {
store::VacuumMigration::NotNeeded(mode) => {
info!(?mode, "auto_vacuum migration: nothing to do");
}
store::VacuumMigration::RefusedNoHeadroom {
needed,
available,
file_bytes,
} => {
tracing::warn!(
needed_bytes = needed,
available_bytes = available,
file_bytes = file_bytes.unwrap_or(0),
"auto_vacuum migration: REFUSED. A full VACUUM writes a second copy of \
the database, so it needs roughly twice the LIVE size free (the file on \
disk is larger; the freelist is not copied). Free space on the volume \
(or grow it) and run this again."
);
}
store::VacuumMigration::Migrated {
bytes_before,
bytes_after,
file_before,
file_after,
} => {
info!(
bytes_before,
bytes_after,
file_before = file_before.unwrap_or(0),
file_after = file_after.unwrap_or(0),
"auto_vacuum migration: complete; the database is now INCREMENTAL"
);
}
}
Ok(())
}
const REVOKE_ALL_SESSIONS_FLAG: &str = "--revoke-all-sessions";
const REVOKE_EXIT_OK: i32 = 0;
const REVOKE_EXIT_SOME_FAILED: i32 = 3;
const REVOKE_EXIT_ABORT: i32 = 2;
fn revoke_all_sentinel(report: &feather_reader::oauth::revoke::RevokeAllReport) -> String {
format!(
"revoke-all-sessions: revoked={} no_session={} failed={}",
report.revoked.len(),
report.no_session.len(),
report.failed.len()
)
}
fn wants_revoke_all<I: IntoIterator<Item = String>>(args: I) -> bool {
args.into_iter()
.skip(1)
.any(|a| a == REVOKE_ALL_SESSIONS_FLAG)
}
const SWEEP_FLAG: &str = "--sweep";
fn wants_sweep<I: IntoIterator<Item = String>>(args: I) -> bool {
args.into_iter().skip(1).any(|a| a == SWEEP_FLAG)
}
const ACCEPT_UNREADABLE_FLAG: &str = "--accept-unreadable";
fn wants_accept_unreadable<I: IntoIterator<Item = String>>(args: I) -> bool {
args.into_iter()
.skip(1)
.any(|a| a == ACCEPT_UNREADABLE_FLAG)
}
fn revoke_all_exit_code(report: &feather_reader::oauth::revoke::RevokeAllReport) -> i32 {
if report.failed.is_empty() {
REVOKE_EXIT_OK
} else {
REVOKE_EXIT_SOME_FAILED
}
}
fn unconfigured_exit_code(stored_sessions: usize) -> i32 {
if stored_sessions == 0 {
REVOKE_EXIT_OK
} else {
REVOKE_EXIT_ABORT
}
}
async fn run_revoke_all(opts: feather_reader::oauth::revoke::PreflightOptions) -> i32 {
let config = match Config::from_env() {
Ok(config) => config,
Err(err) => {
eprintln!("revoke-all: ABORT — loading configuration: {err:#}");
return REVOKE_EXIT_ABORT;
}
};
init_tracing();
run_revoke_all_with(&config, opts).await
}
async fn run_revoke_all_with(
config: &Config,
opts: feather_reader::oauth::revoke::PreflightOptions,
) -> i32 {
if !config.db_path.exists() {
eprintln!(
"revoke-all: ABORT — no database at {} (is FEATHERREADER_DB set as the app sees it?)",
config.db_path.display()
);
return REVOKE_EXIT_ABORT;
}
let db = match store::init(config).await {
Ok(db) => db,
Err(err) => {
eprintln!("revoke-all: ABORT — opening the database: {err:#}");
return REVOKE_EXIT_ABORT;
}
};
let http = match feather_reader::build_http_client() {
Ok(http) => http,
Err(err) => {
eprintln!("revoke-all: ABORT — building the HTTP client: {err:#}");
db.close().await;
return REVOKE_EXIT_ABORT;
}
};
let runtime = feather_reader::oauth::runtime::OauthRuntime::without_creating_key(config);
let code = revoke_all_sessions(&db, runtime, &http, opts).await;
db.close().await;
code
}
async fn revoke_all_sessions(
db: &store::Pool,
runtime: Result<feather_reader::oauth::runtime::OauthRuntime>,
http: &reqwest::Client,
opts: feather_reader::oauth::revoke::PreflightOptions,
) -> i32 {
let runtime = match runtime {
Ok(runtime) => runtime,
Err(err) => {
let stored = match feather_reader::oauth::store::list_session_subs(db).await {
Ok(subs) => subs.len(),
Err(err) => {
eprintln!("revoke-all: ABORT — listing the sessions: {err:#}");
return REVOKE_EXIT_ABORT;
}
};
let code = unconfigured_exit_code(stored);
if code == REVOKE_EXIT_OK {
println!(
"revoke-all: the Rust OAuth client is not configured ({err:#}), and no \
sessions are stored — nothing to revoke."
);
println!(
"{}",
revoke_all_sentinel(&feather_reader::oauth::revoke::RevokeAllReport::default())
);
} else {
eprintln!(
"revoke-all: ABORT — {stored} session(s) are stored but the Rust OAuth \
client cannot be built ({err:#}). Nothing was revoked and NOTHING WAS \
DELETED. Run this with the app's own environment (\
FEATHERREADER_OAUTH_ENCRYPTION_KEY, FEATHERREADER_PUBLIC_URL, \
FEATHERREADER_OAUTH_KEY_PATH) and try again."
);
}
return code;
}
};
let jwks_url = feather_reader::oauth::metadata::jwks_uri(&runtime.client);
if let Err(err) = feather_reader::oauth::revoke::preflight(&runtime, db, &jwks_url, opts).await
{
eprintln!(
"revoke-all: ABORT — {err:#}. Nothing was revoked and NOTHING WAS DELETED. Run \
this inside the app's own environment."
);
return REVOKE_EXIT_ABORT;
}
let clock = || chrono::Utc::now().timestamp();
let report = match feather_reader::oauth::revoke::revoke_all(&runtime, http, db, clock).await {
Ok(report) => report,
Err(err) => {
eprintln!("revoke-all: ABORT — listing the sessions: {err:#}");
return REVOKE_EXIT_ABORT;
}
};
for did in &report.revoked {
println!(" revoked {did}");
}
for did in &report.no_session {
println!(" already signed out {did}");
}
for did in &report.late {
println!(" appeared during the walk (signed out by a re-list) {did}");
}
for (did, reason) in &report.failed {
println!(" FAILED {did}: {reason}");
}
println!(
"revoke-all: {} revoked, {} already gone, {} failed ({} appeared during the walk).",
report.revoked.len(),
report.no_session.len(),
report.failed.len(),
report.late.len()
);
println!("{}", revoke_all_sentinel(&report));
revoke_all_exit_code(&report)
}
fn check_watermark_vs_disk(config: &Config) {
let watermark = config.db_size_watermark_bytes;
if watermark <= 0 {
info!("DB-size watermark disabled (0): the poller will not pause on disk pressure");
return;
}
info!(
watermark_bytes = watermark,
db = %config.db_path.display(),
"DB-size watermark effective (poller pauses new fetches at/above this)"
);
match available_disk_bytes(&config.db_path) {
Some(avail) if watermark as u64 >= avail => {
tracing::warn!(
watermark_bytes = watermark,
available_bytes = avail,
db = %config.db_path.display(),
"DB-size watermark is >= free space on its volume: it cannot protect the disk \
(the volume fills before the poller pauses). Set \
FEATHERREADER_DB_SIZE_WATERMARK_BYTES BELOW the volume size."
);
}
Some(avail) => info!(
available_bytes = avail,
"DB volume free space checked; watermark below it"
),
None => debug_no_statvfs(),
}
}
fn debug_no_statvfs() {
info!(
"could not read DB volume free space (statvfs unavailable); watermark value logged above"
);
}
#[cfg(unix)]
fn available_disk_bytes(path: &std::path::Path) -> Option<u64> {
use std::os::unix::ffi::OsStrExt;
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
let target = dir.unwrap_or_else(|| std::path::Path::new("."));
let cstr = std::ffi::CString::new(target.as_os_str().as_bytes()).ok()?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(cstr.as_ptr(), &mut stat) };
if rc != 0 {
return None;
}
let frsize = stat.f_frsize as u128;
let bavail = stat.f_bavail as u128;
Some((frsize.saturating_mul(bavail)).min(u64::MAX as u128) as u64)
}
#[cfg(not(unix))]
fn available_disk_bytes(_path: &std::path::Path) -> Option<u64> {
None
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
r = tokio::signal::ctrl_c() => {
if let Err(err) = r {
tracing::error!(%err, "failed to install Ctrl-C handler");
}
}
_ = sigterm.recv() => {}
}
}
Err(err) => {
tracing::error!(%err, "failed to install SIGTERM handler");
let _ = tokio::signal::ctrl_c().await;
}
}
info!("shutdown signal received");
}
#[cfg(not(unix))]
{
if let Err(err) = tokio::signal::ctrl_c().await {
tracing::error!(%err, "failed to install Ctrl-C handler");
}
info!("shutdown signal received");
}
}
async fn wait_for_shutdown(mut rx: watch::Receiver<()>) {
let _ = rx.changed().await;
}
#[cfg(test)]
mod tests {
use super::*;
use feather_reader::oauth::revoke::{fit_to_revoke, PreflightOptions, RevokeAllReport};
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn the_sweep_flag_is_an_argument_not_the_program_path() {
assert!(wants_sweep(args(&[
"/app/featherreader",
"--revoke-all-sessions",
"--sweep"
])));
assert!(!wants_sweep(args(&[
"/app/featherreader",
"--revoke-all-sessions"
])));
assert!(!wants_sweep(args(&["--sweep", "--revoke-all-sessions"])));
}
#[test]
fn the_accept_unreadable_flag_is_an_argument_not_the_program_path() {
assert!(wants_accept_unreadable(args(&[
"/app/featherreader",
"--revoke-all-sessions",
"--accept-unreadable"
])));
assert!(!wants_accept_unreadable(args(&[
"/app/featherreader",
"--revoke-all-sessions"
])));
assert!(!wants_accept_unreadable(args(&[
"--accept-unreadable",
"--revoke-all-sessions"
])));
}
#[tokio::test]
async fn accept_unreadable_proceeds_past_an_all_unreadable_store() {
let dir = scratch("acceptbin");
let mut config = confidential_config(&dir);
config.public_url = "https://feather-reader.invalid".into();
let sweep = PreflightOptions {
sweep: true,
accept_unreadable: false,
};
let db = db_with_rows(2).await;
let rt = feather_reader::oauth::runtime::OauthRuntime::new(&config).unwrap();
let code = revoke_all_sessions(&db, Ok(rt), &reqwest::Client::new(), sweep).await;
assert_eq!(
code, 2,
"an all-unreadable store passed without the override"
);
assert_eq!(rows(&db).await, 2);
let rt = feather_reader::oauth::runtime::OauthRuntime::new(&config).unwrap();
let code = revoke_all_sessions(
&db,
Ok(rt),
&reqwest::Client::new(),
PreflightOptions {
accept_unreadable: true,
..sweep
},
)
.await;
assert_eq!(code, 3, "the unrevocable rows must be reported as failures");
assert_eq!(rows(&db).await, 0, "the override did not proceed");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_wrong_encryption_key_is_refused_and_deletes_nothing() {
let dir = scratch("wrongenc");
let config = confidential_config(&dir);
let runtime = feather_reader::oauth::runtime::OauthRuntime::new(&config).unwrap();
assert!(fit_to_revoke(&runtime).is_ok(), "precondition: fit");
let db = store::init_url("sqlite::memory:").await.unwrap();
let other = feather_reader::oauth::crypto::Codec::new(Some(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
))
.unwrap();
for i in 0..2 {
feather_reader::oauth::store::put_session(
&db,
&other,
&feather_reader::oauth::store::OAuthSession {
sub: format!("did:plc:{i:024}"),
issuer: "https://as.invalid".into(),
aud: "https://pds.invalid".into(),
dpop_key_jwk: "{}".into(),
access_token: "a".into(),
refresh_token: "r".into(),
token_type: "DPoP".into(),
granted_scope: "atproto".into(),
expires_at: None,
},
)
.await
.unwrap();
}
let code = revoke_all_sessions(
&db,
Ok(runtime),
&reqwest::Client::new(),
PreflightOptions::default(),
)
.await;
assert_eq!(
code, 2,
"a key that decrypts nothing was allowed to sign out"
);
assert_eq!(rows(&db).await, 2, "rows were deleted unrevoked");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_revoke_all_flag_is_an_argument_not_the_program_path() {
assert!(wants_revoke_all(args(&[
"/app/featherreader",
"--revoke-all-sessions"
])));
assert!(!wants_revoke_all(args(&["--revoke-all-sessions"])));
assert!(!wants_revoke_all(args(&["/app/featherreader"])));
assert!(!wants_revoke_all(args(&[
"/app/featherreader",
"--migrate-auto-vacuum"
])));
}
#[test]
fn the_exit_code_reports_any_failure() {
assert_eq!(revoke_all_exit_code(&RevokeAllReport::default()), 0);
let ok = RevokeAllReport {
revoked: vec!["did:plc:a".into()],
no_session: vec!["did:plc:b".into()],
failed: vec![],
late: vec![],
};
assert_eq!(revoke_all_exit_code(&ok), 0);
let some_failed = RevokeAllReport {
failed: vec![("did:plc:c".into(), "status 500".into())],
..ok
};
assert_eq!(
revoke_all_exit_code(&some_failed),
3,
"partial failure must not share 1 with every generic failure"
);
}
#[test]
fn the_sentinel_names_every_count() {
let report = RevokeAllReport {
revoked: vec!["a".into(), "b".into()],
no_session: vec!["c".into()],
failed: vec![("d".into(), "x".into())],
late: vec![],
};
assert_eq!(
revoke_all_sentinel(&report),
"revoke-all-sessions: revoked=2 no_session=1 failed=1"
);
assert_eq!(
revoke_all_sentinel(&RevokeAllReport::default()),
"revoke-all-sessions: revoked=0 no_session=0 failed=0"
);
}
fn scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fr-main-test-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn confidential_config(dir: &std::path::Path) -> Config {
Config {
db_path: dir.join("featherreader.db"),
repo_backend: feather_reader::metrics::Backend::Rust,
public_url: "https://feather-reader.com".into(),
oauth: feather_reader::config::OauthConfig {
encryption_key: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into()),
key_path: dir.join("oauth-signing-key.json"),
plc_directory: "https://plc.invalid".into(),
..feather_reader::config::OauthConfig::default()
},
..Config::default()
}
}
#[tokio::test]
async fn a_missing_database_is_refused_and_not_created() {
let dir = scratch("nodb");
let config = confidential_config(&dir);
assert_eq!(
run_revoke_all_with(&config, PreflightOptions::default()).await,
2
);
assert!(
!config.db_path.exists(),
"the revoke-all created a database at the wrong path"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_missing_signing_key_is_refused_and_not_created() {
let dir = scratch("nokey");
let config = confidential_config(&dir);
let url = format!("sqlite://{}", config.db_path.display());
let pool = store::init_url(&url).await.unwrap();
sqlx::query(
"INSERT INTO oauth_session (sub, issuer, aud, dpop_key_jwk, access_token, \
refresh_token, token_type, granted_scope, expires_at) \
VALUES ('did:plc:keyless', 'https://as.invalid', 'https://pds.invalid', \
'x', 'x', 'x', 'DPoP', 'atproto', NULL)",
)
.execute(&pool)
.await
.unwrap();
pool.close().await;
assert_eq!(
run_revoke_all_with(&config, PreflightOptions::default()).await,
2
);
assert!(
!config.oauth.key_path.exists(),
"the revoke-all CREATED a signing key the PDSes have never seen"
);
let pool = store::init_url(&url).await.unwrap();
assert_eq!(
rows(&pool).await,
1,
"rows were deleted without a revocation"
);
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_public_dev_client_runtime_is_refused_while_sessions_are_stored() {
let dir = scratch("devclient");
let mut config = confidential_config(&dir);
config.public_url = "http://127.0.0.1:8080".into();
let runtime = feather_reader::oauth::runtime::OauthRuntime::new(&config);
assert!(runtime.is_ok(), "precondition: the dev runtime builds");
let why = format!(
"{:#}",
fit_to_revoke(runtime.as_ref().unwrap()).expect_err("the dev client was accepted")
);
assert!(why.contains("public dev client"), "{why}");
let db = db_with_rows(2).await;
let code = revoke_all_sessions(
&db,
runtime,
&reqwest::Client::new(),
PreflightOptions::default(),
)
.await;
assert_eq!(code, 2, "revoked as the public dev client");
assert_eq!(rows(&db).await, 2, "rows were deleted unrevoked");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_null_codec_runtime_is_refused_while_sessions_are_stored() {
let dir = scratch("nullcodec");
let mut config = confidential_config(&dir);
config.oauth.encryption_key = None;
let runtime = feather_reader::oauth::runtime::OauthRuntime::new(&config);
assert!(
matches!(
runtime.as_ref().map(|rt| &rt.codec),
Ok(feather_reader::oauth::crypto::Codec::Null)
),
"precondition: a confidential runtime with the Null codec"
);
let db = db_with_rows(2).await;
let code = revoke_all_sessions(
&db,
runtime,
&reqwest::Client::new(),
PreflightOptions::default(),
)
.await;
assert_eq!(code, 2, "revoked with the Null codec");
assert_eq!(rows(&db).await, 2, "rows were deleted unrevoked");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_the_production_client_is_fit_to_revoke() {
let dir = scratch("fit");
let config = confidential_config(&dir);
let rt = feather_reader::oauth::runtime::OauthRuntime::new(&config).unwrap();
assert!(fit_to_revoke(&rt).is_ok(), "{:?}", fit_to_revoke(&rt).err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_confidential_client_without_its_key_is_not_fit() {
let dir = scratch("nokeyfit");
let mut config = confidential_config(&dir);
config.repo_backend = feather_reader::metrics::Backend::Sidecar;
let rt = feather_reader::oauth::runtime::OauthRuntime::new(&config).unwrap();
assert!(rt.client_key.is_none(), "precondition: no key loaded");
let err = fit_to_revoke(&rt).expect_err("a keyless confidential client was accepted");
assert!(format!("{err:#}").contains("signing key"), "{err:#}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unconfigured_runtime_refuses_only_when_sessions_exist() {
assert_eq!(unconfigured_exit_code(0), 0);
assert_eq!(unconfigured_exit_code(1), 2);
assert_eq!(unconfigured_exit_code(500), 2);
}
async fn db_with_rows(n: usize) -> store::Pool {
let pool = store::init_url("sqlite::memory:").await.unwrap();
for i in 0..n {
sqlx::query(
"INSERT INTO oauth_session (sub, issuer, aud, dpop_key_jwk, access_token, \
refresh_token, token_type, granted_scope, expires_at) \
VALUES (?, 'https://as.example', 'https://pds.example', 'x', 'x', 'x', \
'DPoP', 'atproto', NULL)",
)
.bind(format!("did:plc:{i:024}"))
.execute(&pool)
.await
.unwrap();
}
pool
}
async fn rows(pool: &store::Pool) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session")
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn an_unconfigured_runtime_with_sessions_refuses_and_deletes_nothing() {
let db = db_with_rows(2).await;
let code = revoke_all_sessions(
&db,
Err(anyhow::anyhow!("no encryption key")),
&reqwest::Client::new(),
PreflightOptions::default(),
)
.await;
assert_eq!(code, 2);
assert_eq!(rows(&db).await, 2, "rows were dropped without a revocation");
}
#[tokio::test]
async fn an_unconfigured_runtime_with_no_sessions_has_nothing_to_do() {
let db = db_with_rows(0).await;
let code = revoke_all_sessions(
&db,
Err(anyhow::anyhow!("no encryption key")),
&reqwest::Client::new(),
PreflightOptions::default(),
)
.await;
assert_eq!(code, 0);
}
}