#include "nocal/storage/sync_cache.hpp" #include "nocal/sync/microsoft_account.hpp" #include "nocal/sync/microsoft_graph.hpp" #include "nocal/sync/microsoft_sync_provider.hpp" #include "nocal/sync/oauth.hpp" #include "nocal/sync/oauth_tokens.hpp" #include "nocal/sync/provider.hpp" #include "nocal/sync/secret_store.hpp" #include "nocal/sync/token_broker.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using nocal::storage::CachedAccount; using nocal::storage::CachedCalendar; using nocal::storage::SyncCache; using nocal::sync::AccountDisconnected; using nocal::sync::AuthorizationCallback; using nocal::sync::AuthorizationCallbackReceiver; using nocal::sync::BrowserLauncher; using nocal::sync::ConnectedAccount; using nocal::sync::EntropySource; using nocal::sync::HttpMethod; using nocal::sync::HttpRequest; using nocal::sync::HttpResponse; using nocal::sync::HttpTransport; using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthTokens; using nocal::sync::SyncObserver; using nocal::sync::SyncProgressEvent; using nocal::sync::SyncStage; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string{message}); } } // ----------------------------------------------------------------------- // TempDirectory for a real SyncCache // ----------------------------------------------------------------------- class TempDirectory { public: TempDirectory() { std::string pattern = (std::filesystem::temp_directory_path() / "nocal-microsoft-sync-provider-tests.XXXXXX") .string(); std::vector 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_; }; // ----------------------------------------------------------------------- // ScriptedEntropy // ----------------------------------------------------------------------- class ScriptedEntropy final : public EntropySource { public: explicit ScriptedEntropy(std::vector> sc) : values(std::move(sc)) {} std::vector> values; std::size_t calls{0}; void fill(std::span output) override { require(output.size() == 32, "entropy request was not exactly 32 bytes"); require(calls < values.size(), "unexpected entropy request"); std::copy(values[calls].begin(), values[calls].end(), output.begin()); ++calls; } }; // ----------------------------------------------------------------------- // ScriptedBrowser // ----------------------------------------------------------------------- class ScriptedBrowser final : public BrowserLauncher { public: std::vector urls; void open(const std::string& url) override { urls.push_back(url); } }; // ----------------------------------------------------------------------- // ScriptedReceiver // ----------------------------------------------------------------------- class ScriptedReceiver final : public AuthorizationCallbackReceiver { public: std::string redirect{"http://127.0.0.1:43123/callback"}; AuthorizationCallback callback; std::size_t receives{0}; bool copy_browser_state{true}; ScriptedBrowser* browser{nullptr}; [[nodiscard]] std::string redirect_uri() const override { return redirect; } AuthorizationCallback receive() override { ++receives; if (copy_browser_state && browser != nullptr && !browser->urls.empty()) { const std::string& url = browser->urls.front(); const std::size_t state_pos = url.find("state="); if (state_pos != std::string::npos) { const std::size_t value_start = state_pos + 6; const std::size_t amp = url.find('&', value_start); const std::size_t len = (amp == std::string::npos) ? url.size() - value_start : amp - value_start; AuthorizationCallback result = callback; result.state = url.substr(value_start, len); return result; } } return callback; } }; // ----------------------------------------------------------------------- // ScriptedTransport // ----------------------------------------------------------------------- class ScriptedTransport final : public HttpTransport { public: struct ResponseSpec { int status{200}; std::string body; bool throw_on_send{false}; std::string send_error_message{}; }; std::vector requests; std::vector responses; std::size_t fail_at{static_cast(-1)}; HttpResponse send(const HttpRequest& request) override { requests.push_back(request); const std::size_t index = requests.size() - 1; if (index == fail_at) { throw std::runtime_error("transport failure"); } if (index >= responses.size()) { return {200, {}, "{}"}; } const ResponseSpec& spec = responses[index]; if (spec.throw_on_send) { throw std::runtime_error(spec.send_error_message); } return {spec.status, {}, spec.body}; } }; // ----------------------------------------------------------------------- // ScriptedSecretStore (token_broker style with per-account script) // ----------------------------------------------------------------------- class ScriptedSecretStore final : public OAuthSecretStore { public: struct StoreResult { bool throw_on_store{false}; std::string store_error_message{}; }; std::map store_data; std::map store_script; std::size_t store_calls{0}; std::size_t load_calls{0}; std::size_t erase_calls{0}; void store(std::string account_id, const OAuthTokens& tokens, std::stop_token = {}) override { ++store_calls; const auto found = store_script.find(account_id); if (found != store_script.end() && found->second.throw_on_store) { throw std::runtime_error(found->second.store_error_message); } store_data[std::move(account_id)] = tokens; } [[nodiscard]] std::optional load( std::string account_id, std::stop_token = {}) override { ++load_calls; const auto stored = store_data.find(account_id); if (stored != store_data.end()) { return stored->second; } return std::nullopt; } void erase(std::string account_id, std::stop_token = {}) override { ++erase_calls; store_data.erase(account_id); } }; // ----------------------------------------------------------------------- // TestObserver - records progress events // ----------------------------------------------------------------------- class TestObserver final : public SyncObserver { public: std::vector events; void on_sync_progress(const SyncProgressEvent& event) override { events.push_back(event); } }; // ----------------------------------------------------------------------- // Controllable clock // ----------------------------------------------------------------------- class ControllableClock { public: std::int64_t value{1000}; [[nodiscard]] std::int64_t operator()() { return value; } }; // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- [[nodiscard]] std::array zeros() { return {}; } [[nodiscard]] std::string token_response_body(std::string_view access_token, std::string_view refresh_token, std::int64_t expires_in) { return "{\"access_token\":\"" + std::string(access_token) + "\",\"refresh_token\":\"" + std::string(refresh_token) + "\",\"token_type\":\"Bearer\",\"expires_in\":" + std::to_string(expires_in) + "}"; } [[nodiscard]] std::string me_response_body( std::string_view id, std::string_view display_name, std::string_view upn) { std::string body = "{\"id\":\"" + std::string(id) + "\""; if (!display_name.empty()) { body += ",\"displayName\":\"" + std::string(display_name) + "\""; } if (!upn.empty()) { body += ",\"userPrincipalName\":\"" + std::string(upn) + "\""; } body += "}"; return body; } // Build a Graph /me/calendars response with one calendar [[nodiscard]] std::string calendars_response_body( const std::vector& remote_ids, const std::vector& names, const std::vector& is_default) { require(remote_ids.size() == names.size(), "calendars_response_body argument size mismatch"); require(remote_ids.size() == is_default.size(), "calendars_response_body argument size mismatch"); std::string body = "{\"value\":["; for (std::size_t i = 0; i < remote_ids.size(); ++i) { if (i > 0) { body += ","; } body += "{\"id\":\"" + remote_ids[i] + "\"" + ",\"name\":\"" + names[i] + "\"" + ",\"isDefaultCalendar\":" + (is_default[i] ? "true" : "false") + ",\"canEdit\":true,\"changeKey\":\"ck\",\"hexColor\":\"#000000\"}"; } body += "]}"; return body; } // Build an empty calendar view delta response (no events) [[nodiscard]] std::string empty_delta_response() { return "{\"value\":[],\"@odata.deltaLink\":\"" "https://graph.microsoft.com/v1.0/me/calendarView/delta?" "deltatoken=final\"}"; } // Build an empty calendarView response [[nodiscard]] std::string empty_calendar_view_response() { return "{\"value\":[]}"; } // ----------------------------------------------------------------------- // Test: provider_id returns "microsoft-graph" // ----------------------------------------------------------------------- void test_provider_id() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); ScriptedTransport transport; ScriptedSecretStore secret_store; ScriptedBrowser browser; ScriptedReceiver receiver; ScriptedEntropy entropy{std::vector>{}}; ControllableClock clock; OAuthClientConfig config; config.client_id = "test-client-id"; config.authorization_endpoint = "https://login.example/auth"; config.token_endpoint = "https://login.example/token"; config.scopes = {"offline_access", "User.Read", "Calendars.Read"}; nocal::sync::OAuthTokenBroker broker( config, transport, secret_store, [&clock]() { return clock(); }); nocal::sync::MicrosoftAccountSyncProvider provider( broker, secret_store, cache, transport, config, [&clock]() { return clock(); }, browser, receiver, entropy); require(provider.provider_id() == "microsoft-graph", "provider_id should return 'microsoft-graph'"); } // ----------------------------------------------------------------------- // Test: connect_account happy path // ----------------------------------------------------------------------- void test_connect_account_happy_path() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {"auth-code", "", "", ""}; ScriptedTransport transport; transport.responses.push_back( {200, token_response_body("access-token-value", "refresh-token-value", 3600)}); transport.responses.push_back( {200, me_response_body("remote-subject", "Display Name", "user@example.com")}); ScriptedSecretStore secret_store; ControllableClock clock; OAuthClientConfig config; config.client_id = "test-client-id"; config.authorization_endpoint = "https://login.example/auth"; config.token_endpoint = "https://login.example/token"; config.scopes = {"offline_access", "User.Read", "Calendars.Read"}; nocal::sync::OAuthTokenBroker broker( config, transport, secret_store, [&clock]() { return clock(); }); nocal::sync::MicrosoftAccountSyncProvider provider( broker, secret_store, cache, transport, config, [&clock]() { return clock(); }, browser, receiver, entropy); const ConnectedAccount result = provider.connect_account(); // Verify returned account const std::string expected_id = nocal::sync::microsoft_account_id("remote-subject"); require(result.account_id == expected_id, "account id mismatch"); require(result.display_name == "Display Name", "display name mismatch"); // Cache has the account const auto snapshot = cache.snapshot(); require(snapshot.accounts.size() == 1, "expected exactly one cached account"); require(snapshot.accounts.front().id == expected_id, "cached account id mismatch"); require(snapshot.accounts.front().provider == "microsoft-graph", "cached account provider mismatch"); // Secret store has the tokens const auto stored = secret_store.store_data.find(expected_id); require(stored != secret_store.store_data.end(), "tokens not stored"); require(stored->second.access_token == "access-token-value", "wrong stored access token"); require(stored->second.token_type == "Bearer", "wrong stored token type"); // Exactly one browser URL, one token request, one /me request require(browser.urls.size() == 1, "expected exactly one browser URL"); require(transport.requests.size() == 2, "expected exactly two HTTP requests"); require(transport.requests[0].method == HttpMethod::post, "first request was not POST (token)"); require(transport.requests[1].method == HttpMethod::get, "second request was not GET (/me)"); } // ----------------------------------------------------------------------- // Test: disconnect_account erases secret // ----------------------------------------------------------------------- void test_disconnect_account() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {"auth-code", "", "", ""}; ScriptedTransport transport; transport.responses.push_back( {200, token_response_body("access-token-value", "refresh-token-value", 3600)}); transport.responses.push_back( {200, me_response_body("remote-subject", "Display Name", "user@example.com")}); ScriptedSecretStore secret_store; ControllableClock clock; OAuthClientConfig config; config.client_id = "test-client-id"; config.authorization_endpoint = "https://login.example/auth"; config.token_endpoint = "https://login.example/token"; config.scopes = {"offline_access", "User.Read", "Calendars.Read"}; nocal::sync::OAuthTokenBroker broker( config, transport, secret_store, [&clock]() { return clock(); }); nocal::sync::MicrosoftAccountSyncProvider provider( broker, secret_store, cache, transport, config, [&clock]() { return clock(); }, browser, receiver, entropy); const ConnectedAccount result = provider.connect_account(); // Verify token exists before disconnect require(secret_store.store_data.find(result.account_id) != secret_store.store_data.end(), "tokens should exist before disconnect"); require(secret_store.erase_calls == 0, "erase should not have been called yet"); // Disconnect provider.disconnect_account(result.account_id); // Tokens erased require(secret_store.store_data.find(result.account_id) == secret_store.store_data.end(), "tokens should be erased after disconnect"); require(secret_store.erase_calls == 1, "erase should have been called once"); // Cache still has the account row (documented behavior) const auto snapshot = cache.snapshot(); require(snapshot.accounts.size() == 1, "cache should retain the account row after disconnect"); } // ----------------------------------------------------------------------- // Test: sync_account progress events (happy path) // ----------------------------------------------------------------------- void test_sync_account_progress() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); // First, connect an account (so we have tokens + account in cache) ScriptedEntropy connect_entropy{{zeros(), zeros()}}; ScriptedBrowser connect_browser; ScriptedReceiver connect_receiver; connect_receiver.browser = &connect_browser; connect_receiver.callback = {"auth-code", "", "", ""}; ScriptedTransport connect_transport; connect_transport.responses.push_back( {200, token_response_body("connect-access", "connect-refresh", 3600)}); connect_transport.responses.push_back( {200, me_response_body("sync-subject", "Sync User", "sync@example.com")}); ScriptedSecretStore secret_store; ControllableClock clock; OAuthClientConfig config; config.client_id = "test-client-id"; config.authorization_endpoint = "https://login.example/auth"; config.token_endpoint = "https://login.example/token"; config.scopes = {"offline_access", "User.Read", "Calendars.Read"}; nocal::sync::OAuthTokenBroker broker( config, connect_transport, secret_store, [&clock]() { return clock(); }); ScriptedEntropy sync_entropy{{zeros(), zeros()}}; nocal::sync::MicrosoftAccountSyncProvider connect_provider( broker, secret_store, cache, connect_transport, config, [&clock]() { return clock(); }, connect_browser, connect_receiver, sync_entropy); const ConnectedAccount result = connect_provider.connect_account(); const std::string account_id = result.account_id; // Now set up a new transport for sync (identity refresh + calendar // discovery + primary calendar delta + secondary calendar view) ScriptedTransport sync_transport; // The token broker will see the stored tokens are still valid (expiry at // 4600, clock at 1000, skew 300 → 1300 < 4600 → no refresh needed). // So we need responses for: // 0: /me (identity refresh) // 1: /me/calendars // 2: primary calendar delta (first page with deltaLink) // 3: secondary calendar calendarView (first page with no nextLink) // // But the token broker won't call the transport since tokens are valid. // So sync transport responses start at 0. // First check what stored tokens look like: expires_at = 1000 + 3600 = 4600 // Clock at 1000, skew 300 → 1000+300 = 1300 < 4600 so no refresh needed. sync_transport.responses.push_back( {200, me_response_body("sync-subject", "Sync User", "sync@example.com")}); // Two calendars: primary + secondary sync_transport.responses.push_back( {200, calendars_response_body({"AAMkAGprimary", "AAMkAGsecondary"}, {"Primary", "Secondary"}, {true, false})}); // Primary delta: empty page with deltaLink → completion sync_transport.responses.push_back({200, empty_delta_response()}); // Secondary calendarView: empty page sync_transport.responses.push_back({200, empty_calendar_view_response()}); TestObserver observer; // Recreate broker with the new transport nocal::sync::OAuthTokenBroker sync_broker( config, sync_transport, secret_store, [&clock]() { return clock(); }); ScriptedEntropy sync_entropy2{{zeros(), zeros()}}; nocal::sync::MicrosoftAccountSyncProvider sync_provider( sync_broker, secret_store, cache, sync_transport, config, [&clock]() { return clock(); }, connect_browser, connect_receiver, sync_entropy2); sync_provider.sync_account(account_id, observer); // Verify progress events // Expected order: // 1. authenticating // 2. refreshing_identity // 3. discovering_calendars // 4. syncing_calendar (primary, 0/2) // 5. syncing_calendar (secondary, 1/2) // 6. completed require(observer.events.size() == 6, "expected 6 progress events, got " + std::to_string(observer.events.size())); require(observer.events[0].stage == SyncStage::authenticating, "event[0] should be authenticating"); require(observer.events[1].stage == SyncStage::refreshing_identity, "event[1] should be refreshing_identity"); require(observer.events[2].stage == SyncStage::discovering_calendars, "event[2] should be discovering_calendars"); require(observer.events[3].stage == SyncStage::syncing_calendar, "event[3] should be syncing_calendar"); require(observer.events[3].calendars_completed == 0, "event[3] calendars_completed should be 0"); require(observer.events[3].calendars_total == 2, "event[3] calendars_total should be 2"); require(observer.events[4].stage == SyncStage::syncing_calendar, "event[4] should be syncing_calendar"); require(observer.events[4].calendars_completed == 1, "event[4] calendars_completed should be 1"); require(observer.events[4].calendars_total == 2, "event[4] calendars_total should be 2"); require(observer.events[5].stage == SyncStage::completed, "event[5] should be completed"); } // ----------------------------------------------------------------------- // Test: sync_account throws AccountDisconnected when tokens refresh fails // ----------------------------------------------------------------------- void test_sync_account_account_disconnected() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); // Set up an account with expired tokens ScriptedTransport connect_transport; connect_transport.responses.push_back( {200, token_response_body("connect-access", "connect-refresh", 1)}); connect_transport.responses.push_back( {200, me_response_body("disco-subject", "Disco User", "disco@example.com")}); ScriptedSecretStore secret_store; ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {"auth-code", "", "", ""}; ControllableClock clock; clock.value = 1000; OAuthClientConfig config; config.client_id = "test-client-id"; config.authorization_endpoint = "https://login.example/auth"; config.token_endpoint = "https://login.example/token"; config.scopes = {"offline_access", "User.Read", "Calendars.Read"}; nocal::sync::OAuthTokenBroker connect_broker( config, connect_transport, secret_store, [&clock]() { return clock(); }); nocal::sync::MicrosoftAccountSyncProvider connect_provider( connect_broker, secret_store, cache, connect_transport, config, [&clock]() { return clock(); }, browser, receiver, entropy); const ConnectedAccount result = connect_provider.connect_account(); // Token expires at 1000 + 1 = 1001. Now advance clock to 2000, well past // expiry + skew (1001 < 2000+300=2300, so expired). clock.value = 2000; // Set up transport that returns 400 on the refresh attempt ScriptedTransport refresh_transport; refresh_transport.responses.push_back({400, "{\"error\":\"invalid_grant\"}"}); nocal::sync::OAuthTokenBroker refresh_broker( config, refresh_transport, secret_store, [&clock]() { return clock(); }); ScriptedEntropy sync_entropy_disco{{zeros(), zeros()}}; nocal::sync::MicrosoftAccountSyncProvider sync_provider( refresh_broker, secret_store, cache, refresh_transport, config, [&clock]() { return clock(); }, browser, receiver, sync_entropy_disco); TestObserver observer; bool caught = false; try { sync_provider.sync_account(result.account_id, observer); } catch (const AccountDisconnected&) { caught = true; } require(caught, "expected AccountDisconnected when tokens refresh fails"); // Observer should have received at least the authenticating event require(!observer.events.empty(), "observer should have received at least one event"); require(observer.events.front().stage == SyncStage::authenticating, "first event should be authenticating"); } // ----------------------------------------------------------------------- // Test: sync_account throws when no tokens exist // ----------------------------------------------------------------------- void test_sync_account_no_tokens() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); ScriptedSecretStore secret_store; // empty store ScriptedTransport transport; ScriptedBrowser browser; ScriptedReceiver receiver; ScriptedEntropy entropy{std::vector>{}}; ControllableClock clock; OAuthClientConfig config; config.client_id = "test-client-id"; config.authorization_endpoint = "https://login.example/auth"; config.token_endpoint = "https://login.example/token"; config.scopes = {"offline_access", "User.Read", "Calendars.Read"}; nocal::sync::OAuthTokenBroker broker( config, transport, secret_store, [&clock]() { return clock(); }); nocal::sync::MicrosoftAccountSyncProvider provider( broker, secret_store, cache, transport, config, [&clock]() { return clock(); }, browser, receiver, entropy); TestObserver observer; bool caught = false; try { provider.sync_account("nonexistent-account", observer); } catch (const std::runtime_error&) { caught = true; } require(caught, "expected std::runtime_error when no tokens exist"); } } // namespace int main() { try { test_provider_id(); test_connect_account_happy_path(); test_disconnect_account(); test_sync_account_progress(); test_sync_account_account_disconnected(); test_sync_account_no_tokens(); } catch (const std::exception& error) { std::cerr << "microsoft sync provider tests failed: " << error.what() << '\n'; return 1; } std::cout << "microsoft sync provider tests passed\n"; return 0; }