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:
28
README.md
28
README.md
@@ -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
|
out of errors. Access tokens are supplied per operation; Secret Service remains
|
||||||
the only persistent token store.
|
the only persistent token store.
|
||||||
|
|
||||||
All Graph requests and opaque next/delta links must use HTTPS on
|
All Graph requests and opaque paging/delta links must use HTTPS on
|
||||||
`graph.microsoft.com`. Primary-calendar event windows are half-open UTC ranges.
|
`graph.microsoft.com`. Event windows are half-open UTC ranges. Primary-calendar
|
||||||
Each page, its tombstones, and its next or delta cursor commit atomically, so an
|
delta pages commit their changes and cursor atomically, so an interrupted round
|
||||||
interrupted round resumes without advancing past durable data.
|
resumes without advancing past durable data.
|
||||||
|
|
||||||
Stable Graph v1.0 documents `calendarView` delta only for the primary calendar.
|
Secondary-calendar reconciliation through stable Graph v1.0 reads the
|
||||||
Nocal therefore discovers all calendars but delta-syncs only the primary one;
|
documented per-calendar `calendarView`, finishes every page, and then atomically
|
||||||
it deliberately avoids beta and undocumented per-calendar routes. Secondary
|
replaces durable membership for that complete window. Events omitted from one
|
||||||
calendars require a product decision between v1 full-window reconciliation with
|
window lose cached instances only after no other complete window references
|
||||||
explicit deletion semantics and a beta-specific delta implementation before
|
them; omission is not mislabeled as a remote deletion tombstone. This avoids
|
||||||
Outlook-equivalent coverage can be claimed. There is still no account UI/CLI
|
beta and undocumented delta routes, at the cost of refetching each secondary
|
||||||
orchestration or live Microsoft credential integration, and remote writes
|
window instead of incrementally updating it.
|
||||||
remain out of scope. The next phase is account setup and orchestration plus that
|
|
||||||
secondary-calendar decision.
|
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.
|
||||||
|
|||||||
@@ -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`
|
system browser and authorization-code flow, a fresh PKCE verifier and `S256`
|
||||||
challenge, and a loopback redirect. The Entra application registration is
|
challenge, and a loopback redirect. The Entra application registration is
|
||||||
multitenant and selects the intended organizational and personal Microsoft
|
multitenant and selects the intended organizational and personal Microsoft
|
||||||
account audience. The initial read-only Graph scope is the delegated
|
account audience. The read-only Graph scopes are delegated `User.Read` for
|
||||||
`Calendars.Read` permission. It normally permits user consent, while tenant
|
identity and `Calendars.Read` for calendar data. They normally permit user
|
||||||
|
consent, while tenant
|
||||||
consent policy remains authoritative and may require administrator approval or
|
consent policy remains authoritative and may require administrator approval or
|
||||||
block consent. A desktop binary distributed as open source is not a confidential
|
block consent. A desktop binary distributed as open source is not a confidential
|
||||||
client and must not embed or depend on a client secret.
|
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
|
values are opaque continuation capabilities: Nocal validates their scheme and
|
||||||
host but does not reconstruct or reinterpret their query parameters.
|
host but does not reconstruct or reinterpret their query parameters.
|
||||||
|
|
||||||
Event synchronization uses half-open UTC windows. Each page's normalized
|
Event synchronization uses half-open UTC windows. For the primary calendar,
|
||||||
events, deletion tombstones, and opaque next/delta cursor commit in one cache
|
each delta page's normalized events, deletion tombstones, and opaque cursor
|
||||||
transaction. A partial round is therefore durable and resumable, while a cursor
|
commit in one cache transaction. A partial round is durable and resumable, and
|
||||||
can never advance beyond its corresponding event changes.
|
the cursor cannot advance beyond its corresponding changes.
|
||||||
|
|
||||||
Stable Microsoft Graph v1.0 documents `calendarView` delta only for the primary
|
Secondary-calendar reconciliation through stable Microsoft Graph v1.0 uses the
|
||||||
calendar. The adapter discovers all calendars but delta-syncs events only from
|
documented per-calendar `calendarView` route, accumulates every page within
|
||||||
the primary calendar. It does not call beta or an undocumented per-calendar
|
fixed total bounds, and publishes only a completely validated listing. Schema
|
||||||
delta route. Secondary calendars remain an explicit architecture decision:
|
version 2 tracks durable event membership by calendar and window. Replacing one
|
||||||
either reconcile complete v1 calendar-view windows with well-defined deletion
|
window upserts its returned events and removes instances for omitted events only
|
||||||
semantics, or accept a beta-specific delta dependency. That choice must be made
|
when no other complete window still references them. The event envelope remains
|
||||||
and tested before Nocal claims Outlook-equivalent calendar coverage.
|
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
|
There is no account UI or CLI orchestration and no live Microsoft credential
|
||||||
integration yet; tests use injected transports and credentials. Remote writes
|
integration yet; tests use injected transports and credentials. Remote writes
|
||||||
remain out of scope. Account setup and orchestration plus the secondary-calendar
|
remain out of scope. Account setup and orchestration are next.
|
||||||
reconciliation decision are next.
|
|
||||||
|
|||||||
@@ -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`,
|
will open the system browser, use the authorization-code flow with PKCE `S256`,
|
||||||
and return through a loopback redirect. Its application registration must be
|
and return through a loopback redirect. Its application registration must be
|
||||||
multitenant for the intended organizational and personal Microsoft account
|
multitenant for the intended organizational and personal Microsoft account
|
||||||
audience. Initial synchronization requests only delegated `Calendars.Read`, not
|
audience. Initial synchronization requests delegated `User.Read` and
|
||||||
write access. Users can normally consent to that delegated permission, but
|
`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
|
their organization's tenant policy may still require administrator consent or
|
||||||
deny the application. Nocal never embeds a client secret in its open-source
|
deny the application. Nocal never embeds a client secret in its open-source
|
||||||
desktop binary.
|
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
|
use strict bounded parsing with credential-safe failures. Requests and opaque
|
||||||
next/delta links are restricted to HTTPS `graph.microsoft.com` URLs.
|
next/delta links are restricted to HTTPS `graph.microsoft.com` URLs.
|
||||||
|
|
||||||
Event windows are half-open UTC intervals. Each event page, its deletions, and
|
Event windows are half-open UTC intervals. Primary-calendar delta pages cross
|
||||||
its continuation cursor cross the cache boundary in one transaction. A partial
|
the cache boundary with their deletions and continuation cursor in one
|
||||||
round can resume from its durable cursor, and deletion tombstones remain
|
transaction, so a partial round resumes safely and tombstones remain explicit.
|
||||||
explicit instead of silently dropping remote state.
|
|
||||||
|
|
||||||
The stable Graph v1.0 `calendarView` delta API is documented only for the
|
Stable-v1 secondary-calendar reconciliation reads every page of a complete
|
||||||
primary calendar. This slice discovers all calendars but delta-syncs only the
|
per-calendar view before publishing the window. Durable per-window membership
|
||||||
primary; it uses neither beta nor undocumented per-calendar routes. Secondary
|
distinguishes an event that moved outside the range from an event Graph actually
|
||||||
calendar support requires a deliberate choice between v1 full-window
|
deleted: an omitted event loses its cached instance only when no other complete
|
||||||
reconciliation with defined deletion semantics and beta-specific delta. Nocal
|
window references it, while its raw event record remains live. This avoids beta
|
||||||
does not yet claim Outlook-equivalent coverage.
|
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
|
There is still no account UI/CLI orchestration or live Microsoft credential
|
||||||
integration, and remote writes remain out of scope. Account setup/orchestration
|
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
|
## Local file interchange
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,18 @@ Completed Microsoft Graph read phase:
|
|||||||
- Half-open UTC event windows and per-page event/tombstone/cursor transactions
|
- Half-open UTC event windows and per-page event/tombstone/cursor transactions
|
||||||
- Resumable primary-calendar v1 delta synchronization
|
- 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:
|
Next provider-boundary slices:
|
||||||
|
|
||||||
- Account setup and CLI/TUI orchestration with live credential integration
|
- 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
|
- Generic `SyncProvider` integration and observable sync status
|
||||||
|
|
||||||
## 0.3 — CalDAV
|
## 0.3 — CalDAV
|
||||||
@@ -104,12 +111,11 @@ Next provider-boundary slices:
|
|||||||
ETag-aware remote writes after the conflict boundary is proven
|
ETag-aware remote writes after the conflict boundary is proven
|
||||||
- Account/calendar management UI and per-calendar ANSI identity
|
- Account/calendar management UI and per-calendar ANSI identity
|
||||||
|
|
||||||
The Graph read phase discovers all calendars but uses stable v1 delta only for
|
The Graph read boundary uses stable v1 delta for the primary calendar and
|
||||||
the primary calendar; it deliberately avoids beta and undocumented
|
complete stable-v1 calendar views for secondary calendars; it deliberately
|
||||||
per-calendar routes. No account UI/CLI orchestration or live Microsoft
|
avoids beta and undocumented per-calendar delta routes. No account UI/CLI
|
||||||
credential integration exists yet, and remote writes remain out of scope. Do
|
orchestration or live Microsoft credential integration exists yet, and remote
|
||||||
not claim Outlook-equivalent coverage until the secondary-calendar strategy is
|
writes remain out of scope.
|
||||||
chosen and verified.
|
|
||||||
|
|
||||||
## 1.0 — distribution quality
|
## 1.0 — distribution quality
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ struct PullPage {
|
|||||||
bool operator==(const PullPage&) const = default;
|
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 {
|
struct CacheSnapshot {
|
||||||
std::vector<CachedAccount> accounts;
|
std::vector<CachedAccount> accounts;
|
||||||
std::vector<CachedCalendar> calendars;
|
std::vector<CachedCalendar> calendars;
|
||||||
@@ -88,7 +98,7 @@ struct CacheSnapshot {
|
|||||||
|
|
||||||
class SyncCache {
|
class SyncCache {
|
||||||
public:
|
public:
|
||||||
static constexpr int current_schema_version = 1;
|
static constexpr int current_schema_version = 2;
|
||||||
|
|
||||||
explicit SyncCache(std::filesystem::path path);
|
explicit SyncCache(std::filesystem::path path);
|
||||||
~SyncCache();
|
~SyncCache();
|
||||||
@@ -112,6 +122,12 @@ public:
|
|||||||
// events retain their last raw payload and have no instances.
|
// events retain their last raw payload and have no instances.
|
||||||
void apply_pull_page(const PullPage& page);
|
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;
|
[[nodiscard]] CacheSnapshot snapshot() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ public:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
|
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
|
||||||
// every calendar returned by /me/calendars. Stable v1.0 event delta is exposed
|
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for
|
||||||
// only for the signed-in user's primary calendar; this class deliberately does
|
// the primary calendar and complete calendarView reconciliation for secondary
|
||||||
// not call beta or undocumented per-calendar delta routes.
|
// calendars; this class does not call beta or undocumented delta routes.
|
||||||
class MicrosoftGraphSync {
|
class MicrosoftGraphSync {
|
||||||
public:
|
public:
|
||||||
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
|
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
|
||||||
@@ -51,6 +51,12 @@ public:
|
|||||||
void sync_primary_calendar_window(const MicrosoftGraphCredentials& credentials,
|
void sync_primary_calendar_window(const MicrosoftGraphCredentials& credentials,
|
||||||
const MicrosoftGraphWindow& window);
|
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:
|
private:
|
||||||
HttpTransport& transport_;
|
HttpTransport& transport_;
|
||||||
storage::SyncCache& cache_;
|
storage::SyncCache& cache_;
|
||||||
|
|||||||
@@ -326,6 +326,22 @@ CREATE TABLE conflicts (
|
|||||||
) STRICT;
|
) STRICT;
|
||||||
)sql";
|
)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) {
|
void validate_nonempty(std::string_view value, std::string_view field) {
|
||||||
if (value.empty()) {
|
if (value.empty()) {
|
||||||
throw std::invalid_argument(std::string(field) + " must not be 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");
|
execute(database, "PRAGMA foreign_keys = ON", "enabling foreign keys");
|
||||||
require_pragma_integer(database, "PRAGMA foreign_keys", 1, "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);
|
Transaction migration(database);
|
||||||
const int locked_version = user_version(database);
|
int locked_version = user_version(database);
|
||||||
if (locked_version == 0) {
|
if (locked_version == 0) {
|
||||||
execute(database, schema_v1, "creating schema version 1");
|
execute(database, schema_v1, "creating schema version 1");
|
||||||
execute(database, "PRAGMA user_version = 1", "recording 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");
|
throw std::runtime_error("sync cache schema changed to an unsupported version");
|
||||||
}
|
}
|
||||||
migration.commit();
|
migration.commit();
|
||||||
@@ -675,6 +698,178 @@ ON CONFLICT(calendar_id, window_start, window_end) DO UPDATE SET
|
|||||||
transaction.commit();
|
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 {
|
CacheSnapshot SyncCache::snapshot() const {
|
||||||
std::scoped_lock lock(impl_->mutex);
|
std::scoped_lock lock(impl_->mutex);
|
||||||
CacheSnapshot result;
|
CacheSnapshot result;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ using nocal::storage::CachedAccount;
|
|||||||
using nocal::storage::CachedCalendar;
|
using nocal::storage::CachedCalendar;
|
||||||
using nocal::storage::CachedEvent;
|
using nocal::storage::CachedEvent;
|
||||||
using nocal::storage::CachedEventInstance;
|
using nocal::storage::CachedEventInstance;
|
||||||
|
using nocal::storage::CompleteWindow;
|
||||||
using nocal::storage::PullPage;
|
using nocal::storage::PullPage;
|
||||||
using nocal::storage::SyncCheckpoint;
|
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_pages = 10'000U;
|
||||||
constexpr std::size_t maximum_values_per_page = 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_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::size_t maximum_mapped_string_bytes = 1024U * 1024U;
|
||||||
constexpr std::string_view graph_v1 = "https://graph.microsoft.com/v1.0";
|
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;
|
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) {
|
[[nodiscard]] std::string sha256_hex(std::string_view input) {
|
||||||
std::array<unsigned char, 32> digest{};
|
std::array<unsigned char, 32> digest{};
|
||||||
unsigned int size = 0;
|
unsigned int size = 0;
|
||||||
@@ -413,6 +442,23 @@ void validate_continuation(std::string_view url, ContinuationRoute route) {
|
|||||||
return primary.front();
|
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,
|
[[nodiscard]] SyncCheckpoint checkpoint_for(const nocal::storage::CacheSnapshot& snapshot,
|
||||||
std::string_view calendar, std::string_view window_start, std::string_view window_end) {
|
std::string_view calendar, std::string_view window_start, std::string_view window_end) {
|
||||||
for (const SyncCheckpoint& checkpoint : snapshot.checkpoints) {
|
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
|
} // namespace nocal::sync
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ namespace {
|
|||||||
|
|
||||||
using namespace std::chrono;
|
using namespace std::chrono;
|
||||||
using nocal::storage::CacheSnapshot;
|
using nocal::storage::CacheSnapshot;
|
||||||
|
using nocal::storage::CachedAccount;
|
||||||
using nocal::storage::CachedCalendar;
|
using nocal::storage::CachedCalendar;
|
||||||
using nocal::storage::CachedEvent;
|
using nocal::storage::CachedEvent;
|
||||||
|
using nocal::storage::CachedEventInstance;
|
||||||
using nocal::storage::PullPage;
|
using nocal::storage::PullPage;
|
||||||
using nocal::storage::SyncCache;
|
using nocal::storage::SyncCache;
|
||||||
using nocal::storage::SyncCheckpoint;
|
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 graph_prefix = "https://graph.microsoft.com/";
|
||||||
constexpr std::string_view primary_remote_id = "remote-primary-calendar";
|
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 primary_local_prefix = "graph-calendar-";
|
||||||
constexpr std::string_view event_local_prefix = "graph-event-";
|
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) {
|
void require(bool condition, std::string_view message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -183,12 +187,31 @@ struct Rig {
|
|||||||
return page.dump();
|
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() {
|
[[nodiscard]] MicrosoftGraphWindow july_window() {
|
||||||
const auto start = sys_days{year{2026} / July / 18};
|
const auto start = sys_days{year{2026} / July / 18};
|
||||||
return {duration_cast<microseconds>(start.time_since_epoch()).count(),
|
return {duration_cast<microseconds>(start.time_since_epoch()).count(),
|
||||||
duration_cast<microseconds>((start + days{1}).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) {
|
[[nodiscard]] const HttpHeader* header(const HttpRequest& request, std::string_view name) {
|
||||||
const auto found = std::ranges::find_if(request.headers, [&](const HttpHeader& value) {
|
const auto found = std::ranges::find_if(request.headers, [&](const HttpHeader& value) {
|
||||||
if (value.name.size() != name.size()) {
|
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);
|
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) {
|
void test_credentials_and_request_security(const std::filesystem::path& root) {
|
||||||
const std::vector<MicrosoftGraphCredentials> invalid{
|
const std::vector<MicrosoftGraphCredentials> invalid{
|
||||||
{"", "token"}, {"bad\naccount", "token"}, {"SENSITIVE_ACCOUNT_ID_123", ""},
|
{"", "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");
|
"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
|
} // namespace
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
@@ -687,6 +1124,13 @@ int main() {
|
|||||||
test_resume_after_later_failure(temporary.path());
|
test_resume_after_later_failure(temporary.path());
|
||||||
test_tombstone_preserves_payload(temporary.path());
|
test_tombstone_preserves_payload(temporary.path());
|
||||||
test_cache_failure_does_not_advance_cursor(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) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << "Microsoft Graph security test failure: " << error.what() << '\n';
|
std::cerr << "Microsoft Graph security test failure: " << error.what() << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <openssl/evp.h>
|
#include <openssl/evp.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -23,6 +24,10 @@ namespace {
|
|||||||
using namespace std::chrono;
|
using namespace std::chrono;
|
||||||
using nocal::storage::CachedAccount;
|
using nocal::storage::CachedAccount;
|
||||||
using nocal::storage::CachedCalendar;
|
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::storage::SyncCache;
|
||||||
using nocal::sync::HttpMethod;
|
using nocal::sync::HttpMethod;
|
||||||
using nocal::sync::HttpRequest;
|
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});
|
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) {
|
void test_profile_mapping_headers_and_atomic_validation(const TemporaryDirectory& temporary) {
|
||||||
SyncCache cache(temporary.database("profile.db"));
|
SyncCache cache(temporary.database("profile.db"));
|
||||||
ScriptedTransport transport;
|
ScriptedTransport transport;
|
||||||
@@ -433,6 +484,340 @@ void test_invalid_inputs_continuations_and_timestamps(const TemporaryDirectory&
|
|||||||
"invalid timestamp leaked or committed event data");
|
"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
|
} // namespace
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
@@ -443,6 +828,14 @@ int main() {
|
|||||||
test_delta_mapping_incremental_tombstone_and_routes(temporary);
|
test_delta_mapping_incremental_tombstone_and_routes(temporary);
|
||||||
test_page_resume_and_primary_only_failure(temporary);
|
test_page_resume_and_primary_only_failure(temporary);
|
||||||
test_invalid_inputs_continuations_and_timestamps(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) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << "Microsoft Graph tests failed: " << error.what() << '\n';
|
std::cerr << "Microsoft Graph tests failed: " << error.what() << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ using nocal::storage::CachedAccount;
|
|||||||
using nocal::storage::CachedCalendar;
|
using nocal::storage::CachedCalendar;
|
||||||
using nocal::storage::CachedEvent;
|
using nocal::storage::CachedEvent;
|
||||||
using nocal::storage::CachedEventInstance;
|
using nocal::storage::CachedEventInstance;
|
||||||
|
using nocal::storage::CompleteWindow;
|
||||||
using nocal::storage::PullPage;
|
using nocal::storage::PullPage;
|
||||||
using nocal::storage::SyncCache;
|
using nocal::storage::SyncCache;
|
||||||
using nocal::storage::SyncCheckpoint;
|
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)};
|
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(),
|
void seed_calendar(SyncCache& cache, const CachedAccount& cached_account = account(),
|
||||||
const CachedCalendar& cached_calendar = calendar()) {
|
const CachedCalendar& cached_calendar = calendar()) {
|
||||||
cache.upsert_account(cached_account);
|
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(
|
const auto tables = inspection.first_column(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
|
||||||
for (const std::string_view required : {"accounts", "calendars", "events",
|
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));
|
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(
|
require(inspection.scalar_int(
|
||||||
std::string{"SELECT COUNT(*) FROM pragma_foreign_key_list('"}
|
std::string{"SELECT COUNT(*) FROM pragma_foreign_key_list('"}
|
||||||
.append(child)
|
.append(child)
|
||||||
@@ -261,6 +271,11 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root
|
|||||||
> 0,
|
> 0,
|
||||||
std::string{"missing foreign key on "}.append(child));
|
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(
|
const auto columns = inspection.first_column(
|
||||||
"SELECT lower(m.name || '.' || p.name) FROM sqlite_master AS m "
|
"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");
|
"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) {
|
void test_multiple_connections_serialize(const std::filesystem::path& root) {
|
||||||
const auto path = root / "connections.db";
|
const auto path = root / "connections.db";
|
||||||
SyncCache first{path};
|
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");
|
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) {
|
void test_migration_failures_are_non_destructive(const std::filesystem::path& root) {
|
||||||
const auto collision_path = root / "collision.db";
|
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");
|
"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";
|
const auto future_path = root / "future.db";
|
||||||
{
|
{
|
||||||
SqliteConnection database{future_path};
|
SqliteConnection database{future_path};
|
||||||
database.execute("CREATE TABLE future_sentinel (value TEXT NOT NULL)");
|
database.execute("CREATE TABLE future_sentinel (value TEXT NOT NULL)");
|
||||||
database.execute("INSERT INTO future_sentinel VALUES ('future-data')");
|
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");
|
require_throws([&] { SyncCache cache{future_path}; }, "future schema version was accepted");
|
||||||
{
|
{
|
||||||
SqliteConnection database{future_path};
|
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");
|
"future schema rejection changed schema version");
|
||||||
require(database.scalar_text("SELECT value FROM future_sentinel") == "future-data",
|
require(database.scalar_text("SELECT value FROM future_sentinel") == "future-data",
|
||||||
"future schema rejection changed data");
|
"future schema rejection changed data");
|
||||||
@@ -804,11 +1083,15 @@ int main() {
|
|||||||
test_existing_permissions_are_tightened(temporary.path());
|
test_existing_permissions_are_tightened(temporary.path());
|
||||||
test_account_calendars_and_deterministic_snapshot(temporary.path());
|
test_account_calendars_and_deterministic_snapshot(temporary.path());
|
||||||
test_pull_pages_replacement_tombstone_and_windows(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_multiple_connections_serialize(temporary.path());
|
||||||
test_input_validation_and_rollback(temporary.path());
|
test_input_validation_and_rollback(temporary.path());
|
||||||
test_sql_failure_rolls_back_snapshot_and_cursor(temporary.path());
|
test_sql_failure_rolls_back_snapshot_and_cursor(temporary.path());
|
||||||
test_checkpoint_calendar_and_shape_validation(temporary.path());
|
test_checkpoint_calendar_and_shape_validation(temporary.path());
|
||||||
test_foreign_key_failure_rolls_back(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_migration_failures_are_non_destructive(temporary.path());
|
||||||
test_invalid_files_are_refused(temporary.path());
|
test_invalid_files_are_refused(temporary.path());
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user