feat(sync): reconcile secondary Graph calendars

Read documented Graph v1 calendarView pages for secondary calendars and publish each finite window only after the complete bounded listing validates.

Add schema-v2 per-window event membership so moved-out occurrences are evicted without being mislabeled as remote tombstones, while overlapping windows retain shared instances.

Verified: 18/18 fresh normal, 18/18 -Werror, and 18/18 ASan/UBSan tests.
This commit is contained in:
2026-07-18 13:05:21 +01:00
parent 0582444c61
commit 98e07e287e
11 changed files with 1520 additions and 60 deletions

View File

@@ -169,17 +169,19 @@ immutable Graph IDs, parse strictly within fixed bounds, and keep credentials
out of errors. Access tokens are supplied per operation; Secret Service remains
the only persistent token store.
All Graph requests and opaque next/delta links must use HTTPS on
`graph.microsoft.com`. Primary-calendar event windows are half-open UTC ranges.
Each page, its tombstones, and its next or delta cursor commit atomically, so an
interrupted round resumes without advancing past durable data.
All Graph requests and opaque paging/delta links must use HTTPS on
`graph.microsoft.com`. Event windows are half-open UTC ranges. Primary-calendar
delta pages commit their changes and cursor atomically, so an interrupted round
resumes without advancing past durable data.
Stable Graph v1.0 documents `calendarView` delta only for the primary calendar.
Nocal therefore discovers all calendars but delta-syncs only the primary one;
it deliberately avoids beta and undocumented per-calendar routes. Secondary
calendars require a product decision between v1 full-window reconciliation with
explicit deletion semantics and a beta-specific delta implementation before
Outlook-equivalent coverage can be claimed. There is still no account UI/CLI
orchestration or live Microsoft credential integration, and remote writes
remain out of scope. The next phase is account setup and orchestration plus that
secondary-calendar decision.
Secondary-calendar reconciliation through stable Graph v1.0 reads the
documented per-calendar `calendarView`, finishes every page, and then atomically
replaces durable membership for that complete window. Events omitted from one
window lose cached instances only after no other complete window references
them; omission is not mislabeled as a remote deletion tombstone. This avoids
beta and undocumented delta routes, at the cost of refetching each secondary
window instead of incrementally updating it.
There is still no account UI/CLI orchestration or live Microsoft credential
integration, and remote writes remain out of scope. Account setup and
orchestration are next.

View File

@@ -177,8 +177,9 @@ Microsoft authentication is designed for a public desktop client. It uses the
system browser and authorization-code flow, a fresh PKCE verifier and `S256`
challenge, and a loopback redirect. The Entra application registration is
multitenant and selects the intended organizational and personal Microsoft
account audience. The initial read-only Graph scope is the delegated
`Calendars.Read` permission. It normally permits user consent, while tenant
account audience. The read-only Graph scopes are delegated `User.Read` for
identity and `Calendars.Read` for calendar data. They normally permit user
consent, while tenant
consent policy remains authoritative and may require administrator approval or
block consent. A desktop binary distributed as open source is not a confidential
client and must not embed or depend on a client secret.
@@ -268,20 +269,25 @@ credential-bearing response material. Requests use only HTTPS
values are opaque continuation capabilities: Nocal validates their scheme and
host but does not reconstruct or reinterpret their query parameters.
Event synchronization uses half-open UTC windows. Each page's normalized
events, deletion tombstones, and opaque next/delta cursor commit in one cache
transaction. A partial round is therefore durable and resumable, while a cursor
can never advance beyond its corresponding event changes.
Event synchronization uses half-open UTC windows. For the primary calendar,
each delta page's normalized events, deletion tombstones, and opaque cursor
commit in one cache transaction. A partial round is durable and resumable, and
the cursor cannot advance beyond its corresponding changes.
Stable Microsoft Graph v1.0 documents `calendarView` delta only for the primary
calendar. The adapter discovers all calendars but delta-syncs events only from
the primary calendar. It does not call beta or an undocumented per-calendar
delta route. Secondary calendars remain an explicit architecture decision:
either reconcile complete v1 calendar-view windows with well-defined deletion
semantics, or accept a beta-specific delta dependency. That choice must be made
and tested before Nocal claims Outlook-equivalent calendar coverage.
Secondary-calendar reconciliation through stable Microsoft Graph v1.0 uses the
documented per-calendar `calendarView` route, accumulates every page within
fixed total bounds, and publishes only a completely validated listing. Schema
version 2 tracks durable event membership by calendar and window. Replacing one
window upserts its returned events and removes instances for omitted events only
when no other complete window still references them. The event envelope remains
live and its raw provider payload is retained, because absence can mean that an
event moved outside the requested range rather than that it was deleted
remotely.
This design avoids beta and undocumented per-calendar delta routes. Its
tradeoff is bandwidth: every secondary window is fetched completely on each
refresh, while the primary calendar remains incremental.
There is no account UI or CLI orchestration and no live Microsoft credential
integration yet; tests use injected transports and credentials. Remote writes
remain out of scope. Account setup and orchestration plus the secondary-calendar
reconciliation decision are next.
remain out of scope. Account setup and orchestration are next.

View File

@@ -133,8 +133,9 @@ The Microsoft sign-in design treats Nocal as a public desktop client: sign-in
will open the system browser, use the authorization-code flow with PKCE `S256`,
and return through a loopback redirect. Its application registration must be
multitenant for the intended organizational and personal Microsoft account
audience. Initial synchronization requests only delegated `Calendars.Read`, not
write access. Users can normally consent to that delegated permission, but
audience. Initial synchronization requests delegated `User.Read` and
`Calendars.Read`, not write access. Users can normally consent to those
delegated permissions, but
their organization's tenant policy may still require administrator consent or
deny the application. Nocal never embeds a client secret in its open-source
desktop binary.
@@ -183,21 +184,21 @@ calendar a deterministic local ID. Event reads request immutable Graph IDs and
use strict bounded parsing with credential-safe failures. Requests and opaque
next/delta links are restricted to HTTPS `graph.microsoft.com` URLs.
Event windows are half-open UTC intervals. Each event page, its deletions, and
its continuation cursor cross the cache boundary in one transaction. A partial
round can resume from its durable cursor, and deletion tombstones remain
explicit instead of silently dropping remote state.
Event windows are half-open UTC intervals. Primary-calendar delta pages cross
the cache boundary with their deletions and continuation cursor in one
transaction, so a partial round resumes safely and tombstones remain explicit.
The stable Graph v1.0 `calendarView` delta API is documented only for the
primary calendar. This slice discovers all calendars but delta-syncs only the
primary; it uses neither beta nor undocumented per-calendar routes. Secondary
calendar support requires a deliberate choice between v1 full-window
reconciliation with defined deletion semantics and beta-specific delta. Nocal
does not yet claim Outlook-equivalent coverage.
Stable-v1 secondary-calendar reconciliation reads every page of a complete
per-calendar view before publishing the window. Durable per-window membership
distinguishes an event that moved outside the range from an event Graph actually
deleted: an omitted event loses its cached instance only when no other complete
window references it, while its raw event record remains live. This avoids beta
and undocumented delta routes, but a secondary window requires a complete
refetch on every refresh.
There is still no account UI/CLI orchestration or live Microsoft credential
integration, and remote writes remain out of scope. Account setup/orchestration
and the secondary-calendar reconciliation decision are the next product phase.
is the next product phase.
## Local file interchange

View File

@@ -84,11 +84,18 @@ Completed Microsoft Graph read phase:
- Half-open UTC event windows and per-page event/tombstone/cursor transactions
- Resumable primary-calendar v1 delta synchronization
Completed secondary-calendar stable-v1 reconciliation:
- Documented per-calendar `calendarView` reads with complete bounded paging
- Schema-v2 durable per-window event membership
- Atomic complete-window replacement after every page validates
- Range omission distinguished from a remote deletion tombstone
- Overlapping windows retain events until their last membership is removed
- No beta or undocumented per-calendar delta dependency
Next provider-boundary slices:
- Account setup and CLI/TUI orchestration with live credential integration
- Choose secondary-calendar behavior: v1 full-window reconciliation with
deletion semantics, or an explicit beta-specific delta dependency
- Generic `SyncProvider` integration and observable sync status
## 0.3 — CalDAV
@@ -104,12 +111,11 @@ Next provider-boundary slices:
ETag-aware remote writes after the conflict boundary is proven
- Account/calendar management UI and per-calendar ANSI identity
The Graph read phase discovers all calendars but uses stable v1 delta only for
the primary calendar; it deliberately avoids beta and undocumented
per-calendar routes. No account UI/CLI orchestration or live Microsoft
credential integration exists yet, and remote writes remain out of scope. Do
not claim Outlook-equivalent coverage until the secondary-calendar strategy is
chosen and verified.
The Graph read boundary uses stable v1 delta for the primary calendar and
complete stable-v1 calendar views for secondary calendars; it deliberately
avoids beta and undocumented per-calendar delta routes. No account UI/CLI
orchestration or live Microsoft credential integration exists yet, and remote
writes remain out of scope.
## 1.0 — distribution quality

View File

@@ -76,6 +76,16 @@ struct PullPage {
bool operator==(const PullPage&) const = default;
};
// A provider's complete view of one finite calendar window. Membership is
// replaced only after the provider listing has completed successfully.
struct CompleteWindow {
std::vector<CachedEvent> events;
std::vector<CachedEventInstance> instances;
SyncCheckpoint checkpoint;
bool operator==(const CompleteWindow&) const = default;
};
struct CacheSnapshot {
std::vector<CachedAccount> accounts;
std::vector<CachedCalendar> calendars;
@@ -88,7 +98,7 @@ struct CacheSnapshot {
class SyncCache {
public:
static constexpr int current_schema_version = 1;
static constexpr int current_schema_version = 2;
explicit SyncCache(std::filesystem::path path);
~SyncCache();
@@ -112,6 +122,12 @@ public:
// events retain their last raw payload and have no instances.
void apply_pull_page(const PullPage& page);
// Atomically replaces membership for one complete, finite provider window.
// Returned events become live and have their instances replaced. Events
// omitted from the window lose cached instances only when no other complete
// window references them; omission is not treated as a remote tombstone.
void replace_complete_window(const CompleteWindow& window);
[[nodiscard]] CacheSnapshot snapshot() const;
private:

View File

@@ -29,9 +29,9 @@ public:
};
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
// every calendar returned by /me/calendars. Stable v1.0 event delta is exposed
// only for the signed-in user's primary calendar; this class deliberately does
// not call beta or undocumented per-calendar delta routes.
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for
// the primary calendar and complete calendarView reconciliation for secondary
// calendars; this class does not call beta or undocumented delta routes.
class MicrosoftGraphSync {
public:
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
@@ -51,6 +51,12 @@ public:
void sync_primary_calendar_window(const MicrosoftGraphCredentials& credentials,
const MicrosoftGraphWindow& window);
// Reads every page of the documented Graph v1.0 calendarView for one
// active, non-primary calendar. The cache window is replaced only after the
// listing completes, so a failed or malformed page leaves it unchanged.
void sync_secondary_calendar_window(const MicrosoftGraphCredentials& credentials,
std::string calendar_id, const MicrosoftGraphWindow& window);
private:
HttpTransport& transport_;
storage::SyncCache& cache_;

View File

@@ -326,6 +326,22 @@ CREATE TABLE conflicts (
) STRICT;
)sql";
constexpr const char* schema_v2 = R"sql(
CREATE UNIQUE INDEX events_id_calendar_id_unique_idx ON events(id, calendar_id);
CREATE TABLE event_window_membership (
calendar_id TEXT NOT NULL,
window_start TEXT NOT NULL CHECK(length(window_start) > 0),
window_end TEXT NOT NULL CHECK(length(window_end) > 0),
event_id TEXT NOT NULL CHECK(length(event_id) > 0),
PRIMARY KEY(calendar_id, window_start, window_end, event_id),
FOREIGN KEY(calendar_id) REFERENCES calendars(id)
ON UPDATE RESTRICT ON DELETE CASCADE,
FOREIGN KEY(event_id, calendar_id) REFERENCES events(id, calendar_id)
ON UPDATE RESTRICT ON DELETE CASCADE
) STRICT;
CREATE INDEX event_window_membership_event_id_idx ON event_window_membership(event_id);
)sql";
void validate_nonempty(std::string_view value, std::string_view field) {
if (value.empty()) {
throw std::invalid_argument(std::string(field) + " must not be empty");
@@ -430,13 +446,20 @@ struct SyncCache::Impl {
}
execute(database, "PRAGMA foreign_keys = ON", "enabling foreign keys");
require_pragma_integer(database, "PRAGMA foreign_keys", 1, "enabling foreign keys");
if (version == 0) {
if (version < SyncCache::current_schema_version) {
Transaction migration(database);
const int locked_version = user_version(database);
int locked_version = user_version(database);
if (locked_version == 0) {
execute(database, schema_v1, "creating schema version 1");
execute(database, "PRAGMA user_version = 1", "recording schema version 1");
} else if (locked_version > SyncCache::current_schema_version) {
locked_version = 1;
}
if (locked_version == 1) {
execute(database, schema_v2, "creating schema version 2");
execute(database, "PRAGMA user_version = 2", "recording schema version 2");
locked_version = 2;
}
if (locked_version > SyncCache::current_schema_version) {
throw std::runtime_error("sync cache schema changed to an unsupported version");
}
migration.commit();
@@ -675,6 +698,178 @@ ON CONFLICT(calendar_id, window_start, window_end) DO UPDATE SET
transaction.commit();
}
void SyncCache::replace_complete_window(const CompleteWindow& window) {
const SyncCheckpoint& checkpoint = window.checkpoint;
validate_nonempty(checkpoint.calendar_id, "checkpoint calendar id");
validate_nonempty(checkpoint.window_start, "checkpoint window start");
validate_nonempty(checkpoint.window_end, "checkpoint window end");
validate_nonempty(checkpoint.cursor, "checkpoint cursor");
if (!checkpoint.complete) {
throw std::invalid_argument("complete window checkpoint must be complete");
}
std::unordered_set<std::string> event_ids;
std::unordered_set<std::string> event_remote_ids;
for (const CachedEvent& event : window.events) {
validate_nonempty(event.id, "event id");
validate_nonempty(event.remote_id, "event remote id");
validate_nonempty(event.raw_payload, "event raw payload");
if (event.calendar_id != checkpoint.calendar_id) {
throw std::invalid_argument("complete-window event belongs to a different calendar");
}
if (event.deleted) {
throw std::invalid_argument("complete window cannot contain a deleted event");
}
if (!event_ids.insert(event.id).second
|| !event_remote_ids.insert(event.remote_id).second) {
throw std::invalid_argument("complete window contains duplicate event identities");
}
}
std::unordered_set<std::string> instance_ids;
for (const CachedEventInstance& instance : window.instances) {
validate_nonempty(instance.id, "event instance id");
validate_nonempty(instance.event_id, "event instance event id");
if (instance.start_epoch_microseconds > instance.end_epoch_microseconds
|| (instance.all_day
&& instance.start_epoch_microseconds == instance.end_epoch_microseconds)) {
throw std::invalid_argument("event instance must have a valid half-open interval");
}
if (!instance_ids.insert(instance.id).second) {
throw std::invalid_argument("complete window contains duplicate event instance ids");
}
if (!event_ids.contains(instance.event_id)) {
throw std::invalid_argument(
"event instance must refer to an event returned in the complete window");
}
}
std::scoped_lock lock(impl_->mutex);
Transaction transaction(impl_->database);
require_active_calendar(impl_->database, checkpoint.calendar_id);
for (const CachedEvent& event : window.events) {
ensure_event_identity(impl_->database, event);
}
std::vector<std::string> previous_members;
{
Statement query(impl_->database, R"sql(
SELECT event_id FROM event_window_membership
WHERE calendar_id = ? AND window_start = ? AND window_end = ?
ORDER BY event_id
)sql", "reading prior complete-window membership");
query.bind_text(1, checkpoint.calendar_id);
query.bind_text(2, checkpoint.window_start);
query.bind_text(3, checkpoint.window_end);
while (query.next()) {
previous_members.push_back(query.text_column(0));
}
}
for (const CachedEvent& event : window.events) {
Statement upsert(impl_->database, R"sql(
INSERT INTO events(
id, calendar_id, remote_id, uid, etag, change_key, raw_payload, deleted)
VALUES(?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(id) DO UPDATE SET
uid = excluded.uid,
etag = excluded.etag,
change_key = excluded.change_key,
raw_payload = excluded.raw_payload,
deleted = 0
)sql", "upserting complete-window event");
upsert.bind_text(1, event.id);
upsert.bind_text(2, event.calendar_id);
upsert.bind_text(3, event.remote_id);
upsert.bind_text(4, event.uid);
upsert.bind_text(5, event.etag);
upsert.bind_text(6, event.change_key);
upsert.bind_text(7, event.raw_payload);
upsert.run();
}
for (const CachedEvent& event : window.events) {
Statement remove(impl_->database,
"DELETE FROM event_instances WHERE event_id = ?",
"replacing complete-window event instances");
remove.bind_text(1, event.id);
remove.run();
}
for (const CachedEventInstance& instance : window.instances) {
Statement insert(impl_->database, R"sql(
INSERT INTO event_instances(
id, event_id, start_epoch_microseconds, end_epoch_microseconds, all_day,
title, location, description, time_zone)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
)sql", "inserting complete-window event instance");
insert.bind_text(1, instance.id);
insert.bind_text(2, instance.event_id);
insert.bind_int64(3, instance.start_epoch_microseconds);
insert.bind_int64(4, instance.end_epoch_microseconds);
insert.bind_bool(5, instance.all_day);
insert.bind_text(6, instance.title);
insert.bind_text(7, instance.location);
insert.bind_text(8, instance.description);
insert.bind_text(9, instance.time_zone);
insert.run();
}
{
Statement remove(impl_->database, R"sql(
DELETE FROM event_window_membership
WHERE calendar_id = ? AND window_start = ? AND window_end = ?
)sql", "replacing complete-window membership");
remove.bind_text(1, checkpoint.calendar_id);
remove.bind_text(2, checkpoint.window_start);
remove.bind_text(3, checkpoint.window_end);
remove.run();
}
for (const CachedEvent& event : window.events) {
Statement insert(impl_->database, R"sql(
INSERT INTO event_window_membership(calendar_id, window_start, window_end, event_id)
VALUES(?, ?, ?, ?)
)sql", "inserting complete-window membership");
insert.bind_text(1, checkpoint.calendar_id);
insert.bind_text(2, checkpoint.window_start);
insert.bind_text(3, checkpoint.window_end);
insert.bind_text(4, event.id);
insert.run();
}
for (const std::string& previous_event_id : previous_members) {
if (event_ids.contains(previous_event_id)) {
continue;
}
bool retained = false;
{
Statement remaining(impl_->database,
"SELECT 1 FROM event_window_membership WHERE event_id = ? LIMIT 1",
"checking remaining complete-window membership");
remaining.bind_text(1, previous_event_id);
retained = remaining.next();
}
if (!retained) {
Statement remove(impl_->database,
"DELETE FROM event_instances WHERE event_id = ?",
"evicting omitted complete-window instances");
remove.bind_text(1, previous_event_id);
remove.run();
}
}
{
Statement upsert(impl_->database, R"sql(
INSERT INTO sync_state(calendar_id, window_start, window_end, cursor, complete)
VALUES(?, ?, ?, ?, 1)
ON CONFLICT(calendar_id, window_start, window_end) DO UPDATE SET
cursor = excluded.cursor,
complete = 1
)sql", "recording complete-window checkpoint");
upsert.bind_text(1, checkpoint.calendar_id);
upsert.bind_text(2, checkpoint.window_start);
upsert.bind_text(3, checkpoint.window_end);
upsert.bind_text(4, checkpoint.cursor);
upsert.run();
}
transaction.commit();
}
CacheSnapshot SyncCache::snapshot() const {
std::scoped_lock lock(impl_->mutex);
CacheSnapshot result;

View File

@@ -26,6 +26,7 @@ using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::CachedEvent;
using nocal::storage::CachedEventInstance;
using nocal::storage::CompleteWindow;
using nocal::storage::PullPage;
using nocal::storage::SyncCheckpoint;
@@ -34,6 +35,8 @@ constexpr std::size_t maximum_continuation_bytes = 16U * 1024U;
constexpr std::size_t maximum_pages = 10'000U;
constexpr std::size_t maximum_values_per_page = 10'000U;
constexpr std::size_t maximum_accumulated_calendars = 100'000U;
constexpr std::size_t maximum_complete_window_bytes = 64U * 1024U * 1024U;
constexpr std::size_t maximum_complete_window_events = 100'000U;
constexpr std::size_t maximum_mapped_string_bytes = 1024U * 1024U;
constexpr std::string_view graph_v1 = "https://graph.microsoft.com/v1.0";
@@ -192,6 +195,32 @@ void validate_continuation(std::string_view url, ContinuationRoute route) {
return value;
}
void validate_secondary_continuation(
std::string_view url, std::string_view calendar_view_route) {
if (url.empty() || url.size() > maximum_continuation_bytes
|| !url.starts_with(calendar_view_route) || url.size() <= calendar_view_route.size()
|| url[calendar_view_route.size()] != '?' || url.find('#') != std::string_view::npos
|| std::any_of(url.begin(), url.end(), [](unsigned char character) {
return character <= 0x20U || character == 0x7fU;
})) {
fail();
}
}
[[nodiscard]] std::string secondary_next_link(
const nlohmann::json& object, std::string_view calendar_view_route) {
const auto found = object.find("@odata.nextLink");
if (found == object.end()) {
return {};
}
if (!found->is_string()) {
fail();
}
const std::string value = found->get<std::string>();
validate_secondary_continuation(value, calendar_view_route);
return value;
}
[[nodiscard]] std::string sha256_hex(std::string_view input) {
std::array<unsigned char, 32> digest{};
unsigned int size = 0;
@@ -413,6 +442,23 @@ void validate_continuation(std::string_view url, ContinuationRoute route) {
return primary.front();
}
[[nodiscard]] CachedCalendar secondary_calendar(
const nocal::storage::CacheSnapshot& snapshot, std::string_view account_id,
std::string_view local_calendar_id) {
if (local_calendar_id.empty()
|| std::any_of(local_calendar_id.begin(), local_calendar_id.end(),
[](unsigned char character) { return character <= 0x20U || character == 0x7fU; })) {
fail();
}
const auto found = std::find_if(snapshot.calendars.begin(), snapshot.calendars.end(),
[&](const CachedCalendar& calendar) { return calendar.id == local_calendar_id; });
if (found == snapshot.calendars.end() || found->account_id != account_id || !found->active
|| raw_calendar_is_primary(found->raw_payload)) {
fail();
}
return *found;
}
[[nodiscard]] SyncCheckpoint checkpoint_for(const nocal::storage::CacheSnapshot& snapshot,
std::string_view calendar, std::string_view window_start, std::string_view window_end) {
for (const SyncCheckpoint& checkpoint : snapshot.checkpoints) {
@@ -615,4 +661,66 @@ void MicrosoftGraphSync::sync_primary_calendar_window(
}
}
void MicrosoftGraphSync::sync_secondary_calendar_window(const MicrosoftGraphCredentials& credentials,
std::string local_calendar_id, const MicrosoftGraphWindow& window) {
try {
validate_credentials(credentials);
if (window.end_epoch_microseconds <= window.start_epoch_microseconds) {
fail();
}
const std::string window_start = canonical_utc(window.start_epoch_microseconds);
const std::string window_end = canonical_utc(window.end_epoch_microseconds);
const CachedCalendar calendar =
secondary_calendar(cache_.snapshot(), credentials.account_id, local_calendar_id);
const std::string calendar_view_route = std::string(graph_v1) + "/me/calendars/"
+ percent_encode(calendar.remote_id) + "/calendarView";
std::string url = calendar_view_route + "?startDateTime=" + percent_encode(window_start)
+ "&endDateTime=" + percent_encode(window_end) + "&$top=1000";
CompleteWindow complete;
std::unordered_set<std::string> remote_ids;
std::unordered_set<std::string> local_ids;
std::unordered_set<std::string> cursors;
std::size_t accumulated_bytes = 0;
for (std::size_t page = 0; page < maximum_pages; ++page) {
if (!cursors.insert(url).second) {
fail();
}
const HttpResponse response = send_graph(transport_, url, credentials);
if (response.body.size() > maximum_complete_window_bytes - accumulated_bytes) {
fail();
}
accumulated_bytes += response.body.size();
const nlohmann::json object = parse_json_object(response.body);
const nlohmann::json& values = required_array(object, "value");
for (const nlohmann::json& value : values) {
CachedEvent event = parse_delta_event(value, calendar.id, complete.instances);
if (event.deleted || !remote_ids.insert(event.remote_id).second
|| !local_ids.insert(event.id).second
|| complete.events.size() >= maximum_complete_window_events) {
fail();
}
complete.events.push_back(std::move(event));
}
if (object.contains("@odata.deltaLink")) {
fail();
}
const std::string next = secondary_next_link(object, calendar_view_route);
if (next.empty()) {
complete.checkpoint = {calendar.id, window_start, window_end,
"graph-calendar-view-v1", true};
cache_.replace_complete_window(complete);
return;
}
if (cursors.contains(next)) {
fail();
}
url = next;
}
fail();
} catch (...) {
throw MicrosoftGraphError("Microsoft Graph secondary calendar synchronization failed");
}
}
} // namespace nocal::sync

View File

@@ -27,8 +27,10 @@ namespace {
using namespace std::chrono;
using nocal::storage::CacheSnapshot;
using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::CachedEvent;
using nocal::storage::CachedEventInstance;
using nocal::storage::PullPage;
using nocal::storage::SyncCache;
using nocal::storage::SyncCheckpoint;
@@ -43,8 +45,10 @@ using nocal::sync::MicrosoftGraphWindow;
constexpr std::string_view graph_prefix = "https://graph.microsoft.com/";
constexpr std::string_view primary_remote_id = "remote-primary-calendar";
constexpr std::string_view secondary_remote_id = "remote-secondary/id +?%";
constexpr std::string_view primary_local_prefix = "graph-calendar-";
constexpr std::string_view event_local_prefix = "graph-event-";
constexpr std::string_view instance_local_prefix = "graph-instance-";
void require(bool condition, std::string_view message) {
if (!condition) {
@@ -183,12 +187,31 @@ struct Rig {
return page.dump();
}
[[nodiscard]] std::string view_page(const nlohmann::json& events,
std::optional<std::string> next = std::nullopt,
std::optional<std::string> delta = std::nullopt) {
nlohmann::json page{{"value", events}};
if (next) {
page["@odata.nextLink"] = *next;
}
if (delta) {
page["@odata.deltaLink"] = *delta;
}
return page.dump();
}
[[nodiscard]] MicrosoftGraphWindow july_window() {
const auto start = sys_days{year{2026} / July / 18};
return {duration_cast<microseconds>(start.time_since_epoch()).count(),
duration_cast<microseconds>((start + days{1}).time_since_epoch()).count()};
}
[[nodiscard]] MicrosoftGraphWindow window_from_days(
year_month_day start, year_month_day end) {
return {duration_cast<microseconds>(sys_days{start}.time_since_epoch()).count(),
duration_cast<microseconds>(sys_days{end}.time_since_epoch()).count()};
}
[[nodiscard]] const HttpHeader* header(const HttpRequest& request, std::string_view name) {
const auto found = std::ranges::find_if(request.headers, [&](const HttpHeader& value) {
if (value.name.size() != name.size()) {
@@ -273,6 +296,70 @@ void seed_calendars(Rig& rig, bool include_other = true) {
return std::string{event_local_prefix} + sha256_hex(input);
}
[[nodiscard]] std::string local_instance_id(std::string_view event_id) {
std::string input{"instance", 8};
input.push_back('\0');
input += event_id;
return std::string{instance_local_prefix} + sha256_hex(input);
}
[[nodiscard]] std::string percent_encode(std::string_view value) {
constexpr char hexadecimal[] = "0123456789ABCDEF";
std::string encoded;
for (const unsigned char character : value) {
const bool unreserved = (character >= 'A' && character <= 'Z')
|| (character >= 'a' && character <= 'z')
|| (character >= '0' && character <= '9') || character == '-'
|| character == '.' || character == '_' || character == '~';
if (unreserved) {
encoded.push_back(static_cast<char>(character));
} else {
encoded.push_back('%');
encoded.push_back(hexadecimal[character >> 4U]);
encoded.push_back(hexadecimal[character & 0x0fU]);
}
}
return encoded;
}
[[nodiscard]] std::string secondary_view_prefix(std::string_view remote) {
return "https://graph.microsoft.com/v1.0/me/calendars/" + percent_encode(remote)
+ "/calendarView";
}
[[nodiscard]] std::string secondary_next(
std::string_view remote, std::string_view marker = "page-2") {
return secondary_view_prefix(remote) + "?$skiptoken=" + std::string{marker};
}
[[nodiscard]] const CachedCalendar& calendar_with_remote(
const CacheSnapshot& snapshot, std::string_view remote) {
const auto found = std::ranges::find_if(snapshot.calendars,
[&](const CachedCalendar& calendar) { return calendar.remote_id == remote; });
if (found == snapshot.calendars.end()) {
throw std::runtime_error("calendar fixture was not cached");
}
return *found;
}
[[nodiscard]] std::string seed_secondary_calendar(
Rig& rig, std::string_view remote = secondary_remote_id) {
seed_account(rig);
const nlohmann::json values = nlohmann::json::array(
{calendar_json(primary_remote_id, "Primary", true),
calendar_json(remote, "Secondary", false, false)});
rig.transport.respond(calendar_page(values));
rig.graph.refresh_calendars(rig.credentials);
return calendar_with_remote(rig.cache.snapshot(), remote).id;
}
void sync_secondary_page(Rig& rig, std::string_view calendar_id,
const nlohmann::json& events, const MicrosoftGraphWindow& window = july_window()) {
rig.transport.respond(view_page(events));
rig.graph.sync_secondary_calendar_window(
rig.credentials, std::string{calendar_id}, window);
}
void test_credentials_and_request_security(const std::filesystem::path& root) {
const std::vector<MicrosoftGraphCredentials> invalid{
{"", "token"}, {"bad\naccount", "token"}, {"SENSITIVE_ACCOUNT_ID_123", ""},
@@ -670,6 +757,356 @@ void test_cache_failure_does_not_advance_cursor(const std::filesystem::path& roo
"cache failure advanced Graph cursor or partially replaced event state");
}
void test_secondary_exact_route_and_preconditions(const std::filesystem::path& root) {
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const std::size_t request_index = rig.transport.requests.size();
sync_secondary_page(rig, secondary_id, nlohmann::json::array());
require(rig.transport.requests.size() == request_index + 1,
"secondary full-window sync sent an unexpected request count");
const HttpRequest& request = rig.transport.requests[request_index];
require_safe_request(request, rig.credentials.access_token);
const std::string expected = secondary_view_prefix(secondary_remote_id)
+ "?startDateTime=2026-07-18T00%3A00%3A00.000000Z&"
"endDateTime=2026-07-19T00%3A00%3A00.000000Z&$top=1000";
require(request.url == expected,
"secondary sync did not use the exact encoded Graph v1 calendarView route");
require(request.url.find("/beta/") == std::string::npos
&& request.url.find("/delta") == std::string::npos,
"secondary sync used beta or a delta route");
const HttpHeader* prefer = header(request, "Prefer");
require(prefer != nullptr && prefer->value.find("UTC") != std::string::npos
&& prefer->value.find("ImmutableId") != std::string::npos,
"secondary sync omitted the UTC/immutable-ID preference");
}
auto require_no_request = [](Rig& rig, std::string calendar_id,
std::string_view message) {
const std::size_t before_requests = rig.transport.requests.size();
const CacheSnapshot before = rig.cache.snapshot();
const std::array<std::string_view, 2> sensitive{
rig.credentials.access_token, calendar_id};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, calendar_id, july_window());
},
message, sensitive);
require(rig.transport.requests.size() == before_requests,
std::string{message}.append(": precondition reached the transport"));
require(rig.cache.snapshot() == before,
std::string{message}.append(": precondition mutated cache"));
};
{
Rig rig{root};
static_cast<void>(seed_secondary_calendar(rig));
const std::string primary_id =
calendar_with_remote(rig.cache.snapshot(), primary_remote_id).id;
require_no_request(rig, primary_id, "primary calendar accepted as secondary");
require_no_request(rig, "missing-calendar", "missing secondary calendar accepted");
}
{
Rig rig{root};
static_cast<void>(seed_secondary_calendar(rig));
const CachedAccount other{
"other-account", "microsoft-graph", "other-subject", "Other"};
rig.cache.upsert_account(other);
const std::string other_id = local_calendar_id(other.id, "other-remote");
const CachedCalendar other_calendar{other_id, other.id, "other-remote", "Other", "",
true, true, calendar_json("other-remote", "Other", false, false).dump()};
rig.cache.replace_calendars_after_complete_listing(other.id, {&other_calendar, 1});
require_no_request(rig, other_id, "wrong-account secondary calendar accepted");
}
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
rig.transport.respond(calendar_page(
nlohmann::json::array({calendar_json(primary_remote_id, "Primary", true)})));
rig.graph.refresh_calendars(rig.credentials);
require_no_request(rig, secondary_id, "inactive secondary calendar accepted");
}
{
Rig rig{root};
seed_account(rig);
const std::string malformed_id =
local_calendar_id(rig.credentials.account_id, "malformed-remote");
const CachedCalendar malformed{malformed_id, rig.credentials.account_id,
"malformed-remote", "Malformed", "", true, true, "{"};
rig.cache.replace_calendars_after_complete_listing(
rig.credentials.account_id, {&malformed, 1});
require_no_request(rig, malformed_id, "malformed cached calendar accepted");
}
}
void test_secondary_partial_pages_are_atomic_and_redacted(
const std::filesystem::path& root) {
for (int failure = 0; failure < 3; ++failure) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("baseline-secondary")}));
const CacheSnapshot baseline = rig.cache.snapshot();
const std::string next = secondary_next(secondary_remote_id);
const std::string partial_remote = "SENSITIVE_PARTIAL_REMOTE_ID";
const std::string first_body =
view_page(nlohmann::json::array({live_event(partial_remote)}), next);
rig.transport.respond(first_body);
const std::string later_body = "SENSITIVE_LATER_PAGE_BODY";
if (failure == 0) {
rig.transport.fail("transport leaked " + rig.credentials.access_token + " "
+ std::string{secondary_remote_id} + " " + next);
} else if (failure == 1) {
rig.transport.respond(later_body, 503);
} else {
rig.transport.respond("{\"value\":[");
}
const std::size_t requests_before = rig.transport.requests.size();
const std::string encoded_remote = percent_encode(secondary_remote_id);
const std::array<std::string_view, 7> sensitive{rig.credentials.access_token,
secondary_remote_id, encoded_remote, partial_remote, first_body, later_body, next};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"partial secondary listing failure was accepted", sensitive);
require(rig.transport.requests.size() == requests_before + 2,
"partial secondary listing failure sent the wrong request count");
require(rig.cache.snapshot() == baseline,
"partial secondary listing changed event, membership, or checkpoint state");
}
}
void test_secondary_continuation_security(const std::filesystem::path& root) {
const std::string exact_prefix = secondary_view_prefix(secondary_remote_id);
const std::vector<std::string> unsafe{
"http://graph.microsoft.com/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
"https://evil.example.test/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
"https://user@graph.microsoft.com/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
"https://graph.microsoft.com:443/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
exact_prefix + "?$skiptoken=x#fragment",
exact_prefix + "?$skiptoken=x\nInjected: yes",
secondary_next("cross-calendar", "cross"),
exact_prefix + "/subpath?$skiptoken=x",
exact_prefix + "/delta?$skiptoken=x",
exact_prefix + "?$skiptoken=" + std::string(16U * 1024U, 'x')};
for (const std::string& next : unsafe) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const CacheSnapshot baseline = rig.cache.snapshot();
const std::string body = view_page(
nlohmann::json::array({live_event("SENSITIVE_UNCOMMITTED_EVENT")}), next);
rig.transport.respond(body);
const std::size_t before = rig.transport.requests.size();
const std::array<std::string_view, 5> sensitive{rig.credentials.access_token,
secondary_remote_id, "SENSITIVE_UNCOMMITTED_EVENT", next, body};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"unsafe secondary continuation was accepted", sensitive);
require(rig.transport.requests.size() == before + 1,
"unsafe secondary continuation reached a credentialed follow-up");
require(rig.cache.snapshot() == baseline,
"unsafe secondary continuation changed cache state");
}
Rig repeated{root};
const std::string secondary_id = seed_secondary_calendar(repeated);
const CacheSnapshot baseline = repeated.cache.snapshot();
const std::string next = secondary_next(secondary_remote_id, "repeated");
repeated.transport.respond(view_page(nlohmann::json::array(), next));
repeated.transport.respond(view_page(nlohmann::json::array(), next));
const std::size_t before = repeated.transport.requests.size();
const std::array<std::string_view, 3> sensitive{
repeated.credentials.access_token, secondary_remote_id, next};
require_throws_redacted(
[&] {
repeated.graph.sync_secondary_calendar_window(
repeated.credentials, secondary_id, july_window());
},
"repeated secondary continuation was accepted", sensitive);
require(repeated.transport.requests.size() == before + 2,
"repeated secondary continuation triggered another credentialed follow-up");
require(repeated.cache.snapshot() == baseline,
"repeated secondary continuation committed a partial listing");
}
void test_secondary_page_shape_is_strict_and_atomic(const std::filesystem::path& root) {
const std::string next = secondary_next(secondary_remote_id, "valid-next");
const std::string other = secondary_next(secondary_remote_id, "other-link");
const std::vector<std::string> invalid{
view_page(nlohmann::json::array(), std::nullopt, other),
view_page(nlohmann::json::array(), next, other),
R"({"value":[],"value":[]})",
R"({"value":{}})",
view_page(nlohmann::json::array(
{live_event("duplicate-secondary"), live_event("duplicate-secondary")})),
view_page(nlohmann::json::array(
{{{"id", "tombstone-secondary"}, {"@removed", {{"reason", "deleted"}}}}})),
};
for (const std::string& body : invalid) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("baseline-shape")}));
const CacheSnapshot baseline = rig.cache.snapshot();
rig.transport.respond(body);
const std::array<std::string_view, 4> sensitive{
rig.credentials.access_token, secondary_remote_id, body, "tombstone-secondary"};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"invalid complete-view page shape was accepted", sensitive);
require(rig.cache.snapshot() == baseline,
"invalid complete-view page changed event, membership, or checkpoint state");
}
}
void test_secondary_omission_and_overlapping_membership(const std::filesystem::path& root) {
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("omitted-secondary")}));
const CacheSnapshot present = rig.cache.snapshot();
require(present.events.size() == 1 && present.instances.size() == 1,
"secondary complete view did not cache its event");
sync_secondary_page(rig, secondary_id, nlohmann::json::array());
const CacheSnapshot omitted = rig.cache.snapshot();
require(omitted.events.size() == 1 && !omitted.events[0].deleted
&& omitted.instances.empty(),
"completed omission tombstoned the event or retained an orphan instance");
}
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const MicrosoftGraphWindow first =
window_from_days(year{2026} / July / 18, year{2026} / July / 21);
const MicrosoftGraphWindow second =
window_from_days(year{2026} / July / 19, year{2026} / July / 22);
const nlohmann::json shared = live_event("shared-secondary",
"2026-07-19T09:00:00.0000000", "2026-07-19T10:00:00.0000000");
sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), first);
sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), second);
require(rig.cache.snapshot().instances.size() == 1,
"overlapping windows duplicated a shared event instance");
sync_secondary_page(rig, secondary_id, nlohmann::json::array(), first);
require(rig.cache.snapshot().instances.size() == 1,
"omission from one window evicted an instance retained by another window");
sync_secondary_page(rig, secondary_id, nlohmann::json::array(), second);
const CacheSnapshot removed = rig.cache.snapshot();
require(removed.instances.empty() && removed.events.size() == 1
&& !removed.events[0].deleted,
"last membership removal did not evict the instance without tombstoning");
}
}
void test_secondary_cache_collisions_roll_back(const std::filesystem::path& root) {
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const std::string target_remote = "target-secondary-identity";
const std::string target_id = local_event_id(secondary_id, target_remote);
const CachedEvent poison{target_id, secondary_id, "different-secondary-remote", "", "",
"", R"({"poison":"identity"})", false};
rig.cache.apply_pull_page(PullPage{{poison}, {},
SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z",
"2026-07-19T00:00:00.000000Z", "identity-baseline", false}});
const CacheSnapshot baseline = rig.cache.snapshot();
const std::string body =
view_page(nlohmann::json::array({live_event(target_remote)}));
rig.transport.respond(body);
const std::array<std::string_view, 4> sensitive{
rig.credentials.access_token, secondary_remote_id, target_remote, body};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"secondary event identity collision was accepted", sensitive);
require(rig.cache.snapshot() == baseline,
"secondary identity collision changed snapshot or checkpoint");
}
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const std::string target_remote = "target-secondary-instance";
const std::string target_event = local_event_id(secondary_id, target_remote);
const std::string poison_event = local_event_id(secondary_id, "poison-owner");
const CachedEvent poison{poison_event, secondary_id, "poison-owner", "", "", "",
R"({"poison":"instance"})", false};
const auto start = sys_days{year{2026} / July / 18} + 7h;
const CachedEventInstance colliding{local_instance_id(target_event), poison_event,
duration_cast<microseconds>(start.time_since_epoch()).count(),
duration_cast<microseconds>((start + 1h).time_since_epoch()).count(), false,
"Poison", "", "", "UTC"};
rig.cache.apply_pull_page(PullPage{{poison}, {colliding},
SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z",
"2026-07-19T00:00:00.000000Z", "instance-baseline", false}});
const CacheSnapshot baseline = rig.cache.snapshot();
rig.transport.respond(
view_page(nlohmann::json::array({live_event(target_remote)})));
const std::array<std::string_view, 3> sensitive{
rig.credentials.access_token, secondary_remote_id, target_remote};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"secondary instance identity collision was accepted", sensitive);
require(rig.cache.snapshot() == baseline,
"secondary instance collision changed snapshot or checkpoint");
}
}
void test_primary_delta_after_secondary_full_sync(const std::filesystem::path& root) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("secondary-before-primary")}));
const std::string delta =
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=after-secondary";
rig.transport.respond(delta_page(
nlohmann::json::array({live_event("primary-after-secondary")}), std::nullopt, delta));
const std::size_t request_index = rig.transport.requests.size();
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
require(rig.transport.requests[request_index].url.find("/v1.0/me/calendarView/delta?")
!= std::string::npos,
"primary delta did not use its documented route after secondary sync");
require(rig.transport.requests[request_index].url.find("/calendars/") == std::string::npos,
"primary delta reused the secondary calendarView route");
const CacheSnapshot snapshot = rig.cache.snapshot();
require(snapshot.events.size() == 2 && snapshot.instances.size() == 2,
"primary delta after secondary sync lost one provider view");
require(std::ranges::any_of(snapshot.checkpoints,
[&](const SyncCheckpoint& checkpoint) {
return checkpoint.calendar_id
== calendar_with_remote(snapshot, primary_remote_id).id
&& checkpoint.cursor == delta && checkpoint.complete;
}),
"primary delta did not commit its checkpoint after secondary sync");
}
} // namespace
int main() {
@@ -687,6 +1124,13 @@ int main() {
test_resume_after_later_failure(temporary.path());
test_tombstone_preserves_payload(temporary.path());
test_cache_failure_does_not_advance_cursor(temporary.path());
test_secondary_exact_route_and_preconditions(temporary.path());
test_secondary_partial_pages_are_atomic_and_redacted(temporary.path());
test_secondary_continuation_security(temporary.path());
test_secondary_page_shape_is_strict_and_atomic(temporary.path());
test_secondary_omission_and_overlapping_membership(temporary.path());
test_secondary_cache_collisions_roll_back(temporary.path());
test_primary_delta_after_secondary_full_sync(temporary.path());
} catch (const std::exception& error) {
std::cerr << "Microsoft Graph security test failure: " << error.what() << '\n';
return 1;

View File

@@ -2,6 +2,7 @@
#include <openssl/evp.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
@@ -23,6 +24,10 @@ namespace {
using namespace std::chrono;
using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::CachedEvent;
using nocal::storage::CachedEventInstance;
using nocal::storage::CompleteWindow;
using nocal::storage::SyncCheckpoint;
using nocal::storage::SyncCache;
using nocal::sync::HttpMethod;
using nocal::sync::HttpRequest;
@@ -170,6 +175,52 @@ void seed_primary(SyncCache& cache, std::string remote = "primary-remote") {
cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1});
}
[[nodiscard]] std::string seed_secondary(
SyncCache& cache, std::string remote = "Secondary /Case?x") {
seed_account(cache);
const std::string local_id = calendar_id(remote);
const CachedCalendar calendar{local_id, "account-local", std::move(remote), "Secondary",
"#445566", false, true, "{\"isDefaultCalendar\":false}"};
cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1});
return local_id;
}
[[nodiscard]] std::string event_object(std::string_view id, std::string_view subject,
std::string_view start = "2026-07-18T09:00:00.1234567Z",
std::string_view end = "2026-07-18T10:00:00.1234567Z") {
return "{\"id\":\"" + std::string(id)
+ "\",\"iCalUId\":\"series-uid\",\"@odata.etag\":\"etag\","
"\"changeKey\":\"change\",\"subject\":\""
+ std::string(subject)
+ "\",\"location\":{\"displayName\":\"Room\"},"
"\"bodyPreview\":\"Preview\",\"isAllDay\":false,\"type\":\"occurrence\","
"\"start\":{\"dateTime\":\""
+ std::string(start)
+ "\",\"timeZone\":\"Ignored\"},\"end\":{\"dateTime\":\""
+ std::string(end) + "\",\"timeZone\":\"Ignored\"}}";
}
[[nodiscard]] std::string view_page(
std::string events, std::optional<std::string> next = std::nullopt) {
std::string body = "{\"value\":[" + std::move(events) + "]";
if (next) {
body += ",\"@odata.nextLink\":\"" + *next + "\"";
}
return body + "}";
}
[[nodiscard]] MicrosoftGraphWindow july_window() {
return {epoch_microseconds(2026y / July / 1), epoch_microseconds(2026y / August / 1)};
}
[[nodiscard]] const CachedEvent& event_by_remote(
const nocal::storage::CacheSnapshot& snapshot, std::string_view remote) {
const auto found = std::find_if(snapshot.events.begin(), snapshot.events.end(),
[&](const CachedEvent& event) { return event.remote_id == remote; });
require(found != snapshot.events.end(), "expected cached event is missing");
return *found;
}
void test_profile_mapping_headers_and_atomic_validation(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("profile.db"));
ScriptedTransport transport;
@@ -433,6 +484,340 @@ void test_invalid_inputs_continuations_and_timestamps(const TemporaryDirectory&
"invalid timestamp leaked or committed event data");
}
void test_secondary_route_pagination_and_occurrence_mapping(
const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-route.db"));
const std::string local_calendar = seed_secondary(cache);
const std::string route = "https://graph.microsoft.com/v1.0/me/calendars/"
"Secondary%20%2FCase%3Fx/calendarView";
const std::string next = route + "?$skiptoken=page-two";
ScriptedTransport transport;
transport.steps = {{json_response(view_page(event_object("occurrence-one", "First"), next))},
{json_response(view_page(event_object("occurrence-two", "Second",
"2026-07-19T11:00:00Z", "2026-07-19T12:00:00Z")))}};
MicrosoftGraphSync graph(transport, cache);
graph.sync_secondary_calendar_window(credentials(), local_calendar, july_window());
require(transport.requests.size() == 2
&& transport.requests[0].url
== route
+ "?startDateTime=2026-07-01T00%3A00%3A00.000000Z&"
"endDateTime=2026-08-01T00%3A00%3A00.000000Z&$top=1000"
&& transport.requests[1].url == next,
"secondary sync used the wrong encoded calendarView route or pagination URL");
for (const HttpRequest& request : transport.requests) {
require_graph_headers(request);
require(request.url.find("/beta/") == std::string::npos
&& request.url.find("/delta") == std::string::npos,
"secondary sync used beta or a delta route");
}
const auto snapshot = cache.snapshot();
require(snapshot.events.size() == 2 && snapshot.instances.size() == 2,
"two-page secondary view was not committed completely");
const CachedEvent& first = event_by_remote(snapshot, "occurrence-one");
require(first.id
== expected_id("graph-event-", "event", local_calendar, "occurrence-one")
&& first.uid == "series-uid" && !first.deleted,
"secondary recurrence occurrence mapping or stable ID is wrong");
const auto first_instance = std::find_if(snapshot.instances.begin(), snapshot.instances.end(),
[&](const CachedEventInstance& instance) { return instance.event_id == first.id; });
require(first_instance != snapshot.instances.end()
&& first_instance->id == expected_id("graph-instance-", "instance", first.id)
&& first_instance->title == "First",
"secondary occurrence instance mapping is wrong");
require(snapshot.checkpoints.size() == 1 && snapshot.checkpoints[0].calendar_id == local_calendar
&& snapshot.checkpoints[0].window_start == "2026-07-01T00:00:00.000000Z"
&& snapshot.checkpoints[0].window_end == "2026-08-01T00:00:00.000000Z"
&& snapshot.checkpoints[0].cursor == "graph-calendar-view-v1"
&& snapshot.checkpoints[0].complete,
"secondary complete-window checkpoint is wrong");
}
void test_secondary_update_empty_and_moved_out(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-replace.db"));
const std::string calendar = seed_secondary(cache, "secondary-replace");
ScriptedTransport transport;
transport.steps.push_back({json_response(view_page(event_object("event-a", "Old A") + ","
+ event_object("event-b", "Moved B")))});
transport.steps.push_back(
{json_response(view_page(event_object("event-a", "Updated A")))});
transport.steps.push_back({json_response(view_page({}))});
MicrosoftGraphSync graph(transport, cache);
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
auto snapshot = cache.snapshot();
const CachedEvent& updated = event_by_remote(snapshot, "event-a");
const CachedEvent& moved = event_by_remote(snapshot, "event-b");
require(!updated.deleted && !moved.deleted,
"secondary update or moved-out omission was treated as a tombstone");
require(std::ranges::any_of(snapshot.instances, [&](const CachedEventInstance& instance) {
return instance.event_id == updated.id && instance.title == "Updated A";
}), "secondary event update did not replace its instance");
require(std::ranges::none_of(snapshot.instances, [&](const CachedEventInstance& instance) {
return instance.event_id == moved.id;
}), "moved-out event retained an instance in the completed window");
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
snapshot = cache.snapshot();
require(snapshot.instances.empty(), "empty secondary window did not evict membership");
require(std::ranges::all_of(snapshot.events,
[](const CachedEvent& event) { return !event.deleted; }),
"empty window marked omitted secondary events deleted");
}
void test_secondary_overlapping_window_retention(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-overlap.db"));
const std::string calendar = seed_secondary(cache, "secondary-overlap");
ScriptedTransport transport;
transport.steps.push_back(
{json_response(view_page(event_object("shared-event", "Shared")))});
transport.steps.push_back({json_response(view_page({}))});
MicrosoftGraphSync graph(transport, cache);
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
const auto seeded = cache.snapshot();
require(seeded.events.size() == 1 && seeded.instances.size() == 1,
"overlap fixture was not cached");
CompleteWindow overlap{{seeded.events[0]}, {seeded.instances[0]},
SyncCheckpoint{calendar, "2026-07-15T00:00:00.000000Z",
"2026-08-15T00:00:00.000000Z", "graph-calendar-view-v1", true}};
cache.replace_complete_window(overlap);
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
const auto retained = cache.snapshot();
require(retained.instances.size() == 1
&& retained.instances[0].event_id == seeded.events[0].id,
"empty overlapping window removed an instance still referenced by another window");
}
void test_secondary_paged_failures_are_atomic_and_redacted(
const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-failure.db"));
const std::string calendar = seed_secondary(cache, "secondary-failure");
ScriptedTransport initial;
initial.steps.push_back(
{json_response(view_page(event_object("existing-event", "Existing")))});
MicrosoftGraphSync initial_graph(initial, cache);
initial_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
const auto baseline = cache.snapshot();
const std::string route =
"https://graph.microsoft.com/v1.0/me/calendars/secondary-failure/calendarView";
const std::string next = route + "?$skiptoken=SENSITIVE_CURSOR";
ScriptedTransport transport_failure;
transport_failure.steps = {
{json_response(view_page(event_object("SENSITIVE_REMOTE_EVENT", "New"), next))},
{{}, true}};
MicrosoftGraphSync transport_graph(transport_failure, cache);
const std::string transport_message = expect_graph_error([&] {
transport_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline, "later secondary transport failure changed prior window");
require(transport_message.find("SENSITIVE") == std::string::npos
&& transport_message.find("ACCESS_TOKEN_SECRET") == std::string::npos
&& transport_message.find("secondary-failure") == std::string::npos,
"secondary transport failure leaked token, cursor, remote ID, or calendar ID");
ScriptedTransport malformed;
malformed.steps = {
{json_response(view_page(event_object("new-first-page", "New"), next))},
{json_response("{\"value\":\"SENSITIVE_MALFORMED_BODY\"")}};
MicrosoftGraphSync malformed_graph(malformed, cache);
const std::string malformed_message = expect_graph_error([&] {
malformed_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline, "later malformed secondary page changed prior window");
require(malformed_message.find("SENSITIVE") == std::string::npos,
"secondary malformed response leaked provider body");
ScriptedTransport duplicate;
duplicate.steps = {
{json_response(view_page(event_object("duplicate-event", "First"), next))},
{json_response(view_page(event_object("duplicate-event", "Second")))}};
MicrosoftGraphSync duplicate_graph(duplicate, cache);
expect_graph_error([&] {
duplicate_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline,
"duplicate event across secondary pages changed prior window");
ScriptedTransport rejected;
rejected.steps.push_back({json_response("SENSITIVE_NON_2XX_BODY", 429)});
MicrosoftGraphSync rejected_graph(rejected, cache);
const std::string rejected_message = expect_graph_error([&] {
rejected_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline, "secondary non-2xx response changed prior window");
require(rejected_message.find("SENSITIVE") == std::string::npos,
"secondary non-2xx error leaked its response body");
}
void test_secondary_preconditions_do_not_use_network(const TemporaryDirectory& temporary) {
const auto require_no_network_failure = [&](SyncCache& cache,
MicrosoftGraphCredentials operation_credentials,
std::string calendar_id_value,
std::string_view message) {
ScriptedTransport transport;
MicrosoftGraphSync graph(transport, cache);
expect_graph_error([&] {
graph.sync_secondary_calendar_window(
operation_credentials, std::move(calendar_id_value), july_window());
});
require(transport.requests.empty(), message);
};
SyncCache primary(temporary.database("secondary-primary-reject.db"));
seed_primary(primary);
require_no_network_failure(primary, credentials(), calendar_id("primary-remote"),
"primary calendar secondary sync reached network");
SyncCache wrong_account(temporary.database("secondary-account-reject.db"));
const std::string wrong_account_calendar = seed_secondary(wrong_account, "wrong-account");
require_no_network_failure(wrong_account, {"different-account", "ACCESS_TOKEN_SECRET"},
wrong_account_calendar, "wrong-account secondary sync reached network");
SyncCache inactive(temporary.database("secondary-inactive-reject.db"));
const std::string inactive_id = seed_secondary(inactive, "inactive");
inactive.replace_calendars_after_complete_listing("account-local", {});
require_no_network_failure(inactive, credentials(), inactive_id,
"inactive secondary sync reached network");
SyncCache missing(temporary.database("secondary-missing-reject.db"));
seed_account(missing);
require_no_network_failure(missing, credentials(), "missing-calendar",
"missing secondary sync reached network");
SyncCache malformed(temporary.database("secondary-malformed-reject.db"));
seed_account(malformed);
const CachedCalendar malformed_calendar{calendar_id("malformed"), "account-local",
"malformed", "Malformed", "", false, true, "{"};
malformed.replace_calendars_after_complete_listing(
"account-local", {&malformed_calendar, 1});
require_no_network_failure(malformed, credentials(), malformed_calendar.id,
"malformed-calendar secondary sync reached network");
SyncCache invalid_window(temporary.database("secondary-window-reject.db"));
const std::string valid_secondary = seed_secondary(invalid_window, "invalid-window");
ScriptedTransport transport;
MicrosoftGraphSync graph(transport, invalid_window);
expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), valid_secondary,
{july_window().start_epoch_microseconds, july_window().start_epoch_microseconds});
});
require(transport.requests.empty(), "invalid secondary window reached network");
}
void test_secondary_continuation_security(const TemporaryDirectory& temporary) {
const std::string route =
"https://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView";
const std::vector<std::string> unsafe{
"http://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://evil.example/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://user@graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://graph.microsoft.com:443/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://graph.microsoft.com/v1.0/me/calendars/other/calendarView?$skip=x",
route + "?$skip=x#fragment", route + "?$skip=x\\nInjected:true",
route + "?" + std::string(16U * 1024U, 'x')};
int sequence = 0;
for (const std::string& next : unsafe) {
SyncCache cache(temporary.database(
"secondary-link-" + std::to_string(sequence++) + ".db"));
const std::string calendar = seed_secondary(cache, "secondary-links");
const auto baseline = cache.snapshot();
ScriptedTransport transport;
transport.steps.push_back({json_response(
view_page(event_object("SENSITIVE_REMOTE_ID", "Sensitive"), next))});
MicrosoftGraphSync graph(transport, cache);
const std::string message = expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(transport.requests.size() == 1,
"unsafe secondary continuation received credentials");
require(cache.snapshot() == baseline, "unsafe secondary continuation changed cache");
require(message.find("SENSITIVE") == std::string::npos
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos,
"unsafe secondary continuation error leaked secrets");
}
SyncCache repeated_cache(temporary.database("secondary-link-repeated.db"));
const std::string repeated_calendar = seed_secondary(repeated_cache, "secondary-links");
const auto repeated_baseline = repeated_cache.snapshot();
const std::string repeated = route + "?$skiptoken=repeated";
ScriptedTransport repeated_transport;
repeated_transport.steps = {
{json_response(view_page({}, repeated))}, {json_response(view_page({}, repeated))}};
MicrosoftGraphSync repeated_graph(repeated_transport, repeated_cache);
expect_graph_error([&] {
repeated_graph.sync_secondary_calendar_window(
credentials(), repeated_calendar, july_window());
});
require(repeated_transport.requests.size() == 2,
"repeated secondary continuation triggered another credentialed request");
require(repeated_cache.snapshot() == repeated_baseline,
"repeated secondary continuation changed cache");
SyncCache delta_cache(temporary.database("secondary-delta-reject.db"));
const std::string delta_calendar = seed_secondary(delta_cache, "secondary-links");
const auto delta_baseline = delta_cache.snapshot();
ScriptedTransport delta_transport;
delta_transport.steps.push_back({json_response(
"{\"value\":[],\"@odata.deltaLink\":\"" + route + "?$delta=x\"}")});
MicrosoftGraphSync delta_graph(delta_transport, delta_cache);
expect_graph_error([&] {
delta_graph.sync_secondary_calendar_window(credentials(), delta_calendar, july_window());
});
require(delta_cache.snapshot() == delta_baseline,
"secondary calendarView accepted or committed a deltaLink");
}
void test_secondary_aggregate_response_bound(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-aggregate.db"));
const std::string calendar = seed_secondary(cache, "secondary-aggregate");
const auto baseline = cache.snapshot();
const std::string route =
"https://graph.microsoft.com/v1.0/me/calendars/secondary-aggregate/calendarView";
ScriptedTransport transport;
const std::string padding(7U * 1024U * 1024U, 'x');
for (int page = 0; page < 10; ++page) {
const std::string next = route + "?$skiptoken=aggregate-" + std::to_string(page + 1);
transport.steps.push_back({json_response("{\"value\":[],\"padding\":\"" + padding
+ "\",\"@odata.nextLink\":\"" + next + "\"}")});
}
MicrosoftGraphSync graph(transport, cache);
expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(transport.requests.size() == 10,
"secondary aggregate response bound fired at the wrong page");
require(cache.snapshot() == baseline,
"aggregate secondary response overflow changed prior cache state");
}
void test_secondary_cache_failure_is_atomic(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-cache-failure.db"));
const std::string calendar = seed_secondary(cache, "secondary-cache-failure");
const std::string target_id =
expected_id("graph-event-", "event", calendar, "target-remote");
CompleteWindow poisoned{{CachedEvent{target_id, calendar, "different-remote", "uid", "", "",
"{\"poisoned\":true}", false}},
{}, SyncCheckpoint{calendar, "2026-07-01T00:00:00.000000Z",
"2026-08-01T00:00:00.000000Z", "graph-calendar-view-v1", true}};
cache.replace_complete_window(poisoned);
const auto baseline = cache.snapshot();
ScriptedTransport transport;
transport.steps.push_back(
{json_response(view_page(event_object("target-remote", "Target")))});
MicrosoftGraphSync graph(transport, cache);
const std::string message = expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline,
"secondary cache failure partially replaced the complete window");
require(message.find("target-remote") == std::string::npos
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos,
"secondary cache failure leaked remote ID or token");
}
} // namespace
int main() {
@@ -443,6 +828,14 @@ int main() {
test_delta_mapping_incremental_tombstone_and_routes(temporary);
test_page_resume_and_primary_only_failure(temporary);
test_invalid_inputs_continuations_and_timestamps(temporary);
test_secondary_route_pagination_and_occurrence_mapping(temporary);
test_secondary_update_empty_and_moved_out(temporary);
test_secondary_overlapping_window_retention(temporary);
test_secondary_paged_failures_are_atomic_and_redacted(temporary);
test_secondary_preconditions_do_not_use_network(temporary);
test_secondary_continuation_security(temporary);
test_secondary_aggregate_response_bound(temporary);
test_secondary_cache_failure_is_atomic(temporary);
} catch (const std::exception& error) {
std::cerr << "Microsoft Graph tests failed: " << error.what() << '\n';
return 1;

View File

@@ -30,6 +30,7 @@ using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::CachedEvent;
using nocal::storage::CachedEventInstance;
using nocal::storage::CompleteWindow;
using nocal::storage::PullPage;
using nocal::storage::SyncCache;
using nocal::storage::SyncCheckpoint;
@@ -211,6 +212,13 @@ PullPage page(CachedEvent changed_event = event(), CachedEventInstance changed_i
return PullPage{{std::move(changed_event)}, {std::move(changed_instance)}, std::move(next)};
}
CompleteWindow complete_window(std::vector<CachedEvent> events = {event()},
std::vector<CachedEventInstance> instances = {instance()},
SyncCheckpoint complete_checkpoint = checkpoint()) {
complete_checkpoint.complete = true;
return {std::move(events), std::move(instances), std::move(complete_checkpoint)};
}
void seed_calendar(SyncCache& cache, const CachedAccount& cached_account = account(),
const CachedCalendar& cached_calendar = calendar()) {
cache.upsert_account(cached_account);
@@ -249,11 +257,13 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root
const auto tables = inspection.first_column(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
for (const std::string_view required : {"accounts", "calendars", "events",
"event_instances", "sync_state", "outbox", "conflicts"}) {
"event_instances", "event_window_membership", "sync_state", "outbox",
"conflicts"}) {
require(contains(tables, required), std::string{"missing cache table: "}.append(required));
}
for (const std::string_view child : {"calendars", "events", "event_instances", "sync_state"}) {
for (const std::string_view child : {"calendars", "events", "event_instances",
"event_window_membership", "sync_state"}) {
require(inspection.scalar_int(
std::string{"SELECT COUNT(*) FROM pragma_foreign_key_list('"}
.append(child)
@@ -261,6 +271,11 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root
> 0,
std::string{"missing foreign key on "}.append(child));
}
require(inspection.scalar_int(
"SELECT COUNT(*) FROM pragma_index_list('event_window_membership') "
"WHERE name='event_window_membership_event_id_idx'")
== 1,
"complete-window event lookup index is missing");
const auto columns = inspection.first_column(
"SELECT lower(m.name || '.' || p.name) FROM sqlite_master AS m "
@@ -397,6 +412,176 @@ void test_pull_pages_replacement_tombstone_and_windows(const std::filesystem::pa
"checkpoints are not returned in deterministic scope order");
}
void test_complete_window_idempotence_revival_and_omission(
const std::filesystem::path& root) {
const auto path = root / "complete-window.db";
SyncCache cache{path};
seed_calendar(cache);
auto completed = complete_window();
cache.replace_complete_window(completed);
const auto initial = cache.snapshot();
require(initial.events.size() == 1 && initial.instances.size() == 1
&& initial.checkpoints.size() == 1 && initial.checkpoints[0].complete,
"complete window did not persist event, instance, and complete checkpoint");
cache.replace_complete_window(completed);
require(cache.snapshot() == initial, "complete window replacement was not idempotent");
auto tombstone = event();
tombstone.deleted = true;
tombstone.raw_payload.clear();
cache.apply_pull_page(PullPage{{tombstone}, {}, checkpoint("delta-tombstone")});
require(cache.snapshot().events[0].deleted && cache.snapshot().instances.empty(),
"delta tombstone fixture did not delete the event");
auto revived = event();
revived.etag = "revived-etag";
revived.raw_payload = R"({"event":"revived"})";
auto revived_instance = instance("instance-revived");
auto revival_checkpoint = checkpoint("complete-revival");
revival_checkpoint.complete = true;
cache.replace_complete_window({{revived}, {revived_instance}, revival_checkpoint});
auto snapshot = cache.snapshot();
require(!snapshot.events[0].deleted && snapshot.events[0].etag == "revived-etag"
&& snapshot.events[0].raw_payload == revived.raw_payload
&& snapshot.instances.size() == 1
&& snapshot.instances[0].id == "instance-revived",
"returned complete-window event was not revived and replaced");
auto empty_checkpoint = checkpoint("complete-empty");
empty_checkpoint.complete = true;
cache.replace_complete_window({{}, {}, empty_checkpoint});
snapshot = cache.snapshot();
require(snapshot.events.size() == 1 && !snapshot.events[0].deleted
&& snapshot.events[0].raw_payload == revived.raw_payload
&& snapshot.instances.empty(),
"complete-window omission acted as a tombstone or retained last-window instances");
require(snapshot.checkpoints[0].cursor == "complete-empty"
&& snapshot.checkpoints[0].complete,
"empty completed window did not advance its checkpoint");
SqliteConnection inspection{path};
require(inspection.scalar_int("SELECT COUNT(*) FROM event_window_membership") == 0,
"empty completed window retained membership rows");
}
void test_overlapping_complete_windows_preserve_instances(
const std::filesystem::path& root) {
const auto path = root / "overlapping-windows.db";
SyncCache cache{path};
seed_calendar(cache);
auto first_checkpoint = checkpoint("first-complete", "calendar-a", "first-start", "first-end");
first_checkpoint.complete = true;
cache.replace_complete_window({{event()}, {instance()}, first_checkpoint});
auto second_instance = instance("instance-second");
second_instance.title = "Second window representation";
auto second_checkpoint =
checkpoint("second-complete", "calendar-a", "second-start", "second-end");
second_checkpoint.complete = true;
cache.replace_complete_window({{event()}, {second_instance}, second_checkpoint});
require(cache.snapshot().instances.size() == 1
&& cache.snapshot().instances[0].id == "instance-second",
"overlapping returned event did not replace its instances");
first_checkpoint.cursor = "first-empty";
cache.replace_complete_window({{}, {}, first_checkpoint});
require(cache.snapshot().instances.size() == 1
&& cache.snapshot().instances[0].id == "instance-second",
"omission from one overlapping window evicted a still-referenced event");
second_checkpoint.cursor = "second-empty";
cache.replace_complete_window({{}, {}, second_checkpoint});
require(cache.snapshot().instances.empty(),
"removing the final complete-window membership did not evict instances");
require(cache.snapshot().events.size() == 1 && !cache.snapshot().events[0].deleted,
"final membership removal deleted or tombstoned the event row");
SqliteConnection inspection{path};
require_throws(
[&] {
inspection.execute("PRAGMA foreign_keys=ON");
inspection.execute(
"INSERT INTO accounts VALUES('other-account','provider','subject','Other')");
inspection.execute(
"INSERT INTO calendars VALUES('other-calendar','other-account','remote','Other','',0,1,'raw')");
inspection.execute(
"INSERT INTO event_window_membership VALUES('other-calendar','start','end','event-a')");
},
"cross-calendar membership bypassed the composite foreign key");
}
void test_complete_window_validation_and_sql_rollback(const std::filesystem::path& root) {
SyncCache cache{root / "complete-validation.db"};
seed_calendar(cache);
auto event_b = event("event-b");
event_b.remote_id = "remote-event-b";
event_b.raw_payload = R"({"event":"b"})";
auto instance_b = instance("instance-b", "event-b");
cache.replace_complete_window(complete_window({event(), event_b}, {instance(), instance_b}));
const auto baseline = cache.snapshot();
auto expect_unchanged = [&](auto&& operation, std::string_view message) {
require_throws(std::forward<decltype(operation)>(operation), message);
require(cache.snapshot() == baseline, "invalid complete window changed cache state");
};
auto incomplete = complete_window();
incomplete.checkpoint.complete = false;
expect_unchanged([&] { cache.replace_complete_window(incomplete); },
"incomplete complete-window checkpoint accepted");
for (const int missing_field : {0, 1, 2, 3}) {
auto invalid = complete_window();
if (missing_field == 0) invalid.checkpoint.calendar_id.clear();
if (missing_field == 1) invalid.checkpoint.window_start.clear();
if (missing_field == 2) invalid.checkpoint.window_end.clear();
if (missing_field == 3) invalid.checkpoint.cursor.clear();
expect_unchanged([&] { cache.replace_complete_window(invalid); },
"empty complete-window checkpoint field accepted");
}
auto deleted = event();
deleted.deleted = true;
expect_unchanged([&] { cache.replace_complete_window(complete_window({deleted}, {})); },
"deleted complete-window event accepted");
auto cross_calendar = event();
cross_calendar.calendar_id = "other-calendar";
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({cross_calendar}, {})); },
"cross-calendar complete-window event accepted");
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event(), event()}, {})); },
"duplicate complete-window event accepted");
auto duplicate_remote = event("different-id");
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event(), duplicate_remote}, {})); },
"duplicate complete-window remote event accepted");
auto orphan = instance();
orphan.event_id = "not-returned";
expect_unchanged([&] { cache.replace_complete_window(complete_window({event()}, {orphan})); },
"orphan complete-window instance accepted");
auto invalid_interval = instance();
invalid_interval.end_epoch_microseconds = invalid_interval.start_epoch_microseconds - 1;
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event()}, {invalid_interval})); },
"invalid complete-window instance interval accepted");
auto changed = event();
changed.etag = "must-roll-back";
changed.raw_payload = R"({"event":"must-roll-back"})";
auto collision = instance("instance-b", "event-a");
auto collision_checkpoint = checkpoint("must-not-advance");
collision_checkpoint.complete = true;
expect_unchanged(
[&] { cache.replace_complete_window({{changed}, {collision}, collision_checkpoint}); },
"complete-window SQL instance collision was accepted");
cache.replace_calendars_after_complete_listing("account-a", {});
const auto inactive_baseline = cache.snapshot();
require_throws([&] { cache.replace_complete_window(complete_window()); },
"inactive calendar accepted a complete window");
require(cache.snapshot() == inactive_baseline,
"inactive-calendar complete window changed cache state");
}
void test_multiple_connections_serialize(const std::filesystem::path& root) {
const auto path = root / "connections.db";
SyncCache first{path};
@@ -721,6 +906,71 @@ void test_foreign_key_failure_rolls_back(const std::filesystem::path& root) {
require(cache.snapshot() == baseline, "foreign-key failure changed cached data");
}
void downgrade_fixture_to_v1(const std::filesystem::path& path) {
SqliteConnection database{path};
database.execute("DROP TABLE event_window_membership");
database.execute("DROP INDEX events_id_calendar_id_unique_idx");
database.execute("PRAGMA user_version=1");
}
void test_v1_to_v2_migration_preserves_data_and_serializes(
const std::filesystem::path& root) {
const auto path = root / "v1-upgrade.db";
CacheSnapshot before;
{
SyncCache cache{path};
seed_calendar(cache);
cache.apply_pull_page(page());
before = cache.snapshot();
}
downgrade_fixture_to_v1(path);
{
SyncCache upgraded{path};
require(upgraded.snapshot() == before, "v1 to v2 migration changed cached data");
}
{
SqliteConnection inspection{path};
require(inspection.scalar_int("PRAGMA user_version") == 2,
"v1 migration did not record schema version 2");
require(inspection.scalar_int(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' "
"AND name='event_window_membership'")
== 1,
"v1 migration did not create complete-window membership");
}
const auto concurrent_path = root / "v1-concurrent.db";
{
SyncCache cache{concurrent_path};
seed_calendar(cache);
}
downgrade_fixture_to_v1(concurrent_path);
std::atomic<bool> begin{false};
std::exception_ptr first_error;
std::exception_ptr second_error;
auto open = [&](std::exception_ptr& error) {
while (!begin.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
try {
SyncCache cache{concurrent_path};
(void)cache.snapshot();
} catch (...) {
error = std::current_exception();
}
};
std::thread first{open, std::ref(first_error)};
std::thread second{open, std::ref(second_error)};
begin.store(true, std::memory_order_release);
first.join();
second.join();
require(first_error == nullptr && second_error == nullptr,
"concurrent v1 opens did not serialize schema migration");
SqliteConnection concurrent_inspection{concurrent_path};
require(concurrent_inspection.scalar_int("PRAGMA user_version") == 2,
"concurrent migration did not finish at schema version 2");
}
void test_migration_failures_are_non_destructive(const std::filesystem::path& root) {
const auto collision_path = root / "collision.db";
{
@@ -743,17 +993,46 @@ void test_migration_failures_are_non_destructive(const std::filesystem::path& ro
"failed migration left partially created v1 tables");
}
const auto v1_collision_path = root / "v1-collision.db";
{
SyncCache cache{v1_collision_path};
seed_calendar(cache);
cache.apply_pull_page(page());
}
downgrade_fixture_to_v1(v1_collision_path);
{
SqliteConnection database{v1_collision_path};
database.execute("CREATE TABLE event_window_membership (sentinel TEXT NOT NULL)");
database.execute("INSERT INTO event_window_membership VALUES ('keep-v1')");
}
require_throws([&] { SyncCache cache{v1_collision_path}; },
"incompatible v1 to v2 migration collision was accepted");
{
SqliteConnection database{v1_collision_path};
require(database.scalar_int("PRAGMA user_version") == 1,
"failed v2 migration changed schema version");
require(database.scalar_text("SELECT sentinel FROM event_window_membership") == "keep-v1",
"failed v2 migration changed preexisting data");
require(database.scalar_int(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' "
"AND name='events_id_calendar_id_unique_idx'")
== 0,
"failed v2 migration left a partially created index");
require(database.scalar_int("SELECT COUNT(*) FROM events") == 1,
"failed v2 migration changed v1 event data");
}
const auto future_path = root / "future.db";
{
SqliteConnection database{future_path};
database.execute("CREATE TABLE future_sentinel (value TEXT NOT NULL)");
database.execute("INSERT INTO future_sentinel VALUES ('future-data')");
database.execute("PRAGMA user_version=2");
database.execute("PRAGMA user_version=3");
}
require_throws([&] { SyncCache cache{future_path}; }, "future schema version was accepted");
{
SqliteConnection database{future_path};
require(database.scalar_int("PRAGMA user_version") == 2,
require(database.scalar_int("PRAGMA user_version") == 3,
"future schema rejection changed schema version");
require(database.scalar_text("SELECT value FROM future_sentinel") == "future-data",
"future schema rejection changed data");
@@ -804,11 +1083,15 @@ int main() {
test_existing_permissions_are_tightened(temporary.path());
test_account_calendars_and_deterministic_snapshot(temporary.path());
test_pull_pages_replacement_tombstone_and_windows(temporary.path());
test_complete_window_idempotence_revival_and_omission(temporary.path());
test_overlapping_complete_windows_preserve_instances(temporary.path());
test_complete_window_validation_and_sql_rollback(temporary.path());
test_multiple_connections_serialize(temporary.path());
test_input_validation_and_rollback(temporary.path());
test_sql_failure_rolls_back_snapshot_and_cursor(temporary.path());
test_checkpoint_calendar_and_shape_validation(temporary.path());
test_foreign_key_failure_rolls_back(temporary.path());
test_v1_to_v2_migration_preserves_data_and_serializes(temporary.path());
test_migration_failures_are_non_destructive(temporary.path());
test_invalid_files_are_refused(temporary.path());
} catch (const std::exception& error) {