15d7f5e873
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity - Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys - Visual view: tables, note embeds, %% comments, wrapping property chips - Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin - Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
143 lines
7.5 KiB
Rust
143 lines
7.5 KiB
Rust
//! Google Calendar, read-only: hashing, sign-in URL, JSON parsing and refreshing a Google feed.
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
use notescraft_calendar::google::{auth_url, base64url, events_url, parse_calendar_list, parse_events, parse_token, percent_encode, pkce_challenge, sha256};
|
|
use notescraft_calendar::{Feed, Fetch, Fetched, Status, refresh};
|
|
|
|
fn hex(bytes: &[u8]) -> String {
|
|
bytes.iter().fold(String::new(), |mut out, b| {
|
|
use std::fmt::Write;
|
|
let _ = write!(out, "{b:02x}");
|
|
out
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn sha256_matches_the_standard_vectors() {
|
|
assert_eq!(hex(&sha256(b"")), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
|
assert_eq!(hex(&sha256(b"abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
|
assert_eq!(hex(&sha256(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")), "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
|
|
assert_eq!(hex(&sha256(&vec![b'a'; 1_000_000])), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
|
}
|
|
|
|
#[test]
|
|
fn base64url_and_pkce_match_the_rfcs() {
|
|
for (input, expected) in [("", ""), ("f", "Zg"), ("fo", "Zm8"), ("foo", "Zm9v"), ("foob", "Zm9vYg"), ("fooba", "Zm9vYmE"), ("foobar", "Zm9vYmFy")] {
|
|
assert_eq!(base64url(input.as_bytes()), expected, "{input:?}");
|
|
}
|
|
assert_eq!(base64url(&[0xfb, 0xff, 0xfe]), "-__-", "the URL alphabet, not + and /");
|
|
assert_eq!(pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
|
}
|
|
|
|
#[test]
|
|
fn the_sign_in_address_asks_for_read_only_access_and_a_loopback_answer() {
|
|
let url = auth_url("id 1.apps", "http://127.0.0.1:5555/", "CHAL", "st&te");
|
|
for part in [
|
|
"client_id=id%201.apps",
|
|
"redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2F",
|
|
"scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
|
|
"code_challenge=CHAL",
|
|
"code_challenge_method=S256",
|
|
"state=st%26te",
|
|
"access_type=offline",
|
|
"response_type=code",
|
|
] {
|
|
assert!(url.contains(part), "{part} in {url}");
|
|
}
|
|
assert!(url.starts_with("https://accounts.google.com/"));
|
|
assert!(!url.contains("calendar%20") && !url.contains(".events"), "no write scope");
|
|
assert_eq!(percent_encode("a b/é~"), "a%20b%2F%C3%A9~");
|
|
}
|
|
|
|
#[test]
|
|
fn tokens_calendars_and_errors_parse() {
|
|
let token = parse_token(r#"{"access_token":"ya29.x","expires_in":3599,"refresh_token":"1//r","token_type":"Bearer"}"#).unwrap();
|
|
assert_eq!((token.access_token.as_str(), token.expires_in, token.refresh_token.as_deref()), ("ya29.x", 3599, Some("1//r")));
|
|
assert_eq!(parse_token(r#"{"access_token":"t"}"#).unwrap().refresh_token, None);
|
|
for bad in [r#"{"error":"invalid_grant","error_description":"Bad Request"}"#, "{}", "nope", r#"{"access_token":""}"#] {
|
|
assert!(parse_token(bad).is_err(), "{bad}");
|
|
}
|
|
let message = parse_token(r#"{"error":"invalid_grant","error_description":"Token has been revoked"}"#).unwrap_err().to_string();
|
|
assert!(message.contains("revoked"), "{message}");
|
|
|
|
let list = parse_calendar_list(
|
|
r#"{"items":[{"id":"me@x.com","summary":"Me","primary":true},{"id":"c2","summary":"Team","summaryOverride":"Work"},{"summary":"no id"}]}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(list.iter().map(|c| (c.id.as_str(), c.name.as_str(), c.primary)).collect::<Vec<_>>(), [("me@x.com", "Me", true), ("c2", "Work", false)]);
|
|
assert!(parse_calendar_list(r#"{"nothing":1}"#).is_err());
|
|
}
|
|
|
|
const EVENTS: &str = r#"{"items":[
|
|
{"id":"e1","status":"confirmed","summary":"Standup","start":{"dateTime":"2026-10-20T09:30:00+01:00","timeZone":"Europe/Lisbon"},"end":{"dateTime":"2026-10-20T09:45:00+01:00"},"hangoutLink":"https://meet.example/x","reminders":{"useDefault":false,"overrides":[{"method":"popup","minutes":10},{"method":"email","minutes":99999999}]}},
|
|
{"id":"e2","summary":"Holiday","start":{"date":"2026-12-24"},"end":{"date":"2026-12-27"}},
|
|
{"id":"e3","summary":"One day","start":{"date":"2026-10-10"},"end":{"date":"2026-10-11"}},
|
|
{"id":"e4","status":"cancelled","start":{"dateTime":"2026-10-21T10:00:00Z"}},
|
|
{"id":"e5","status":"tentative","summary":"Maybe","start":{"dateTime":"2026-10-22T10:00:00Z"},"htmlLink":"https://calendar.example/e5"},
|
|
{"id":"bad","summary":"Broken","start":{"dateTime":"yesterday"}},
|
|
{"id":"none","summary":"No start"}
|
|
]}"#;
|
|
|
|
#[test]
|
|
fn events_become_read_only_events_and_broken_ones_are_counted() {
|
|
let (events, skipped) = parse_events(EVENTS).unwrap();
|
|
assert_eq!((events.len(), skipped), (5, 2));
|
|
let standup = &events[0];
|
|
assert_eq!(standup.uid.as_deref(), Some("e1"));
|
|
let e = &standup.event;
|
|
assert_eq!(
|
|
(e.start.to_string().as_str(), e.end.unwrap().to_string().as_str(), e.tz.as_deref()),
|
|
("2026-10-20T09:30", "2026-10-20T09:45", Some("Europe/Lisbon"))
|
|
);
|
|
assert_eq!(
|
|
(e.link.as_deref(), e.reminders.iter().map(|r| r.minutes_before).collect::<Vec<_>>()),
|
|
(Some("https://meet.example/x"), vec![10]),
|
|
"an absurd reminder is dropped"
|
|
);
|
|
let holiday = &events[1].event;
|
|
assert_eq!((holiday.start.to_string().as_str(), holiday.end.unwrap().to_string().as_str()), ("2026-12-24", "2026-12-26"));
|
|
assert!(events[2].event.end.is_none(), "a one-day all-day event has no end");
|
|
assert_eq!((events[3].event.status, events[3].event.title.as_str()), (Some(Status::Cancelled), "(no title)"));
|
|
assert_eq!((events[4].event.status, events[4].event.link.as_deref()), (Some(Status::Tentative), Some("https://calendar.example/e5")));
|
|
for bad in ["", "nope", "{}", r#"{"items":"x"}"#] {
|
|
assert!(parse_events(bad).is_err(), "{bad:?}");
|
|
}
|
|
assert!(parse_events(&format!(r#"{{"items":[{}]}}"#, vec![r#"{"start":{"date":"2026-01-01"}}"#; 10_001].join(","))).is_err(), "too many events");
|
|
}
|
|
|
|
#[test]
|
|
fn pages_of_events_are_asked_for_in_order() {
|
|
let first = events_url("a@b.com", "2026-10-01T00:00:00Z", "2027-10-01T00:00:00Z", None);
|
|
assert!(first.starts_with("https://www.googleapis.com/calendar/v3/calendars/a%40b.com/events?singleEvents=true"), "{first}");
|
|
assert!(first.contains("timeMin=2026-10-01T00%3A00%3A00Z") && !first.contains("pageToken"));
|
|
assert!(events_url("c", "a", "b", Some("tok/en")).ends_with("&pageToken=tok%2Fen"));
|
|
}
|
|
|
|
struct Reply(Option<Result<Fetched, String>>);
|
|
|
|
impl Fetch for Reply {
|
|
fn fetch(&mut self, _url: &str, _etag: Option<&str>) -> Result<Fetched, String> {
|
|
self.0.take().unwrap()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_google_feed_holds_no_secret_and_refreshes_from_json() {
|
|
let feed = Feed::google("Work", "me@x.com", None).unwrap();
|
|
assert_eq!((feed.id.as_str(), feed.url.as_str(), feed.google_calendar()), ("google-work", "google://calendar/me@x.com", Some("me@x.com")));
|
|
assert!(Feed::new("x", "https://a.example/x.ics", None).unwrap().google_calendar().is_none());
|
|
for (name, id) in [("", "c"), ("x", ""), ("x", "a b")] {
|
|
assert!(Feed::google(name, id, None).is_err(), "{name:?} {id:?}");
|
|
}
|
|
let cached = refresh(&feed, None, &mut Reply(Some(Ok(Fetched::Body { text: EVENTS.to_owned(), etag: None }))), "2026-10-20T09:00".parse().unwrap());
|
|
assert_eq!((cached.events.len(), cached.status.skipped, cached.status.error.clone()), (5, 2, None));
|
|
let broken = refresh(
|
|
&feed,
|
|
Some(&cached),
|
|
&mut Reply(Some(Ok(Fetched::Body { text: "BEGIN:VCALENDAR".to_owned(), etag: None }))),
|
|
"2026-10-20T10:00".parse().unwrap(),
|
|
);
|
|
assert_eq!(broken.events.len(), 5, "a bad answer keeps the cache");
|
|
assert!(broken.status.error.is_some());
|
|
}
|