snapshot of https://github.com/ActivityWatch/aw-server-rust.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / ubuntu-latest (push) Failing after 5s
Build / Android (push) Failing after 2s
Build / Code coverage (push) Failing after 2s
Lint / format (push) Failing after 3s
Lint / clippy (push) Failing after 3s
Build / macOS-latest (push) Has been cancelled
Build / windows-latest (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:19:17 +05:00
commit da934b693c
120 changed files with 26659 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
use std::future::Future;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::thread;
use aw_client_rust::blocking;
use aw_client_rust::AwClient;
fn block_on<F: Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build test runtime")
.block_on(future)
}
struct MockResponse {
status_line: &'static str,
content_type: &'static str,
body: &'static str,
}
/// Drain the HTTP request fully before responding.
///
/// Parses Content-Length from headers so POST body data (which may arrive
/// in a separate TCP segment) is consumed before the mock writes its
/// response. Without this, reqwest may see a broken pipe on loopback if
/// the response arrives before the body finishes sending.
fn drain_request(stream: &mut impl Read) {
let mut reader = BufReader::new(stream);
let mut content_length = 0_usize;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).expect("read request line");
if n == 0 {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
// End of headers; body follows if Content-Length > 0
break;
}
if let Some(val) = trimmed.strip_prefix("Content-Length:") {
content_length = val.trim().parse().unwrap_or(0);
}
}
if content_length > 0 {
let mut body_buf = vec![0_u8; content_length];
reader
.read_exact(&mut body_buf)
.expect("drain request body");
}
}
fn spawn_mock_server(responses: Vec<MockResponse>) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind mock server");
let port = listener.local_addr().expect("mock server addr").port();
let handle = thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("accept request");
drain_request(&mut stream);
let body = response.body.as_bytes();
write!(
stream,
"HTTP/1.1 {}\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n{}",
response.status_line,
body.len(),
response.content_type,
response.body
)
.expect("write response");
stream.flush().expect("flush response");
}
});
(port, handle)
}
#[test]
fn async_client_rejects_non_success_statuses() {
let (port, handle) = spawn_mock_server(vec![
MockResponse {
status_line: "500 Internal Server Error",
content_type: "application/json",
body: "{}",
},
MockResponse {
status_line: "409 Conflict",
content_type: "text/plain",
body: "",
},
]);
let client = AwClient::new("127.0.0.1", port, "aw-client-rust-test").expect("create client");
let err = block_on(client.get_buckets()).expect_err("500 response must fail");
assert_eq!(
err.status(),
Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
);
let err = block_on(client.create_bucket_simple("bucket", "type"))
.expect_err("409 response must fail");
assert_eq!(err.status(), Some(reqwest::StatusCode::CONFLICT));
handle.join().expect("join mock server");
}
#[test]
fn blocking_client_rejects_non_success_statuses() {
let (port, handle) = spawn_mock_server(vec![
MockResponse {
status_line: "500 Internal Server Error",
content_type: "application/json",
body: "{}",
},
MockResponse {
status_line: "409 Conflict",
content_type: "text/plain",
body: "",
},
]);
let client =
blocking::AwClient::new("127.0.0.1", port, "aw-client-rust-test").expect("create client");
let err = client
.get_buckets()
.expect_err("500 response must fail for blocking client");
assert_eq!(
err.status(),
Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
);
let err = client
.create_bucket_simple("bucket", "type")
.expect_err("409 response must fail for blocking client");
assert_eq!(err.status(), Some(reqwest::StatusCode::CONFLICT));
handle.join().expect("join mock server");
}

View File

@@ -0,0 +1,303 @@
extern crate aw_client_rust;
extern crate aw_datastore;
extern crate aw_server;
extern crate chrono;
extern crate rocket;
extern crate serde_json;
extern crate tokio_test;
#[cfg(test)]
mod test {
use aw_client_rust::blocking::AwClient;
use aw_client_rust::Event;
use chrono::{DateTime, Duration, Utc};
use serde_json::Map;
use std::cell::RefCell;
use std::fs;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio_test::block_on;
// Config-reading helpers — only needed in tests; production code passes API keys explicitly.
#[derive(serde::Deserialize, Default)]
struct LocalAuthConfig {
#[serde(default)]
api_key: Option<String>,
}
#[derive(serde::Deserialize, Default)]
struct LocalServerConfig {
#[serde(default)]
port: Option<u16>,
#[serde(default)]
auth: LocalAuthConfig,
}
#[derive(Clone, Copy)]
struct ConfigCandidate {
filename: &'static str,
default_port: u16,
}
fn get_server_config_dir() -> Option<PathBuf> {
Some(
dirs::config_dir()?
.join("activitywatch")
.join("aw-server-rust"),
)
}
fn load_local_api_key(host: &str, port: u16) -> Option<String> {
if host != "127.0.0.1" && host != "localhost" {
return None;
}
let config_dir = get_server_config_dir()?;
let candidates = [
ConfigCandidate {
filename: "config.toml",
default_port: 5600,
},
ConfigCandidate {
filename: "config-testing.toml",
default_port: 5666,
},
];
for candidate in candidates {
let path = config_dir.join(candidate.filename);
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(_) => continue,
};
let config: LocalServerConfig = match toml::from_str(&content) {
Ok(config) => config,
Err(_) => continue,
};
let configured_port = config.port.unwrap_or(candidate.default_port);
if configured_port == port {
return config.auth.api_key.filter(|k| !k.is_empty());
}
}
None
}
// A random port, but still not guaranteed to not be bound
// FIXME: Bind to a port that is free for certain and use that for the client instead
static PORT: u16 = 41293;
static ENV_LOCK: Mutex<()> = Mutex::new(());
// Keep the listener alive until the server binds — prevents TOCTOU race in reserve_port
thread_local! {
static RESERVED_PORT: RefCell<Option<TcpListener>> = const { RefCell::new(None) };
}
fn wait_for_server(timeout_s: u32, client: &AwClient) {
for i in 0.. {
match client.get_info() {
Ok(_) => break,
Err(err) => {
if i == timeout_s - 1 {
panic!("Timed out starting aw-server after {timeout_s}s: {err:?}");
}
}
}
use std::time;
let duration = time::Duration::from_secs(1);
thread::sleep(duration);
}
}
fn setup_testserver(port: u16, api_key: Option<&str>) -> rocket::Shutdown {
use aw_server::endpoints::AssetResolver;
use aw_server::endpoints::ServerState;
let state = ServerState {
datastore: aw_datastore::Datastore::new_in_memory(false),
asset_resolver: AssetResolver::new(None),
device_id: "test_id".to_string(),
};
let aw_config = aw_server::config::AWConfig {
port,
testing: true,
auth: aw_server::config::AWAuthConfig {
api_key: api_key.map(str::to_owned),
},
..Default::default()
};
let server = aw_server::endpoints::build_rocket(state, aw_config);
let server = block_on(server.ignite()).unwrap();
let shutdown_handler = server.shutdown();
thread::spawn(move || {
let _ = block_on(server.launch()).unwrap();
});
shutdown_handler
}
fn reserve_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
// Keep the listener alive until the server binds — prevents TOCTOU race
RESERVED_PORT.with(|cell| *cell.borrow_mut() = Some(listener));
port
}
fn write_server_config(port: u16, api_key: Option<&str>) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let config_home = std::env::temp_dir().join(format!(
"aw-client-rust-config-{}-{}",
std::process::id(),
unique
));
let config_dir = config_home.join("activitywatch").join("aw-server-rust");
fs::create_dir_all(&config_dir).unwrap();
let mut content = format!("port = {port}\n");
if let Some(api_key) = api_key {
content.push_str("\n[auth]\n");
content.push_str(&format!("api_key = \"{api_key}\"\n"));
}
fs::write(config_dir.join("config.toml"), content).unwrap();
config_home
}
fn with_config_home<T>(config_home: &Path, f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap();
let old_value = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("XDG_CONFIG_HOME", config_home);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
if let Some(old_value) = old_value {
std::env::set_var("XDG_CONFIG_HOME", old_value);
} else {
std::env::remove_var("XDG_CONFIG_HOME");
}
let _ = fs::remove_dir_all(config_home);
result.unwrap()
}
#[test]
fn test_full() {
let clientname = "aw-client-rust-test";
// Hold ENV_LOCK during client creation to prevent parallel-test interference
// with test_reads_api_key_from_matching_server_config (which holds the lock
// via with_config_home for the entire client+server lifetime).
let client: AwClient = {
let _guard = ENV_LOCK.lock().unwrap();
AwClient::new("127.0.0.1", PORT, clientname).expect("Client creation failed")
};
let shutdown_handler = setup_testserver(PORT, None);
wait_for_server(20, &client);
let info = client.get_info().unwrap();
assert!(info.testing);
let bucketname = format!("aw-client-rust-test_{}", client.hostname);
let buckettype = "test-type";
client
.create_bucket_simple(&bucketname, buckettype)
.unwrap();
let bucket = client.get_bucket(&bucketname).unwrap();
assert!(bucket.id == bucketname);
println!("{}", bucket.id);
let buckets = client.get_buckets().unwrap();
println!("Buckets: {buckets:?}");
let mut event = Event {
id: None,
timestamp: DateTime::from_utc(
DateTime::parse_from_rfc3339("2017-12-30T01:00:00+00:00")
.unwrap()
.naive_utc(),
Utc,
),
duration: Duration::seconds(0),
data: Map::new(),
};
println!("{event:?}");
client.insert_event(&bucketname, &event).unwrap();
// Ugly way to create a UTC from timestamp, see https://github.com/chronotope/chrono/issues/263
event.timestamp = DateTime::from_utc(
DateTime::parse_from_rfc3339("2017-12-30T01:00:01+00:00")
.unwrap()
.naive_utc(),
Utc,
);
client.heartbeat(&bucketname, &event, 10.0).unwrap();
let events = client.get_events(&bucketname, None, None, None).unwrap();
println!("Events: {events:?}");
assert!(events[0].duration == Duration::seconds(1));
// Query
let query = format!(
"events = query_bucket(\"{}\");
RETURN = events;",
bucket.id
);
let start: DateTime<Utc> = DateTime::parse_from_rfc3339("1996-12-19T00:00:00-08:00")
.unwrap()
.into();
let end: DateTime<Utc> = DateTime::parse_from_rfc3339("2020-12-19T00:00:00-08:00")
.unwrap()
.into();
let timeperiods = (start, end);
let query_result = client.query(&query, vec![timeperiods]).unwrap();
println!("Query result: {query_result:?}");
client
.delete_event(&bucketname, events[0].id.unwrap())
.unwrap();
let count = client.get_event_count(&bucketname).unwrap();
assert_eq!(count, 0);
client.delete_bucket(&bucketname).unwrap();
shutdown_handler.notify();
}
// XDG_CONFIG_HOME is only respected by dirs::config_dir() on Linux.
// On macOS it returns $HOME/Library/Application Support (ignoring XDG_CONFIG_HOME),
// so this test would fail — gate it on Linux only.
#[test]
#[cfg(target_os = "linux")]
fn test_reads_api_key_from_matching_server_config() {
let clientname = "aw-client-rust-auth-test";
let port = reserve_port();
let config_home = write_server_config(port, Some("secret123"));
with_config_home(&config_home, || {
let api_key = load_local_api_key("127.0.0.1", port);
let client: AwClient =
AwClient::new_with_api_key("127.0.0.1", port, clientname, api_key)
.expect("Client creation failed");
// Drop the reserved listener before Rocket tries to bind the same port.
RESERVED_PORT.with(|cell| *cell.borrow_mut() = None);
let shutdown_handler = setup_testserver(port, Some("secret123"));
wait_for_server(20, &client);
let bucketname = format!("aw-client-rust-auth-test_{}", client.hostname);
client
.create_bucket_simple(&bucketname, "test-type")
.unwrap();
let bucket = client.get_bucket(&bucketname).unwrap();
assert_eq!(bucket.id, bucketname);
shutdown_handler.notify();
});
}
}