#include "nocal/storage/sync_cache.hpp" #include "nocal/sync/microsoft_account.hpp" #include "nocal/sync/microsoft_graph.hpp" #include "nocal/sync/oauth.hpp" #include "nocal/sync/oauth_tokens.hpp" #include "nocal/sync/secret_store.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using nocal::storage::CachedAccount; using nocal::storage::SyncCache; using nocal::sync::AuthorizationCallback; using nocal::sync::AuthorizationCallbackReceiver; using nocal::sync::BrowserLauncher; using nocal::sync::EntropySource; using nocal::sync::HttpMethod; using nocal::sync::HttpRequest; using nocal::sync::HttpResponse; using nocal::sync::HttpTransport; using nocal::sync::MicrosoftAccountError; using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthTokens; 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-account-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 – fixed deterministic values // ----------------------------------------------------------------------- class ScriptedEntropy final : public EntropySource { public: explicit ScriptedEntropy(std::vector> scripted_values) : values(std::move(scripted_values)) {} 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"); const std::size_t call = calls++; require(call < values.size(), "unexpected entropy request"); std::copy(values[call].begin(), values[call].end(), output.begin()); } }; // ----------------------------------------------------------------------- // ScriptedBrowser // ----------------------------------------------------------------------- class ScriptedBrowser final : public BrowserLauncher { public: std::vector urls; void open(const std::string& url) override { urls.push_back(url); } }; // ----------------------------------------------------------------------- // ScriptedReceiver – copies state from browser URL if needed // ----------------------------------------------------------------------- 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()) { // Extract state from the authorization URL 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 – scripted responses for token exchange then /me // ----------------------------------------------------------------------- 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; HttpResponse send(const HttpRequest& request) override { requests.push_back(request); const std::size_t index = requests.size() - 1; 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 – in-memory with injectable store failure // ----------------------------------------------------------------------- class ScriptedSecretStore final : public OAuthSecretStore { public: std::map store_data; bool throw_on_store{false}; std::string store_error_message{}; 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; if (throw_on_store) { throw std::runtime_error(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 found = store_data.find(account_id); if (found != store_data.end()) { return found->second; } return std::nullopt; } void erase(std::string account_id, std::stop_token = {}) override { ++erase_calls; store_data.erase(account_id); } }; // ----------------------------------------------------------------------- // 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 user_principal_name) { std::string body = "{\"id\":\"" + std::string(id) + "\""; if (!display_name.empty()) { body += ",\"displayName\":\"" + std::string(display_name) + "\""; } if (!user_principal_name.empty()) { body += ",\"userPrincipalName\":\"" + std::string(user_principal_name) + "\""; } body += "}"; return body; } // ----------------------------------------------------------------------- // Test: Happy path // ----------------------------------------------------------------------- void test_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"}; const CachedAccount account = nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); // Verify returned account require(account.provider == "microsoft-graph", "wrong provider"); // Compute expected account id const std::string expected_id = nocal::sync::microsoft_account_id("remote-subject"); require(account.id == expected_id, "account id mismatch"); require(account.remote_subject == "remote-subject", "remote subject mismatch"); require(account.display_name == "Display Name", "display name mismatch"); // Cache snapshot has exactly one account const auto snapshot = cache.snapshot(); require(snapshot.accounts.size() == 1, "expected exactly one cached account"); require(snapshot.accounts.front() == account, "cached account differs from returned"); // Secret store holds the parsed tokens under the same id const auto stored_tokens = secret_store.store_data.find(expected_id); require(stored_tokens != secret_store.store_data.end(), "tokens not stored"); require(stored_tokens->second.access_token == "access-token-value", "wrong stored access token"); require(stored_tokens->second.refresh_token == "refresh-token-value", "wrong stored refresh token"); require(stored_tokens->second.token_type == "Bearer", "wrong stored token type"); // Exactly one browser URL, one token request, one /me GET with Bearer token 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[0].url == "https://login.example/token", "first request URL mismatch"); require(transport.requests[1].method == HttpMethod::get, "second request was not GET (/me)"); require(transport.requests[1].url.find("graph.microsoft.com") != std::string::npos, "second request URL does not contain graph.microsoft.com"); bool has_bearer = false; for (const auto& header : transport.requests[1].headers) { if (header.name == "Authorization") { has_bearer = header.value.find("Bearer access-token-value") != std::string::npos; } } require(has_bearer, "/me request did not carry Bearer token"); } // ----------------------------------------------------------------------- // Test: Idempotent reconnect // ----------------------------------------------------------------------- void test_idempotent_reconnect() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); auto do_connect = [&]() -> std::string { 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-" + std::to_string(transport.requests.size()), "refresh-token-" + std::to_string(transport.requests.size()), 3600)}); transport.responses.push_back( {200, me_response_body("same-subject", "Same User", "same@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"}; const CachedAccount account = nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); return account.id; }; const std::string first_id = do_connect(); const std::string second_id = do_connect(); // Same account id require(first_id == second_id, "reconnect produced different account id"); // Cache still has exactly one account const auto snapshot = cache.snapshot(); require(snapshot.accounts.size() == 1, "expected exactly one account after reconnect"); require(snapshot.accounts.front().id == first_id, "account id mismatch in cache"); } // ----------------------------------------------------------------------- // Test: Authorization rejected // ----------------------------------------------------------------------- void test_authorization_rejected() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {"", "", "access_denied", ""}; ScriptedTransport transport; 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"}; bool caught = false; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const MicrosoftAccountError& e) { caught = true; require(std::string(e.what()) == "Microsoft authorization failed", "wrong error message on authorization rejection"); } catch (const std::exception& e) { throw std::runtime_error( std::string("wrong exception type: ") + e.what()); } require(caught, "expected MicrosoftAccountError"); // Cache and secret store both untouched require(cache.snapshot().accounts.empty(), "cache was modified on authorization rejection"); require(secret_store.store_calls == 0, "secret store was called on authorization rejection"); } // ----------------------------------------------------------------------- // Test: Token endpoint returns non-2xx // ----------------------------------------------------------------------- void test_token_endpoint_non2xx() { 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({400, "{\"error\":\"invalid_grant\"}"}); 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"}; bool caught = false; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const MicrosoftAccountError& e) { caught = true; require(std::string(e.what()) == "Microsoft authorization failed", "wrong error message on token rejection"); } require(caught, "expected MicrosoftAccountError"); // Nothing stored require(cache.snapshot().accounts.empty(), "cache was modified on token error"); require(secret_store.store_calls == 0, "secret store was called on token error"); } // ----------------------------------------------------------------------- // Test: Token response missing refresh_token // ----------------------------------------------------------------------- void test_token_response_missing_refresh() { 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; // No refresh_token in response — parse_oauth_token_response will reject transport.responses.push_back( {200, "{\"access_token\":\"acc\",\"token_type\":\"Bearer\",\"expires_in\":3600}"}); 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"}; bool caught = false; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const MicrosoftAccountError& e) { caught = true; require(std::string(e.what()) == "Microsoft token response invalid", "wrong error message on missing refresh token"); } require(caught, "expected MicrosoftAccountError"); // Nothing stored require(cache.snapshot().accounts.empty(), "cache was modified on parse error"); require(secret_store.store_calls == 0, "secret store was called on parse error"); } // ----------------------------------------------------------------------- // Test: /me failure // ----------------------------------------------------------------------- void test_me_failure() { 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; // Successful token response transport.responses.push_back( {200, token_response_body("acc-token", "ref-token", 3600)}); // /me returns garbage (malformed JSON) transport.responses.push_back({200, "not-valid-json"}); 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"}; bool caught = false; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const MicrosoftAccountError& e) { caught = true; require(std::string(e.what()) == "Microsoft identity read failed", "wrong error message on /me failure"); } require(caught, "expected MicrosoftAccountError"); // Cache and secret store untouched require(cache.snapshot().accounts.empty(), "cache was modified on /me failure"); require(secret_store.store_calls == 0, "secret store was called on /me failure"); } // ----------------------------------------------------------------------- // Test: Secret-store store() failure — cache retains the account row // ----------------------------------------------------------------------- void test_secret_store_failure() { 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("acc-token", "ref-token", 3600)}); transport.responses.push_back( {200, me_response_body("remote-subject", "Display Name", "user@example.com")}); ScriptedSecretStore secret_store; secret_store.throw_on_store = true; secret_store.store_error_message = "secret service unavailable"; 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"}; bool caught = false; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const MicrosoftAccountError& e) { caught = true; require(std::string(e.what()) == "credential storage failed", "wrong error message on store failure"); } require(caught, "expected MicrosoftAccountError"); // Cache DOES retain the account row (documented disconnected state) const auto snapshot = cache.snapshot(); require(snapshot.accounts.size() == 1, "cache should retain the account row even when secret store fails"); require(snapshot.accounts.front().remote_subject == "remote-subject", "cached account has wrong remote subject"); // Verify secret store does NOT have the credentials const std::string expected_id = nocal::sync::microsoft_account_id("remote-subject"); const auto stored = secret_store.store_data.find(expected_id); require(stored == secret_store.store_data.end(), "secret store should not contain credentials after store failure"); require(secret_store.store_calls == 1, "store should have been attempted once"); // A subsequent successful connect should complete (idempotent) ScriptedEntropy entropy2{{zeros(), zeros()}}; ScriptedBrowser browser2; ScriptedReceiver receiver2; receiver2.browser = &browser2; receiver2.callback = {"auth-code-2", "", "", ""}; ScriptedTransport transport2; transport2.responses.push_back( {200, token_response_body("acc-token-2", "ref-token-2", 3600)}); transport2.responses.push_back( {200, me_response_body("remote-subject", "Display Name", "user@example.com")}); ScriptedSecretStore secret_store2; ControllableClock clock2; const CachedAccount retry_account = nocal::sync::connect_microsoft_account( config, browser2, receiver2, transport2, entropy2, secret_store2, cache, [&clock2]() { return clock2(); }); require(retry_account.id == expected_id, "retry account id should match deterministic id"); require(cache.snapshot().accounts.size() == 1, "cache should still have exactly one account after successful retry"); const auto stored2 = secret_store2.store_data.find(expected_id); require(stored2 != secret_store2.store_data.end(), "credentials should be stored after successful retry"); } // ----------------------------------------------------------------------- // Test: Empty displayName falls back to userPrincipalName // ----------------------------------------------------------------------- void test_empty_display_name_fallback() { 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("acc-token", "ref-token", 3600)}); // No displayName in /me response transport.responses.push_back( {200, me_response_body("remote-subject", "", "fallback@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"}; const CachedAccount account = nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); require(account.display_name == "fallback@example.com", "display name should fall back to userPrincipalName"); } // ----------------------------------------------------------------------- // Test: default_microsoft_client_id with env unset and set // ----------------------------------------------------------------------- void test_default_client_id() { // Save original env state and unset const char* original = std::getenv("NOCAL_MICROSOFT_CLIENT_ID"); // Test with env unset if (original != nullptr) { ::unsetenv("NOCAL_MICROSOFT_CLIENT_ID"); } const std::string unset_result = nocal::sync::default_microsoft_client_id(); require(unset_result == nocal::sync::microsoft_client_id_placeholder, "default client id should be placeholder when env is unset"); // Test with env set ::setenv("NOCAL_MICROSOFT_CLIENT_ID", "my-test-client-id", 1); const std::string set_result = nocal::sync::default_microsoft_client_id(); require(set_result == "my-test-client-id", "default client id should match env when set"); // Restore original if (original != nullptr) { ::setenv("NOCAL_MICROSOFT_CLIENT_ID", original, 1); } else { ::unsetenv("NOCAL_MICROSOFT_CLIENT_ID"); } } // ----------------------------------------------------------------------- // Test: microsoft_oauth_config // ----------------------------------------------------------------------- void test_microsoft_oauth_config() { const OAuthClientConfig config = nocal::sync::microsoft_oauth_config("my-client-id"); require(config.client_id == "my-client-id", "client_id mismatch"); require(config.authorization_endpoint == "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", "wrong authorization endpoint"); require(config.token_endpoint == "https://login.microsoftonline.com/common/oauth2/v2.0/token", "wrong token endpoint"); require(config.scopes.size() == 3, "expected exactly 3 scopes"); require(config.scopes[0] == "offline_access", "scope[0] mismatch"); require(config.scopes[1] == "User.Read", "scope[1] mismatch"); require(config.scopes[2] == "Calendars.Read", "scope[2] mismatch"); } // ----------------------------------------------------------------------- // Test: Error messages never contain credential material // ----------------------------------------------------------------------- void test_error_message_redaction() { TempDirectory temp; SyncCache cache(temp.path() / "cache.db"); const std::string access_marker = "ACCESS_CRED_MARKER_12345"; const std::string refresh_marker = "REFRESH_CRED_MARKER_67890"; const std::string auth_code_marker = "AUTH_CODE_MARKER_ABCDE"; // Test 1: Token endpoint error (400) { ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {auth_code_marker, "", "", ""}; ScriptedTransport transport; transport.responses.push_back({400, access_marker + refresh_marker}); 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"}; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const std::exception& e) { const std::string msg = e.what(); require(msg.find(access_marker) == std::string::npos, "error leaked access token on 400"); require(msg.find(refresh_marker) == std::string::npos, "error leaked refresh token on 400"); require(msg.find(auth_code_marker) == std::string::npos, "error leaked auth code on 400"); } } // Test 2: Parse error (missing refresh_token) { ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {auth_code_marker, "", "", ""}; ScriptedTransport transport; transport.responses.push_back( {200, "{\"access_token\":\"" + access_marker + "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"}); 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"}; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const std::exception& e) { const std::string msg = e.what(); require(msg.find(access_marker) == std::string::npos, "error leaked access token on parse failure"); require(msg.find(auth_code_marker) == std::string::npos, "error leaked auth code on parse failure"); } } // Test 3: /me failure { ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {auth_code_marker, "", "", ""}; ScriptedTransport transport; transport.responses.push_back( {200, "{\"access_token\":\"" + access_marker + "\",\"refresh_token\":\"" + refresh_marker + "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"}); transport.responses.push_back( {200, "{\"bad_field\":" + access_marker + "}"}); 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"}; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const std::exception& e) { const std::string msg = e.what(); require(msg.find(access_marker) == std::string::npos, "error leaked access token on /me failure"); require(msg.find(refresh_marker) == std::string::npos, "error leaked refresh token on /me failure"); require(msg.find(auth_code_marker) == std::string::npos, "error leaked auth code on /me failure"); } } // Test 4: Secret store failure { ScriptedEntropy entropy{{zeros(), zeros()}}; ScriptedBrowser browser; ScriptedReceiver receiver; receiver.browser = &browser; receiver.callback = {auth_code_marker, "", "", ""}; ScriptedTransport transport; transport.responses.push_back( {200, "{\"access_token\":\"" + access_marker + "\",\"refresh_token\":\"" + refresh_marker + "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"}); transport.responses.push_back( {200, me_response_body("remote-subject", "Display", "user@example.com")}); ScriptedSecretStore secret_store; secret_store.throw_on_store = true; 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"}; try { (void)nocal::sync::connect_microsoft_account( config, browser, receiver, transport, entropy, secret_store, cache, [&clock]() { return clock(); }); } catch (const std::exception& e) { const std::string msg = e.what(); require(msg.find(access_marker) == std::string::npos, "error leaked access token on store failure"); require(msg.find(refresh_marker) == std::string::npos, "error leaked refresh token on store failure"); require(msg.find(auth_code_marker) == std::string::npos, "error leaked auth code on store failure"); } } } // ----------------------------------------------------------------------- // Test: microsoft_account_id is deterministic // ----------------------------------------------------------------------- void test_microsoft_account_id_deterministic() { const std::string first = nocal::sync::microsoft_account_id("user@example.com"); const std::string second = nocal::sync::microsoft_account_id("user@example.com"); require( first == second, "microsoft_account_id is not deterministic for same input"); const std::string different = nocal::sync::microsoft_account_id("other@example.com"); require(first != different, "microsoft_account_id should differ for different remote subjects"); } } // namespace int main() { try { test_happy_path(); test_idempotent_reconnect(); test_authorization_rejected(); test_token_endpoint_non2xx(); test_token_response_missing_refresh(); test_me_failure(); test_secret_store_failure(); test_empty_display_name_fallback(); test_default_client_id(); test_microsoft_oauth_config(); test_error_message_redaction(); test_microsoft_account_id_deterministic(); } catch (const std::exception& error) { std::cerr << "microsoft account tests failed: " << error.what() << '\n'; return 1; } std::cout << "microsoft account tests passed\n"; return 0; }