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.
846 lines
41 KiB
C++
846 lines
41 KiB
C++
#include "nocal/sync/microsoft_graph.hpp"
|
|
|
|
#include <openssl/evp.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <exception>
|
|
#include <filesystem>
|
|
#include <iostream>
|
|
#include <optional>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include <unistd.h>
|
|
|
|
namespace {
|
|
|
|
using namespace std::chrono;
|
|
using nocal::storage::CachedAccount;
|
|
using nocal::storage::CachedCalendar;
|
|
using nocal::storage::CachedEvent;
|
|
using nocal::storage::CachedEventInstance;
|
|
using nocal::storage::CompleteWindow;
|
|
using nocal::storage::SyncCheckpoint;
|
|
using nocal::storage::SyncCache;
|
|
using nocal::sync::HttpMethod;
|
|
using nocal::sync::HttpRequest;
|
|
using nocal::sync::HttpResponse;
|
|
using nocal::sync::HttpTransport;
|
|
using nocal::sync::MicrosoftGraphCredentials;
|
|
using nocal::sync::MicrosoftGraphError;
|
|
using nocal::sync::MicrosoftGraphSync;
|
|
using nocal::sync::MicrosoftGraphWindow;
|
|
|
|
void require(bool condition, std::string_view message) {
|
|
if (!condition) {
|
|
throw std::runtime_error(std::string(message));
|
|
}
|
|
}
|
|
|
|
class TemporaryDirectory {
|
|
public:
|
|
TemporaryDirectory() {
|
|
path_ = std::filesystem::temp_directory_path()
|
|
/ ("nocal-graph-tests-" + std::to_string(::getpid()) + "-XXXXXX");
|
|
std::string pattern = path_.string();
|
|
std::vector<char> writable(pattern.begin(), pattern.end());
|
|
writable.push_back('\0');
|
|
char* created = ::mkdtemp(writable.data());
|
|
require(created != nullptr, "unable to create temporary Graph test directory");
|
|
path_ = created;
|
|
}
|
|
|
|
~TemporaryDirectory() {
|
|
std::error_code ignored;
|
|
std::filesystem::remove_all(path_, ignored);
|
|
}
|
|
|
|
[[nodiscard]] std::filesystem::path database(std::string_view name) const {
|
|
return path_ / std::string(name);
|
|
}
|
|
|
|
private:
|
|
std::filesystem::path path_;
|
|
};
|
|
|
|
struct TransportStep {
|
|
HttpResponse response;
|
|
bool fail{false};
|
|
};
|
|
|
|
class ScriptedTransport final : public HttpTransport {
|
|
public:
|
|
HttpResponse send(const HttpRequest& request) override {
|
|
requests.push_back(request);
|
|
if (next >= steps.size()) {
|
|
throw std::runtime_error("UNEXPECTED_REQUEST_SECRET");
|
|
}
|
|
const TransportStep& step = steps[next++];
|
|
if (step.fail) {
|
|
throw std::runtime_error("SENSITIVE_TRANSPORT_DETAIL");
|
|
}
|
|
return step.response;
|
|
}
|
|
|
|
std::vector<TransportStep> steps;
|
|
std::vector<HttpRequest> requests;
|
|
std::size_t next{0};
|
|
};
|
|
|
|
[[nodiscard]] HttpResponse json_response(std::string body, int status = 200) {
|
|
return {status, {{"Content-Type", "application/json"}}, std::move(body)};
|
|
}
|
|
|
|
[[nodiscard]] MicrosoftGraphCredentials credentials() {
|
|
return {"account-local", "ACCESS_TOKEN_SECRET"};
|
|
}
|
|
|
|
[[nodiscard]] std::string hash(std::string_view input) {
|
|
std::array<unsigned char, 32> digest{};
|
|
unsigned int size = 0;
|
|
require(EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) == 1
|
|
&& size == digest.size(),
|
|
"test SHA-256 failed");
|
|
static constexpr char hexadecimal[] = "0123456789abcdef";
|
|
std::string output;
|
|
for (const unsigned char byte : digest) {
|
|
output.push_back(hexadecimal[byte >> 4U]);
|
|
output.push_back(hexadecimal[byte & 0x0fU]);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
[[nodiscard]] std::string expected_id(std::string_view prefix, std::string_view type,
|
|
std::string_view parent, std::string_view remote = {}) {
|
|
std::string input(type);
|
|
input.push_back('\0');
|
|
input += parent;
|
|
if (!remote.empty()) {
|
|
input.push_back('\0');
|
|
input += remote;
|
|
}
|
|
return std::string(prefix) + hash(input);
|
|
}
|
|
|
|
[[nodiscard]] std::string calendar_id(std::string_view remote) {
|
|
return expected_id("graph-calendar-", "calendar", "account-local", remote);
|
|
}
|
|
|
|
[[nodiscard]] std::int64_t epoch_microseconds(
|
|
year_month_day date, hours hour = 0h, minutes minute = 0min, seconds second = 0s,
|
|
microseconds fractional = 0us) {
|
|
return duration_cast<microseconds>(
|
|
(sys_days{date} + hour + minute + second + fractional).time_since_epoch())
|
|
.count();
|
|
}
|
|
|
|
void require_graph_headers(const HttpRequest& request) {
|
|
require(request.method == HttpMethod::get && request.body.empty(),
|
|
"Graph request was not a bodyless GET");
|
|
require(request.headers
|
|
== std::vector<nocal::sync::HttpHeader>{{"Authorization",
|
|
"Bearer ACCESS_TOKEN_SECRET"},
|
|
{"Accept", "application/json"},
|
|
{"Content-Type", "application/json"},
|
|
{"Prefer", "outlook.timezone=\"UTC\", IdType=\"ImmutableId\""}},
|
|
"Graph request headers were not exact");
|
|
}
|
|
|
|
template <typename Action>
|
|
std::string expect_graph_error(Action&& action) {
|
|
try {
|
|
action();
|
|
} catch (const MicrosoftGraphError& error) {
|
|
return error.what();
|
|
}
|
|
throw std::runtime_error("expected MicrosoftGraphError was not thrown");
|
|
}
|
|
|
|
void seed_account(SyncCache& cache) {
|
|
cache.upsert_account({"account-local", "microsoft-graph", "remote-user", "User"});
|
|
}
|
|
|
|
void seed_primary(SyncCache& cache, std::string remote = "primary-remote") {
|
|
seed_account(cache);
|
|
const CachedCalendar calendar{calendar_id(remote), "account-local", std::move(remote),
|
|
"Primary", "#112233", false, true,
|
|
"{\"id\":\"primary-remote\",\"isDefaultCalendar\":true}"};
|
|
cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1});
|
|
}
|
|
|
|
[[nodiscard]] std::string seed_secondary(
|
|
SyncCache& cache, std::string remote = "Secondary /Case?x") {
|
|
seed_account(cache);
|
|
const std::string local_id = calendar_id(remote);
|
|
const CachedCalendar calendar{local_id, "account-local", std::move(remote), "Secondary",
|
|
"#445566", false, true, "{\"isDefaultCalendar\":false}"};
|
|
cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1});
|
|
return local_id;
|
|
}
|
|
|
|
[[nodiscard]] std::string event_object(std::string_view id, std::string_view subject,
|
|
std::string_view start = "2026-07-18T09:00:00.1234567Z",
|
|
std::string_view end = "2026-07-18T10:00:00.1234567Z") {
|
|
return "{\"id\":\"" + std::string(id)
|
|
+ "\",\"iCalUId\":\"series-uid\",\"@odata.etag\":\"etag\","
|
|
"\"changeKey\":\"change\",\"subject\":\""
|
|
+ std::string(subject)
|
|
+ "\",\"location\":{\"displayName\":\"Room\"},"
|
|
"\"bodyPreview\":\"Preview\",\"isAllDay\":false,\"type\":\"occurrence\","
|
|
"\"start\":{\"dateTime\":\""
|
|
+ std::string(start)
|
|
+ "\",\"timeZone\":\"Ignored\"},\"end\":{\"dateTime\":\""
|
|
+ std::string(end) + "\",\"timeZone\":\"Ignored\"}}";
|
|
}
|
|
|
|
[[nodiscard]] std::string view_page(
|
|
std::string events, std::optional<std::string> next = std::nullopt) {
|
|
std::string body = "{\"value\":[" + std::move(events) + "]";
|
|
if (next) {
|
|
body += ",\"@odata.nextLink\":\"" + *next + "\"";
|
|
}
|
|
return body + "}";
|
|
}
|
|
|
|
[[nodiscard]] MicrosoftGraphWindow july_window() {
|
|
return {epoch_microseconds(2026y / July / 1), epoch_microseconds(2026y / August / 1)};
|
|
}
|
|
|
|
[[nodiscard]] const CachedEvent& event_by_remote(
|
|
const nocal::storage::CacheSnapshot& snapshot, std::string_view remote) {
|
|
const auto found = std::find_if(snapshot.events.begin(), snapshot.events.end(),
|
|
[&](const CachedEvent& event) { return event.remote_id == remote; });
|
|
require(found != snapshot.events.end(), "expected cached event is missing");
|
|
return *found;
|
|
}
|
|
|
|
void test_profile_mapping_headers_and_atomic_validation(const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("profile.db"));
|
|
ScriptedTransport transport;
|
|
transport.steps.push_back({json_response(
|
|
"{\"id\":\"remote-subject\",\"displayName\":\"\","
|
|
"\"userPrincipalName\":\"user@example.test\"}")});
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
const CachedAccount account = graph.refresh_account(credentials());
|
|
require(account == CachedAccount{"account-local", "microsoft-graph", "remote-subject",
|
|
"user@example.test"},
|
|
"Graph profile was not mapped exactly");
|
|
require(cache.snapshot().accounts == std::vector<CachedAccount>{account},
|
|
"Graph profile was not persisted");
|
|
require(transport.requests.size() == 1
|
|
&& transport.requests[0].url == "https://graph.microsoft.com/v1.0/me",
|
|
"Graph profile used the wrong route");
|
|
require_graph_headers(transport.requests[0]);
|
|
|
|
const auto baseline = cache.snapshot();
|
|
ScriptedTransport invalid_transport;
|
|
invalid_transport.steps.push_back(
|
|
{json_response("{\"id\":\"SENSITIVE_REMOTE\",\"id\":\"duplicate\","
|
|
"\"displayName\":\"Secret\"}")});
|
|
MicrosoftGraphSync invalid_graph(invalid_transport, cache);
|
|
const std::string message = expect_graph_error(
|
|
[&] { (void)invalid_graph.refresh_account(credentials()); });
|
|
require(cache.snapshot() == baseline, "invalid profile changed cached account");
|
|
require(message.find("SENSITIVE") == std::string::npos
|
|
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos,
|
|
"profile error leaked provider data");
|
|
}
|
|
|
|
void test_paged_listing_atomicity_primary_and_stable_ids(
|
|
const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("listing.db"));
|
|
seed_account(cache);
|
|
const CachedCalendar old{"old-local", "account-local", "old-remote", "Old", "", false,
|
|
true, "{\"isDefaultCalendar\":false}"};
|
|
cache.replace_calendars_after_complete_listing("account-local", {&old, 1});
|
|
const auto baseline = cache.snapshot();
|
|
const std::string next =
|
|
"https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=opaque-one";
|
|
|
|
ScriptedTransport partial;
|
|
partial.steps = {{json_response(
|
|
"{\"value\":[{\"id\":\"A\",\"name\":\"First\","
|
|
"\"hexColor\":\"#abcdef\",\"color\":\"lightBlue\","
|
|
"\"canEdit\":true,\"isDefaultCalendar\":true}],"
|
|
"\"@odata.nextLink\":\""
|
|
+ next + "\"}")},
|
|
{json_response("{\"value\":\"SENSITIVE_BAD_PAGE\"}")}};
|
|
MicrosoftGraphSync partial_graph(partial, cache);
|
|
const std::string partial_message =
|
|
expect_graph_error([&] { partial_graph.refresh_calendars(credentials()); });
|
|
require(cache.snapshot() == baseline, "partial calendar listing changed the cache");
|
|
require(partial_message.find("SENSITIVE") == std::string::npos,
|
|
"calendar parse error leaked response body");
|
|
|
|
ScriptedTransport success;
|
|
success.steps = {{partial.steps[0].response},
|
|
{json_response(
|
|
"{\"value\":[{\"id\":\"a\",\"name\":\"Second\","
|
|
"\"hexColor\":null,\"color\":\"darkRed\",\"canEdit\":false,"
|
|
"\"isDefaultCalendar\":false}]}")}};
|
|
MicrosoftGraphSync success_graph(success, cache);
|
|
success_graph.refresh_calendars(credentials());
|
|
const auto snapshot = cache.snapshot();
|
|
require(snapshot.calendars.size() == 3, "completed listing did not retain omitted calendar");
|
|
const auto find_remote = [&](std::string_view remote) -> const CachedCalendar& {
|
|
const auto found = std::find_if(snapshot.calendars.begin(), snapshot.calendars.end(),
|
|
[&](const CachedCalendar& value) { return value.remote_id == remote; });
|
|
require(found != snapshot.calendars.end(), "listed calendar is missing");
|
|
return *found;
|
|
};
|
|
require(!find_remote("old-remote").active, "omitted calendar was not deactivated");
|
|
require(find_remote("A").id == calendar_id("A") && find_remote("A").color == "#abcdef"
|
|
&& !find_remote("A").read_only,
|
|
"primary calendar mapping or ID is wrong");
|
|
require(find_remote("a").id == calendar_id("a") && find_remote("a").color == "darkRed"
|
|
&& find_remote("a").read_only && calendar_id("A") != calendar_id("a"),
|
|
"case-sensitive calendar identity or fallback mapping is wrong");
|
|
require(success.requests.size() == 2 && success.requests[1].url == next,
|
|
"calendar pagination did not use the opaque nextLink");
|
|
for (const HttpRequest& request : success.requests) {
|
|
require_graph_headers(request);
|
|
}
|
|
|
|
const auto completed = cache.snapshot();
|
|
ScriptedTransport multiple;
|
|
multiple.steps.push_back({json_response(
|
|
"{\"value\":[{\"id\":\"one\",\"name\":\"One\",\"canEdit\":true,"
|
|
"\"isDefaultCalendar\":true},{\"id\":\"two\",\"name\":\"Two\","
|
|
"\"canEdit\":true,\"isDefaultCalendar\":true}]}")});
|
|
MicrosoftGraphSync multiple_graph(multiple, cache);
|
|
expect_graph_error([&] { multiple_graph.refresh_calendars(credentials()); });
|
|
require(cache.snapshot() == completed, "multiple-primary listing changed cache");
|
|
}
|
|
|
|
void test_delta_mapping_incremental_tombstone_and_routes(
|
|
const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("delta.db"));
|
|
seed_primary(cache);
|
|
const std::string next =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=NEXT_OPAQUE";
|
|
const std::string delta =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=DELTA_OPAQUE";
|
|
ScriptedTransport transport;
|
|
transport.steps = {{json_response(
|
|
"{\"value\":[{\"id\":\"occurrence-1\","
|
|
"\"iCalUId\":\"series-uid\",\"@odata.etag\":\"etag-1\","
|
|
"\"changeKey\":\"change-1\",\"subject\":\"Occurrence\","
|
|
"\"location\":{\"displayName\":\"Room\"},"
|
|
"\"bodyPreview\":\"Preview\",\"isAllDay\":false,"
|
|
"\"type\":\"occurrence\","
|
|
"\"start\":{\"dateTime\":\"2026-07-18T10:00:00.1234567+01:00\","
|
|
"\"timeZone\":\"Ignored/Zone\"},"
|
|
"\"end\":{\"dateTime\":\"2026-07-18T10:00:01.1234567\","
|
|
"\"timeZone\":\"UTC\"}}],\"@odata.nextLink\":\""
|
|
+ next + "\"}")},
|
|
{json_response("{\"value\":[],\"@odata.deltaLink\":\"" + delta + "\"}")}};
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
const MicrosoftGraphWindow window{
|
|
epoch_microseconds(2026y / July / 1), epoch_microseconds(2026y / August / 1)};
|
|
graph.sync_primary_calendar_window(credentials(), window);
|
|
|
|
auto snapshot = cache.snapshot();
|
|
require(transport.requests.size() == 2
|
|
&& transport.requests[0].url
|
|
== "https://graph.microsoft.com/v1.0/me/calendarView/delta?"
|
|
"startDateTime=2026-07-01T00%3A00%3A00.000000Z&"
|
|
"endDateTime=2026-08-01T00%3A00%3A00.000000Z"
|
|
&& transport.requests[1].url == next,
|
|
"initial delta used the wrong documented route or canonical window");
|
|
for (const HttpRequest& request : transport.requests) {
|
|
require_graph_headers(request);
|
|
require(request.url.find("/beta/") == std::string::npos
|
|
&& request.url.find("/calendars/") == std::string::npos,
|
|
"delta used beta or a per-calendar route");
|
|
}
|
|
const std::string local_calendar = calendar_id("primary-remote");
|
|
const std::string local_event =
|
|
expected_id("graph-event-", "event", local_calendar, "occurrence-1");
|
|
require(snapshot.events.size() == 1
|
|
&& snapshot.events[0]
|
|
== nocal::storage::CachedEvent{local_event, local_calendar, "occurrence-1",
|
|
"series-uid", "etag-1", "change-1", snapshot.events[0].raw_payload, false},
|
|
"delta event mapping or deterministic identity is wrong");
|
|
require(snapshot.instances.size() == 1
|
|
&& snapshot.instances[0].id
|
|
== expected_id("graph-instance-", "instance", local_event)
|
|
&& snapshot.instances[0].start_epoch_microseconds
|
|
== epoch_microseconds(2026y / July / 18, 9h, 0min, 0s, 123456us)
|
|
&& snapshot.instances[0].end_epoch_microseconds
|
|
== epoch_microseconds(2026y / July / 18, 10h, 0min, 1s, 123456us)
|
|
&& snapshot.instances[0].location == "Room"
|
|
&& snapshot.instances[0].time_zone == "UTC",
|
|
"delta occurrence timestamp or instance mapping is wrong");
|
|
require(snapshot.checkpoints.size() == 1 && snapshot.checkpoints[0].complete
|
|
&& snapshot.checkpoints[0].cursor == delta,
|
|
"delta checkpoint was not committed complete");
|
|
|
|
ScriptedTransport incremental;
|
|
const std::string delta_two =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=DELTA_TWO";
|
|
incremental.steps.push_back({json_response(
|
|
"{\"value\":[{\"id\":\"occurrence-1\",\"@removed\":{\"reason\":\"deleted\"}}],"
|
|
"\"@odata.deltaLink\":\""
|
|
+ delta_two + "\"}")});
|
|
MicrosoftGraphSync incremental_graph(incremental, cache);
|
|
incremental_graph.sync_primary_calendar_window(credentials(), window);
|
|
snapshot = cache.snapshot();
|
|
require(incremental.requests.size() == 1 && incremental.requests[0].url == delta,
|
|
"incremental sync did not resume the exact delta cursor");
|
|
require(snapshot.events.size() == 1 && snapshot.events[0].deleted
|
|
&& !snapshot.events[0].raw_payload.empty() && snapshot.instances.empty(),
|
|
"tombstone did not retain raw payload and remove instances");
|
|
}
|
|
|
|
void test_page_resume_and_primary_only_failure(const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("resume.db"));
|
|
seed_primary(cache);
|
|
const MicrosoftGraphWindow window{
|
|
epoch_microseconds(2026y / January / 1), epoch_microseconds(2026y / February / 1)};
|
|
const std::string next =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=resume-here";
|
|
ScriptedTransport first;
|
|
first.steps = {{json_response(
|
|
"{\"value\":[{\"id\":\"event-one\",\"isAllDay\":false,"
|
|
"\"start\":{\"dateTime\":\"2026-01-02T00:00:00Z\"},"
|
|
"\"end\":{\"dateTime\":\"2026-01-02T01:00:00Z\"}}],"
|
|
"\"@odata.nextLink\":\""
|
|
+ next + "\"}")},
|
|
{{}, true}};
|
|
MicrosoftGraphSync first_graph(first, cache);
|
|
const std::string message = expect_graph_error(
|
|
[&] { first_graph.sync_primary_calendar_window(credentials(), window); });
|
|
auto snapshot = cache.snapshot();
|
|
require(message.find("SENSITIVE") == std::string::npos
|
|
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos
|
|
&& message.find("resume-here") == std::string::npos,
|
|
"later-page error leaked sensitive state");
|
|
require(snapshot.events.size() == 1 && snapshot.checkpoints.size() == 1
|
|
&& snapshot.checkpoints[0].cursor == next && !snapshot.checkpoints[0].complete,
|
|
"first page and resume cursor were not committed before later failure");
|
|
|
|
const std::string final =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=finished";
|
|
ScriptedTransport resumed;
|
|
resumed.steps.push_back(
|
|
{json_response("{\"value\":[],\"@odata.deltaLink\":\"" + final + "\"}")});
|
|
MicrosoftGraphSync resumed_graph(resumed, cache);
|
|
resumed_graph.sync_primary_calendar_window(credentials(), window);
|
|
require(resumed.requests.size() == 1 && resumed.requests[0].url == next,
|
|
"sync did not resume from the committed next cursor");
|
|
|
|
SyncCache no_primary_cache(temporary.database("no-primary.db"));
|
|
seed_account(no_primary_cache);
|
|
const CachedCalendar secondary{calendar_id("secondary"), "account-local", "secondary",
|
|
"Secondary", "", false, true, "{\"isDefaultCalendar\":false}"};
|
|
no_primary_cache.replace_calendars_after_complete_listing(
|
|
"account-local", {&secondary, 1});
|
|
ScriptedTransport no_network;
|
|
MicrosoftGraphSync no_primary_graph(no_network, no_primary_cache);
|
|
expect_graph_error(
|
|
[&] { no_primary_graph.sync_primary_calendar_window(credentials(), window); });
|
|
require(no_network.requests.empty(), "missing primary calendar caused a network request");
|
|
}
|
|
|
|
void test_invalid_inputs_continuations_and_timestamps(const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("invalid.db"));
|
|
seed_primary(cache);
|
|
const auto baseline = cache.snapshot();
|
|
ScriptedTransport transport;
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
const MicrosoftGraphWindow valid{
|
|
epoch_microseconds(2026y / January / 1), epoch_microseconds(2026y / January / 2)};
|
|
const std::vector<MicrosoftGraphCredentials> invalid_credentials = {
|
|
{"", "token"}, {"account", "bad\r\ntoken"}, {"account", " "}};
|
|
for (const MicrosoftGraphCredentials& invalid : invalid_credentials) {
|
|
expect_graph_error([&] { graph.sync_primary_calendar_window(invalid, valid); });
|
|
}
|
|
expect_graph_error([&] {
|
|
graph.sync_primary_calendar_window(credentials(), {valid.start_epoch_microseconds,
|
|
valid.start_epoch_microseconds});
|
|
});
|
|
require(transport.requests.empty() && cache.snapshot() == baseline,
|
|
"invalid credentials/window caused side effects");
|
|
|
|
ScriptedTransport bad_timestamp;
|
|
bad_timestamp.steps.push_back({json_response(
|
|
"{\"value\":[{\"id\":\"SENSITIVE_EVENT_ID\",\"isAllDay\":false,"
|
|
"\"start\":{\"dateTime\":\"2026-02-30T10:00:00\",\"timeZone\":\"UTC\"},"
|
|
"\"end\":{\"dateTime\":\"2026-03-01T10:00:00\",\"timeZone\":\"UTC\"}}],"
|
|
"\"@odata.deltaLink\":"
|
|
"\"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=x\"}")});
|
|
MicrosoftGraphSync bad_timestamp_graph(bad_timestamp, cache);
|
|
const std::string timestamp_message = expect_graph_error(
|
|
[&] { bad_timestamp_graph.sync_primary_calendar_window(credentials(), valid); });
|
|
require(timestamp_message.find("SENSITIVE") == std::string::npos
|
|
&& cache.snapshot() == baseline,
|
|
"invalid timestamp leaked or committed event data");
|
|
}
|
|
|
|
void test_secondary_route_pagination_and_occurrence_mapping(
|
|
const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("secondary-route.db"));
|
|
const std::string local_calendar = seed_secondary(cache);
|
|
const std::string route = "https://graph.microsoft.com/v1.0/me/calendars/"
|
|
"Secondary%20%2FCase%3Fx/calendarView";
|
|
const std::string next = route + "?$skiptoken=page-two";
|
|
ScriptedTransport transport;
|
|
transport.steps = {{json_response(view_page(event_object("occurrence-one", "First"), next))},
|
|
{json_response(view_page(event_object("occurrence-two", "Second",
|
|
"2026-07-19T11:00:00Z", "2026-07-19T12:00:00Z")))}};
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
graph.sync_secondary_calendar_window(credentials(), local_calendar, july_window());
|
|
|
|
require(transport.requests.size() == 2
|
|
&& transport.requests[0].url
|
|
== route
|
|
+ "?startDateTime=2026-07-01T00%3A00%3A00.000000Z&"
|
|
"endDateTime=2026-08-01T00%3A00%3A00.000000Z&$top=1000"
|
|
&& transport.requests[1].url == next,
|
|
"secondary sync used the wrong encoded calendarView route or pagination URL");
|
|
for (const HttpRequest& request : transport.requests) {
|
|
require_graph_headers(request);
|
|
require(request.url.find("/beta/") == std::string::npos
|
|
&& request.url.find("/delta") == std::string::npos,
|
|
"secondary sync used beta or a delta route");
|
|
}
|
|
|
|
const auto snapshot = cache.snapshot();
|
|
require(snapshot.events.size() == 2 && snapshot.instances.size() == 2,
|
|
"two-page secondary view was not committed completely");
|
|
const CachedEvent& first = event_by_remote(snapshot, "occurrence-one");
|
|
require(first.id
|
|
== expected_id("graph-event-", "event", local_calendar, "occurrence-one")
|
|
&& first.uid == "series-uid" && !first.deleted,
|
|
"secondary recurrence occurrence mapping or stable ID is wrong");
|
|
const auto first_instance = std::find_if(snapshot.instances.begin(), snapshot.instances.end(),
|
|
[&](const CachedEventInstance& instance) { return instance.event_id == first.id; });
|
|
require(first_instance != snapshot.instances.end()
|
|
&& first_instance->id == expected_id("graph-instance-", "instance", first.id)
|
|
&& first_instance->title == "First",
|
|
"secondary occurrence instance mapping is wrong");
|
|
require(snapshot.checkpoints.size() == 1 && snapshot.checkpoints[0].calendar_id == local_calendar
|
|
&& snapshot.checkpoints[0].window_start == "2026-07-01T00:00:00.000000Z"
|
|
&& snapshot.checkpoints[0].window_end == "2026-08-01T00:00:00.000000Z"
|
|
&& snapshot.checkpoints[0].cursor == "graph-calendar-view-v1"
|
|
&& snapshot.checkpoints[0].complete,
|
|
"secondary complete-window checkpoint is wrong");
|
|
}
|
|
|
|
void test_secondary_update_empty_and_moved_out(const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("secondary-replace.db"));
|
|
const std::string calendar = seed_secondary(cache, "secondary-replace");
|
|
ScriptedTransport transport;
|
|
transport.steps.push_back({json_response(view_page(event_object("event-a", "Old A") + ","
|
|
+ event_object("event-b", "Moved B")))});
|
|
transport.steps.push_back(
|
|
{json_response(view_page(event_object("event-a", "Updated A")))});
|
|
transport.steps.push_back({json_response(view_page({}))});
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
auto snapshot = cache.snapshot();
|
|
const CachedEvent& updated = event_by_remote(snapshot, "event-a");
|
|
const CachedEvent& moved = event_by_remote(snapshot, "event-b");
|
|
require(!updated.deleted && !moved.deleted,
|
|
"secondary update or moved-out omission was treated as a tombstone");
|
|
require(std::ranges::any_of(snapshot.instances, [&](const CachedEventInstance& instance) {
|
|
return instance.event_id == updated.id && instance.title == "Updated A";
|
|
}), "secondary event update did not replace its instance");
|
|
require(std::ranges::none_of(snapshot.instances, [&](const CachedEventInstance& instance) {
|
|
return instance.event_id == moved.id;
|
|
}), "moved-out event retained an instance in the completed window");
|
|
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
snapshot = cache.snapshot();
|
|
require(snapshot.instances.empty(), "empty secondary window did not evict membership");
|
|
require(std::ranges::all_of(snapshot.events,
|
|
[](const CachedEvent& event) { return !event.deleted; }),
|
|
"empty window marked omitted secondary events deleted");
|
|
}
|
|
|
|
void test_secondary_overlapping_window_retention(const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("secondary-overlap.db"));
|
|
const std::string calendar = seed_secondary(cache, "secondary-overlap");
|
|
ScriptedTransport transport;
|
|
transport.steps.push_back(
|
|
{json_response(view_page(event_object("shared-event", "Shared")))});
|
|
transport.steps.push_back({json_response(view_page({}))});
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
const auto seeded = cache.snapshot();
|
|
require(seeded.events.size() == 1 && seeded.instances.size() == 1,
|
|
"overlap fixture was not cached");
|
|
|
|
CompleteWindow overlap{{seeded.events[0]}, {seeded.instances[0]},
|
|
SyncCheckpoint{calendar, "2026-07-15T00:00:00.000000Z",
|
|
"2026-08-15T00:00:00.000000Z", "graph-calendar-view-v1", true}};
|
|
cache.replace_complete_window(overlap);
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
const auto retained = cache.snapshot();
|
|
require(retained.instances.size() == 1
|
|
&& retained.instances[0].event_id == seeded.events[0].id,
|
|
"empty overlapping window removed an instance still referenced by another window");
|
|
}
|
|
|
|
void test_secondary_paged_failures_are_atomic_and_redacted(
|
|
const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("secondary-failure.db"));
|
|
const std::string calendar = seed_secondary(cache, "secondary-failure");
|
|
ScriptedTransport initial;
|
|
initial.steps.push_back(
|
|
{json_response(view_page(event_object("existing-event", "Existing")))});
|
|
MicrosoftGraphSync initial_graph(initial, cache);
|
|
initial_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
const auto baseline = cache.snapshot();
|
|
const std::string route =
|
|
"https://graph.microsoft.com/v1.0/me/calendars/secondary-failure/calendarView";
|
|
const std::string next = route + "?$skiptoken=SENSITIVE_CURSOR";
|
|
|
|
ScriptedTransport transport_failure;
|
|
transport_failure.steps = {
|
|
{json_response(view_page(event_object("SENSITIVE_REMOTE_EVENT", "New"), next))},
|
|
{{}, true}};
|
|
MicrosoftGraphSync transport_graph(transport_failure, cache);
|
|
const std::string transport_message = expect_graph_error([&] {
|
|
transport_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
});
|
|
require(cache.snapshot() == baseline, "later secondary transport failure changed prior window");
|
|
require(transport_message.find("SENSITIVE") == std::string::npos
|
|
&& transport_message.find("ACCESS_TOKEN_SECRET") == std::string::npos
|
|
&& transport_message.find("secondary-failure") == std::string::npos,
|
|
"secondary transport failure leaked token, cursor, remote ID, or calendar ID");
|
|
|
|
ScriptedTransport malformed;
|
|
malformed.steps = {
|
|
{json_response(view_page(event_object("new-first-page", "New"), next))},
|
|
{json_response("{\"value\":\"SENSITIVE_MALFORMED_BODY\"")}};
|
|
MicrosoftGraphSync malformed_graph(malformed, cache);
|
|
const std::string malformed_message = expect_graph_error([&] {
|
|
malformed_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
});
|
|
require(cache.snapshot() == baseline, "later malformed secondary page changed prior window");
|
|
require(malformed_message.find("SENSITIVE") == std::string::npos,
|
|
"secondary malformed response leaked provider body");
|
|
|
|
ScriptedTransport duplicate;
|
|
duplicate.steps = {
|
|
{json_response(view_page(event_object("duplicate-event", "First"), next))},
|
|
{json_response(view_page(event_object("duplicate-event", "Second")))}};
|
|
MicrosoftGraphSync duplicate_graph(duplicate, cache);
|
|
expect_graph_error([&] {
|
|
duplicate_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
});
|
|
require(cache.snapshot() == baseline,
|
|
"duplicate event across secondary pages changed prior window");
|
|
|
|
ScriptedTransport rejected;
|
|
rejected.steps.push_back({json_response("SENSITIVE_NON_2XX_BODY", 429)});
|
|
MicrosoftGraphSync rejected_graph(rejected, cache);
|
|
const std::string rejected_message = expect_graph_error([&] {
|
|
rejected_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
});
|
|
require(cache.snapshot() == baseline, "secondary non-2xx response changed prior window");
|
|
require(rejected_message.find("SENSITIVE") == std::string::npos,
|
|
"secondary non-2xx error leaked its response body");
|
|
}
|
|
|
|
void test_secondary_preconditions_do_not_use_network(const TemporaryDirectory& temporary) {
|
|
const auto require_no_network_failure = [&](SyncCache& cache,
|
|
MicrosoftGraphCredentials operation_credentials,
|
|
std::string calendar_id_value,
|
|
std::string_view message) {
|
|
ScriptedTransport transport;
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
expect_graph_error([&] {
|
|
graph.sync_secondary_calendar_window(
|
|
operation_credentials, std::move(calendar_id_value), july_window());
|
|
});
|
|
require(transport.requests.empty(), message);
|
|
};
|
|
|
|
SyncCache primary(temporary.database("secondary-primary-reject.db"));
|
|
seed_primary(primary);
|
|
require_no_network_failure(primary, credentials(), calendar_id("primary-remote"),
|
|
"primary calendar secondary sync reached network");
|
|
|
|
SyncCache wrong_account(temporary.database("secondary-account-reject.db"));
|
|
const std::string wrong_account_calendar = seed_secondary(wrong_account, "wrong-account");
|
|
require_no_network_failure(wrong_account, {"different-account", "ACCESS_TOKEN_SECRET"},
|
|
wrong_account_calendar, "wrong-account secondary sync reached network");
|
|
|
|
SyncCache inactive(temporary.database("secondary-inactive-reject.db"));
|
|
const std::string inactive_id = seed_secondary(inactive, "inactive");
|
|
inactive.replace_calendars_after_complete_listing("account-local", {});
|
|
require_no_network_failure(inactive, credentials(), inactive_id,
|
|
"inactive secondary sync reached network");
|
|
|
|
SyncCache missing(temporary.database("secondary-missing-reject.db"));
|
|
seed_account(missing);
|
|
require_no_network_failure(missing, credentials(), "missing-calendar",
|
|
"missing secondary sync reached network");
|
|
|
|
SyncCache malformed(temporary.database("secondary-malformed-reject.db"));
|
|
seed_account(malformed);
|
|
const CachedCalendar malformed_calendar{calendar_id("malformed"), "account-local",
|
|
"malformed", "Malformed", "", false, true, "{"};
|
|
malformed.replace_calendars_after_complete_listing(
|
|
"account-local", {&malformed_calendar, 1});
|
|
require_no_network_failure(malformed, credentials(), malformed_calendar.id,
|
|
"malformed-calendar secondary sync reached network");
|
|
|
|
SyncCache invalid_window(temporary.database("secondary-window-reject.db"));
|
|
const std::string valid_secondary = seed_secondary(invalid_window, "invalid-window");
|
|
ScriptedTransport transport;
|
|
MicrosoftGraphSync graph(transport, invalid_window);
|
|
expect_graph_error([&] {
|
|
graph.sync_secondary_calendar_window(credentials(), valid_secondary,
|
|
{july_window().start_epoch_microseconds, july_window().start_epoch_microseconds});
|
|
});
|
|
require(transport.requests.empty(), "invalid secondary window reached network");
|
|
}
|
|
|
|
void test_secondary_continuation_security(const TemporaryDirectory& temporary) {
|
|
const std::string route =
|
|
"https://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView";
|
|
const std::vector<std::string> unsafe{
|
|
"http://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
|
|
"https://evil.example/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
|
|
"https://user@graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
|
|
"https://graph.microsoft.com:443/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
|
|
"https://graph.microsoft.com/v1.0/me/calendars/other/calendarView?$skip=x",
|
|
route + "?$skip=x#fragment", route + "?$skip=x\\nInjected:true",
|
|
route + "?" + std::string(16U * 1024U, 'x')};
|
|
int sequence = 0;
|
|
for (const std::string& next : unsafe) {
|
|
SyncCache cache(temporary.database(
|
|
"secondary-link-" + std::to_string(sequence++) + ".db"));
|
|
const std::string calendar = seed_secondary(cache, "secondary-links");
|
|
const auto baseline = cache.snapshot();
|
|
ScriptedTransport transport;
|
|
transport.steps.push_back({json_response(
|
|
view_page(event_object("SENSITIVE_REMOTE_ID", "Sensitive"), next))});
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
const std::string message = expect_graph_error([&] {
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
});
|
|
require(transport.requests.size() == 1,
|
|
"unsafe secondary continuation received credentials");
|
|
require(cache.snapshot() == baseline, "unsafe secondary continuation changed cache");
|
|
require(message.find("SENSITIVE") == std::string::npos
|
|
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos,
|
|
"unsafe secondary continuation error leaked secrets");
|
|
}
|
|
|
|
SyncCache repeated_cache(temporary.database("secondary-link-repeated.db"));
|
|
const std::string repeated_calendar = seed_secondary(repeated_cache, "secondary-links");
|
|
const auto repeated_baseline = repeated_cache.snapshot();
|
|
const std::string repeated = route + "?$skiptoken=repeated";
|
|
ScriptedTransport repeated_transport;
|
|
repeated_transport.steps = {
|
|
{json_response(view_page({}, repeated))}, {json_response(view_page({}, repeated))}};
|
|
MicrosoftGraphSync repeated_graph(repeated_transport, repeated_cache);
|
|
expect_graph_error([&] {
|
|
repeated_graph.sync_secondary_calendar_window(
|
|
credentials(), repeated_calendar, july_window());
|
|
});
|
|
require(repeated_transport.requests.size() == 2,
|
|
"repeated secondary continuation triggered another credentialed request");
|
|
require(repeated_cache.snapshot() == repeated_baseline,
|
|
"repeated secondary continuation changed cache");
|
|
|
|
SyncCache delta_cache(temporary.database("secondary-delta-reject.db"));
|
|
const std::string delta_calendar = seed_secondary(delta_cache, "secondary-links");
|
|
const auto delta_baseline = delta_cache.snapshot();
|
|
ScriptedTransport delta_transport;
|
|
delta_transport.steps.push_back({json_response(
|
|
"{\"value\":[],\"@odata.deltaLink\":\"" + route + "?$delta=x\"}")});
|
|
MicrosoftGraphSync delta_graph(delta_transport, delta_cache);
|
|
expect_graph_error([&] {
|
|
delta_graph.sync_secondary_calendar_window(credentials(), delta_calendar, july_window());
|
|
});
|
|
require(delta_cache.snapshot() == delta_baseline,
|
|
"secondary calendarView accepted or committed a deltaLink");
|
|
}
|
|
|
|
void test_secondary_aggregate_response_bound(const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("secondary-aggregate.db"));
|
|
const std::string calendar = seed_secondary(cache, "secondary-aggregate");
|
|
const auto baseline = cache.snapshot();
|
|
const std::string route =
|
|
"https://graph.microsoft.com/v1.0/me/calendars/secondary-aggregate/calendarView";
|
|
ScriptedTransport transport;
|
|
const std::string padding(7U * 1024U * 1024U, 'x');
|
|
for (int page = 0; page < 10; ++page) {
|
|
const std::string next = route + "?$skiptoken=aggregate-" + std::to_string(page + 1);
|
|
transport.steps.push_back({json_response("{\"value\":[],\"padding\":\"" + padding
|
|
+ "\",\"@odata.nextLink\":\"" + next + "\"}")});
|
|
}
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
expect_graph_error([&] {
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
});
|
|
require(transport.requests.size() == 10,
|
|
"secondary aggregate response bound fired at the wrong page");
|
|
require(cache.snapshot() == baseline,
|
|
"aggregate secondary response overflow changed prior cache state");
|
|
}
|
|
|
|
void test_secondary_cache_failure_is_atomic(const TemporaryDirectory& temporary) {
|
|
SyncCache cache(temporary.database("secondary-cache-failure.db"));
|
|
const std::string calendar = seed_secondary(cache, "secondary-cache-failure");
|
|
const std::string target_id =
|
|
expected_id("graph-event-", "event", calendar, "target-remote");
|
|
CompleteWindow poisoned{{CachedEvent{target_id, calendar, "different-remote", "uid", "", "",
|
|
"{\"poisoned\":true}", false}},
|
|
{}, SyncCheckpoint{calendar, "2026-07-01T00:00:00.000000Z",
|
|
"2026-08-01T00:00:00.000000Z", "graph-calendar-view-v1", true}};
|
|
cache.replace_complete_window(poisoned);
|
|
const auto baseline = cache.snapshot();
|
|
ScriptedTransport transport;
|
|
transport.steps.push_back(
|
|
{json_response(view_page(event_object("target-remote", "Target")))});
|
|
MicrosoftGraphSync graph(transport, cache);
|
|
const std::string message = expect_graph_error([&] {
|
|
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
|
|
});
|
|
require(cache.snapshot() == baseline,
|
|
"secondary cache failure partially replaced the complete window");
|
|
require(message.find("target-remote") == std::string::npos
|
|
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos,
|
|
"secondary cache failure leaked remote ID or token");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
try {
|
|
TemporaryDirectory temporary;
|
|
test_profile_mapping_headers_and_atomic_validation(temporary);
|
|
test_paged_listing_atomicity_primary_and_stable_ids(temporary);
|
|
test_delta_mapping_incremental_tombstone_and_routes(temporary);
|
|
test_page_resume_and_primary_only_failure(temporary);
|
|
test_invalid_inputs_continuations_and_timestamps(temporary);
|
|
test_secondary_route_pagination_and_occurrence_mapping(temporary);
|
|
test_secondary_update_empty_and_moved_out(temporary);
|
|
test_secondary_overlapping_window_retention(temporary);
|
|
test_secondary_paged_failures_are_atomic_and_redacted(temporary);
|
|
test_secondary_preconditions_do_not_use_network(temporary);
|
|
test_secondary_continuation_security(temporary);
|
|
test_secondary_aggregate_response_bound(temporary);
|
|
test_secondary_cache_failure_is_atomic(temporary);
|
|
} catch (const std::exception& error) {
|
|
std::cerr << "Microsoft Graph tests failed: " << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
std::cout << "Microsoft Graph tests passed\n";
|
|
return 0;
|
|
}
|