Add strict /me identity, atomic paged calendar discovery, and resumable primary-calendar v1 delta synchronization through the durable provider cache. Keep opaque continuations on trusted Graph URLs and retain credentials only per operation. Document that stable Graph v1 delta covers only the primary calendar; secondary calendars still require a v1 reconciliation or beta dependency decision. Verified: 18/18 normal, 18/18 -Werror, and 18/18 ASan/UBSan tests.
453 lines
20 KiB
C++
453 lines
20 KiB
C++
#include "nocal/sync/microsoft_graph.hpp"
|
|
|
|
#include <openssl/evp.h>
|
|
|
|
#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::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});
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
} // 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);
|
|
} 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;
|
|
}
|