Tests for proxy sign-in, passwords, account settings and ipx user (#399)
Who gets in had no test above the token check: the router now runs in tests with a peer address, as the listener gives it. A name in the proxy's header signs in only from the trusted proxy (not from cloudflared's gateway or anyone else) and makes the first account an admin; with Access set, the name is the signed token's, and a token for another application, an expired one, garbage, a header without a token or a token without the header are refused, as is a good token not via Caddy. Wrong passwords and unknown names are 401 and the right one after them signs in with a session cookie; a forged one is refused. PATCH /api/me refuses an over-long block list and a malformed TypeSafe key before asking TypeSafe. The signing keys are read from a local stand-in for the team's certs, and a 503 or 404 is an error. ipx user add, list, passwd, rename and rm run against a scratch database: user_cmd takes its stdin as an argument, so a password can be given without a terminal, and access's fetch takes the certs address. There is no 429 for a flood of wrong passwords to test: that is #409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,13 +38,14 @@ impl Keys {
|
|||||||
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||||
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||||
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||||
self.verify_with(team, aud, token, || fetch(client, team)).await
|
let url = certs(team);
|
||||||
|
self.verify_with(team, aud, token, || fetch(client, &url)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||||
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||||
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||||
match fetch(client, team).await {
|
match fetch(client, &certs(team)).await {
|
||||||
Ok(set) => self.store(set),
|
Ok(set) => self.store(set),
|
||||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||||
}
|
}
|
||||||
@@ -111,10 +112,14 @@ impl Keys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
/// Where a team publishes its signing keys.
|
||||||
let url = format!("https://{team}/cdn-cgi/access/certs");
|
fn certs(team: &str) -> String {
|
||||||
|
format!("https://{team}/cdn-cgi/access/certs")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(client: &reqwest::Client, url: &str) -> Result<JwkSet> {
|
||||||
client
|
client
|
||||||
.get(&url)
|
.get(url)
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -126,25 +131,25 @@ async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
pub(crate) mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use jsonwebtoken::{EncodingKey, Header};
|
use jsonwebtoken::{EncodingKey, Header};
|
||||||
|
|
||||||
const TEAM: &str = "team.cloudflareaccess.com";
|
pub(crate) const TEAM: &str = "team.cloudflareaccess.com";
|
||||||
const AUD: &str = "aud-tag";
|
pub(crate) const AUD: &str = "aud-tag";
|
||||||
|
|
||||||
fn jwks() -> JwkSet {
|
fn jwks() -> JwkSet {
|
||||||
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
|
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
pub(crate) fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||||
let mut h = Header::new(alg);
|
let mut h = Header::new(alg);
|
||||||
h.kid = Some("k1".into());
|
h.kid = Some("k1".into());
|
||||||
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||||
jsonwebtoken::encode(&h, &claims, &k).unwrap()
|
jsonwebtoken::encode(&h, &claims, &k).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
pub(crate) fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||||
let now = jsonwebtoken::get_current_timestamp() as i64;
|
let now = jsonwebtoken::get_current_timestamp() as i64;
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||||
@@ -152,13 +157,45 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
|
pub(crate) const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
|
||||||
const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
|
const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
|
||||||
|
|
||||||
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||||
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keys holding the test signer's, as if fetched from the team.
|
||||||
|
pub(crate) fn keys() -> Keys {
|
||||||
|
let keys = Keys::default();
|
||||||
|
keys.store(jwks());
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_keys_are_read_from_the_teams_certs_and_a_failure_is_an_error() {
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route(
|
||||||
|
"/cdn-cgi/access/certs",
|
||||||
|
axum::routing::get(|| async { include_str!("../../../tests/data/access-test.jwks.json") }),
|
||||||
|
)
|
||||||
|
.route("/down", axum::routing::get(|| async { axum::http::StatusCode::SERVICE_UNAVAILABLE }));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let at = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let set = fetch(&client, &format!("http://{at}/cdn-cgi/access/certs")).await.unwrap();
|
||||||
|
assert_eq!(set.keys.len(), jwks().keys.len());
|
||||||
|
let keys = Keys::default();
|
||||||
|
keys.store(set);
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "the fetched key verifies");
|
||||||
|
|
||||||
|
assert!(fetch(&client, &format!("http://{at}/down")).await.is_err(), "a 503 is not a key set");
|
||||||
|
assert!(fetch(&client, &format!("http://{at}/nothing")).await.is_err(), "nor a 404");
|
||||||
|
assert_eq!(certs(TEAM), "https://team.cloudflareaccess.com/cdn-cgi/access/certs");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||||
let keys = Keys::default();
|
let keys = Keys::default();
|
||||||
|
|||||||
@@ -104,11 +104,10 @@ pub(crate) enum UserCmd {
|
|||||||
|
|
||||||
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
|
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
|
||||||
/// listing.
|
/// listing.
|
||||||
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::io::Read) -> Result<()> {
|
||||||
let read_password = || -> Result<String> {
|
let mut read_password = || -> Result<String> {
|
||||||
use std::io::Read;
|
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
std::io::stdin().read_to_string(&mut buf)?;
|
stdin.read_to_string(&mut buf)?;
|
||||||
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
||||||
if pw.is_empty() {
|
if pw.is_empty() {
|
||||||
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
|
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
|
||||||
@@ -232,3 +231,50 @@ pub(crate) fn duration(secs: u64) -> String {
|
|||||||
s => format!("{}d", s / 86_400),
|
s => format!("{}d", s / 86_400),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn accounts_are_added_renamed_given_a_password_and_removed() {
|
||||||
|
let ctx = Arc::new(crate::test_util::test_ctx(crate::config::Config::default()).await);
|
||||||
|
let user = |cmd, stdin: &'static str| user_cmd(&ctx, cmd, stdin.as_bytes());
|
||||||
|
let add = |name: &str, no_password| UserCmd::Add { name: name.into(), admin: false, no_password };
|
||||||
|
|
||||||
|
user(add(" Ray ", false), "first secret\n").await.unwrap();
|
||||||
|
let ray = ctx.db.user_by_name("ray").await.unwrap().expect("lower-cased and trimmed");
|
||||||
|
assert!(ray.is_admin, "the first account runs the place");
|
||||||
|
assert!(
|
||||||
|
crate::auth::check_password("first secret".into(), ray.pass_hash.clone()).await,
|
||||||
|
"the newline is not part of it"
|
||||||
|
);
|
||||||
|
user(add("sam", true), "").await.unwrap();
|
||||||
|
let sam = ctx.db.user_by_name("sam").await.unwrap().unwrap();
|
||||||
|
assert!(!sam.is_admin && sam.pass_hash.is_none(), "signs in through the proxy");
|
||||||
|
|
||||||
|
assert!(user(add("RAY", true), "").await.is_err(), "already exists");
|
||||||
|
assert!(user(add(" ", true), "").await.is_err(), "a name is required");
|
||||||
|
assert!(user(add("dee", false), "").await.is_err(), "no password on stdin");
|
||||||
|
user(UserCmd::List, "").await.unwrap();
|
||||||
|
|
||||||
|
// A new password signs every browser out.
|
||||||
|
let sid = crate::auth::new_session_token();
|
||||||
|
ctx.db.create_session(ray.id, &sid).await.unwrap();
|
||||||
|
user(UserCmd::Passwd { name: "ray".into() }, "second secret").await.unwrap();
|
||||||
|
let ray = ctx.db.user_by_name("ray").await.unwrap().unwrap();
|
||||||
|
assert!(crate::auth::check_password("second secret".into(), ray.pass_hash).await);
|
||||||
|
assert!(ctx.db.session_user(&sid, 86_400).await.unwrap().is_none(), "signed out");
|
||||||
|
assert!(user(UserCmd::Passwd { name: "nobody".into() }, "x").await.is_err());
|
||||||
|
|
||||||
|
let rename = |name: &str, new_name: &str| UserCmd::Rename { name: name.into(), new_name: new_name.into() };
|
||||||
|
assert!(user(rename("sam", "ray"), "").await.is_err(), "taken");
|
||||||
|
assert!(user(rename("sam", "a,b"), "").await.is_err(), "the proxy could never send it");
|
||||||
|
user(rename("sam", "Sam@X.test"), "").await.unwrap();
|
||||||
|
assert_eq!(ctx.db.user_by_name("sam@x.test").await.unwrap().map(|u| u.id), Some(sam.id), "same account");
|
||||||
|
|
||||||
|
user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.unwrap();
|
||||||
|
assert!(ctx.db.user_by_name("sam@x.test").await.unwrap().is_none());
|
||||||
|
assert!(user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.is_err(), "already gone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ async fn main() -> Result<()> {
|
|||||||
catalogue::add(&ctx, &url, folder, keywords, category, list).await
|
catalogue::add(&ctx, &url, folder, keywords, category, list).await
|
||||||
}
|
}
|
||||||
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
|
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
|
||||||
Command::User { cmd } => cli::user_cmd(&ctx, cmd).await,
|
Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await,
|
||||||
Command::Import { file } => catalogue::import(&ctx, &file).await,
|
Command::Import { file } => catalogue::import(&ctx, &file).await,
|
||||||
Command::Export { file } => catalogue::export(&ctx, &file).await,
|
Command::Export { file } => catalogue::export(&ctx, &file).await,
|
||||||
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||||
|
|||||||
@@ -1293,6 +1293,157 @@ impl IntoResponse for ApiError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use crate::access::tests as access;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
/// The router as the daemon serves it, on a scratch database, with the test signer's keys.
|
||||||
|
async fn app(cfg: crate::config::Config) -> (Router, Arc<Ctx>) {
|
||||||
|
let ctx = Arc::new(crate::test_util::test_ctx(cfg).await);
|
||||||
|
let state = WebState {
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
cmds: mpsc::channel(8).0,
|
||||||
|
events: broadcast::channel(8).0,
|
||||||
|
access: Arc::new(access::keys()),
|
||||||
|
playing: Default::default(),
|
||||||
|
};
|
||||||
|
(router(state), ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request from `from`, as the listener would see its peer; the status, Set-Cookie and body.
|
||||||
|
async fn ask(
|
||||||
|
app: &Router,
|
||||||
|
method: &str,
|
||||||
|
uri: &str,
|
||||||
|
from: &str,
|
||||||
|
headers: &[(&str, &str)],
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> (u16, String, String) {
|
||||||
|
let mut req = axum::http::Request::builder().method(method).uri(uri);
|
||||||
|
for (k, v) in headers {
|
||||||
|
req = req.header(*k, *v);
|
||||||
|
}
|
||||||
|
let req = match body {
|
||||||
|
Some(b) => req.header("content-type", "application/json").body(axum::body::Body::from(b.to_string())),
|
||||||
|
None => req.body(axum::body::Body::empty()),
|
||||||
|
};
|
||||||
|
let mut req = req.unwrap();
|
||||||
|
req.extensions_mut().insert(ConnectInfo(SocketAddr::new(from.parse().unwrap(), 40000)));
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let cookie = resp.headers().get(header::SET_COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||||
|
(status, cookie, String::from_utf8_lossy(&body).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
const CADDY: &str = "192.168.64.10";
|
||||||
|
|
||||||
|
fn proxied() -> crate::config::Config {
|
||||||
|
let mut cfg = crate::config::Config::default();
|
||||||
|
cfg.web.trusted_header = "Cf-Access-Authenticated-User-Email".into();
|
||||||
|
cfg.web.trusted_proxies = vec![CADDY.into()];
|
||||||
|
cfg.web.auto_create_users = true;
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_name_is_taken_from_the_proxys_header_only_from_the_proxy() {
|
||||||
|
let (app, _) = app(proxied()).await;
|
||||||
|
let who = [("Cf-Access-Authenticated-User-Email", "Rays@SDF1.net")];
|
||||||
|
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &who, None).await;
|
||||||
|
assert_eq!(s, 200, "{me}");
|
||||||
|
let me: serde_json::Value = serde_json::from_str(&me).unwrap();
|
||||||
|
assert_eq!((me["name"].as_str(), me["admin"].as_bool()), (Some("rays@sdf1.net"), Some(true)), "made, first");
|
||||||
|
|
||||||
|
// Anyone else able to reach the port could send the header and be whoever they liked.
|
||||||
|
assert_eq!(ask(&app, "GET", "/api/me", "192.168.64.1", &who, None).await.0, 401, "cloudflared itself");
|
||||||
|
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &who, None).await.0, 401);
|
||||||
|
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &[], None).await.0, 401, "from the proxy, vouching for nobody");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn with_access_set_the_name_is_the_signed_tokens_and_nothing_else() {
|
||||||
|
let mut cfg = proxied();
|
||||||
|
cfg.web.access_team = access::TEAM.into();
|
||||||
|
cfg.web.access_aud = access::AUD.into();
|
||||||
|
let (app, _) = app(cfg).await;
|
||||||
|
let rs = jsonwebtoken::Algorithm::RS256;
|
||||||
|
let good = access::token(access::SIGNER, rs, access::claims(access::AUD, 300));
|
||||||
|
fn with(t: &str) -> [(&str, &str); 2] {
|
||||||
|
[("Cf-Access-Authenticated-User-Email", "someone-else@x.test"), ("Cf-Access-Jwt-Assertion", t)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &with(&good), None).await;
|
||||||
|
assert_eq!(s, 200, "{me}");
|
||||||
|
assert!(me.contains(r#""name":"rays@sdf1.net""#), "the token's email, not the header's: {me}");
|
||||||
|
|
||||||
|
let other_app = access::token(access::SIGNER, rs, access::claims("another-app", 300));
|
||||||
|
let expired = access::token(access::SIGNER, rs, access::claims(access::AUD, -3600));
|
||||||
|
for (t, why) in
|
||||||
|
[(other_app.as_str(), "another application's"), (expired.as_str(), "expired"), ("x.y.z", "garbage")]
|
||||||
|
{
|
||||||
|
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &with(t), None).await.0, 401, "{why}");
|
||||||
|
}
|
||||||
|
let header_only = [("Cf-Access-Authenticated-User-Email", "rays@sdf1.net")];
|
||||||
|
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &header_only, None).await.0, 401, "no token");
|
||||||
|
let token_only = [("Cf-Access-Jwt-Assertion", good.as_str())];
|
||||||
|
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &token_only, None).await.0, 401, "no header to switch it on");
|
||||||
|
assert_eq!(
|
||||||
|
ask(&app, "GET", "/api/me", "10.0.0.5", &with(&good), None).await.0,
|
||||||
|
401,
|
||||||
|
"a good token, not via Caddy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_wrong_password_is_refused_and_the_right_one_after_it_signs_in() {
|
||||||
|
let (app, ctx) = app(crate::config::Config::default()).await;
|
||||||
|
let hash = crate::auth::hash_password("correct horse").unwrap();
|
||||||
|
ctx.db.create_user("dee", Some(&hash), false).await.unwrap();
|
||||||
|
let login = |pw: &str| serde_json::json!({ "name": "Dee", "password": pw });
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("wrong"))).await.0, 401);
|
||||||
|
}
|
||||||
|
let (s, _, _) = ask(
|
||||||
|
&app,
|
||||||
|
"POST",
|
||||||
|
"/api/login",
|
||||||
|
"10.0.0.5",
|
||||||
|
&[],
|
||||||
|
Some(serde_json::json!({ "name": "nobody", "password": "x" })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, 401, "an unknown name has the same answer");
|
||||||
|
let (s, cookie, _) = ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("correct horse"))).await;
|
||||||
|
assert_eq!(s, 200);
|
||||||
|
let session = cookie.split(';').next().unwrap();
|
||||||
|
assert!(session.starts_with("ipx_session=") && cookie.contains("HttpOnly"), "{cookie}");
|
||||||
|
let (s, _, me) = ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", session)], None).await;
|
||||||
|
assert!(s == 200 && me.contains(r#""name":"dee""#), "{s} {me}");
|
||||||
|
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", "ipx_session=forged")], None).await.0, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_block_list_and_the_typesafe_key_are_checked_before_they_are_saved() {
|
||||||
|
let (app, ctx) = app(proxied()).await;
|
||||||
|
let who = [("Cf-Access-Authenticated-User-Email", "dee@x.test")];
|
||||||
|
let patch = |b: serde_json::Value| ask(&app, "PATCH", "/api/me", CADDY, &who, Some(b));
|
||||||
|
|
||||||
|
let many: Vec<String> = (0..501).map(|i| format!("w{i}")).collect();
|
||||||
|
assert_eq!(patch(serde_json::json!({ "blocked": many })).await.0, 400, "501 words");
|
||||||
|
assert_eq!(patch(serde_json::json!({ "blocked": ["x".repeat(201)] })).await.0, 400, "a 201-character word");
|
||||||
|
assert_eq!(patch(serde_json::json!({ "blocked": [" Spoiler ", "spoiler", "", "ads"] })).await.0, 204);
|
||||||
|
let me = ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap();
|
||||||
|
assert_eq!(ctx.db.blocklist(me.id, "").await.unwrap(), ["Spoiler", "ads"], "trimmed, once each");
|
||||||
|
|
||||||
|
// Refused here, before TypeSafe is asked anything.
|
||||||
|
for bad in ["has a space", "tab\there", &"k".repeat(501)] {
|
||||||
|
assert_eq!(patch(serde_json::json!({ "typesafe_key": bad })).await.0, 400, "{bad}");
|
||||||
|
}
|
||||||
|
assert!(!ctx.db.typesafe_key_set(me.id).await.unwrap());
|
||||||
|
assert_eq!(patch(serde_json::json!({ "typesafe_key": null })).await.0, 204, "forgetting needs no check");
|
||||||
|
assert_eq!(patch(serde_json::json!({ "typesafe_key": " " })).await.0, 204, "nor does an empty one");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn icon_picks_are_counted_by_the_hour_and_the_person() {
|
fn icon_picks_are_counted_by_the_hour_and_the_person() {
|
||||||
let mut picks = std::collections::HashMap::new();
|
let mut picks = std::collections::HashMap::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user