Finish M2: run lock, systemd units, discover; drop the web UI
Designing the configuration UI in full made the case against building it. Its audience would be people who find TOML hard, but with bring-your-own OAuth client settled, every user must first create a Google Cloud project, configure a consent screen and put a secret in a keyring — a far higher bar than editing thirty lines of config. Anyone who clears it can edit the file; anyone who cannot never reaches the file. Against that stood three dependencies, five modules, an auth.rs refactor and a security surface guarding something that reads the config and touches the keyring. `doctor` and `status` had already absorbed most of what it was for. The reasoning is recorded in TODO.md and SPECS.md rather than left as an apparent oversight. The run lock is not a UI feature and closes a gap that already existed: nothing stopped a timer firing into a hand-run cycle, and two cycles interleaving writes over the same vdirs is what the design otherwise avoids. flock is used rather than a pid file because the kernel releases it however the process ends, so a crash cannot leave a lock to clear by hand — which also means a lock we failed to take is held by a live process, so the pid in it is worth reporting. The one idea worth keeping from the UI design was collection discovery, which needed no web layer. `calcalist discover` prints a ready-to-paste endpoint block per calendar a server offers, removing the most error-prone field in the config. pimsync's discovery output is undocumented, so the format was established against a real server first. Two things it teaches: everything arrives on stdout including failures, and a pair has two storages, so pimsync reports the scratch vdir's contents too — parsing anchors on the heading naming the server, or a probe directory's leftovers would be offered as the user's calendars. Verified against Posteo as well as Radicale: all four calendars found, the first matching the URL already configured. Also fixes a real defect in the test harness rather than its symptom. Ports were chosen by binding one and letting go, so two tests could pick the same number — and the loser's readiness check then succeeded against the winner's server, silently sharing it. Startup now confirms the child we spawned is the one alive, retries on another port if not, and waits for a real HTTP response rather than an open socket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
47ad8b4c47
commit
ef6482ed62
13 changed files with 808 additions and 92 deletions
114
tests/caldav.rs
114
tests/caldav.rs
|
|
@ -383,3 +383,117 @@ fn the_local_marker_keeps_an_event_out_of_a_configured_sink() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovery, against a server that really has two calendars.
|
||||
///
|
||||
/// The command exists because the CalDAV `url` must name the collection exactly
|
||||
/// and providers rarely show it. What it prints therefore has to be usable
|
||||
/// as-is, which is what the parse-back below actually checks.
|
||||
#[test]
|
||||
fn discover_lists_the_servers_calendars_as_pasteable_config() {
|
||||
let Some(fixture) = fixture() else { return };
|
||||
|
||||
let run = fixture.calcalist.run(&[
|
||||
"discover",
|
||||
&format!(
|
||||
"http://127.0.0.1:{}/{}/",
|
||||
fixture.server.port,
|
||||
support::USER
|
||||
),
|
||||
"--username",
|
||||
support::USER,
|
||||
"--secret-command",
|
||||
"printf password",
|
||||
]);
|
||||
assert!(
|
||||
run.succeeded(),
|
||||
"discover failed\n{}\n{}",
|
||||
run.stdout,
|
||||
run.stderr
|
||||
);
|
||||
|
||||
assert!(run.stdout.contains("id = \"work\""), "{}", run.stdout);
|
||||
assert!(run.stdout.contains("id = \"unified\""), "{}", run.stdout);
|
||||
// The URL has to come back absolute, not as the bare path pimsync reports.
|
||||
assert!(
|
||||
run.stdout
|
||||
.contains(&format!("http://127.0.0.1:{}/", fixture.server.port)),
|
||||
"{}",
|
||||
run.stdout
|
||||
);
|
||||
|
||||
// The point of the command: what it prints is valid configuration.
|
||||
let config = format!("version = 1\n{}", run.stdout);
|
||||
let parsed: toml::Value = toml::from_str(&config).expect("the output should be valid TOML");
|
||||
let endpoints = parsed["endpoint"].as_array().expect("endpoints");
|
||||
assert_eq!(endpoints.len(), 2, "{}", run.stdout);
|
||||
assert_eq!(endpoints[0]["type"].as_str(), Some("caldav"));
|
||||
assert_eq!(
|
||||
endpoints[0]["secret_command"].as_str(),
|
||||
Some("printf password")
|
||||
);
|
||||
}
|
||||
|
||||
/// An unreachable server has to say so rather than print an empty result that
|
||||
/// looks like "this server has no calendars".
|
||||
#[test]
|
||||
fn discover_reports_a_server_it_cannot_reach() {
|
||||
if !support::missing_binaries().is_empty() {
|
||||
eprintln!("skipped: run under devbox");
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("temp");
|
||||
let calcalist = Calcalist::new(root.path(), "version = 1\n");
|
||||
|
||||
// Port 9 is discard: it refuses or blackholes, and nothing listens for DAV.
|
||||
let run = calcalist.run(&[
|
||||
"discover",
|
||||
"http://127.0.0.1:9/calendars/me/",
|
||||
"--username",
|
||||
"me",
|
||||
]);
|
||||
|
||||
assert!(
|
||||
!run.succeeded(),
|
||||
"it should not claim success\n{}",
|
||||
run.stdout
|
||||
);
|
||||
assert!(
|
||||
!run.stderr.is_empty(),
|
||||
"the failure should be explained: {run:?}",
|
||||
run = run.stderr
|
||||
);
|
||||
}
|
||||
|
||||
/// The lock is what stops a timer firing into a hand-run cycle.
|
||||
///
|
||||
/// The lock is taken here rather than by racing two real syncs, which would be
|
||||
/// timing-dependent and would pass by accident most of the time.
|
||||
#[test]
|
||||
fn a_second_cycle_is_refused_while_one_is_running() {
|
||||
let Some(fixture) = fixture() else { return };
|
||||
assert!(fixture.calcalist.run(&["sync"]).succeeded());
|
||||
|
||||
let lock_path = fixture.calcalist.state.join("calcalist/lock");
|
||||
let held = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
.expect("the lock file should exist after a cycle");
|
||||
rustix::fs::flock(&held, rustix::fs::FlockOperation::NonBlockingLockExclusive)
|
||||
.expect("the test should be able to take the lock");
|
||||
|
||||
let refused = fixture.calcalist.run(&["sync"]);
|
||||
assert!(!refused.succeeded(), "{}", refused.stdout);
|
||||
assert!(
|
||||
refused.stderr.contains("already running"),
|
||||
"the refusal should say why: {}",
|
||||
refused.stderr
|
||||
);
|
||||
|
||||
drop(held);
|
||||
assert!(
|
||||
fixture.calcalist.run(&["sync"]).succeeded(),
|
||||
"the lock should be free again"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue