Files
nocal/tests/microsoft_account_tests.cpp
Bernardo Magri 74c798e7aa 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.
2026-07-21 18:44:47 +01:00

886 lines
36 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <cstdint>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <functional>
#include <iostream>
#include <map>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
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<char> writable(pattern.begin(), pattern.end());
writable.push_back('\0');
const char* created = ::mkdtemp(writable.data());
if (created == nullptr) {
throw std::runtime_error("mkdtemp failed");
}
path_ = created;
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
// -----------------------------------------------------------------------
// ScriptedEntropy fixed deterministic values
// -----------------------------------------------------------------------
class ScriptedEntropy final : public EntropySource {
public:
explicit ScriptedEntropy(std::vector<std::array<std::byte, 32>> scripted_values)
: values(std::move(scripted_values)) {}
std::vector<std::array<std::byte, 32>> values;
std::size_t calls{0};
void fill(std::span<std::byte> 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<std::string> 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<HttpRequest> requests;
std::vector<ResponseSpec> 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<std::string, OAuthTokens> 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<OAuthTokens> 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<std::byte, 32> 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;
}