From 74c798e7aabe2084c2b5e76faedd3fa510750798 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 21 Jul 2026 18:44:47 +0100 Subject: [PATCH] feat(sync): add Microsoft account connect CLI nocal account connect microsoft runs the PKCE browser flow, reads /me, upserts the deterministic account row, and persists tokens to Secret Service as the final connected-state gate. Account IDs are hashed from the remote subject so reconnect is idempotent, and a secret-store failure leaves the same coherent state as a disconnect. Ships a placeholder client ID with NOCAL_MICROSOFT_CLIENT_ID override until a real Entra registration exists. --- README.md | 10 +- docs/ARCHITECTURE.md | 16 +- docs/ROADMAP.md | 24 +- include/nocal/sync/microsoft_account.hpp | 70 ++ include/nocal/sync/microsoft_graph.hpp | 12 + meson.build | 4 + src/main.cpp | 67 ++ src/sync/microsoft_account.cpp | 117 +++ src/sync/microsoft_graph.cpp | 29 +- tests/microsoft_account_tests.cpp | 886 +++++++++++++++++++++++ 10 files changed, 1216 insertions(+), 19 deletions(-) create mode 100644 include/nocal/sync/microsoft_account.hpp create mode 100644 src/sync/microsoft_account.cpp create mode 100644 tests/microsoft_account_tests.cpp diff --git a/README.md b/README.md index dd3463b..ff03ed3 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,10 @@ them; omission is not mislabeled as a remote deletion tombstone. This avoids beta and undocumented delta routes, at the cost of refetching each secondary window instead of incrementally updating it. -There is still no account UI/CLI orchestration or live Microsoft credential -integration, and remote writes remain out of scope. Account setup and -orchestration are next. +Account setup begins with `nocal account connect microsoft`: it runs the +PKCE browser flow, reads `/me`, upserts the deterministic account row, and +stores the tokens in the Secret Service keyring as the final connected-state +gate. This build ships a placeholder client ID; live connect requires a real +mobile/desktop Entra registration supplied via `NOCAL_MICROSOFT_CLIENT_ID`. +There is still no account TUI or sync orchestration, and remote writes remain +out of scope. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f59d005..b26d41f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -288,6 +288,16 @@ This design avoids beta and undocumented per-calendar delta routes. Its tradeoff is bandwidth: every secondary window is fetched completely on each refresh, while the primary calendar remains incremental. -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 are next. +Account setup now has a CLI entry point, `nocal account connect microsoft`. +It composes the desktop OAuth adapters, libcurl transport, token parsing, one +`/me` identity read, the sync cache, and the Secret Service store in a fixed +order: the account row is upserted first, and the versioned secret payload is +stored last as the connected-state gate. A secret-store failure therefore +leaves the same coherent state as a disconnect, and reconnecting is idempotent +because the local account id is a deterministic hash of the remote subject. +This build carries a placeholder client ID; a real Entra registration is +supplied through the `NOCAL_MICROSOFT_CLIENT_ID` environment variable (a +public client ID, never a secret). There is no account TUI and no sync +orchestration yet; tests use injected transports and credentials. Remote +writes remain out of scope. Generic `SyncProvider` integration and observable +sync status are next. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9f0a847..dd5ad29 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -105,10 +105,23 @@ Completed provider-boundary contracts and token broker: `AccountDisconnected` for re-authentication - Credential material never appears in exception messages +Completed account setup (CLI): + +- `nocal account connect microsoft` runs the PKCE browser flow, reads `/me`, + upserts the deterministic account row, and persists tokens to Secret + Service as the final connected-state gate +- Deterministic local account IDs hashed from the remote subject make + reconnect idempotent; a secret-store failure leaves the same coherent state + as a disconnect +- Placeholder client ID with `NOCAL_MICROSOFT_CLIENT_ID` override until a + real Entra mobile/desktop registration exists +- Deterministic failure-path tests: rejection, token errors, identity + failure, storage failure, and credential redaction in error messages + Next provider-boundary slices: -- Account setup and CLI/TUI orchestration with live credential integration -- Generic `SyncProvider` integration and observable sync status +- TUI account orchestration and generic `SyncProvider` integration with + observable sync status ## 0.3 — CalDAV @@ -125,9 +138,10 @@ Next provider-boundary slices: The Graph read boundary uses stable v1 delta for the primary calendar and complete stable-v1 calendar views for secondary calendars; it deliberately -avoids beta and undocumented per-calendar delta routes. No account UI/CLI -orchestration or live Microsoft credential integration exists yet, and remote -writes remain out of scope. +avoids beta and undocumented per-calendar delta routes. Account connect exists +as a CLI command with live credential integration through Secret Service, but +there is no account TUI or sync orchestration yet, and remote writes remain +out of scope. ## 1.0 — distribution quality diff --git a/include/nocal/sync/microsoft_account.hpp b/include/nocal/sync/microsoft_account.hpp new file mode 100644 index 0000000..b598877 --- /dev/null +++ b/include/nocal/sync/microsoft_account.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "nocal/storage/sync_cache.hpp" +#include "nocal/sync/oauth.hpp" + +#include +#include +#include +#include +#include + +namespace nocal::sync { + +class HttpTransport; +class OAuthSecretStore; + +// Placeholder used when no real Entra application registration is available. +// It is not a registered client; interactive connect with it fails at the +// authorization endpoint. Override with the NOCAL_MICROSOFT_CLIENT_ID +// environment variable. A client ID is public configuration, never a secret. +inline constexpr std::string_view microsoft_client_id_placeholder = + "nocal-unregistered-client-id"; + +// Client ID used for interactive connect: the NOCAL_MICROSOFT_CLIENT_ID +// environment variable when set and non-empty, otherwise the placeholder. +[[nodiscard]] std::string default_microsoft_client_id(); + +// Public-desktop Microsoft OAuth configuration for the multitenant +// organizational and personal Microsoft account audience with delegated +// read-only scopes. The open-source binary never embeds a client secret. +[[nodiscard]] OAuthClientConfig microsoft_oauth_config(std::string client_id); + +class MicrosoftAccountError final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +// Deterministic local account id derived from the stable remote subject, so +// reconnecting the same Microsoft account upserts the same cache row and +// reuses the same Secret Service key. +[[nodiscard]] std::string microsoft_account_id(std::string_view remote_subject); + +// Runs one interactive Microsoft account connection: +// 1. authorization-code flow with PKCE S256 (one browser launch, one +// loopback callback, one token request) via authorize_with_pkce; +// 2. strict token parsing that requires an initial refresh token; +// 3. one /me identity read for the remote subject and display name; +// 4. the account row upserted into the sync cache; +// 5. the versioned token payload persisted to the Secret Service store. +// +// Invariants and failure semantics: +// - The account is connected only after step 5 succeeds; there is no +// plaintext fallback and no partial connected state. +// - Failure before step 4 leaves the cache and the secret store untouched. +// - Failure in step 5 leaves a cached account without credentials — the same +// coherent state that disconnecting produces. Reconnecting is idempotent +// because the account id is deterministic. +// - The access token is never retained after return, and error messages +// never contain credential material. +[[nodiscard]] storage::CachedAccount connect_microsoft_account( + const OAuthClientConfig& config, + BrowserLauncher& browser, + AuthorizationCallbackReceiver& receiver, + HttpTransport& transport, + EntropySource& entropy, + OAuthSecretStore& secret_store, + storage::SyncCache& cache, + const std::function& clock); + +} // namespace nocal::sync diff --git a/include/nocal/sync/microsoft_graph.hpp b/include/nocal/sync/microsoft_graph.hpp index adc77de..f27ddc6 100644 --- a/include/nocal/sync/microsoft_graph.hpp +++ b/include/nocal/sync/microsoft_graph.hpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace nocal::sync { @@ -28,6 +29,13 @@ public: using std::runtime_error::runtime_error; }; +// Validated /me identity. Fetched without persisting anything so account +// connect can derive the deterministic local account id before any cache write. +struct MicrosoftGraphIdentity { + std::string remote_subject; + std::string display_name; +}; + // Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers // every calendar returned by /me/calendars. Stable v1.0 event delta is used for // the primary calendar and complete calendarView reconciliation for secondary @@ -36,6 +44,10 @@ class MicrosoftGraphSync { public: MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache); + // Requires delegated User.Read. Reads /me and returns the validated + // identity without touching the cache; the access token is not retained. + [[nodiscard]] MicrosoftGraphIdentity fetch_identity(std::string_view access_token) const; + // 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); diff --git a/meson.build b/meson.build index 659fe75..88a26b9 100644 --- a/meson.build +++ b/meson.build @@ -28,6 +28,7 @@ nocal_sources = files( 'src/storage/sync_cache.cpp', 'src/sync/curl_http.cpp', 'src/sync/desktop_oauth.cpp', + 'src/sync/microsoft_account.cpp', 'src/sync/microsoft_graph.cpp', 'src/sync/oauth.cpp', 'src/sync/oauth_tokens.cpp', @@ -81,6 +82,9 @@ test('oauth-tokens', oauth_token_tests) token_broker_tests = executable('token-broker-tests', 'tests/token_broker_tests.cpp', dependencies: nocal_dep) test('token-broker', token_broker_tests) +microsoft_account_tests = executable('microsoft-account-tests', + 'tests/microsoft_account_tests.cpp', dependencies: nocal_dep) +test('microsoft-account', microsoft_account_tests) secret_store_tests = executable('secret-store-tests', 'tests/secret_store_tests.cpp', dependencies: nocal_dep) test('secret-store', shell, diff --git a/src/main.cpp b/src/main.cpp index c21de7e..408dde0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,11 @@ #include "nocal/domain/calendar_transfer.hpp" #include "nocal/domain/date.hpp" #include "nocal/storage/ics_store.hpp" +#include "nocal/storage/sync_cache.hpp" +#include "nocal/sync/curl_http.hpp" +#include "nocal/sync/desktop_oauth.hpp" +#include "nocal/sync/microsoft_account.hpp" +#include "nocal/sync/secret_store.hpp" #include "nocal/tui/Screen.hpp" #include "nocal/tui/TuiApp.hpp" @@ -13,6 +18,7 @@ #include #include #include +#include #include #include @@ -45,6 +51,61 @@ std::filesystem::path default_calendar_path() return "calendar.ics"; } +std::filesystem::path default_sync_cache_path() +{ + if (const char* data_home = std::getenv("XDG_DATA_HOME"); + data_home != nullptr && *data_home != '\0') { + return std::filesystem::path{data_home} / "nocal" / "sync.db"; + } + if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') { + return std::filesystem::path{home} / ".local" / "share" / "nocal" / "sync.db"; + } + return "sync.db"; +} + +int connect_microsoft_account() +{ + const std::string client_id = nocal::sync::default_microsoft_client_id(); + if (client_id == nocal::sync::microsoft_client_id_placeholder) { + std::cerr << + "nocal: this build has no registered Microsoft application client ID.\n" + "Register a mobile and desktop public client with redirect URI\n" + "http://localhost/nocal/oauth/callback, then set NOCAL_MICROSOFT_CLIENT_ID.\n"; + return EXIT_FAILURE; + } + const std::filesystem::path cache_path = default_sync_cache_path(); + std::error_code error; + std::filesystem::create_directories(cache_path.parent_path(), error); + if (error) { + throw std::runtime_error("cannot create " + cache_path.parent_path().string() + + ": " + error.message()); + } + nocal::storage::SyncCache cache(cache_path); + nocal::sync::CurlHttpTransport transport; + nocal::sync::XdgBrowserLauncher browser; + nocal::sync::LoopbackCallbackReceiver receiver; + nocal::sync::SystemEntropySource entropy; + nocal::sync::LibsecretOAuthSecretStore secrets; + const auto account = nocal::sync::connect_microsoft_account( + nocal::sync::microsoft_oauth_config(client_id), browser, receiver, transport, + entropy, secrets, cache, [] { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + }); + std::cout << "Connected " << account.display_name << " (" << account.id << ")\n"; + return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; +} + +int account_command(int argc, char** argv) +{ + if (argc == 3 && std::string_view{argv[1]} == "connect" && + std::string_view{argv[2]} == "microsoft") { + return connect_microsoft_account(); + } + throw std::invalid_argument("usage: nocal account connect microsoft"); +} + void print_help(std::ostream& output) { output << @@ -60,6 +121,9 @@ void print_help(std::ostream& output) " --no-color disable ANSI styling\n" " -h, --help show this help\n" " -V, --version show the version\n\n" + "Accounts: nocal account connect microsoft\n" + " links a Microsoft account (browser sign-in, tokens stay in\n" + " the Secret Service keyring) and exits.\n\n" "Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n" " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n" @@ -313,6 +377,9 @@ int export_calendar(const std::filesystem::path& source_path, int main(int argc, char** argv) { try { + if (argc >= 2 && std::string_view{argv[1]} == "account") { + return account_command(argc - 1, argv + 1); + } const auto options = parse_options(argc, argv); auto loaded = nocal::storage::IcsStore::load(options.calendar_path); print_warnings(loaded, options.calendar_path); diff --git a/src/sync/microsoft_account.cpp b/src/sync/microsoft_account.cpp new file mode 100644 index 0000000..2bda36b --- /dev/null +++ b/src/sync/microsoft_account.cpp @@ -0,0 +1,117 @@ +#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 + +namespace nocal::sync { +namespace { + +[[nodiscard]] std::string sha256_hex(std::string_view input) { + std::array digest{}; + unsigned int size = 0; + if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1 + || size != digest.size()) { + throw std::runtime_error("cryptographic failure"); + } + 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; +} + +} // namespace + +std::string default_microsoft_client_id() { + const char* value = std::getenv("NOCAL_MICROSOFT_CLIENT_ID"); + if (value != nullptr && value[0] != '\0') { + return std::string(value); + } + return std::string(microsoft_client_id_placeholder); +} + +OAuthClientConfig microsoft_oauth_config(std::string client_id) { + OAuthClientConfig config; + config.client_id = std::move(client_id); + config.authorization_endpoint = + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; + config.token_endpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + config.scopes = {"offline_access", "User.Read", "Calendars.Read"}; + return config; +} + +std::string microsoft_account_id(std::string_view remote_subject) { + std::string input("account"); + input.push_back('\0'); + input += "microsoft-graph"; + input.push_back('\0'); + input += remote_subject; + return std::string("graph-account-") + sha256_hex(input); +} + +storage::CachedAccount connect_microsoft_account(const OAuthClientConfig& config, + BrowserLauncher& browser, AuthorizationCallbackReceiver& receiver, + HttpTransport& transport, EntropySource& entropy, OAuthSecretStore& secret_store, + storage::SyncCache& cache, const std::function& clock) { + // Step 1: authorization-code flow with PKCE S256 + HttpResponse response; + try { + response = authorize_with_pkce(config, browser, receiver, transport, entropy); + } catch (const std::exception&) { + throw MicrosoftAccountError("Microsoft authorization failed"); + } + + // Step 2: strict token parsing (requires initial refresh token) + OAuthTokens tokens; + try { + tokens = parse_oauth_token_response(response.body, clock()); + } catch (const std::exception&) { + throw MicrosoftAccountError("Microsoft token response invalid"); + } + + // Step 3: one /me identity read + MicrosoftGraphSync graph{transport, cache}; + MicrosoftGraphIdentity identity; + try { + identity = graph.fetch_identity(tokens.access_token); + } catch (const std::exception&) { + throw MicrosoftAccountError("Microsoft identity read failed"); + } + + // Step 4: upsert account into the sync cache + const std::string account_id = microsoft_account_id(identity.remote_subject); + const storage::CachedAccount account{ + account_id, "microsoft-graph", identity.remote_subject, identity.display_name}; + try { + cache.upsert_account(account); + } catch (const std::exception&) { + throw MicrosoftAccountError("sync cache update failed"); + } + + // Step 5: persist credentials to the secret store + // On failure the cached account row remains (documented disconnected state). + try { + secret_store.store(account_id, tokens); + } catch (const std::exception&) { + throw MicrosoftAccountError("credential storage failed"); + } + + return account; +} + +} // namespace nocal::sync \ No newline at end of file diff --git a/src/sync/microsoft_graph.cpp b/src/sync/microsoft_graph.cpp index 2ac95a5..a8399ee 100644 --- a/src/sync/microsoft_graph.cpp +++ b/src/sync/microsoft_graph.cpp @@ -533,19 +533,32 @@ struct DeltaContinuation { MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache) : transport_(transport), cache_(cache) {} +MicrosoftGraphIdentity MicrosoftGraphSync::fetch_identity( + std::string_view access_token) const { + try { + const MicrosoftGraphCredentials credentials{"identity", std::string(access_token)}; + validate_credentials(credentials); + const nlohmann::json object = parse_json_object( + send_graph(transport_, std::string(graph_v1) + "/me", credentials).body); + MicrosoftGraphIdentity identity{required_string(object, "id"), + optional_string(object, "displayName")}; + if (identity.display_name.empty()) { + identity.display_name = required_string(object, "userPrincipalName"); + } + return identity; + } catch (...) { + throw MicrosoftGraphError("Microsoft Graph identity read failed"); + } +} + 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"); - } + const MicrosoftGraphIdentity identity = fetch_identity(credentials.access_token); CachedAccount account{ - credentials.account_id, "microsoft-graph", remote_subject, std::move(display_name)}; + credentials.account_id, "microsoft-graph", identity.remote_subject, + identity.display_name}; cache_.upsert_account(account); return account; } catch (...) { diff --git a/tests/microsoft_account_tests.cpp b/tests/microsoft_account_tests.cpp new file mode 100644 index 0000000..d18a5f1 --- /dev/null +++ b/tests/microsoft_account_tests.cpp @@ -0,0 +1,886 @@ +#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; +} \ No newline at end of file