use actix_identity::IdentityMiddleware;
use actix_session::{SessionMiddleware, config::PersistentSession};
use actix_web::cookie::Cookie;
use actix_web::http::StatusCode;
use actix_web::test as aw_test;
use actix_web::{App, cookie::time::Duration, web};
use serde::Serialize;
use crate::auth::SESSION_COOKIE_NAME;
use crate::config::Config;
use crate::db::Database;
#[derive(Serialize)]
struct Signup<'a> {
username: &'a str,
email: &'a str,
password: &'a str,
invite: &'a str,
}
#[derive(Serialize)]
struct CreateInvite<'a> {
invite_secret: &'a str,
}
#[derive(Serialize)]
struct Login<'a> {
username: &'a str,
password: &'a str,
}
async fn setup() -> (Database, Config, String) {
let db_path = format!("/tmp/test_pear_it_{}.db", uuid::Uuid::new_v4());
let db = Database::new(&db_path);
db.init().await.expect("init db");
let mut config = Config::new(8080, None, db_path.clone(), false);
config.invite_secret = Some("test-invite-secret".to_string());
db.create_invite("test-invite")
.await
.expect("create test invite");
(db, config, db_path)
}
fn cleanup(path: &str) {
for suffix in ["", "-shm", "-wal"] {
let _ = std::fs::remove_file(format!("{path}{suffix}"));
}
}
macro_rules! test_app {
($db:expr, $config:expr) => {
aw_test::init_service(
App::new()
.wrap(IdentityMiddleware::default())
.wrap(
SessionMiddleware::builder(
crate::db::session_store::SqliteSessionStore::new($db.clone()),
($config).session_key.clone(),
)
.cookie_name(SESSION_COOKIE_NAME.to_string())
.session_lifecycle(PersistentSession::default().session_ttl(Duration::days(30)))
.build(),
)
.app_data(web::Data::new($db))
.app_data(web::Data::new($config))
.configure(crate::configure_routes),
)
.await
};
}
fn session_token(resp: &actix_web::dev::ServiceResponse) -> Option<String> {
resp.response()
.cookies()
.find(|c| c.name() == SESSION_COOKIE_NAME)
.map(|c| c.value().to_string())
}
async fn body_string(resp: actix_web::dev::ServiceResponse) -> String {
let bytes = aw_test::read_body(resp).await;
String::from_utf8(bytes.to_vec()).expect("utf-8 body")
}
#[actix_web::test]
async fn test_signup_login_and_add_a_private_task() {
let (db, config, db_path) = setup().await;
let app = test_app!(db.clone(), config);
// Sign up.
let req = aw_test::TestRequest::post()
.uri("/auth/signup")
.insert_header(("HX-Request", "true"))
.set_form(Signup {
username: "alice",
email: "alice@example.com",
password: "password123",
invite: "test-invite",
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
assert!(body_string(resp).await.contains("Account created"));
// Log in and capture the session cookie.
let req = aw_test::TestRequest::post()
.uri("/auth/login")
.insert_header(("HX-Request", "true"))
.set_form(Login {
username: "alice",
password: "password123",
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
assert_eq!(
resp.headers().get("HX-Redirect").unwrap(),
"/",
"successful login redirects home"
);
let token = session_token(&resp).expect("login sets a session cookie");
let cookie = Cookie::new(SESSION_COOKIE_NAME, token);
// Tasks stand alone and can have optional tags.
let req = aw_test::TestRequest::post()
.uri("/tickets")
.cookie(cookie.clone())
.insert_header(("Content-Type", "application/x-www-form-urlencoded"))
.set_payload("title=Keep+this+private&new_tags=personal%2C+today")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
// Visitors see the done counter and login, never private task details.
let req = aw_test::TestRequest::get().uri("/").to_request();
let body = body_string(aw_test::call_service(&app, req).await).await;
assert!(body.contains("0</strong>") && body.contains("tickets"));
assert!(body.contains("me.silenlocatelli.ch"));
assert!(body.contains("/auth/login"));
assert!(!body.contains("Keep this private"));
// Only its creator sees the task and its tags.
let req = aw_test::TestRequest::get()
.uri("/")
.cookie(cookie)
.to_request();
let body = body_string(aw_test::call_service(&app, req).await).await;
assert!(body.contains("Keep this private"));
assert!(body.contains("#personal") && body.contains("#today"));
cleanup(&db_path);
}
#[actix_web::test]
async fn test_done_and_not_planned_are_distinct_closed_outcomes() {
let (db, config, db_path) = setup().await;
let app = test_app!(db.clone(), config);
let req = aw_test::TestRequest::post()
.uri("/auth/signup")
.insert_header(("HX-Request", "true"))
.set_form(Signup {
username: "alice",
email: "alice@example.com",
password: "password123",
invite: "test-invite",
})
.to_request();
aw_test::call_service(&app, req).await;
let req = aw_test::TestRequest::post()
.uri("/auth/login")
.insert_header(("HX-Request", "true"))
.set_form(Login {
username: "alice",
password: "password123",
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
let cookie = Cookie::new(
SESSION_COOKIE_NAME,
session_token(&resp).expect("session cookie"),
);
let user = db.get_user_by_username("alice").await.unwrap().unwrap();
let req = aw_test::TestRequest::post()
.uri("/tickets")
.cookie(cookie.clone())
.insert_header(("Content-Type", "application/x-www-form-urlencoded"))
.set_payload("title=Write+the+outline&new_tags=writing%2C+important")
.to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::SEE_OTHER
);
let req = aw_test::TestRequest::post()
.uri("/tickets")
.cookie(cookie.clone())
.insert_header(("Content-Type", "application/x-www-form-urlencoded"))
.set_payload("title=Maybe+learn+the+harp")
.to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::SEE_OTHER
);
let tasks = db.visible_tickets(Some(&user.id)).await.unwrap();
assert_eq!(tasks.len(), 2);
let done_id = tasks
.iter()
.find(|item| item.ticket.title == "Write the outline")
.unwrap()
.ticket
.id
.clone();
let unplanned_id = tasks
.iter()
.find(|item| item.ticket.title == "Maybe learn the harp")
.unwrap()
.ticket
.id
.clone();
let writing_tag = tasks
.iter()
.find(|item| item.ticket.id == done_id)
.unwrap()
.tags
.iter()
.find(|tag| tag.name == "writing")
.unwrap()
.id
.clone();
assert!(db.visible_tickets(None).await.unwrap().is_empty());
let req = aw_test::TestRequest::get()
.uri(&format!("/?tag={writing_tag}"))
.cookie(cookie.clone())
.to_request();
let body = body_string(aw_test::call_service(&app, req).await).await;
assert!(body.contains("Write the outline"));
assert!(!body.contains("Maybe learn the harp"));
let req = aw_test::TestRequest::post()
.uri(&format!("/tickets/{done_id}/close"))
.cookie(cookie.clone())
.insert_header(("Content-Type", "application/x-www-form-urlencoded"))
.set_payload("outcome=done")
.to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::SEE_OTHER
);
let req = aw_test::TestRequest::post()
.uri(&format!("/tickets/{unplanned_id}/close"))
.cookie(cookie.clone())
.insert_header(("Content-Type", "application/x-www-form-urlencoded"))
.set_payload("outcome=not_planned")
.to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::SEE_OTHER
);
assert_eq!(db.done_ticket_count(&user.id).await.unwrap(), 1);
let req = aw_test::TestRequest::get()
.uri("/?view=closed")
.cookie(cookie.clone())
.to_request();
let body = body_string(aw_test::call_service(&app, req).await).await;
assert!(body.contains("Write the outline") && body.contains("Maybe learn the harp"));
assert!(body.contains("Not planned"));
assert!(body.contains("done"));
assert!(!body.contains("XP") && !body.contains("Level 1"));
// A closed ticket cannot be counted again unless it is reopened.
assert!(!db.close_ticket(&done_id, &user.id, "done").await.unwrap());
assert_eq!(db.done_ticket_count(&user.id).await.unwrap(), 1);
assert!(db.reopen_ticket(&unplanned_id, &user.id).await.unwrap());
assert!(
db.close_ticket(&unplanned_id, &user.id, "done")
.await
.unwrap()
);
assert_eq!(db.done_ticket_count(&user.id).await.unwrap(), 2);
cleanup(&db_path);
}
#[actix_web::test]
async fn test_signup_requires_a_valid_invite() {
let (db, mut config, db_path) = setup().await;
config.invite_secret = None;
let app = test_app!(db.clone(), config);
let req = aw_test::TestRequest::get().uri("/auth/login").to_request();
let body = body_string(aw_test::call_service(&app, req).await).await;
assert!(!body.contains("/auth/invites"));
let req = aw_test::TestRequest::get().uri("/auth/signup").to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::NOT_FOUND
);
let req = aw_test::TestRequest::post()
.uri("/auth/signup")
.set_form(Signup {
username: "alice",
email: "alice@example.com",
password: "password123",
invite: "not-a-valid-invite",
})
.to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::NOT_FOUND
);
cleanup(&db_path);
}
#[actix_web::test]
async fn test_signup_rejects_invalid_invite() {
let (db, config, db_path) = setup().await;
let app = test_app!(db, config);
let req = aw_test::TestRequest::post()
.uri("/auth/signup")
.set_form(Signup {
username: "alice",
email: "alice@example.com",
password: "password123",
invite: "wrong-token",
})
.to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::NOT_FOUND
);
cleanup(&db_path);
}
#[actix_web::test]
async fn test_invite_secret_creates_a_one_time_signup_link() {
let (db, config, db_path) = setup().await;
let app = test_app!(db.clone(), config);
let req = aw_test::TestRequest::post()
.uri("/auth/invites")
.insert_header(("HX-Request", "true"))
.set_form(CreateInvite {
invite_secret: "test-invite-secret",
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = body_string(resp).await;
let link_start =
body.find("/auth/signup?invite=").expect("invite link") + "/auth/signup?invite=".len();
let invite = &body[link_start..body[link_start..].find('"').unwrap() + link_start];
let req = aw_test::TestRequest::get()
.uri(&format!("/auth/signup?invite={invite}"))
.to_request();
assert!(aw_test::call_service(&app, req).await.status().is_success());
let signup = || {
aw_test::TestRequest::post()
.uri("/auth/signup")
.insert_header(("HX-Request", "true"))
.set_form(Signup {
username: "invited-user",
email: "invited@example.com",
password: "password123",
invite,
})
.to_request()
};
let resp = aw_test::call_service(&app, signup()).await;
assert!(resp.status().is_success());
assert!(body_string(resp).await.contains("Account created"));
assert_eq!(
aw_test::call_service(&app, signup()).await.status(),
StatusCode::NOT_FOUND
);
cleanup(&db_path);
}
#[actix_web::test]
async fn test_private_ticket_is_hidden_from_others() {
let (db, config, db_path) = setup().await;
let app = test_app!(db.clone(), config);
// Two users.
for (index, (username, email)) in [("alice", "alice@example.com"), ("bob", "bob@example.com")]
.into_iter()
.enumerate()
{
let invite = format!("invite-{index}");
db.create_invite(&invite).await.unwrap();
let req = aw_test::TestRequest::post()
.uri("/auth/signup")
.insert_header(("HX-Request", "true"))
.set_form(Signup {
username,
email,
password: "password123",
invite: &invite,
})
.to_request();
assert!(aw_test::call_service(&app, req).await.status().is_success());
}
let login = |username: &'static str| async {
let req = aw_test::TestRequest::post()
.uri("/auth/login")
.insert_header(("HX-Request", "true"))
.set_form(Login {
username,
password: "password123",
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
Cookie::new(
SESSION_COOKIE_NAME,
session_token(&resp).expect("session cookie"),
)
};
let alice = login("alice").await;
let bob = login("bob").await;
let req = aw_test::TestRequest::post()
.uri("/tickets")
.cookie(alice.clone())
.insert_header(("Content-Type", "application/x-www-form-urlencoded"))
.set_payload("title=Alice%27s+private+note")
.to_request();
assert_eq!(
aw_test::call_service(&app, req).await.status(),
StatusCode::SEE_OTHER
);
// A different logged-in user and an anonymous visitor never receive it.
for cookie in [Some(bob), None] {
let mut req = aw_test::TestRequest::get().uri("/");
if let Some(cookie) = cookie {
req = req.cookie(cookie);
}
let body = body_string(aw_test::call_service(&app, req.to_request()).await).await;
assert!(!body.contains("private note"));
}
assert_eq!(db.all_done_ticket_count().await.unwrap(), 0);
cleanup(&db_path);
}
#[actix_web::test]
async fn test_login_rejects_bad_credentials() {
let (db, config, db_path) = setup().await;
let app = test_app!(db.clone(), config);
let req = aw_test::TestRequest::post()
.uri("/auth/login")
.insert_header(("HX-Request", "true"))
.set_form(Login {
username: "nobody",
password: "password123",
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert!(session_token(&resp).is_none());
cleanup(&db_path);
}
#[actix_web::test]
async fn test_ticket_log_requires_login() {
let (db, config, db_path) = setup().await;
let app = test_app!(db.clone(), config);
let req = aw_test::TestRequest::post()
.uri("/tickets")
.insert_header(("Content-Type", "application/x-www-form-urlencoded"))
.set_payload("title=Nope")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("Location").unwrap(), "/");
let req = aw_test::TestRequest::get()
.uri("/tickets")
.insert_header(("HX-Request", "true"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
cleanup(&db_path);
}
#[actix_web::test]
async fn test_logout_clears_session() {
let (db, config, db_path) = setup().await;
let app = test_app!(db.clone(), config);
let req = aw_test::TestRequest::post()
.uri("/auth/signup")
.insert_header(("HX-Request", "true"))
.set_form(Signup {
username: "alice",
email: "alice@example.com",
password: "password123",
invite: "test-invite",
})
.to_request();
assert!(aw_test::call_service(&app, req).await.status().is_success());
let req = aw_test::TestRequest::post()
.uri("/auth/login")
.insert_header(("HX-Request", "true"))
.set_form(Login {
username: "alice",
password: "password123",
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
let token = session_token(&resp).expect("session cookie");
let cookie = Cookie::new(SESSION_COOKIE_NAME, token.clone());
let req = aw_test::TestRequest::post()
.uri("/auth/logout")
.cookie(cookie)
.insert_header(("HX-Request", "true"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let mut rows = db
.conn()
.await
.unwrap()
.query("SELECT COUNT(*) FROM sessions", ())
.await
.unwrap();
let count = rows.next().await.unwrap().unwrap().get::<i64>(0).unwrap();
assert_eq!(count, 0, "logout must delete the server-side session");
cleanup(&db_path);
}