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.
1141 lines
53 KiB
C++
1141 lines
53 KiB
C++
#include "nocal/sync/microsoft_graph.hpp"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <openssl/evp.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <exception>
|
|
#include <filesystem>
|
|
#include <functional>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <optional>
|
|
#include <span>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include <unistd.h>
|
|
|
|
namespace {
|
|
|
|
using namespace std::chrono;
|
|
using nocal::storage::CacheSnapshot;
|
|
using nocal::storage::CachedAccount;
|
|
using nocal::storage::CachedCalendar;
|
|
using nocal::storage::CachedEvent;
|
|
using nocal::storage::CachedEventInstance;
|
|
using nocal::storage::PullPage;
|
|
using nocal::storage::SyncCache;
|
|
using nocal::storage::SyncCheckpoint;
|
|
using nocal::sync::HttpHeader;
|
|
using nocal::sync::HttpMethod;
|
|
using nocal::sync::HttpRequest;
|
|
using nocal::sync::HttpResponse;
|
|
using nocal::sync::HttpTransport;
|
|
using nocal::sync::MicrosoftGraphCredentials;
|
|
using nocal::sync::MicrosoftGraphSync;
|
|
using nocal::sync::MicrosoftGraphWindow;
|
|
|
|
constexpr std::string_view graph_prefix = "https://graph.microsoft.com/";
|
|
constexpr std::string_view primary_remote_id = "remote-primary-calendar";
|
|
constexpr std::string_view secondary_remote_id = "remote-secondary/id +?%";
|
|
constexpr std::string_view primary_local_prefix = "graph-calendar-";
|
|
constexpr std::string_view event_local_prefix = "graph-event-";
|
|
constexpr std::string_view instance_local_prefix = "graph-instance-";
|
|
|
|
void require(bool condition, std::string_view message) {
|
|
if (!condition) {
|
|
throw std::runtime_error(std::string{message});
|
|
}
|
|
}
|
|
|
|
template <typename Function>
|
|
std::string require_throws_redacted(Function&& function, std::string_view message,
|
|
std::span<const std::string_view> sensitive = {}) {
|
|
try {
|
|
function();
|
|
} catch (const std::exception& error) {
|
|
const std::string description = error.what();
|
|
for (const auto value : sensitive) {
|
|
require(value.empty() || description.find(value) == std::string::npos,
|
|
std::string{message}.append(": sensitive data leaked in exception"));
|
|
}
|
|
return description;
|
|
}
|
|
throw std::runtime_error(std::string{message}.append(": no exception"));
|
|
}
|
|
|
|
class TempDirectory {
|
|
public:
|
|
TempDirectory() {
|
|
std::string pattern =
|
|
(std::filesystem::temp_directory_path() / "nocal-graph-security.XXXXXX").string();
|
|
std::vector<char> writable(pattern.begin(), pattern.end());
|
|
writable.push_back('\0');
|
|
const char* created = ::mkdtemp(writable.data());
|
|
if (created == nullptr) {
|
|
throw std::runtime_error("mkdtemp failed");
|
|
}
|
|
path_ = created;
|
|
}
|
|
|
|
TempDirectory(const TempDirectory&) = delete;
|
|
TempDirectory& operator=(const TempDirectory&) = delete;
|
|
~TempDirectory() {
|
|
std::error_code ignored;
|
|
std::filesystem::remove_all(path_, ignored);
|
|
}
|
|
|
|
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
|
|
|
|
private:
|
|
std::filesystem::path path_;
|
|
};
|
|
|
|
struct ScriptStep {
|
|
HttpResponse response;
|
|
std::optional<std::string> exception;
|
|
};
|
|
|
|
class ScriptedTransport final : public HttpTransport {
|
|
public:
|
|
HttpResponse send(const HttpRequest& request) override {
|
|
requests.push_back(request);
|
|
if (position >= steps.size()) {
|
|
throw std::runtime_error("unscripted Graph request");
|
|
}
|
|
ScriptStep& step = steps[position++];
|
|
if (step.exception) {
|
|
throw std::runtime_error(*step.exception);
|
|
}
|
|
return step.response;
|
|
}
|
|
|
|
void respond(std::string body, int status = 200) {
|
|
steps.push_back({HttpResponse{status, {}, std::move(body)}, std::nullopt});
|
|
}
|
|
|
|
void fail(std::string description) {
|
|
steps.push_back({HttpResponse{}, std::move(description)});
|
|
}
|
|
|
|
std::vector<ScriptStep> steps;
|
|
std::vector<HttpRequest> requests;
|
|
std::size_t position{0};
|
|
};
|
|
|
|
struct Rig {
|
|
explicit Rig(const std::filesystem::path& root)
|
|
: cache(root / ("graph-" + std::to_string(sequence++) + ".db")), graph(transport, cache) {}
|
|
|
|
static inline int sequence = 0;
|
|
ScriptedTransport transport;
|
|
SyncCache cache;
|
|
MicrosoftGraphSync graph;
|
|
MicrosoftGraphCredentials credentials{"local-account", "SENSITIVE_ACCESS_TOKEN"};
|
|
};
|
|
|
|
[[nodiscard]] std::string profile_body(
|
|
std::string_view remote_subject = "remote-user", std::string_view display = "Alice") {
|
|
return nlohmann::json{{"id", remote_subject}, {"displayName", display},
|
|
{"userPrincipalName", "alice@example.test"}}
|
|
.dump();
|
|
}
|
|
|
|
[[nodiscard]] nlohmann::json calendar_json(std::string_view id, std::string_view name,
|
|
bool primary, bool can_edit = true) {
|
|
return {{"id", id}, {"name", name}, {"color", "auto"}, {"hexColor", "#123456"},
|
|
{"canEdit", can_edit}, {"isDefaultCalendar", primary}};
|
|
}
|
|
|
|
[[nodiscard]] std::string calendar_page(const nlohmann::json& calendars,
|
|
std::optional<std::string> next = std::nullopt) {
|
|
nlohmann::json page{{"value", calendars}};
|
|
if (next) {
|
|
page["@odata.nextLink"] = *next;
|
|
}
|
|
return page.dump();
|
|
}
|
|
|
|
[[nodiscard]] nlohmann::json live_event(std::string_view id = "remote-event-1",
|
|
std::string_view start = "2026-07-18T09:00:00.1234567",
|
|
std::string_view end = "2026-07-18T10:00:00.1234567",
|
|
std::string_view zone = "UTC") {
|
|
return {{"id", id}, {"iCalUId", "uid-1"}, {"subject", "Planning"},
|
|
{"@odata.etag", "etag-1"}, {"changeKey", "change-1"}, {"isAllDay", false},
|
|
{"start", {{"dateTime", start}, {"timeZone", zone}}},
|
|
{"end", {{"dateTime", end}, {"timeZone", zone}}},
|
|
{"location", {{"displayName", "Room 1"}}}, {"bodyPreview", "Agenda"}};
|
|
}
|
|
|
|
[[nodiscard]] std::string delta_page(const nlohmann::json& events,
|
|
std::optional<std::string> next, std::optional<std::string> delta) {
|
|
nlohmann::json page{{"value", events}};
|
|
if (next) {
|
|
page["@odata.nextLink"] = *next;
|
|
}
|
|
if (delta) {
|
|
page["@odata.deltaLink"] = *delta;
|
|
}
|
|
return page.dump();
|
|
}
|
|
|
|
[[nodiscard]] std::string view_page(const nlohmann::json& events,
|
|
std::optional<std::string> next = std::nullopt,
|
|
std::optional<std::string> delta = std::nullopt) {
|
|
nlohmann::json page{{"value", events}};
|
|
if (next) {
|
|
page["@odata.nextLink"] = *next;
|
|
}
|
|
if (delta) {
|
|
page["@odata.deltaLink"] = *delta;
|
|
}
|
|
return page.dump();
|
|
}
|
|
|
|
[[nodiscard]] MicrosoftGraphWindow july_window() {
|
|
const auto start = sys_days{year{2026} / July / 18};
|
|
return {duration_cast<microseconds>(start.time_since_epoch()).count(),
|
|
duration_cast<microseconds>((start + days{1}).time_since_epoch()).count()};
|
|
}
|
|
|
|
[[nodiscard]] MicrosoftGraphWindow window_from_days(
|
|
year_month_day start, year_month_day end) {
|
|
return {duration_cast<microseconds>(sys_days{start}.time_since_epoch()).count(),
|
|
duration_cast<microseconds>(sys_days{end}.time_since_epoch()).count()};
|
|
}
|
|
|
|
[[nodiscard]] const HttpHeader* header(const HttpRequest& request, std::string_view name) {
|
|
const auto found = std::ranges::find_if(request.headers, [&](const HttpHeader& value) {
|
|
if (value.name.size() != name.size()) {
|
|
return false;
|
|
}
|
|
for (std::size_t index = 0; index < name.size(); ++index) {
|
|
const auto lower = [](unsigned char character) {
|
|
return character >= 'A' && character <= 'Z'
|
|
? static_cast<unsigned char>(character + ('a' - 'A'))
|
|
: character;
|
|
};
|
|
if (lower(static_cast<unsigned char>(value.name[index]))
|
|
!= lower(static_cast<unsigned char>(name[index]))) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
return found == request.headers.end() ? nullptr : &*found;
|
|
}
|
|
|
|
void require_safe_request(const HttpRequest& request, std::string_view token) {
|
|
require(request.method == HttpMethod::get, "Graph read adapter sent a non-GET request");
|
|
require(request.body.empty(), "Graph GET request unexpectedly contains a body");
|
|
const HttpHeader* authorization = header(request, "Authorization");
|
|
require(authorization != nullptr && authorization->value == "Bearer " + std::string{token},
|
|
"Graph request is missing the exact bearer credential");
|
|
require(request.url.starts_with(graph_prefix), "Graph request left the trusted origin");
|
|
require(request.url.find("/beta/") == std::string::npos,
|
|
"Graph adapter called the beta API");
|
|
}
|
|
|
|
void seed_account(Rig& rig) {
|
|
rig.transport.respond(profile_body());
|
|
static_cast<void>(rig.graph.refresh_account(rig.credentials));
|
|
}
|
|
|
|
void seed_calendars(Rig& rig, bool include_other = true) {
|
|
seed_account(rig);
|
|
nlohmann::json values = nlohmann::json::array(
|
|
{calendar_json(primary_remote_id, "Primary", true)});
|
|
if (include_other) {
|
|
values.push_back(calendar_json("remote-other-calendar", "Other", false, false));
|
|
}
|
|
rig.transport.respond(calendar_page(values));
|
|
rig.graph.refresh_calendars(rig.credentials);
|
|
}
|
|
|
|
[[nodiscard]] std::string sha256_hex(std::string_view value) {
|
|
std::array<unsigned char, 32> digest{};
|
|
unsigned int size = 0;
|
|
require(EVP_Digest(value.data(), value.size(), digest.data(), &size, EVP_sha256(), nullptr) == 1
|
|
&& size == digest.size(),
|
|
"could not hash Graph local identifier fixture");
|
|
constexpr char hexadecimal[] = "0123456789abcdef";
|
|
std::string result;
|
|
result.reserve(digest.size() * 2);
|
|
for (const unsigned char byte : digest) {
|
|
result.push_back(hexadecimal[byte >> 4U]);
|
|
result.push_back(hexadecimal[byte & 0x0fU]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] std::string local_calendar_id(
|
|
std::string_view account, std::string_view remote) {
|
|
std::string input{"calendar", 8};
|
|
input.push_back('\0');
|
|
input += account;
|
|
input.push_back('\0');
|
|
input += remote;
|
|
return std::string{primary_local_prefix} + sha256_hex(input);
|
|
}
|
|
|
|
[[nodiscard]] std::string local_event_id(
|
|
std::string_view calendar_id, std::string_view remote) {
|
|
std::string input{"event", 5};
|
|
input.push_back('\0');
|
|
input += calendar_id;
|
|
input.push_back('\0');
|
|
input += remote;
|
|
return std::string{event_local_prefix} + sha256_hex(input);
|
|
}
|
|
|
|
[[nodiscard]] std::string local_instance_id(std::string_view event_id) {
|
|
std::string input{"instance", 8};
|
|
input.push_back('\0');
|
|
input += event_id;
|
|
return std::string{instance_local_prefix} + sha256_hex(input);
|
|
}
|
|
|
|
[[nodiscard]] std::string percent_encode(std::string_view value) {
|
|
constexpr char hexadecimal[] = "0123456789ABCDEF";
|
|
std::string encoded;
|
|
for (const unsigned char character : value) {
|
|
const bool unreserved = (character >= 'A' && character <= 'Z')
|
|
|| (character >= 'a' && character <= 'z')
|
|
|| (character >= '0' && character <= '9') || character == '-'
|
|
|| character == '.' || character == '_' || character == '~';
|
|
if (unreserved) {
|
|
encoded.push_back(static_cast<char>(character));
|
|
} else {
|
|
encoded.push_back('%');
|
|
encoded.push_back(hexadecimal[character >> 4U]);
|
|
encoded.push_back(hexadecimal[character & 0x0fU]);
|
|
}
|
|
}
|
|
return encoded;
|
|
}
|
|
|
|
[[nodiscard]] std::string secondary_view_prefix(std::string_view remote) {
|
|
return "https://graph.microsoft.com/v1.0/me/calendars/" + percent_encode(remote)
|
|
+ "/calendarView";
|
|
}
|
|
|
|
[[nodiscard]] std::string secondary_next(
|
|
std::string_view remote, std::string_view marker = "page-2") {
|
|
return secondary_view_prefix(remote) + "?$skiptoken=" + std::string{marker};
|
|
}
|
|
|
|
[[nodiscard]] const CachedCalendar& calendar_with_remote(
|
|
const CacheSnapshot& snapshot, std::string_view remote) {
|
|
const auto found = std::ranges::find_if(snapshot.calendars,
|
|
[&](const CachedCalendar& calendar) { return calendar.remote_id == remote; });
|
|
if (found == snapshot.calendars.end()) {
|
|
throw std::runtime_error("calendar fixture was not cached");
|
|
}
|
|
return *found;
|
|
}
|
|
|
|
[[nodiscard]] std::string seed_secondary_calendar(
|
|
Rig& rig, std::string_view remote = secondary_remote_id) {
|
|
seed_account(rig);
|
|
const nlohmann::json values = nlohmann::json::array(
|
|
{calendar_json(primary_remote_id, "Primary", true),
|
|
calendar_json(remote, "Secondary", false, false)});
|
|
rig.transport.respond(calendar_page(values));
|
|
rig.graph.refresh_calendars(rig.credentials);
|
|
return calendar_with_remote(rig.cache.snapshot(), remote).id;
|
|
}
|
|
|
|
void sync_secondary_page(Rig& rig, std::string_view calendar_id,
|
|
const nlohmann::json& events, const MicrosoftGraphWindow& window = july_window()) {
|
|
rig.transport.respond(view_page(events));
|
|
rig.graph.sync_secondary_calendar_window(
|
|
rig.credentials, std::string{calendar_id}, window);
|
|
}
|
|
|
|
void test_credentials_and_request_security(const std::filesystem::path& root) {
|
|
const std::vector<MicrosoftGraphCredentials> invalid{
|
|
{"", "token"}, {"bad\naccount", "token"}, {"SENSITIVE_ACCOUNT_ID_123", ""},
|
|
{"SENSITIVE_ACCOUNT_ID_123", " "},
|
|
{"SENSITIVE_ACCOUNT_ID_123", "token\r\nInjected: yes"}};
|
|
for (const auto& credentials : invalid) {
|
|
Rig rig{root};
|
|
const CacheSnapshot before = rig.cache.snapshot();
|
|
const std::array<std::string_view, 2> sensitive{
|
|
credentials.account_id, credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { static_cast<void>(rig.graph.refresh_account(credentials)); },
|
|
"invalid Graph credentials were accepted", sensitive);
|
|
require(rig.transport.requests.empty(), "invalid credentials reached the transport");
|
|
require(rig.cache.snapshot() == before, "invalid credentials changed the cache");
|
|
}
|
|
|
|
Rig rig{root};
|
|
rig.transport.respond(profile_body());
|
|
const auto account = rig.graph.refresh_account(rig.credentials);
|
|
require(account.id == rig.credentials.account_id && account.provider == "microsoft-graph"
|
|
&& account.remote_subject == "remote-user" && account.display_name == "Alice",
|
|
"valid profile did not map to the frozen account contract");
|
|
require(rig.transport.requests.size() == 1, "profile refresh sent the wrong request count");
|
|
require_safe_request(rig.transport.requests[0], rig.credentials.access_token);
|
|
|
|
rig.transport.respond(profile_body("remote-user", ""));
|
|
const auto fallback = rig.graph.refresh_account(rig.credentials);
|
|
require(fallback.display_name == "alice@example.test",
|
|
"empty displayName did not fall back to userPrincipalName");
|
|
}
|
|
|
|
void test_account_failures_are_atomic_and_redacted(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
rig.transport.respond(profile_body());
|
|
static_cast<void>(rig.graph.refresh_account(rig.credentials));
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
|
|
const std::string response_secret = "SENSITIVE_PROFILE_BODY";
|
|
rig.transport.respond(response_secret, 401);
|
|
const std::array<std::string_view, 2> response_sensitive{
|
|
rig.credentials.access_token, response_secret};
|
|
require_throws_redacted([&] { static_cast<void>(rig.graph.refresh_account(rig.credentials)); },
|
|
"non-2xx profile response was accepted", response_sensitive);
|
|
require(rig.cache.snapshot() == baseline, "non-2xx profile response mutated account cache");
|
|
|
|
rig.transport.fail("transport detail " + rig.credentials.access_token);
|
|
const std::array<std::string_view, 1> token_sensitive{rig.credentials.access_token};
|
|
require_throws_redacted([&] { static_cast<void>(rig.graph.refresh_account(rig.credentials)); },
|
|
"profile transport failure did not propagate safely", token_sensitive);
|
|
require(rig.cache.snapshot() == baseline, "profile transport failure mutated account cache");
|
|
|
|
const std::vector<std::string> malformed{"{", "[]", R"({"id":"x"})",
|
|
R"({"id":"first","id":"second","displayName":"name"})",
|
|
std::string(8U * 1024U * 1024U + 1U, 'x')};
|
|
for (const auto& body : malformed) {
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 2> sensitive{body, rig.credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { static_cast<void>(rig.graph.refresh_account(rig.credentials)); },
|
|
"malformed profile body was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline, "malformed profile body mutated account cache");
|
|
}
|
|
}
|
|
|
|
void test_calendar_listing_atomicity_and_duplicates(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
const std::string next =
|
|
"https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=partial-cursor";
|
|
rig.transport.respond(calendar_page(
|
|
nlohmann::json::array({calendar_json(primary_remote_id, "Changed", true)}), next));
|
|
rig.transport.fail("later calendar page failed SENSITIVE_ACCESS_TOKEN");
|
|
const std::array<std::string_view, 3> sensitive{
|
|
rig.credentials.access_token, next, primary_remote_id};
|
|
require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); },
|
|
"partial calendar listing was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline,
|
|
"partial calendar listing changed or deactivated cached calendars");
|
|
|
|
const std::vector<std::string> invalid_pages{
|
|
calendar_page(nlohmann::json::array(
|
|
{calendar_json("duplicate", "One", true),
|
|
calendar_json("duplicate", "Two", false)})),
|
|
calendar_page(nlohmann::json::array(
|
|
{calendar_json("primary-a", "One", true),
|
|
calendar_json("primary-b", "Two", true)})),
|
|
R"({"value":{},"@odata.nextLink":null})",
|
|
R"({"value":[],"value":[]})",
|
|
"{",
|
|
std::string(8U * 1024U * 1024U + 1U, 'x')};
|
|
for (const auto& body : invalid_pages) {
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 2> page_sensitive{body, rig.credentials.access_token};
|
|
require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); },
|
|
"invalid calendar listing was accepted", page_sensitive);
|
|
require(rig.cache.snapshot() == baseline, "invalid calendar listing mutated cache");
|
|
}
|
|
}
|
|
|
|
void test_continuation_url_rejection(const std::filesystem::path& root) {
|
|
const std::vector<std::string> unsafe{
|
|
"http://graph.microsoft.com/v1.0/next",
|
|
"https://evil.example.test/v1.0/next",
|
|
"https://graph.microsoft.com.evil.test/v1.0/next",
|
|
"https://user@graph.microsoft.com/v1.0/next",
|
|
"https://graph.microsoft.com:443/v1.0/next",
|
|
"https://graph.microsoft.com/v1.0/next#fragment",
|
|
"https://graph.microsoft.com/v1.0/next\nInjected: yes",
|
|
std::string{"https://graph.microsoft.com/"} + std::string(16U * 1024U, 'x')};
|
|
for (const auto& url : unsafe) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const std::size_t requests_before = rig.transport.requests.size();
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
rig.transport.respond(calendar_page(nlohmann::json::array(), url));
|
|
const std::array<std::string_view, 3> sensitive{
|
|
url, rig.credentials.access_token, primary_remote_id};
|
|
require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); },
|
|
"unsafe Graph continuation was accepted", sensitive);
|
|
require(rig.transport.requests.size() == requests_before + 1,
|
|
"unsafe continuation reached a credentialed request");
|
|
require(rig.cache.snapshot() == baseline, "unsafe continuation mutated cache");
|
|
}
|
|
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const std::string repeated =
|
|
"https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=repeated";
|
|
const std::size_t before = rig.transport.requests.size();
|
|
rig.transport.respond(calendar_page(nlohmann::json::array(), repeated));
|
|
rig.transport.respond(calendar_page(nlohmann::json::array(), repeated));
|
|
const std::array<std::string_view, 2> sensitive{repeated, rig.credentials.access_token};
|
|
require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); },
|
|
"repeated Graph continuation was accepted", sensitive);
|
|
require(rig.transport.requests.size() == before + 2,
|
|
"repeated continuation triggered another credentialed request");
|
|
}
|
|
|
|
void test_window_and_primary_preconditions(const std::filesystem::path& root) {
|
|
const std::vector<MicrosoftGraphWindow> invalid{{0, 0}, {1, 0},
|
|
{std::numeric_limits<std::int64_t>::min(), 0},
|
|
{0, std::numeric_limits<std::int64_t>::max()}};
|
|
for (const auto window : invalid) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const std::size_t before = rig.transport.requests.size();
|
|
require_throws_redacted(
|
|
[&] { rig.graph.sync_primary_calendar_window(rig.credentials, window); },
|
|
"invalid Graph window was accepted");
|
|
require(rig.transport.requests.size() == before,
|
|
"invalid Graph window reached the transport");
|
|
}
|
|
|
|
Rig no_primary{root};
|
|
seed_account(no_primary);
|
|
no_primary.transport.respond(calendar_page(
|
|
nlohmann::json::array({calendar_json("not-primary", "Other", false)})));
|
|
no_primary.graph.refresh_calendars(no_primary.credentials);
|
|
const std::size_t before = no_primary.transport.requests.size();
|
|
require_throws_redacted(
|
|
[&] { no_primary.graph.sync_primary_calendar_window(
|
|
no_primary.credentials, july_window()); },
|
|
"sync without a primary calendar was accepted");
|
|
require(no_primary.transport.requests.size() == before,
|
|
"missing-primary sync reached the transport");
|
|
}
|
|
|
|
void test_delta_request_and_strict_page_shape(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
const std::vector<std::string> invalid{
|
|
R"({"value":[]})",
|
|
R"({"value":[],"@odata.nextLink":"https://graph.microsoft.com/v1.0/next","@odata.deltaLink":"https://graph.microsoft.com/v1.0/delta"})",
|
|
R"({"value":{},"@odata.deltaLink":"https://graph.microsoft.com/v1.0/delta"})",
|
|
R"({"value":[],"value":[],"@odata.deltaLink":"https://graph.microsoft.com/v1.0/delta"})",
|
|
"{", std::string(8U * 1024U * 1024U + 1U, 'x')};
|
|
for (const auto& body : invalid) {
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 2> sensitive{body, rig.credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); },
|
|
"invalid delta page was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline,
|
|
"invalid delta page advanced cursor or changed cache");
|
|
}
|
|
|
|
const std::string delta = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=done";
|
|
rig.transport.respond(delta_page(nlohmann::json::array(), std::nullopt, delta));
|
|
const std::size_t request_index = rig.transport.requests.size();
|
|
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
|
|
const HttpRequest& request = rig.transport.requests[request_index];
|
|
require_safe_request(request, rig.credentials.access_token);
|
|
require(request.url.find("/v1.0/me/calendarView/delta?") != std::string::npos,
|
|
"initial sync did not use stable primary calendarView/delta");
|
|
require(request.url.find("startDateTime=2026-07-18T00%3A00%3A00.000000Z")
|
|
!= std::string::npos
|
|
&& request.url.find("endDateTime=2026-07-19T00%3A00%3A00.000000Z")
|
|
!= std::string::npos,
|
|
"initial delta window was not canonically encoded");
|
|
require(request.url.find("/beta/") == std::string::npos
|
|
&& request.url.find("/calendars/") == std::string::npos,
|
|
"adapter used beta or undocumented per-calendar delta");
|
|
const HttpHeader* prefer = header(request, "Prefer");
|
|
require(prefer != nullptr && prefer->value.find("UTC") != std::string::npos,
|
|
"delta request is missing its UTC Prefer header");
|
|
}
|
|
|
|
void test_delta_continuation_security(const std::filesystem::path& root) {
|
|
{
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
const std::string unsafe = "https://evil.example.test/v1.0/delta?SENSITIVE_CURSOR";
|
|
const std::string body = delta_page(
|
|
nlohmann::json::array({live_event("SENSITIVE_REMOTE_ID")}), unsafe, std::nullopt);
|
|
const std::size_t before = rig.transport.requests.size();
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 4> sensitive{
|
|
unsafe, body, "SENSITIVE_REMOTE_ID", rig.credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); },
|
|
"unsafe delta continuation was accepted", sensitive);
|
|
require(rig.transport.requests.size() == before + 1,
|
|
"unsafe delta continuation reached a credentialed request");
|
|
require(rig.cache.snapshot() == baseline,
|
|
"page with unsafe delta continuation was committed");
|
|
}
|
|
|
|
{
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const std::string repeated =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=repeated";
|
|
const std::size_t before = rig.transport.requests.size();
|
|
rig.transport.respond(delta_page(nlohmann::json::array(), repeated, std::nullopt));
|
|
rig.transport.respond(delta_page(nlohmann::json::array(), repeated, std::nullopt));
|
|
const std::array<std::string_view, 2> sensitive{repeated, rig.credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); },
|
|
"repeated delta continuation was accepted", sensitive);
|
|
require(rig.transport.requests.size() == before + 2,
|
|
"repeated delta continuation triggered a third credentialed request");
|
|
}
|
|
}
|
|
|
|
void test_authoritative_timestamp_offsets(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const auto event = live_event("offset-event", "2026-07-18T09:00:00.1234567Z",
|
|
"2026-07-18T11:00:00.1234567+01:00", "Unknown/But-Ignored");
|
|
rig.transport.respond(delta_page(nlohmann::json::array({event}), std::nullopt,
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=offset"));
|
|
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
|
|
const CacheSnapshot snapshot = rig.cache.snapshot();
|
|
require(snapshot.instances.size() == 1, "valid explicit-offset event was not cached");
|
|
const auto day = sys_days{year{2026} / July / 18};
|
|
const auto expected_start =
|
|
duration_cast<microseconds>((day + 9h).time_since_epoch()).count() + 123'456;
|
|
const auto expected_end =
|
|
duration_cast<microseconds>((day + 10h).time_since_epoch()).count() + 123'456;
|
|
require(snapshot.instances[0].start_epoch_microseconds == expected_start
|
|
&& snapshot.instances[0].end_epoch_microseconds == expected_end,
|
|
"explicit offset authority or seventh-digit truncation is incorrect");
|
|
}
|
|
|
|
void test_timestamp_identity_and_interval_rejection(const std::filesystem::path& root) {
|
|
const std::vector<nlohmann::json> invalid_events{
|
|
live_event("unknown-zone", "2026-07-18T09:00:00", "2026-07-18T10:00:00",
|
|
"Europe/London"),
|
|
live_event("invalid-date", "2026-02-30T09:00:00", "2026-02-30T10:00:00"),
|
|
live_event("leap-second", "2026-07-18T09:00:60Z", "2026-07-18T10:00:00Z",
|
|
"Ignored"),
|
|
live_event("bad-offset", "2026-07-18T09:00:00+25:00",
|
|
"2026-07-18T10:00:00+25:00", "Ignored"),
|
|
live_event("backwards", "2026-07-18T10:00:00", "2026-07-18T09:00:00"),
|
|
};
|
|
for (const auto& event : invalid_events) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
const std::string delta =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=invalid";
|
|
const std::string body =
|
|
delta_page(nlohmann::json::array({event}), std::nullopt, delta);
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 3> sensitive{
|
|
body, event.at("id").get_ref<const std::string&>(), rig.credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); },
|
|
"invalid Graph event timestamp was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline,
|
|
"invalid Graph event advanced cursor or changed cache");
|
|
}
|
|
|
|
Rig duplicate{root};
|
|
seed_calendars(duplicate);
|
|
const CacheSnapshot baseline = duplicate.cache.snapshot();
|
|
duplicate.transport.respond(delta_page(
|
|
nlohmann::json::array({live_event("duplicate"), live_event("duplicate")}),
|
|
std::nullopt,
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=duplicate"));
|
|
require_throws_redacted(
|
|
[&] { duplicate.graph.sync_primary_calendar_window(
|
|
duplicate.credentials, july_window()); },
|
|
"duplicate Graph event identity was accepted");
|
|
require(duplicate.cache.snapshot() == baseline,
|
|
"duplicate Graph event advanced cursor or changed cache");
|
|
}
|
|
|
|
void test_resume_after_later_failure(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const std::string next = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=page-2";
|
|
rig.transport.respond(
|
|
delta_page(nlohmann::json::array({live_event()}), next, std::nullopt));
|
|
rig.transport.fail("later transport failed " + next + " remote-event-1 SENSITIVE_ACCESS_TOKEN");
|
|
const std::size_t start_request = rig.transport.requests.size();
|
|
const std::array<std::string_view, 3> sensitive{
|
|
next, "remote-event-1", rig.credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); },
|
|
"later delta transport failure was accepted", sensitive);
|
|
const CacheSnapshot committed = rig.cache.snapshot();
|
|
require(committed.events.size() == 1 && committed.instances.size() == 1
|
|
&& committed.checkpoints.size() == 1 && committed.checkpoints[0].cursor == next
|
|
&& !committed.checkpoints[0].complete,
|
|
"validated first delta page was not committed atomically before later failure");
|
|
require(rig.transport.requests.size() == start_request + 2,
|
|
"paged delta failure sent an unexpected request count");
|
|
|
|
const std::string final =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=complete";
|
|
rig.transport.respond(delta_page(nlohmann::json::array(), std::nullopt, final));
|
|
const std::size_t resume_index = rig.transport.requests.size();
|
|
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
|
|
require(rig.transport.requests[resume_index].url == next,
|
|
"later sync did not resume from the last committed opaque cursor");
|
|
require(rig.cache.snapshot().checkpoints[0].cursor == final
|
|
&& rig.cache.snapshot().checkpoints[0].complete,
|
|
"resumed delta did not commit its final cursor");
|
|
}
|
|
|
|
void test_tombstone_preserves_payload(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
seed_calendars(rig);
|
|
const std::string first_delta =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=first";
|
|
rig.transport.respond(
|
|
delta_page(nlohmann::json::array({live_event()}), std::nullopt, first_delta));
|
|
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
|
|
const std::string raw = rig.cache.snapshot().events[0].raw_payload;
|
|
|
|
const std::string second_delta =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=second";
|
|
const nlohmann::json removed =
|
|
{{"id", "remote-event-1"}, {"@removed", {{"reason", "deleted"}}}};
|
|
rig.transport.respond(
|
|
delta_page(nlohmann::json::array({removed}), std::nullopt, second_delta));
|
|
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
|
|
const CacheSnapshot snapshot = rig.cache.snapshot();
|
|
require(snapshot.events.size() == 1 && snapshot.events[0].deleted
|
|
&& snapshot.events[0].raw_payload == raw && snapshot.instances.empty(),
|
|
"Graph tombstone did not retain raw payload and remove instances");
|
|
}
|
|
|
|
void test_cache_failure_does_not_advance_cursor(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
seed_calendars(rig, false);
|
|
const std::string calendar_id =
|
|
local_calendar_id(rig.credentials.account_id, primary_remote_id);
|
|
const std::string poisoned_id = local_event_id(calendar_id, "target-remote-event");
|
|
const std::string prior =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=prior";
|
|
CachedEvent poisoned{poisoned_id, calendar_id, "different-remote-id", "uid", "", "",
|
|
R"({"poisoned":true})", false};
|
|
rig.cache.apply_pull_page(PullPage{{poisoned}, {},
|
|
SyncCheckpoint{calendar_id, "2026-07-18T00:00:00.000000Z",
|
|
"2026-07-19T00:00:00.000000Z", prior, false}});
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
|
|
const std::string next =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=must-not-commit";
|
|
const std::string body = delta_page(
|
|
nlohmann::json::array({live_event("target-remote-event")}), std::nullopt, next);
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 4> sensitive{
|
|
prior, next, "target-remote-event", rig.credentials.access_token};
|
|
require_throws_redacted(
|
|
[&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); },
|
|
"Graph cache identity failure was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline,
|
|
"cache failure advanced Graph cursor or partially replaced event state");
|
|
}
|
|
|
|
void test_secondary_exact_route_and_preconditions(const std::filesystem::path& root) {
|
|
{
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
const std::size_t request_index = rig.transport.requests.size();
|
|
sync_secondary_page(rig, secondary_id, nlohmann::json::array());
|
|
require(rig.transport.requests.size() == request_index + 1,
|
|
"secondary full-window sync sent an unexpected request count");
|
|
const HttpRequest& request = rig.transport.requests[request_index];
|
|
require_safe_request(request, rig.credentials.access_token);
|
|
const std::string expected = secondary_view_prefix(secondary_remote_id)
|
|
+ "?startDateTime=2026-07-18T00%3A00%3A00.000000Z&"
|
|
"endDateTime=2026-07-19T00%3A00%3A00.000000Z&$top=1000";
|
|
require(request.url == expected,
|
|
"secondary sync did not use the exact encoded Graph v1 calendarView route");
|
|
require(request.url.find("/beta/") == std::string::npos
|
|
&& request.url.find("/delta") == std::string::npos,
|
|
"secondary sync used beta or a delta route");
|
|
const HttpHeader* prefer = header(request, "Prefer");
|
|
require(prefer != nullptr && prefer->value.find("UTC") != std::string::npos
|
|
&& prefer->value.find("ImmutableId") != std::string::npos,
|
|
"secondary sync omitted the UTC/immutable-ID preference");
|
|
}
|
|
|
|
auto require_no_request = [](Rig& rig, std::string calendar_id,
|
|
std::string_view message) {
|
|
const std::size_t before_requests = rig.transport.requests.size();
|
|
const CacheSnapshot before = rig.cache.snapshot();
|
|
const std::array<std::string_view, 2> sensitive{
|
|
rig.credentials.access_token, calendar_id};
|
|
require_throws_redacted(
|
|
[&] {
|
|
rig.graph.sync_secondary_calendar_window(
|
|
rig.credentials, calendar_id, july_window());
|
|
},
|
|
message, sensitive);
|
|
require(rig.transport.requests.size() == before_requests,
|
|
std::string{message}.append(": precondition reached the transport"));
|
|
require(rig.cache.snapshot() == before,
|
|
std::string{message}.append(": precondition mutated cache"));
|
|
};
|
|
|
|
{
|
|
Rig rig{root};
|
|
static_cast<void>(seed_secondary_calendar(rig));
|
|
const std::string primary_id =
|
|
calendar_with_remote(rig.cache.snapshot(), primary_remote_id).id;
|
|
require_no_request(rig, primary_id, "primary calendar accepted as secondary");
|
|
require_no_request(rig, "missing-calendar", "missing secondary calendar accepted");
|
|
}
|
|
|
|
{
|
|
Rig rig{root};
|
|
static_cast<void>(seed_secondary_calendar(rig));
|
|
const CachedAccount other{
|
|
"other-account", "microsoft-graph", "other-subject", "Other"};
|
|
rig.cache.upsert_account(other);
|
|
const std::string other_id = local_calendar_id(other.id, "other-remote");
|
|
const CachedCalendar other_calendar{other_id, other.id, "other-remote", "Other", "",
|
|
true, true, calendar_json("other-remote", "Other", false, false).dump()};
|
|
rig.cache.replace_calendars_after_complete_listing(other.id, {&other_calendar, 1});
|
|
require_no_request(rig, other_id, "wrong-account secondary calendar accepted");
|
|
}
|
|
|
|
{
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
rig.transport.respond(calendar_page(
|
|
nlohmann::json::array({calendar_json(primary_remote_id, "Primary", true)})));
|
|
rig.graph.refresh_calendars(rig.credentials);
|
|
require_no_request(rig, secondary_id, "inactive secondary calendar accepted");
|
|
}
|
|
|
|
{
|
|
Rig rig{root};
|
|
seed_account(rig);
|
|
const std::string malformed_id =
|
|
local_calendar_id(rig.credentials.account_id, "malformed-remote");
|
|
const CachedCalendar malformed{malformed_id, rig.credentials.account_id,
|
|
"malformed-remote", "Malformed", "", true, true, "{"};
|
|
rig.cache.replace_calendars_after_complete_listing(
|
|
rig.credentials.account_id, {&malformed, 1});
|
|
require_no_request(rig, malformed_id, "malformed cached calendar accepted");
|
|
}
|
|
}
|
|
|
|
void test_secondary_partial_pages_are_atomic_and_redacted(
|
|
const std::filesystem::path& root) {
|
|
for (int failure = 0; failure < 3; ++failure) {
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
sync_secondary_page(rig, secondary_id,
|
|
nlohmann::json::array({live_event("baseline-secondary")}));
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
const std::string next = secondary_next(secondary_remote_id);
|
|
const std::string partial_remote = "SENSITIVE_PARTIAL_REMOTE_ID";
|
|
const std::string first_body =
|
|
view_page(nlohmann::json::array({live_event(partial_remote)}), next);
|
|
rig.transport.respond(first_body);
|
|
const std::string later_body = "SENSITIVE_LATER_PAGE_BODY";
|
|
if (failure == 0) {
|
|
rig.transport.fail("transport leaked " + rig.credentials.access_token + " "
|
|
+ std::string{secondary_remote_id} + " " + next);
|
|
} else if (failure == 1) {
|
|
rig.transport.respond(later_body, 503);
|
|
} else {
|
|
rig.transport.respond("{\"value\":[");
|
|
}
|
|
const std::size_t requests_before = rig.transport.requests.size();
|
|
const std::string encoded_remote = percent_encode(secondary_remote_id);
|
|
const std::array<std::string_view, 7> sensitive{rig.credentials.access_token,
|
|
secondary_remote_id, encoded_remote, partial_remote, first_body, later_body, next};
|
|
require_throws_redacted(
|
|
[&] {
|
|
rig.graph.sync_secondary_calendar_window(
|
|
rig.credentials, secondary_id, july_window());
|
|
},
|
|
"partial secondary listing failure was accepted", sensitive);
|
|
require(rig.transport.requests.size() == requests_before + 2,
|
|
"partial secondary listing failure sent the wrong request count");
|
|
require(rig.cache.snapshot() == baseline,
|
|
"partial secondary listing changed event, membership, or checkpoint state");
|
|
}
|
|
}
|
|
|
|
void test_secondary_continuation_security(const std::filesystem::path& root) {
|
|
const std::string exact_prefix = secondary_view_prefix(secondary_remote_id);
|
|
const std::vector<std::string> unsafe{
|
|
"http://graph.microsoft.com/v1.0/me/calendars/"
|
|
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
|
|
"https://evil.example.test/v1.0/me/calendars/"
|
|
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
|
|
"https://user@graph.microsoft.com/v1.0/me/calendars/"
|
|
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
|
|
"https://graph.microsoft.com:443/v1.0/me/calendars/"
|
|
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
|
|
exact_prefix + "?$skiptoken=x#fragment",
|
|
exact_prefix + "?$skiptoken=x\nInjected: yes",
|
|
secondary_next("cross-calendar", "cross"),
|
|
exact_prefix + "/subpath?$skiptoken=x",
|
|
exact_prefix + "/delta?$skiptoken=x",
|
|
exact_prefix + "?$skiptoken=" + std::string(16U * 1024U, 'x')};
|
|
|
|
for (const std::string& next : unsafe) {
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
const std::string body = view_page(
|
|
nlohmann::json::array({live_event("SENSITIVE_UNCOMMITTED_EVENT")}), next);
|
|
rig.transport.respond(body);
|
|
const std::size_t before = rig.transport.requests.size();
|
|
const std::array<std::string_view, 5> sensitive{rig.credentials.access_token,
|
|
secondary_remote_id, "SENSITIVE_UNCOMMITTED_EVENT", next, body};
|
|
require_throws_redacted(
|
|
[&] {
|
|
rig.graph.sync_secondary_calendar_window(
|
|
rig.credentials, secondary_id, july_window());
|
|
},
|
|
"unsafe secondary continuation was accepted", sensitive);
|
|
require(rig.transport.requests.size() == before + 1,
|
|
"unsafe secondary continuation reached a credentialed follow-up");
|
|
require(rig.cache.snapshot() == baseline,
|
|
"unsafe secondary continuation changed cache state");
|
|
}
|
|
|
|
Rig repeated{root};
|
|
const std::string secondary_id = seed_secondary_calendar(repeated);
|
|
const CacheSnapshot baseline = repeated.cache.snapshot();
|
|
const std::string next = secondary_next(secondary_remote_id, "repeated");
|
|
repeated.transport.respond(view_page(nlohmann::json::array(), next));
|
|
repeated.transport.respond(view_page(nlohmann::json::array(), next));
|
|
const std::size_t before = repeated.transport.requests.size();
|
|
const std::array<std::string_view, 3> sensitive{
|
|
repeated.credentials.access_token, secondary_remote_id, next};
|
|
require_throws_redacted(
|
|
[&] {
|
|
repeated.graph.sync_secondary_calendar_window(
|
|
repeated.credentials, secondary_id, july_window());
|
|
},
|
|
"repeated secondary continuation was accepted", sensitive);
|
|
require(repeated.transport.requests.size() == before + 2,
|
|
"repeated secondary continuation triggered another credentialed follow-up");
|
|
require(repeated.cache.snapshot() == baseline,
|
|
"repeated secondary continuation committed a partial listing");
|
|
}
|
|
|
|
void test_secondary_page_shape_is_strict_and_atomic(const std::filesystem::path& root) {
|
|
const std::string next = secondary_next(secondary_remote_id, "valid-next");
|
|
const std::string other = secondary_next(secondary_remote_id, "other-link");
|
|
const std::vector<std::string> invalid{
|
|
view_page(nlohmann::json::array(), std::nullopt, other),
|
|
view_page(nlohmann::json::array(), next, other),
|
|
R"({"value":[],"value":[]})",
|
|
R"({"value":{}})",
|
|
view_page(nlohmann::json::array(
|
|
{live_event("duplicate-secondary"), live_event("duplicate-secondary")})),
|
|
view_page(nlohmann::json::array(
|
|
{{{"id", "tombstone-secondary"}, {"@removed", {{"reason", "deleted"}}}}})),
|
|
};
|
|
|
|
for (const std::string& body : invalid) {
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
sync_secondary_page(rig, secondary_id,
|
|
nlohmann::json::array({live_event("baseline-shape")}));
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 4> sensitive{
|
|
rig.credentials.access_token, secondary_remote_id, body, "tombstone-secondary"};
|
|
require_throws_redacted(
|
|
[&] {
|
|
rig.graph.sync_secondary_calendar_window(
|
|
rig.credentials, secondary_id, july_window());
|
|
},
|
|
"invalid complete-view page shape was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline,
|
|
"invalid complete-view page changed event, membership, or checkpoint state");
|
|
}
|
|
}
|
|
|
|
void test_secondary_omission_and_overlapping_membership(const std::filesystem::path& root) {
|
|
{
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
sync_secondary_page(rig, secondary_id,
|
|
nlohmann::json::array({live_event("omitted-secondary")}));
|
|
const CacheSnapshot present = rig.cache.snapshot();
|
|
require(present.events.size() == 1 && present.instances.size() == 1,
|
|
"secondary complete view did not cache its event");
|
|
|
|
sync_secondary_page(rig, secondary_id, nlohmann::json::array());
|
|
const CacheSnapshot omitted = rig.cache.snapshot();
|
|
require(omitted.events.size() == 1 && !omitted.events[0].deleted
|
|
&& omitted.instances.empty(),
|
|
"completed omission tombstoned the event or retained an orphan instance");
|
|
}
|
|
|
|
{
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
const MicrosoftGraphWindow first =
|
|
window_from_days(year{2026} / July / 18, year{2026} / July / 21);
|
|
const MicrosoftGraphWindow second =
|
|
window_from_days(year{2026} / July / 19, year{2026} / July / 22);
|
|
const nlohmann::json shared = live_event("shared-secondary",
|
|
"2026-07-19T09:00:00.0000000", "2026-07-19T10:00:00.0000000");
|
|
sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), first);
|
|
sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), second);
|
|
require(rig.cache.snapshot().instances.size() == 1,
|
|
"overlapping windows duplicated a shared event instance");
|
|
|
|
sync_secondary_page(rig, secondary_id, nlohmann::json::array(), first);
|
|
require(rig.cache.snapshot().instances.size() == 1,
|
|
"omission from one window evicted an instance retained by another window");
|
|
sync_secondary_page(rig, secondary_id, nlohmann::json::array(), second);
|
|
const CacheSnapshot removed = rig.cache.snapshot();
|
|
require(removed.instances.empty() && removed.events.size() == 1
|
|
&& !removed.events[0].deleted,
|
|
"last membership removal did not evict the instance without tombstoning");
|
|
}
|
|
}
|
|
|
|
void test_secondary_cache_collisions_roll_back(const std::filesystem::path& root) {
|
|
{
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
const std::string target_remote = "target-secondary-identity";
|
|
const std::string target_id = local_event_id(secondary_id, target_remote);
|
|
const CachedEvent poison{target_id, secondary_id, "different-secondary-remote", "", "",
|
|
"", R"({"poison":"identity"})", false};
|
|
rig.cache.apply_pull_page(PullPage{{poison}, {},
|
|
SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z",
|
|
"2026-07-19T00:00:00.000000Z", "identity-baseline", false}});
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
const std::string body =
|
|
view_page(nlohmann::json::array({live_event(target_remote)}));
|
|
rig.transport.respond(body);
|
|
const std::array<std::string_view, 4> sensitive{
|
|
rig.credentials.access_token, secondary_remote_id, target_remote, body};
|
|
require_throws_redacted(
|
|
[&] {
|
|
rig.graph.sync_secondary_calendar_window(
|
|
rig.credentials, secondary_id, july_window());
|
|
},
|
|
"secondary event identity collision was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline,
|
|
"secondary identity collision changed snapshot or checkpoint");
|
|
}
|
|
|
|
{
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
const std::string target_remote = "target-secondary-instance";
|
|
const std::string target_event = local_event_id(secondary_id, target_remote);
|
|
const std::string poison_event = local_event_id(secondary_id, "poison-owner");
|
|
const CachedEvent poison{poison_event, secondary_id, "poison-owner", "", "", "",
|
|
R"({"poison":"instance"})", false};
|
|
const auto start = sys_days{year{2026} / July / 18} + 7h;
|
|
const CachedEventInstance colliding{local_instance_id(target_event), poison_event,
|
|
duration_cast<microseconds>(start.time_since_epoch()).count(),
|
|
duration_cast<microseconds>((start + 1h).time_since_epoch()).count(), false,
|
|
"Poison", "", "", "UTC"};
|
|
rig.cache.apply_pull_page(PullPage{{poison}, {colliding},
|
|
SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z",
|
|
"2026-07-19T00:00:00.000000Z", "instance-baseline", false}});
|
|
const CacheSnapshot baseline = rig.cache.snapshot();
|
|
rig.transport.respond(
|
|
view_page(nlohmann::json::array({live_event(target_remote)})));
|
|
const std::array<std::string_view, 3> sensitive{
|
|
rig.credentials.access_token, secondary_remote_id, target_remote};
|
|
require_throws_redacted(
|
|
[&] {
|
|
rig.graph.sync_secondary_calendar_window(
|
|
rig.credentials, secondary_id, july_window());
|
|
},
|
|
"secondary instance identity collision was accepted", sensitive);
|
|
require(rig.cache.snapshot() == baseline,
|
|
"secondary instance collision changed snapshot or checkpoint");
|
|
}
|
|
}
|
|
|
|
void test_primary_delta_after_secondary_full_sync(const std::filesystem::path& root) {
|
|
Rig rig{root};
|
|
const std::string secondary_id = seed_secondary_calendar(rig);
|
|
sync_secondary_page(rig, secondary_id,
|
|
nlohmann::json::array({live_event("secondary-before-primary")}));
|
|
|
|
const std::string delta =
|
|
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=after-secondary";
|
|
rig.transport.respond(delta_page(
|
|
nlohmann::json::array({live_event("primary-after-secondary")}), std::nullopt, delta));
|
|
const std::size_t request_index = rig.transport.requests.size();
|
|
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
|
|
require(rig.transport.requests[request_index].url.find("/v1.0/me/calendarView/delta?")
|
|
!= std::string::npos,
|
|
"primary delta did not use its documented route after secondary sync");
|
|
require(rig.transport.requests[request_index].url.find("/calendars/") == std::string::npos,
|
|
"primary delta reused the secondary calendarView route");
|
|
const CacheSnapshot snapshot = rig.cache.snapshot();
|
|
require(snapshot.events.size() == 2 && snapshot.instances.size() == 2,
|
|
"primary delta after secondary sync lost one provider view");
|
|
require(std::ranges::any_of(snapshot.checkpoints,
|
|
[&](const SyncCheckpoint& checkpoint) {
|
|
return checkpoint.calendar_id
|
|
== calendar_with_remote(snapshot, primary_remote_id).id
|
|
&& checkpoint.cursor == delta && checkpoint.complete;
|
|
}),
|
|
"primary delta did not commit its checkpoint after secondary sync");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
try {
|
|
TempDirectory temporary;
|
|
test_credentials_and_request_security(temporary.path());
|
|
test_account_failures_are_atomic_and_redacted(temporary.path());
|
|
test_calendar_listing_atomicity_and_duplicates(temporary.path());
|
|
test_continuation_url_rejection(temporary.path());
|
|
test_window_and_primary_preconditions(temporary.path());
|
|
test_delta_request_and_strict_page_shape(temporary.path());
|
|
test_delta_continuation_security(temporary.path());
|
|
test_authoritative_timestamp_offsets(temporary.path());
|
|
test_timestamp_identity_and_interval_rejection(temporary.path());
|
|
test_resume_after_later_failure(temporary.path());
|
|
test_tombstone_preserves_payload(temporary.path());
|
|
test_cache_failure_does_not_advance_cursor(temporary.path());
|
|
test_secondary_exact_route_and_preconditions(temporary.path());
|
|
test_secondary_partial_pages_are_atomic_and_redacted(temporary.path());
|
|
test_secondary_continuation_security(temporary.path());
|
|
test_secondary_page_shape_is_strict_and_atomic(temporary.path());
|
|
test_secondary_omission_and_overlapping_membership(temporary.path());
|
|
test_secondary_cache_collisions_roll_back(temporary.path());
|
|
test_primary_delta_after_secondary_full_sync(temporary.path());
|
|
} catch (const std::exception& error) {
|
|
std::cerr << "Microsoft Graph security test failure: " << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
std::cout << "Microsoft Graph security tests passed\n";
|
|
return 0;
|
|
}
|