feat(sync): add Microsoft Graph read adapter
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.
This commit is contained in:
36
README.md
36
README.md
@@ -123,10 +123,11 @@ conflict records are reserved for later write synchronization.
|
|||||||
The provider boundary models Microsoft sign-in as a public desktop client
|
The provider boundary models Microsoft sign-in as a public desktop client
|
||||||
using the system browser, the OAuth authorization-code flow, PKCE with `S256`,
|
using the system browser, the OAuth authorization-code flow, PKCE with `S256`,
|
||||||
and a loopback redirect. Nocal's application registration must be multitenant,
|
and a loopback redirect. Nocal's application registration must be multitenant,
|
||||||
with the intended organizational/personal Microsoft account audience, and the
|
with the intended organizational/personal Microsoft account audience. The
|
||||||
first read-only synchronization will request only delegated `Calendars.Read`.
|
read-only adapter requests delegated `User.Read` for `/me` plus
|
||||||
This permission normally supports user consent, but an organization's tenant
|
`Calendars.Read` for calendar and event reads. Neither delegated permission
|
||||||
policy can still require administrator consent or block user consent. An
|
normally requires administrator consent, but an organization's tenant policy
|
||||||
|
can still require approval or block user consent. An
|
||||||
open-source desktop executable cannot keep a client secret, so none is embedded
|
open-source desktop executable cannot keep a client secret, so none is embedded
|
||||||
or expected.
|
or expected.
|
||||||
|
|
||||||
@@ -160,8 +161,25 @@ cancelled, and storage failures remain distinct errors and never fall back to
|
|||||||
another storage channel. Nocal does not mark an account connected until the
|
another storage channel. Nocal does not mark an account connected until the
|
||||||
complete secret has been stored successfully.
|
complete secret has been stored successfully.
|
||||||
|
|
||||||
This remains foundation, not working Office 365 integration. There is still no
|
The Microsoft Graph read phase calls `/me` and discovers every calendar through
|
||||||
Microsoft Graph call, account UI, or usable account setup. The next slice is a
|
the complete paged listing, replacing the
|
||||||
Microsoft adapter that calls `/me`, discovers
|
cached calendar set atomically only after every page succeeds. Provider/account
|
||||||
calendars, and performs read-only delta synchronization with delegated
|
identity produces stable deterministic local IDs. Event requests prefer
|
||||||
`Calendars.Read`.
|
immutable Graph IDs, parse strictly within fixed bounds, and keep credentials
|
||||||
|
out of errors. Access tokens are supplied per operation; Secret Service remains
|
||||||
|
the only persistent token store.
|
||||||
|
|
||||||
|
All Graph requests and opaque next/delta links must use HTTPS on
|
||||||
|
`graph.microsoft.com`. Primary-calendar event windows are half-open UTC ranges.
|
||||||
|
Each page, its tombstones, and its next or delta cursor commit atomically, so an
|
||||||
|
interrupted round resumes without advancing past durable data.
|
||||||
|
|
||||||
|
Stable Graph v1.0 documents `calendarView` delta only for the primary calendar.
|
||||||
|
Nocal therefore discovers all calendars but delta-syncs only the primary one;
|
||||||
|
it deliberately avoids beta and undocumented per-calendar routes. Secondary
|
||||||
|
calendars require a product decision between v1 full-window reconciliation with
|
||||||
|
explicit deletion semantics and a beta-specific delta implementation before
|
||||||
|
Outlook-equivalent coverage can be claimed. There is still no account UI/CLI
|
||||||
|
orchestration or live Microsoft credential integration, and remote writes
|
||||||
|
remain out of scope. The next phase is account setup and orchestration plus that
|
||||||
|
secondary-calendar decision.
|
||||||
|
|||||||
@@ -240,7 +240,48 @@ read/write/delete failure remains an explicit failure; none triggers plaintext
|
|||||||
fallback or partial connected state. An account becomes connected only after
|
fallback or partial connected state. An account becomes connected only after
|
||||||
the complete versioned payload has been stored successfully.
|
the complete versioned payload has been stored successfully.
|
||||||
|
|
||||||
Graph requests, account UI, and usable account setup remain absent. After this
|
Graph requests, account UI, and usable account setup remain absent at this
|
||||||
boundary passes verification, the Microsoft adapter will call `/me`, discover
|
boundary. The read adapter uses delegated `User.Read` for `/me`, then delegated
|
||||||
calendars, and apply read-only delta pages through the durable cache using only
|
`Calendars.Read` for calendars and events. Neither permission normally requires
|
||||||
delegated `Calendars.Read`.
|
administrator consent, although tenant policy can require approval or prevent
|
||||||
|
user consent.
|
||||||
|
|
||||||
|
### Microsoft Graph read boundary
|
||||||
|
|
||||||
|
The adapter remains a public desktop, multitenant client for organizational and
|
||||||
|
personal Microsoft accounts. It takes
|
||||||
|
an access token for each operation without retaining it; persistent access and
|
||||||
|
refresh tokens remain solely in the versioned Secret Service payload.
|
||||||
|
|
||||||
|
Identity uses `/me`. Calendar discovery consumes every page before atomically
|
||||||
|
replacing the account's cached calendar set, so a malformed or failed later page
|
||||||
|
leaves the prior complete listing intact. Local calendar IDs are deterministic
|
||||||
|
from stable provider, account, and remote identities. Event requests send the
|
||||||
|
Graph `Prefer: IdType="ImmutableId"` header so the provider identity remains
|
||||||
|
stable when an item moves within its mailbox. Nocal's calendar-scoped local
|
||||||
|
event identity remains stable for the lifetime of that cached calendar mapping.
|
||||||
|
|
||||||
|
JSON parsing is strict and bounded for identity, calendars, events, and paging
|
||||||
|
metadata. Error construction excludes authorization headers, access tokens, and
|
||||||
|
credential-bearing response material. Requests use only HTTPS
|
||||||
|
`graph.microsoft.com` URLs. Returned `@odata.nextLink` and `@odata.deltaLink`
|
||||||
|
values are opaque continuation capabilities: Nocal validates their scheme and
|
||||||
|
host but does not reconstruct or reinterpret their query parameters.
|
||||||
|
|
||||||
|
Event synchronization uses half-open UTC windows. Each page's normalized
|
||||||
|
events, deletion tombstones, and opaque next/delta cursor commit in one cache
|
||||||
|
transaction. A partial round is therefore durable and resumable, while a cursor
|
||||||
|
can never advance beyond its corresponding event changes.
|
||||||
|
|
||||||
|
Stable Microsoft Graph v1.0 documents `calendarView` delta only for the primary
|
||||||
|
calendar. The adapter discovers all calendars but delta-syncs events only from
|
||||||
|
the primary calendar. It does not call beta or an undocumented per-calendar
|
||||||
|
delta route. Secondary calendars remain an explicit architecture decision:
|
||||||
|
either reconcile complete v1 calendar-view windows with well-defined deletion
|
||||||
|
semantics, or accept a beta-specific delta dependency. That choice must be made
|
||||||
|
and tested before Nocal claims Outlook-equivalent calendar coverage.
|
||||||
|
|
||||||
|
There is no account UI or CLI orchestration and no live Microsoft credential
|
||||||
|
integration yet; tests use injected transports and credentials. Remote writes
|
||||||
|
remain out of scope. Account setup and orchestration plus the secondary-calendar
|
||||||
|
reconciliation decision are next.
|
||||||
|
|||||||
@@ -169,11 +169,35 @@ parsing, and storage failures are reported distinctly, with no plaintext
|
|||||||
fallback and no partially connected account. Connection state is published
|
fallback and no partially connected account. Connection state is published
|
||||||
only after the complete secret store succeeds.
|
only after the complete secret store succeeds.
|
||||||
|
|
||||||
This phase still does not make Office 365 usable. It contains no Microsoft Graph
|
The Microsoft Graph read phase keeps Nocal a public desktop multitenant
|
||||||
call, account UI, or usable account setup. After verification, the next product
|
application for organizational and personal
|
||||||
slice calls `/me`, discovers calendars, and performs read-only Graph delta
|
Microsoft accounts. It requests delegated `User.Read` for `/me` and delegated
|
||||||
synchronization using delegated `Calendars.Read`. Remote writes remain gated on
|
`Calendars.Read` for calendars and events. Neither normally requires
|
||||||
the durability and conflict paths being exercised end to end.
|
administrator consent, although tenant policy may require approval or block
|
||||||
|
user consent. Access tokens are passed only to the operation that needs them;
|
||||||
|
Secret Service retains the persistent token set.
|
||||||
|
|
||||||
|
Calendar discovery reads every page and publishes an atomic replacement only
|
||||||
|
after the full listing succeeds. Stable provider/account identity gives each
|
||||||
|
calendar a deterministic local ID. Event reads request immutable Graph IDs and
|
||||||
|
use strict bounded parsing with credential-safe failures. Requests and opaque
|
||||||
|
next/delta links are restricted to HTTPS `graph.microsoft.com` URLs.
|
||||||
|
|
||||||
|
Event windows are half-open UTC intervals. Each event page, its deletions, and
|
||||||
|
its continuation cursor cross the cache boundary in one transaction. A partial
|
||||||
|
round can resume from its durable cursor, and deletion tombstones remain
|
||||||
|
explicit instead of silently dropping remote state.
|
||||||
|
|
||||||
|
The stable Graph v1.0 `calendarView` delta API is documented only for the
|
||||||
|
primary calendar. This slice discovers all calendars but delta-syncs only the
|
||||||
|
primary; it uses neither beta nor undocumented per-calendar routes. Secondary
|
||||||
|
calendar support requires a deliberate choice between v1 full-window
|
||||||
|
reconciliation with defined deletion semantics and beta-specific delta. Nocal
|
||||||
|
does not yet claim Outlook-equivalent coverage.
|
||||||
|
|
||||||
|
There is still no account UI/CLI orchestration or live Microsoft credential
|
||||||
|
integration, and remote writes remain out of scope. Account setup/orchestration
|
||||||
|
and the secondary-calendar reconciliation decision are the next product phase.
|
||||||
|
|
||||||
## Local file interchange
|
## Local file interchange
|
||||||
|
|
||||||
|
|||||||
@@ -70,11 +70,26 @@ Completed token/secret boundary:
|
|||||||
- OAuth secrets only in Secret Service, never SQLite, logs, CLI arguments, or
|
- OAuth secrets only in Secret Service, never SQLite, logs, CLI arguments, or
|
||||||
environment variables
|
environment variables
|
||||||
|
|
||||||
|
Completed Microsoft Graph read phase:
|
||||||
|
|
||||||
|
- Public desktop multitenant organizational/personal account support
|
||||||
|
- Delegated `User.Read` for `/me` and `Calendars.Read` for calendars/events;
|
||||||
|
tenant policy may still require approval or block user consent
|
||||||
|
- Per-operation access tokens with persistent tokens retained only by Secret
|
||||||
|
Service
|
||||||
|
- Complete paged all-calendar discovery with atomic cache replacement and
|
||||||
|
deterministic local IDs
|
||||||
|
- Immutable Graph event IDs, strict bounded parsing, and credential-safe errors
|
||||||
|
- HTTPS `graph.microsoft.com`-only opaque continuation/delta links
|
||||||
|
- Half-open UTC event windows and per-page event/tombstone/cursor transactions
|
||||||
|
- Resumable primary-calendar v1 delta synchronization
|
||||||
|
|
||||||
Next provider-boundary slices:
|
Next provider-boundary slices:
|
||||||
|
|
||||||
- Microsoft `/me` identity and read-only calendar discovery
|
- Account setup and CLI/TUI orchestration with live credential integration
|
||||||
- Read-only Graph delta synchronization using delegated `Calendars.Read`
|
- Choose secondary-calendar behavior: v1 full-window reconciliation with
|
||||||
- Generic `SyncProvider` contract and observable sync status
|
deletion semantics, or an explicit beta-specific delta dependency
|
||||||
|
- Generic `SyncProvider` integration and observable sync status
|
||||||
|
|
||||||
## 0.3 — CalDAV
|
## 0.3 — CalDAV
|
||||||
|
|
||||||
@@ -89,11 +104,12 @@ Next provider-boundary slices:
|
|||||||
ETag-aware remote writes after the conflict boundary is proven
|
ETag-aware remote writes after the conflict boundary is proven
|
||||||
- Account/calendar management UI and per-calendar ANSI identity
|
- Account/calendar management UI and per-calendar ANSI identity
|
||||||
|
|
||||||
The cache, generic HTTP/OAuth boundary, and concrete Linux adapters are
|
The Graph read phase discovers all calendars but uses stable v1 delta only for
|
||||||
prerequisites, not usable Microsoft 365 integration. The token/Secret
|
the primary calendar; it deliberately avoids beta and undocumented
|
||||||
Service phase still provides no Graph call, account UI, or usable account setup.
|
per-calendar routes. No account UI/CLI orchestration or live Microsoft
|
||||||
After it passes verification, the immediate sequence is Microsoft `/me`,
|
credential integration exists yet, and remote writes remain out of scope. Do
|
||||||
read-only calendar discovery, and Graph delta synchronization.
|
not claim Outlook-equivalent coverage until the secondary-calendar strategy is
|
||||||
|
chosen and verified.
|
||||||
|
|
||||||
## 1.0 — distribution quality
|
## 1.0 — distribution quality
|
||||||
|
|
||||||
|
|||||||
59
include/nocal/sync/microsoft_graph.hpp
Normal file
59
include/nocal/sync/microsoft_graph.hpp
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/storage/sync_cache.hpp"
|
||||||
|
#include "nocal/sync/http.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace nocal::sync {
|
||||||
|
|
||||||
|
// Per-operation credentials for Microsoft Graph. The adapter never retains the
|
||||||
|
// access token after the public operation returns.
|
||||||
|
struct MicrosoftGraphCredentials {
|
||||||
|
std::string account_id;
|
||||||
|
std::string access_token;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A half-open UTC calendar window. Values are Unix epoch microseconds and are
|
||||||
|
// serialized canonically before they become part of a durable checkpoint.
|
||||||
|
struct MicrosoftGraphWindow {
|
||||||
|
std::int64_t start_epoch_microseconds{0};
|
||||||
|
std::int64_t end_epoch_microseconds{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
class MicrosoftGraphError : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
|
||||||
|
// every calendar returned by /me/calendars. Stable v1.0 event delta is exposed
|
||||||
|
// only for the signed-in user's primary calendar; this class deliberately does
|
||||||
|
// not call beta or undocumented per-calendar delta routes.
|
||||||
|
class MicrosoftGraphSync {
|
||||||
|
public:
|
||||||
|
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
|
||||||
|
|
||||||
|
// Requires delegated User.Read. A complete and validated /me response is
|
||||||
|
// persisted atomically; failures leave the cached account unchanged.
|
||||||
|
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials);
|
||||||
|
|
||||||
|
// Requires delegated Calendars.Read. Pagination is completed before the
|
||||||
|
// cache listing is replaced, so a partial listing never deactivates data.
|
||||||
|
void refresh_calendars(const MicrosoftGraphCredentials& credentials);
|
||||||
|
|
||||||
|
// Requires delegated Calendars.Read. Each validated page and its opaque
|
||||||
|
// next/delta URL are committed in one cache transaction. A later-page
|
||||||
|
// failure therefore resumes from the last committed cursor. Only HTTPS
|
||||||
|
// continuations on graph.microsoft.com are accepted.
|
||||||
|
void sync_primary_calendar_window(const MicrosoftGraphCredentials& credentials,
|
||||||
|
const MicrosoftGraphWindow& window);
|
||||||
|
|
||||||
|
private:
|
||||||
|
HttpTransport& transport_;
|
||||||
|
storage::SyncCache& cache_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nocal::sync
|
||||||
@@ -28,6 +28,7 @@ nocal_sources = files(
|
|||||||
'src/storage/sync_cache.cpp',
|
'src/storage/sync_cache.cpp',
|
||||||
'src/sync/curl_http.cpp',
|
'src/sync/curl_http.cpp',
|
||||||
'src/sync/desktop_oauth.cpp',
|
'src/sync/desktop_oauth.cpp',
|
||||||
|
'src/sync/microsoft_graph.cpp',
|
||||||
'src/sync/oauth.cpp',
|
'src/sync/oauth.cpp',
|
||||||
'src/sync/oauth_tokens.cpp',
|
'src/sync/oauth_tokens.cpp',
|
||||||
'src/sync/secret_store.cpp',
|
'src/sync/secret_store.cpp',
|
||||||
@@ -75,6 +76,12 @@ secret_store_tests = executable('secret-store-tests',
|
|||||||
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
||||||
test('secret-store', shell,
|
test('secret-store', shell,
|
||||||
args: [files('tests/run_secret_store_test.sh'), secret_store_tests])
|
args: [files('tests/run_secret_store_test.sh'), secret_store_tests])
|
||||||
|
microsoft_graph_tests = executable('microsoft-graph-tests',
|
||||||
|
'tests/microsoft_graph_tests.cpp', dependencies: nocal_dep)
|
||||||
|
test('microsoft-graph', microsoft_graph_tests)
|
||||||
|
microsoft_graph_security_tests = executable('microsoft-graph-security-tests',
|
||||||
|
'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep)
|
||||||
|
test('microsoft-graph-security', microsoft_graph_security_tests)
|
||||||
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
|
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
|
||||||
dependencies: nocal_dep)
|
dependencies: nocal_dep)
|
||||||
test('tui', tui_tests)
|
test('tui', tui_tests)
|
||||||
|
|||||||
618
src/sync/microsoft_graph.cpp
Normal file
618
src/sync/microsoft_graph.cpp
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
#include "nocal/sync/microsoft_graph.hpp"
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <charconv>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <limits>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace nocal::sync {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace std::chrono;
|
||||||
|
using nocal::storage::CachedAccount;
|
||||||
|
using nocal::storage::CachedCalendar;
|
||||||
|
using nocal::storage::CachedEvent;
|
||||||
|
using nocal::storage::CachedEventInstance;
|
||||||
|
using nocal::storage::PullPage;
|
||||||
|
using nocal::storage::SyncCheckpoint;
|
||||||
|
|
||||||
|
constexpr std::size_t maximum_response_bytes = 8U * 1024U * 1024U;
|
||||||
|
constexpr std::size_t maximum_continuation_bytes = 16U * 1024U;
|
||||||
|
constexpr std::size_t maximum_pages = 10'000U;
|
||||||
|
constexpr std::size_t maximum_values_per_page = 10'000U;
|
||||||
|
constexpr std::size_t maximum_accumulated_calendars = 100'000U;
|
||||||
|
constexpr std::size_t maximum_mapped_string_bytes = 1024U * 1024U;
|
||||||
|
constexpr std::string_view graph_v1 = "https://graph.microsoft.com/v1.0";
|
||||||
|
|
||||||
|
class ParseFailure final : public std::exception {};
|
||||||
|
class DuplicateKey final : public std::exception {};
|
||||||
|
|
||||||
|
[[noreturn]] void fail() { throw ParseFailure{}; }
|
||||||
|
|
||||||
|
[[nodiscard]] nlohmann::json parse_json_object(std::string_view body) {
|
||||||
|
if (body.empty() || body.size() > maximum_response_bytes) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
std::vector<std::unordered_set<std::string>> object_keys;
|
||||||
|
const nlohmann::json::parser_callback_t callback =
|
||||||
|
[&object_keys](int, nlohmann::json::parse_event_t event, nlohmann::json& value) {
|
||||||
|
if (event == nlohmann::json::parse_event_t::object_start) {
|
||||||
|
object_keys.emplace_back();
|
||||||
|
} else if (event == nlohmann::json::parse_event_t::key) {
|
||||||
|
if (object_keys.empty()
|
||||||
|
|| !object_keys.back().insert(value.get<std::string>()).second) {
|
||||||
|
throw DuplicateKey{};
|
||||||
|
}
|
||||||
|
} else if (event == nlohmann::json::parse_event_t::object_end) {
|
||||||
|
if (object_keys.empty()) {
|
||||||
|
throw DuplicateKey{};
|
||||||
|
}
|
||||||
|
object_keys.pop_back();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
nlohmann::json parsed =
|
||||||
|
nlohmann::json::parse(body.begin(), body.end(), callback, true, false);
|
||||||
|
if (!object_keys.empty() || !parsed.is_object()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (const DuplicateKey&) {
|
||||||
|
fail();
|
||||||
|
} catch (const nlohmann::json::exception&) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string required_string(
|
||||||
|
const nlohmann::json& object, std::string_view field, bool nonempty = true) {
|
||||||
|
const auto found = object.find(std::string(field));
|
||||||
|
if (found == object.end() || !found->is_string()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string value = found->get<std::string>();
|
||||||
|
if ((nonempty && value.empty()) || value.size() > maximum_mapped_string_bytes) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string optional_string(
|
||||||
|
const nlohmann::json& object, std::string_view field) {
|
||||||
|
const auto found = object.find(std::string(field));
|
||||||
|
if (found == object.end() || found->is_null()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!found->is_string()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string value = found->get<std::string>();
|
||||||
|
if (value.size() > maximum_mapped_string_bytes) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool required_bool(const nlohmann::json& object, std::string_view field) {
|
||||||
|
const auto found = object.find(std::string(field));
|
||||||
|
if (found == object.end() || !found->is_boolean()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return found->get<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] const nlohmann::json& required_array(
|
||||||
|
const nlohmann::json& object, std::string_view field) {
|
||||||
|
const auto found = object.find(std::string(field));
|
||||||
|
if (found == object.end() || !found->is_array()
|
||||||
|
|| found->size() > maximum_values_per_page) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return *found;
|
||||||
|
}
|
||||||
|
|
||||||
|
void validate_credentials(const MicrosoftGraphCredentials& credentials) {
|
||||||
|
const auto unsafe = [](std::string_view value) {
|
||||||
|
const bool blank = value.empty()
|
||||||
|
|| std::all_of(value.begin(), value.end(), [](unsigned char character) {
|
||||||
|
return character == ' ' || character == '\t' || character == '\r'
|
||||||
|
|| character == '\n' || character == '\f' || character == '\v';
|
||||||
|
});
|
||||||
|
return blank || value.find_first_of("\r\n\0", 0, 3) != std::string_view::npos;
|
||||||
|
};
|
||||||
|
if (unsafe(credentials.account_id) || unsafe(credentials.access_token)) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] HttpRequest graph_get(
|
||||||
|
std::string url, const MicrosoftGraphCredentials& credentials) {
|
||||||
|
return {HttpMethod::get, std::move(url),
|
||||||
|
{{"Authorization", "Bearer " + credentials.access_token}, {"Accept", "application/json"},
|
||||||
|
{"Content-Type", "application/json"},
|
||||||
|
{"Prefer", "outlook.timezone=\"UTC\", IdType=\"ImmutableId\""}},
|
||||||
|
{}};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] HttpResponse send_graph(HttpTransport& transport, std::string url,
|
||||||
|
const MicrosoftGraphCredentials& credentials) {
|
||||||
|
HttpResponse response = transport.send(graph_get(std::move(url), credentials));
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
if (response.body.size() > maximum_response_bytes) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ContinuationRoute { calendars, delta };
|
||||||
|
|
||||||
|
void validate_continuation(std::string_view url, ContinuationRoute route) {
|
||||||
|
const std::string_view required_prefix = route == ContinuationRoute::calendars
|
||||||
|
? "https://graph.microsoft.com/v1.0/me/calendars"
|
||||||
|
: "https://graph.microsoft.com/v1.0/me/calendarView/delta";
|
||||||
|
if (url.empty() || url.size() > maximum_continuation_bytes
|
||||||
|
|| !url.starts_with(required_prefix)
|
||||||
|
|| (url.size() > required_prefix.size()
|
||||||
|
&& url[required_prefix.size()] != '?')
|
||||||
|
|| url.find('#') != std::string_view::npos
|
||||||
|
|| std::any_of(url.begin(), url.end(), [](unsigned char character) {
|
||||||
|
return character <= 0x20U || character == 0x7fU;
|
||||||
|
})) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string optional_next_link(
|
||||||
|
const nlohmann::json& object, ContinuationRoute route) {
|
||||||
|
const auto found = object.find("@odata.nextLink");
|
||||||
|
if (found == object.end()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!found->is_string()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string value = found->get<std::string>();
|
||||||
|
validate_continuation(value, route);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string sha256_hex(std::string_view input) {
|
||||||
|
std::array<unsigned char, 32> digest{};
|
||||||
|
unsigned int size = 0;
|
||||||
|
if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1
|
||||||
|
|| size != digest.size()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
static constexpr char hexadecimal[] = "0123456789abcdef";
|
||||||
|
std::string output;
|
||||||
|
output.reserve(digest.size() * 2U);
|
||||||
|
for (const unsigned char byte : digest) {
|
||||||
|
output.push_back(hexadecimal[byte >> 4U]);
|
||||||
|
output.push_back(hexadecimal[byte & 0x0fU]);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string stable_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) + sha256_hex(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string calendar_id(
|
||||||
|
std::string_view account_id, std::string_view remote_id) {
|
||||||
|
return stable_id("graph-calendar-", "calendar", account_id, remote_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string event_id(
|
||||||
|
std::string_view local_calendar_id, std::string_view remote_id) {
|
||||||
|
return stable_id("graph-event-", "event", local_calendar_id, remote_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string instance_id(std::string_view local_event_id) {
|
||||||
|
return stable_id("graph-instance-", "instance", local_event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool supported_epoch(std::int64_t value) {
|
||||||
|
const auto minimum = duration_cast<microseconds>(sys_days{year{1} / January / 1}.time_since_epoch())
|
||||||
|
.count();
|
||||||
|
const auto maximum =
|
||||||
|
duration_cast<microseconds>(sys_days{year{10000} / January / 1}.time_since_epoch()).count();
|
||||||
|
return value >= minimum && value < maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string canonical_utc(std::int64_t epoch_microseconds) {
|
||||||
|
if (!supported_epoch(epoch_microseconds)) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const sys_time<microseconds> value{microseconds{epoch_microseconds}};
|
||||||
|
const sys_days day_point = floor<days>(value);
|
||||||
|
const year_month_day date{day_point};
|
||||||
|
const hh_mm_ss time{value - day_point};
|
||||||
|
std::array<char, 40> buffer{};
|
||||||
|
const int length = std::snprintf(buffer.data(), buffer.size(), "%04d-%02u-%02uT%02lld:%02lld:%02lld.%06lldZ",
|
||||||
|
static_cast<int>(date.year()), static_cast<unsigned>(date.month()),
|
||||||
|
static_cast<unsigned>(date.day()), static_cast<long long>(time.hours().count()),
|
||||||
|
static_cast<long long>(time.minutes().count()),
|
||||||
|
static_cast<long long>(time.seconds().count()),
|
||||||
|
static_cast<long long>(time.subseconds().count()));
|
||||||
|
if (length <= 0 || static_cast<std::size_t>(length) >= buffer.size()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return {buffer.data(), static_cast<std::size_t>(length)};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string percent_encode(std::string_view value) {
|
||||||
|
static constexpr char hexadecimal[] = "0123456789ABCDEF";
|
||||||
|
std::string output;
|
||||||
|
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) {
|
||||||
|
output.push_back(static_cast<char>(character));
|
||||||
|
} else {
|
||||||
|
output.push_back('%');
|
||||||
|
output.push_back(hexadecimal[character >> 4U]);
|
||||||
|
output.push_back(hexadecimal[character & 0x0fU]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] int parse_digits(std::string_view value, std::size_t offset, std::size_t count) {
|
||||||
|
if (offset + count > value.size()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
int result = 0;
|
||||||
|
const char* first = value.data() + offset;
|
||||||
|
const char* last = first + count;
|
||||||
|
const auto [end, error] = std::from_chars(first, last, result);
|
||||||
|
if (error != std::errc{} || end != last) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::int64_t parse_graph_datetime(const nlohmann::json& parent,
|
||||||
|
std::string_view field) {
|
||||||
|
const auto found = parent.find(std::string(field));
|
||||||
|
if (found == parent.end() || !found->is_object()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string text = required_string(*found, "dateTime");
|
||||||
|
const std::string zone = optional_string(*found, "timeZone");
|
||||||
|
if (text.size() < 19 || text[4] != '-' || text[7] != '-' || text[10] != 'T'
|
||||||
|
|| text[13] != ':' || text[16] != ':') {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const int year_value = parse_digits(text, 0, 4);
|
||||||
|
const int month_value = parse_digits(text, 5, 2);
|
||||||
|
const int day_value = parse_digits(text, 8, 2);
|
||||||
|
const int hour_value = parse_digits(text, 11, 2);
|
||||||
|
const int minute_value = parse_digits(text, 14, 2);
|
||||||
|
const int second_value = parse_digits(text, 17, 2);
|
||||||
|
const year_month_day date{year{year_value}, month{static_cast<unsigned>(month_value)},
|
||||||
|
day{static_cast<unsigned>(day_value)}};
|
||||||
|
if (year_value < 1 || year_value > 9999 || !date.ok() || hour_value > 23
|
||||||
|
|| minute_value > 59 || second_value > 59) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t offset = 19;
|
||||||
|
std::int64_t fractional_microseconds = 0;
|
||||||
|
if (offset < text.size() && text[offset] == '.') {
|
||||||
|
++offset;
|
||||||
|
const std::size_t start = offset;
|
||||||
|
while (offset < text.size() && text[offset] >= '0' && text[offset] <= '9') {
|
||||||
|
++offset;
|
||||||
|
}
|
||||||
|
const std::size_t digits = offset - start;
|
||||||
|
if (digits == 0 || digits > 7) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
for (std::size_t index = 0; index < std::min<std::size_t>(digits, 6); ++index) {
|
||||||
|
fractional_microseconds = fractional_microseconds * 10 + (text[start + index] - '0');
|
||||||
|
}
|
||||||
|
for (std::size_t index = digits; index < 6; ++index) {
|
||||||
|
fractional_microseconds *= 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int offset_minutes = 0;
|
||||||
|
bool has_suffix = false;
|
||||||
|
if (offset < text.size() && text[offset] == 'Z' && offset + 1 == text.size()) {
|
||||||
|
has_suffix = true;
|
||||||
|
} else if (offset < text.size() && (text[offset] == '+' || text[offset] == '-')) {
|
||||||
|
if (offset + 6 != text.size() || text[offset + 3] != ':') {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const int offset_hours = parse_digits(text, offset + 1, 2);
|
||||||
|
const int offset_minute_part = parse_digits(text, offset + 4, 2);
|
||||||
|
if (offset_hours > 23 || offset_minute_part > 59) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
offset_minutes = offset_hours * 60 + offset_minute_part;
|
||||||
|
if (text[offset] == '-') {
|
||||||
|
offset_minutes = -offset_minutes;
|
||||||
|
}
|
||||||
|
has_suffix = true;
|
||||||
|
} else if (offset != text.size()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
if (!has_suffix && zone != "UTC") {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sys_time<microseconds> local = sys_days{date} + hours{hour_value}
|
||||||
|
+ minutes{minute_value} + seconds{second_value} + microseconds{fractional_microseconds};
|
||||||
|
const sys_time<microseconds> utc = local - minutes{offset_minutes};
|
||||||
|
const std::int64_t result = utc.time_since_epoch().count();
|
||||||
|
if (!supported_epoch(result)) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string raw_json(const nlohmann::json& object) {
|
||||||
|
if (!object.is_object()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string raw = object.dump();
|
||||||
|
if (raw.empty() || raw.size() > maximum_response_bytes) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string calendar_color(const nlohmann::json& object) {
|
||||||
|
const std::string hexadecimal = optional_string(object, "hexColor");
|
||||||
|
return hexadecimal.empty() ? optional_string(object, "color") : hexadecimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool raw_calendar_is_primary(std::string_view raw) {
|
||||||
|
const nlohmann::json object = parse_json_object(raw);
|
||||||
|
return required_bool(object, "isDefaultCalendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] CachedCalendar primary_calendar(
|
||||||
|
const nocal::storage::CacheSnapshot& snapshot, std::string_view account_id) {
|
||||||
|
std::vector<CachedCalendar> primary;
|
||||||
|
for (const CachedCalendar& calendar : snapshot.calendars) {
|
||||||
|
if (calendar.account_id == account_id && calendar.active
|
||||||
|
&& raw_calendar_is_primary(calendar.raw_payload)) {
|
||||||
|
primary.push_back(calendar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (primary.size() != 1) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return primary.front();
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] SyncCheckpoint checkpoint_for(const nocal::storage::CacheSnapshot& snapshot,
|
||||||
|
std::string_view calendar, std::string_view window_start, std::string_view window_end) {
|
||||||
|
for (const SyncCheckpoint& checkpoint : snapshot.checkpoints) {
|
||||||
|
if (checkpoint.calendar_id == calendar && checkpoint.window_start == window_start
|
||||||
|
&& checkpoint.window_end == window_end) {
|
||||||
|
return checkpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] CachedEvent parse_delta_event(const nlohmann::json& object,
|
||||||
|
std::string_view local_calendar_id, std::vector<CachedEventInstance>& instances) {
|
||||||
|
if (!object.is_object()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string remote_id = required_string(object, "id");
|
||||||
|
const std::string local_event_id = event_id(local_calendar_id, remote_id);
|
||||||
|
const auto removed = object.find("@removed");
|
||||||
|
if (removed != object.end()) {
|
||||||
|
if (!removed->is_object()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
return {local_event_id, std::string(local_calendar_id), remote_id, {}, {}, {}, {}, true};
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::int64_t start = parse_graph_datetime(object, "start");
|
||||||
|
const std::int64_t end = parse_graph_datetime(object, "end");
|
||||||
|
if (end <= start) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const bool all_day = required_bool(object, "isAllDay");
|
||||||
|
std::string location;
|
||||||
|
const auto location_field = object.find("location");
|
||||||
|
if (location_field != object.end() && !location_field->is_null()) {
|
||||||
|
if (!location_field->is_object()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
location = optional_string(*location_field, "displayName");
|
||||||
|
}
|
||||||
|
CachedEvent event{local_event_id, std::string(local_calendar_id), remote_id,
|
||||||
|
optional_string(object, "iCalUId"), optional_string(object, "@odata.etag"),
|
||||||
|
optional_string(object, "changeKey"), raw_json(object), false};
|
||||||
|
instances.push_back({instance_id(local_event_id), local_event_id, start, end, all_day,
|
||||||
|
optional_string(object, "subject"), std::move(location),
|
||||||
|
optional_string(object, "bodyPreview"), "UTC"});
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeltaContinuation {
|
||||||
|
std::string cursor;
|
||||||
|
bool complete{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] DeltaContinuation delta_continuation(const nlohmann::json& object) {
|
||||||
|
const auto next = object.find("@odata.nextLink");
|
||||||
|
const auto delta = object.find("@odata.deltaLink");
|
||||||
|
if ((next == object.end()) == (delta == object.end())) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const nlohmann::json& selected = next != object.end() ? *next : *delta;
|
||||||
|
if (!selected.is_string()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string cursor = selected.get<std::string>();
|
||||||
|
validate_continuation(cursor, ContinuationRoute::delta);
|
||||||
|
return {cursor, delta != object.end()};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache)
|
||||||
|
: transport_(transport), cache_(cache) {}
|
||||||
|
|
||||||
|
CachedAccount MicrosoftGraphSync::refresh_account(
|
||||||
|
const MicrosoftGraphCredentials& credentials) {
|
||||||
|
try {
|
||||||
|
validate_credentials(credentials);
|
||||||
|
const nlohmann::json object =
|
||||||
|
parse_json_object(send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
|
||||||
|
const std::string remote_subject = required_string(object, "id");
|
||||||
|
std::string display_name = optional_string(object, "displayName");
|
||||||
|
if (display_name.empty()) {
|
||||||
|
display_name = required_string(object, "userPrincipalName");
|
||||||
|
}
|
||||||
|
CachedAccount account{
|
||||||
|
credentials.account_id, "microsoft-graph", remote_subject, std::move(display_name)};
|
||||||
|
cache_.upsert_account(account);
|
||||||
|
return account;
|
||||||
|
} catch (...) {
|
||||||
|
throw MicrosoftGraphError("Microsoft Graph account refresh failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MicrosoftGraphSync::refresh_calendars(const MicrosoftGraphCredentials& credentials) {
|
||||||
|
try {
|
||||||
|
validate_credentials(credentials);
|
||||||
|
std::vector<CachedCalendar> calendars;
|
||||||
|
std::unordered_set<std::string> remote_ids;
|
||||||
|
std::unordered_set<std::string> local_ids;
|
||||||
|
std::unordered_set<std::string> cursors;
|
||||||
|
std::string url = std::string(graph_v1) + "/me/calendars";
|
||||||
|
std::size_t primary_count = 0;
|
||||||
|
for (std::size_t page = 0; page < maximum_pages; ++page) {
|
||||||
|
if (!cursors.insert(url).second) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const nlohmann::json object =
|
||||||
|
parse_json_object(send_graph(transport_, url, credentials).body);
|
||||||
|
const nlohmann::json& values = required_array(object, "value");
|
||||||
|
for (const nlohmann::json& value : values) {
|
||||||
|
if (!value.is_object()) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string remote_id = required_string(value, "id");
|
||||||
|
const std::string local_id = calendar_id(credentials.account_id, remote_id);
|
||||||
|
if (!remote_ids.insert(remote_id).second || !local_ids.insert(local_id).second) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const bool primary = required_bool(value, "isDefaultCalendar");
|
||||||
|
primary_count += primary ? 1U : 0U;
|
||||||
|
if (primary_count > 1) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
calendars.push_back({local_id, credentials.account_id, remote_id,
|
||||||
|
required_string(value, "name", false), calendar_color(value),
|
||||||
|
!required_bool(value, "canEdit"), true, raw_json(value)});
|
||||||
|
if (calendars.size() > maximum_accumulated_calendars) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (object.contains("@odata.deltaLink")) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string next = optional_next_link(object, ContinuationRoute::calendars);
|
||||||
|
if (next.empty()) {
|
||||||
|
cache_.replace_calendars_after_complete_listing(credentials.account_id, calendars);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cursors.contains(next)) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
url = next;
|
||||||
|
}
|
||||||
|
fail();
|
||||||
|
} catch (...) {
|
||||||
|
throw MicrosoftGraphError("Microsoft Graph calendar refresh failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MicrosoftGraphSync::sync_primary_calendar_window(
|
||||||
|
const MicrosoftGraphCredentials& credentials, const MicrosoftGraphWindow& window) {
|
||||||
|
try {
|
||||||
|
validate_credentials(credentials);
|
||||||
|
if (window.end_epoch_microseconds <= window.start_epoch_microseconds) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const std::string window_start = canonical_utc(window.start_epoch_microseconds);
|
||||||
|
const std::string window_end = canonical_utc(window.end_epoch_microseconds);
|
||||||
|
const nocal::storage::CacheSnapshot snapshot = cache_.snapshot();
|
||||||
|
const CachedCalendar calendar = primary_calendar(snapshot, credentials.account_id);
|
||||||
|
const SyncCheckpoint existing =
|
||||||
|
checkpoint_for(snapshot, calendar.id, window_start, window_end);
|
||||||
|
std::string url;
|
||||||
|
if (!existing.cursor.empty()) {
|
||||||
|
validate_continuation(existing.cursor, ContinuationRoute::delta);
|
||||||
|
url = existing.cursor;
|
||||||
|
} else {
|
||||||
|
url = std::string(graph_v1) + "/me/calendarView/delta?startDateTime="
|
||||||
|
+ percent_encode(window_start) + "&endDateTime=" + percent_encode(window_end);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_set<std::string> cursors;
|
||||||
|
for (std::size_t page = 0; page < maximum_pages; ++page) {
|
||||||
|
if (!cursors.insert(url).second) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
const nlohmann::json object =
|
||||||
|
parse_json_object(send_graph(transport_, url, credentials).body);
|
||||||
|
const nlohmann::json& values = required_array(object, "value");
|
||||||
|
PullPage pull;
|
||||||
|
for (const nlohmann::json& value : values) {
|
||||||
|
pull.events.push_back(parse_delta_event(value, calendar.id, pull.instances));
|
||||||
|
}
|
||||||
|
const DeltaContinuation continuation = delta_continuation(object);
|
||||||
|
if (!continuation.complete && cursors.contains(continuation.cursor)) {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
pull.checkpoint = {calendar.id, window_start, window_end, continuation.cursor,
|
||||||
|
continuation.complete};
|
||||||
|
cache_.apply_pull_page(pull);
|
||||||
|
if (continuation.complete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url = continuation.cursor;
|
||||||
|
}
|
||||||
|
fail();
|
||||||
|
} catch (...) {
|
||||||
|
throw MicrosoftGraphError("Microsoft Graph calendar synchronization failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::sync
|
||||||
696
tests/microsoft_graph_security_tests.cpp
Normal file
696
tests/microsoft_graph_security_tests.cpp
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
#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::CachedCalendar;
|
||||||
|
using nocal::storage::CachedEvent;
|
||||||
|
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 primary_local_prefix = "graph-calendar-";
|
||||||
|
constexpr std::string_view event_local_prefix = "graph-event-";
|
||||||
|
|
||||||
|
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]] 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]] 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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());
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
452
tests/microsoft_graph_tests.cpp
Normal file
452
tests/microsoft_graph_tests.cpp
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user