use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web};
use maud::{DOCTYPE, Markup, html};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::db::Database;
use crate::session::SessionStore;
pub mod debug;
pub mod export;
pub mod guard;
pub mod icons;
pub mod install;
pub mod notify;
pub use export::export_config;
pub use install::{
download_file, install_page, linux_installer_script, mac_installer_script,
omarchy_installer_script, omarchy_plugin_config, omarchy_plugin_file,
};
pub fn is_htmx(req: &HttpRequest) -> bool {
req.headers().contains_key("hx-request")
}
#[derive(Deserialize, Serialize)]
pub struct PinForm {
pub pin: String,
}
#[get("/")]
pub async fn index() -> Markup {
main()
}
#[post("/pin")]
pub async fn verify_pin(
form: web::Form<PinForm>,
config: web::Data<Config>,
session_store: web::Data<SessionStore>,
) -> impl Responder {
if config.is_valid_pin(&form.pin) {
let token = session_store.create_session().await;
let cookie = actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token)
.path("/")
.http_only(true)
.same_site(actix_web::cookie::SameSite::Lax)
.max_age(actix_web::cookie::time::Duration::seconds(
crate::session::SESSION_TTL_SECS as i64,
))
.finish();
HttpResponse::Ok()
.cookie(cookie)
.content_type("text/html; charset=utf-8")
.body(notify::notify_content().into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(guard::guard_view(Some("Invalid PIN. Please try again.")).into_string())
}
}
#[derive(Deserialize, Serialize, Default)]
pub struct NotifyQuery {
pub message: Option<String>,
}
#[derive(Deserialize, Serialize, Default, Debug, Clone, PartialEq, Eq)]
pub struct CreateNotificationPayload {
#[serde(default)]
pub kind: Option<String>,
#[serde(default, alias = "text", alias = "body", alias = "msg")]
pub message: Option<String>,
}
pub async fn post_notification_json(
_auth: crate::auth::PinAuth,
body: Option<web::Json<CreateNotificationPayload>>,
db: web::Data<Database>,
) -> impl Responder {
let payload = body.map(|b| b.into_inner()).unwrap_or_default();
let raw_kind = payload.kind.as_deref().unwrap_or("notification").trim();
let kind = if raw_kind.is_empty() {
"notification"
} else {
raw_kind
};
let default_message = match kind {
"heart" => "Look at your phone",
"cat" => "Missing you",
"emergency" => "Emergency",
_ => "Notification",
};
let raw_msg = payload.message.as_deref().unwrap_or(default_message).trim();
let message = if raw_msg.is_empty() {
default_message
} else {
raw_msg
};
match db.save_notification(kind, message, false).await {
Ok(notification) => HttpResponse::Created().json(notification),
Err(e) => {
log::error!("Failed to save notification: {e}");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to save notification",
"details": e,
}))
}
}
}
#[derive(Deserialize, Serialize, Default, Debug)]
pub struct ScanRequest {
pub limit: Option<usize>,
}
async fn handle_notify_action(
req: HttpRequest,
kind: String,
query: NotifyQuery,
config: web::Data<Config>,
session_store: web::Data<SessionStore>,
db: web::Data<Database>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized().finish();
}
let default_message = match kind.as_str() {
"heart" => "Look at your phone",
"cat" => "Missing you",
"emergency" => "Emergency",
_ => "Notification",
};
let message = query
.message
.as_deref()
.filter(|m| !m.trim().is_empty())
.unwrap_or(default_message);
// Save initial row in database with sent = false (ready for scanning)
if let Err(e) = db.save_notification(&kind, message, false).await {
log::error!("Failed to save notification: {e}");
return HttpResponse::InternalServerError().finish();
}
// 204 No Content: HTMX request finishes once saved in DB, resetting the pending state
HttpResponse::NoContent().finish()
}
#[post("/notify/{kind}")]
pub async fn notify_action(
req: HttpRequest,
path: web::Path<String>,
query: web::Query<NotifyQuery>,
config: web::Data<Config>,
session_store: web::Data<SessionStore>,
db: web::Data<Database>,
) -> impl Responder {
handle_notify_action(
req,
path.into_inner(),
query.into_inner(),
config,
session_store,
db,
)
.await
}
#[post("/api/notify/{kind}")]
pub async fn api_notify_action(
req: HttpRequest,
path: web::Path<String>,
query: web::Query<NotifyQuery>,
config: web::Data<Config>,
session_store: web::Data<SessionStore>,
db: web::Data<Database>,
) -> impl Responder {
handle_notify_action(
req,
path.into_inner(),
query.into_inner(),
config,
session_store,
db,
)
.await
}
pub async fn scan_notifications(
_auth: crate::auth::PinAuth,
body: Option<web::Json<ScanRequest>>,
db: web::Data<Database>,
) -> impl Responder {
let limit = body.and_then(|b| b.limit);
log::debug!("Scanning pending notifications (limit: {limit:?})");
let notifications = match db.get_pending_notifications(limit).await {
Ok(items) => items,
Err(e) => {
log::error!("Failed to scan notifications: {e}");
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to scan notifications",
"details": e,
}));
}
};
if notifications.is_empty() {
// Return 204 No Content (2xx indicating nothing changed)
return HttpResponse::NoContent().finish();
}
HttpResponse::Ok().json(notifications)
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum MarkSentRequest {
Object {
#[serde(default)]
ids: Option<Vec<i64>>,
#[serde(default)]
id: Option<i64>,
},
List(Vec<i64>),
}
impl MarkSentRequest {
pub fn into_ids(self) -> Vec<i64> {
match self {
MarkSentRequest::Object { ids, id } => {
if let Some(ids) = ids {
ids
} else if let Some(id) = id {
vec![id]
} else {
Vec::new()
}
}
MarkSentRequest::List(ids) => ids,
}
}
}
pub async fn mark_notifications_sent(
_auth: crate::auth::PinAuth,
body: Option<web::Json<MarkSentRequest>>,
db: web::Data<Database>,
) -> impl Responder {
let ids = body.map(|b| b.into_inner().into_ids()).unwrap_or_default();
match db.mark_as_sent(&ids).await {
Ok(updated) => HttpResponse::Ok().json(serde_json::json!({
"updated": updated,
})),
Err(e) => {
log::error!("Failed to mark notifications as sent: {e}");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to mark notifications as sent",
"details": e,
}))
}
}
}
pub async fn mark_single_notification_sent(
_auth: crate::auth::PinAuth,
path: web::Path<i64>,
db: web::Data<Database>,
) -> impl Responder {
let id = path.into_inner();
match db.mark_as_sent(&[id]).await {
Ok(updated) => HttpResponse::Ok().json(serde_json::json!({
"updated": updated,
})),
Err(e) => {
log::error!("Failed to mark notification as sent: {e}");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to mark notification as sent",
"details": e,
}))
}
}
}
pub fn main() -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { "Notify" }
link rel="stylesheet" href="/assets/daisyui.css";
link rel="stylesheet" href="/assets/app.css";
script src="/assets/h.js" {}
}
body class="min-h-screen bg-base-100 text-base-content" {
div id="main" class="min-h-screen w-full" hx-get="/notify" hx-trigger="load" {
// fetch main body content
// by default /notify
}
}
}
}
}
pub fn main_with_content(content: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { "Notify" }
link rel="stylesheet" href="/assets/daisyui.css";
link rel="stylesheet" href="/assets/app.css";
script src="/assets/h.js" {}
}
body class="min-h-screen bg-base-100 text-base-content" {
div id="main" class="min-h-screen w-full" {
(content)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::header::HeaderValue;
use actix_web::{App, test as aw_test};
fn test_config() -> Config {
Config::new(
8080,
HeaderValue::from_static(crate::config::DEFAULT_CACHE_CONTROL),
"notify.db".to_string(),
false,
"123456".to_string(),
)
}
#[test]
fn test_main_view_contains_daisyui_and_app_css() {
let html_str = main().into_string();
assert!(html_str.contains("/assets/daisyui.css"));
assert!(html_str.contains("/assets/app.css"));
assert!(html_str.contains("/assets/h.js"));
assert!(html_str.contains("hx-get=\"/notify\""));
}
#[actix_web::test]
async fn test_index_endpoint() {
let app = aw_test::init_service(App::new().service(index)).await;
let req = aw_test::TestRequest::get().uri("/").to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("hx-get=\"/notify\""));
assert!(body_str.contains("id=\"main\""));
}
#[actix_web::test]
async fn test_verify_pin_success() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config.clone())
.app_data(session_store.clone())
.service(verify_pin),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/pin")
.set_form(PinForm {
pin: "123456".to_string(),
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let cookie = resp
.response()
.cookies()
.find(|c| c.name() == crate::session::SESSION_COOKIE_NAME);
assert!(cookie.is_some(), "Should set session cookie");
let cookie = cookie.unwrap();
assert_eq!(
cookie.max_age(),
Some(actix_web::cookie::time::Duration::days(365)),
"Session cookie should last a year"
);
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Look at your phone"));
}
#[actix_web::test]
async fn test_verify_pin_failure() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config.clone())
.app_data(session_store.clone())
.service(verify_pin),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/pin")
.set_form(PinForm {
pin: "9999".to_string(),
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let cookie = resp
.response()
.cookies()
.find(|c| c.name() == crate::session::SESSION_COOKIE_NAME);
assert!(cookie.is_none(), "Should NOT set session cookie on failure");
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Invalid PIN"));
assert!(body_str.contains("class=\"otp\""));
}
#[actix_web::test]
async fn test_notify_endpoints_unauthorized() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_notify_unauth_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store.clone())
.app_data(db_data)
.service(notify_action),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/notify/heart")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
// Verify no notification was saved
let list = db.list_notifications().await.unwrap();
assert!(list.is_empty());
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_notify_endpoints_authorized() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let token = session_store.create_session().await;
let cookie =
actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_notify_auth_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store.clone())
.app_data(db_data)
.service(notify_action),
)
.await;
for kind in ["heart", "cat", "emergency"] {
let req = aw_test::TestRequest::post()
.uri(&format!("/notify/{kind}"))
.cookie(cookie.clone())
.to_request();
let resp = aw_test::call_service(&app, req).await;
// 204 No Content tells HTMX that saving is complete without replacing button content with alerts
assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
// Must NOT say "notification sent"
assert!(!body_str.contains("notification sent"));
assert!(!body_str.contains("Notification sent"));
assert!(body_str.is_empty());
}
// Verify that 3 rows were saved into the database ready to be scanned (sent == false)
let list = db.list_notifications().await.unwrap();
assert_eq!(list.len(), 3);
for item in &list {
assert!(
!item.sent,
"Notification {} should initially be ready/unsent",
item.id
);
}
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_scan_notifications_when_empty_returns_204_no_content() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_scan_empty_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data)
.route("/notifications/scan", web::post().to(scan_notifications)),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/notifications/scan")
.insert_header(("X-PIN", "123456"))
.set_json(ScanRequest::default())
.to_request();
let resp = aw_test::call_service(&app, req).await;
// 204 No Content indicates nothing changed
assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
assert!(resp.status().is_success());
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_scan_notifications_does_not_flag_them_as_sent() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_scan_no_flag_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
// Save 2 notifications that are ready
db.save_notification("heart", "Look at your phone", false)
.await
.unwrap();
db.save_notification("cat", "Missing you", false)
.await
.unwrap();
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data.clone())
.route("/notifications/scan", web::post().to(scan_notifications)),
)
.await;
// Call scan endpoint
let req = aw_test::TestRequest::post()
.uri("/notifications/scan")
.insert_header(("X-PIN", "123456"))
.set_json(ScanRequest { limit: Some(10) })
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let notifications: Vec<crate::db::Notification> =
serde_json::from_slice(&body).expect("Should deserialize JSON response");
assert_eq!(notifications.len(), 2);
assert_eq!(notifications[0].kind, "heart");
assert_eq!(notifications[1].kind, "cat");
// The endpoint should NOT flag them as sent!
assert!(!notifications[0].sent);
assert!(!notifications[1].sent);
// Verify in DB directly that sent is still false (0)
let in_db = db.list_notifications().await.unwrap();
for item in in_db {
assert!(
!item.sent,
"Notification {} should still NOT be flagged as sent",
item.id
);
}
// Subsequent scan still returns both notifications (not 204) because they weren't flagged as sent
let req2 = aw_test::TestRequest::post()
.uri("/notifications/scan")
.insert_header(("X-PIN", "123456"))
.set_json(ScanRequest::default())
.to_request();
let resp2 = aw_test::call_service(&app, req2).await;
assert_eq!(resp2.status(), actix_web::http::StatusCode::OK);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_mark_notifications_sent_endpoint() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_mark_sent_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let n1 = db
.save_notification("heart", "Look at your phone", false)
.await
.unwrap();
let n2 = db
.save_notification("cat", "Missing you", false)
.await
.unwrap();
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data.clone())
.route("/notifications/scan", web::post().to(scan_notifications))
.route(
"/notifications/sent",
web::post().to(mark_notifications_sent),
),
)
.await;
// Mark only n1 as sent via JSON body { "ids": [n1.id] }
let req = aw_test::TestRequest::post()
.uri("/notifications/sent")
.insert_header(("X-PIN", "123456"))
.set_json(MarkSentRequest::Object {
ids: Some(vec![n1.id]),
id: None,
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["updated"], serde_json::json!([n1.id]));
// Now scan only returns n2
let scan_req = aw_test::TestRequest::post()
.uri("/notifications/scan")
.insert_header(("X-PIN", "123456"))
.set_json(ScanRequest::default())
.to_request();
let scan_resp = aw_test::call_service(&app, scan_req).await;
assert_eq!(scan_resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(scan_resp).await;
let ready: Vec<crate::db::Notification> = serde_json::from_slice(&body).unwrap();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].id, n2.id);
// Mark remaining sent with empty payload (marks all pending as sent)
let req2 = aw_test::TestRequest::post()
.uri("/notifications/sent")
.insert_header(("X-PIN", "123456"))
.set_json(MarkSentRequest::Object {
ids: None,
id: None,
})
.to_request();
let resp2 = aw_test::call_service(&app, req2).await;
assert_eq!(resp2.status(), actix_web::http::StatusCode::OK);
// Now scan returns 204 No Content
let scan_req2 = aw_test::TestRequest::post()
.uri("/notifications/scan")
.insert_header(("X-PIN", "123456"))
.set_json(ScanRequest::default())
.to_request();
let scan_resp2 = aw_test::call_service(&app, scan_req2).await;
assert_eq!(scan_resp2.status(), actix_web::http::StatusCode::NO_CONTENT);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_mark_single_notification_sent_endpoint() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_mark_single_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let n1 = db
.save_notification("emergency", "Emergency", false)
.await
.unwrap();
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data.clone())
.route("/notifications/scan", web::get().to(scan_notifications))
.route(
"/notifications/{id}/sent",
web::post().to(mark_single_notification_sent),
),
)
.await;
// Mark single notification sent
let req = aw_test::TestRequest::post()
.uri(&format!("/notifications/{}/sent", n1.id))
.insert_header(("X-PIN", "123456"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
// Scan now returns 204 No Content
let scan_req = aw_test::TestRequest::get()
.uri("/notifications/scan")
.insert_header(("X-PIN", "123456"))
.to_request();
let scan_resp = aw_test::call_service(&app, scan_req).await;
assert_eq!(scan_resp.status(), actix_web::http::StatusCode::NO_CONTENT);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_notify_with_custom_message_query() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let token = session_store.create_session().await;
let cookie =
actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_notify_custom_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store.clone())
.app_data(db_data)
.service(notify_action),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/notify/custom_kind?message=Please%20pick%20up%20milk")
.cookie(cookie.clone())
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let list = db.list_notifications().await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].kind, "custom_kind");
assert_eq!(list[0].message, "Please pick up milk");
assert!(!list[0].sent);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_mark_notification_completed_endpoint() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_mark_completed_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let n1 = db
.save_notification("cat", "Missing you", false)
.await
.unwrap();
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data.clone())
.route("/notifications/scan", web::get().to(scan_notifications))
.route(
"/notifications/{id}/completed",
web::post().to(mark_single_notification_sent),
),
)
.await;
// Mark completed
let req = aw_test::TestRequest::post()
.uri(&format!("/notifications/{}/completed", n1.id))
.insert_header(("X-PIN", "123456"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
// Scan now returns 204
let scan_req = aw_test::TestRequest::get()
.uri("/notifications/scan")
.insert_header(("X-PIN", "123456"))
.to_request();
let scan_resp = aw_test::call_service(&app, scan_req).await;
assert_eq!(scan_resp.status(), actix_web::http::StatusCode::NO_CONTENT);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_scan_notifications_unauthorized_without_pin() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_scan_no_pin_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data)
.route("/notifications/scan", web::get().to(scan_notifications)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/notifications/scan")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_scan_notifications_unauthorized_with_wrong_pin() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_scan_wrong_pin_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data)
.route("/notifications/scan", web::get().to(scan_notifications)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/notifications/scan")
.insert_header(("X-PIN", "9999"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_scan_notifications_authorized_with_bearer_token() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_scan_bearer_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data)
.route("/notifications/scan", web::get().to(scan_notifications)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/notifications/scan")
.insert_header(("Authorization", "Bearer 123456"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
// 204 No Content because there are no pending notifications, but it is authenticated!
assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_scan_notifications_authorized_with_query_param() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_scan_query_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data)
.route("/notifications/scan", web::get().to(scan_notifications)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/notifications/scan?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_notify_action_authorized_with_pin() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_notify_pin_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.app_data(db_data.clone())
.service(notify_action),
)
.await;
// No session cookie, but passes X-PIN
let req = aw_test::TestRequest::post()
.uri("/notify/emergency")
.insert_header(("X-PIN", "123456"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
let list = db.list_notifications().await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].kind, "emergency");
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_api_notify_action_authorized_with_pin() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_api_notify_pin_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.app_data(db_data.clone())
.service(api_notify_action),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/api/notify/cat")
.insert_header(("X-PIN", "123456"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
let list = db.list_notifications().await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].kind, "cat");
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_api_notify_action_unauthorized() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_api_notify_unauth_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.app_data(db_data.clone())
.service(api_notify_action),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/api/notify/cat")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_export_config_routes_and_plugin_compatibility() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.route("/config/export", web::get().to(export_config))
.route("/export", web::get().to(export_config))
.route("/api/config/export", web::get().to(export_config)),
)
.await;
for uri in ["/config/export", "/export", "/api/config/export"] {
let req = aw_test::TestRequest::get()
.uri(uri)
.insert_header(("X-PIN", "123456"))
.insert_header(("Host", "192.168.1.50:8080"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let val: serde_json::Value = serde_json::from_slice(&body).unwrap();
// Validate that exported config has all fields needed by plugin
assert_eq!(val["name"], "notify");
assert_eq!(val["url"], "http://192.168.1.50:8080");
assert_eq!(val["pin"], "123456");
assert_eq!(val["api_key"], "123456");
assert_eq!(val["active"], "notify");
}
}
#[actix_web::test]
async fn test_install_page_routes_and_plugin_download() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let token = session_store.create_session().await;
let cookie =
actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();
let app = aw_test::init_service(
App::new()
.app_data(config.clone())
.app_data(session_store.clone())
.service(install_page)
.service(
web::resource("/install/omarchy.sh")
.route(web::get().to(omarchy_installer_script)),
)
.service(
web::resource("/install/omarchy")
.route(web::get().to(omarchy_installer_script)),
)
.service(
web::resource("/api/install/omarchy.sh")
.route(web::get().to(omarchy_installer_script)),
)
.service(
web::resource("/install/omarchy/download")
.route(web::get().to(omarchy_installer_script)),
)
.service(
web::resource("/install/omarchy/plugin/{filename:.*}")
.route(web::get().to(omarchy_plugin_file)),
)
.service(
web::resource("/install/omarchy/config")
.route(web::get().to(omarchy_plugin_config)),
),
)
.await;
// 1. Test GET /install via HTMX
let req = aw_test::TestRequest::get()
.uri("/install")
.cookie(cookie.clone())
.insert_header(("HX-Request", "true"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Omarchy Plugin"));
assert!(body_str.contains("macOS App"));
assert!(body_str.contains("Linux App"));
assert!(body_str.contains("Ready to install"));
assert!(body_str.contains("Notify.app"));
assert!(body_str.contains("curl -fsSL"));
assert!(body_str.contains("/install/omarchy.sh?pin=123456"));
assert!(!body_str.contains("<!DOCTYPE html>")); // HTMX partial
assert!(!body_str.contains("download=\"notify.json\""));
assert!(!body_str.contains("omarchy-notify-plugin.tar.gz"));
// 2. Test GET /install directly (non-HTMX browser request)
let req = aw_test::TestRequest::get()
.uri("/install")
.cookie(cookie.clone())
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("<!DOCTYPE html>")); // Full page wrapper
assert!(body_str.contains("/assets/daisyui.css"));
assert!(body_str.contains("Omarchy Plugin"));
assert!(body_str.contains("macOS App"));
assert!(body_str.contains("Linux App"));
assert!(body_str.contains("Ready to install"));
// 3. Test downloading installer script across endpoints
for uri in [
"/install/omarchy.sh",
"/install/omarchy",
"/api/install/omarchy.sh",
"/install/omarchy/download",
] {
let req = aw_test::TestRequest::get()
.uri(uri)
.insert_header(("X-PIN", "123456"))
.insert_header(("Host", "my-notify-server.local:8080"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/x-shellscript; charset=utf-8"
);
let body = aw_test::read_body(resp).await;
let script_str = String::from_utf8(body.to_vec()).unwrap();
assert!(script_str.starts_with("#!/usr/bin/env bash"));
assert!(script_str.contains(
"SERVER_URL=\"${NOTIFY_SERVER_URL:-\"http://my-notify-server.local:8080\"}\""
));
assert!(script_str.contains("PIN=\"${NOTIFY_PIN:-\"123456\"}\""));
assert!(script_str.contains("manifest.json"));
assert!(script_str.contains("Service.qml"));
assert!(script_str.contains("/install/omarchy/config?pin=${PIN}"));
assert!(script_str.contains("omarchy plugin enable notify.desktop"));
}
// 4. Test plugin file serving and config endpoint
let req = aw_test::TestRequest::get()
.uri("/install/omarchy/plugin/manifest.json?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("\"id\": \"notify.desktop\""));
let req = aw_test::TestRequest::get()
.uri("/install/omarchy/config?pin=123456")
.insert_header(("Host", "my-notify-server.local:8080"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let parsed_config: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed_config["url"], "http://my-notify-server.local:8080");
assert_eq!(parsed_config["pin"], "123456");
assert_eq!(parsed_config["active"], "notify");
}
#[actix_web::test]
async fn test_post_notification_json_unauthorized() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_post_json_unauth_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data)
.route("/api/notify", web::post().to(post_notification_json)),
)
.await;
let req = aw_test::TestRequest::post()
.uri("/api/notify")
.set_json(CreateNotificationPayload {
kind: Some("heart".to_string()),
message: Some("hello".to_string()),
})
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
let _ = std::fs::remove_file(&db_path);
}
#[actix_web::test]
async fn test_post_notification_json_authorized_success() {
let config_data = web::Data::new(test_config());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let db_path = format!("/tmp/test_post_json_auth_{now}.db");
let db = Database::new(&db_path);
db.init().await.unwrap();
let db_data = web::Data::new(db.clone());
let app = aw_test::init_service(
App::new()
.app_data(config_data)
.app_data(db_data.clone())
.route("/api/notify", web::post().to(post_notification_json))
.route("/api/notifications", web::post().to(post_notification_json))
.route("/notifications", web::post().to(post_notification_json)),
)
.await;
// 1. Full payload on /api/notify
let req = aw_test::TestRequest::post()
.uri("/api/notify")
.insert_header(("X-PIN", "123456"))
.set_json(serde_json::json!({
"kind": "heart",
"message": "Custom heart message"
}))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::CREATED);
let body = aw_test::read_body(resp).await;
let created: crate::db::Notification = serde_json::from_slice(&body).unwrap();
assert_eq!(created.kind, "heart");
assert_eq!(created.message, "Custom heart message");
assert!(!created.sent);
// 2. Kind only (should use default message) on /api/notifications
let req2 = aw_test::TestRequest::post()
.uri("/api/notifications")
.insert_header(("Authorization", "Bearer 123456"))
.set_json(serde_json::json!({
"kind": "cat"
}))
.to_request();
let resp2 = aw_test::call_service(&app, req2).await;
assert_eq!(resp2.status(), actix_web::http::StatusCode::CREATED);
let body2 = aw_test::read_body(resp2).await;
let created2: crate::db::Notification = serde_json::from_slice(&body2).unwrap();
assert_eq!(created2.kind, "cat");
assert_eq!(created2.message, "Missing you");
// 3. Message only (should default kind to "notification") on /notifications
let req3 = aw_test::TestRequest::post()
.uri("/notifications")
.insert_header(("X-PIN", "123456"))
.set_json(serde_json::json!({
"message": "Custom text from mac app"
}))
.to_request();
let resp3 = aw_test::call_service(&app, req3).await;
assert_eq!(resp3.status(), actix_web::http::StatusCode::CREATED);
let body3 = aw_test::read_body(resp3).await;
let created3: crate::db::Notification = serde_json::from_slice(&body3).unwrap();
assert_eq!(created3.kind, "notification");
assert_eq!(created3.message, "Custom text from mac app");
// 4. Empty JSON body
let req4 = aw_test::TestRequest::post()
.uri("/api/notify")
.insert_header(("X-PIN", "123456"))
.set_json(serde_json::json!({}))
.to_request();
let resp4 = aw_test::call_service(&app, req4).await;
assert_eq!(resp4.status(), actix_web::http::StatusCode::CREATED);
let body4 = aw_test::read_body(resp4).await;
let created4: crate::db::Notification = serde_json::from_slice(&body4).unwrap();
assert_eq!(created4.kind, "notification");
assert_eq!(created4.message, "Notification");
// Verify all 4 saved in DB
let list = db.list_notifications().await.unwrap();
assert_eq!(list.len(), 4);
let _ = std::fs::remove_file(&db_path);
}
}