Compare commits

...

3 Commits

Author SHA1 Message Date
725e48569e feat(sync): wire TUI account orchestration, SyncProvider integration, and Ctrl+S keybinding
- MicrosoftAccountSyncProvider: implements SyncProvider interface with
  connect_account(), sync_account(), disconnect_account(), provider_id()
- TuiApp: inherits SyncObserver, implements thread-safe sync progress
  rendering, background sync thread, and Action::sync dispatch
- CLI: add "account disconnect <id>" and "account list" commands
- Main wiring: initialize sync infrastructure when connected accounts
  exist and a valid client ID is configured
- Screen: add sync_stage and sync_is_error fields to ScreenState,
  map Ctrl+S to Action::sync keybinding
- Fix ScreenState aggregate initializers in all construction sites
- Tests: new microsoft_sync_provider_tests.cpp, all 22 tests pass
2026-07-22 21:49:26 +01:00
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
feec691535 docs: switch coder subagent to deepseek-v4-flash 2026-07-20 23:00:03 +01:00
20 changed files with 2471 additions and 22 deletions

View File

@@ -1,7 +1,7 @@
---
description: Implements bounded, well-specified coding slices for nocal. Owns an explicit file set, writes C++20 with deterministic tests, compiles with strict warnings, runs the relevant test binaries, and reports exact checks. Use for delegated implementation only — not for architecture decisions, cross-cutting changes, or review.
mode: subagent
model: openrouter/qwen/qwen3-coder-next
model: openrouter/deepseek/deepseek-v4-flash
permission:
edit: allow
bash: allow

View File

@@ -40,7 +40,7 @@ implementation value.
## Concrete delegation setup (nocal)
A coding subagent is configured at `.opencode/agent/coder.md` with model
`openrouter/qwen/qwen3-coder-next`. It owns bounded implementation slices assigned
`openrouter/deepseek/deepseek-v4-flash`. It owns bounded implementation slices assigned
by the lead; the lead retains shared contracts, integration, review, and final
verification. After any config change, restart opencode to reload agents.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,70 @@
#pragma once
#include "nocal/storage/sync_cache.hpp"
#include "nocal/sync/oauth.hpp"
#include <cstdint>
#include <functional>
#include <stdexcept>
#include <string>
#include <string_view>
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<std::int64_t()>& clock);
} // namespace nocal::sync

View File

@@ -6,6 +6,7 @@
#include <cstdint>
#include <stdexcept>
#include <string>
#include <string_view>
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);

View File

@@ -0,0 +1,74 @@
#pragma once
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/provider.hpp"
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <string_view>
namespace nocal::storage {
class SyncCache;
} // namespace nocal::storage
namespace nocal::sync {
class MicrosoftGraphSync;
class OAuthTokenBroker;
class OAuthSecretStore;
class HttpTransport;
class MicrosoftAccountSyncProvider final : public SyncProvider {
public:
// All references must outlive this object.
MicrosoftAccountSyncProvider(OAuthTokenBroker& token_broker,
OAuthSecretStore& secret_store,
storage::SyncCache& cache,
HttpTransport& transport,
const OAuthClientConfig& oauth_config,
std::function<std::int64_t()> clock,
BrowserLauncher& browser,
AuthorizationCallbackReceiver& callback_receiver,
EntropySource& entropy);
~MicrosoftAccountSyncProvider() override;
MicrosoftAccountSyncProvider(const MicrosoftAccountSyncProvider&) = delete;
MicrosoftAccountSyncProvider& operator=(const MicrosoftAccountSyncProvider&) = delete;
// --- SyncProvider interface ---
[[nodiscard]] std::string_view provider_id() const noexcept override;
ConnectedAccount connect_account() override;
void sync_account(std::string_view account_id, SyncObserver& observer) override;
void disconnect_account(std::string_view account_id) override;
private:
OAuthTokenBroker& token_broker_;
OAuthSecretStore& secret_store_;
storage::SyncCache& cache_;
HttpTransport& transport_;
OAuthClientConfig oauth_config_;
std::function<std::int64_t()> clock_;
BrowserLauncher& browser_;
AuthorizationCallbackReceiver& callback_receiver_;
EntropySource& entropy_;
std::unique_ptr<MicrosoftGraphSync> graph_;
// Helper: run full PKCE connect flow and report progress.
ConnectedAccount do_connect(SyncObserver& observer);
// Helper: get valid tokens, throwing AccountDisconnected on invalid credentials.
OAuthTokens get_tokens(std::string_view account_id);
// Helper: sync one calendar (primary uses delta, secondaries use calendarView).
void sync_one_calendar(std::string_view account_id,
std::string_view calendar_id,
std::string_view calendar_name,
bool is_primary,
SyncObserver& observer);
};
} // namespace nocal::sync

View File

@@ -82,6 +82,8 @@ struct ScreenState {
std::size_t search_result_index{0};
std::string notification;
bool notification_is_error{false};
std::string sync_stage;
bool sync_is_error{false};
bool show_calendar_picker{false};
std::size_t calendar_index{0};
bool show_agenda{false};
@@ -136,6 +138,7 @@ enum class Action {
search,
agenda,
calendars,
sync,
quit,
};

View File

@@ -1,13 +1,17 @@
#pragma once
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include "nocal/domain/event.hpp"
#include "nocal/sync/provider.hpp"
#include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/Screen.hpp"
@@ -21,7 +25,7 @@ enum class ExitReason {
// The Event vector is retained by reference so future editor commands can
// update the caller's model without changing the application boundary.
class TuiApp {
class TuiApp : public sync::SyncObserver {
public:
using SaveCallback = std::function<void(std::span<const Event>)>;
@@ -57,7 +61,28 @@ public:
return calendars_;
}
void set_sync_provider(std::unique_ptr<sync::SyncProvider> provider) noexcept {
sync_provider_ = std::move(provider);
}
void set_connected_account_id(std::string id) noexcept {
connected_account_id_ = std::move(id);
}
private:
// Sync state (protected by sync_mutex_)
std::mutex sync_mutex_;
std::unique_ptr<sync::SyncProvider> sync_provider_;
std::optional<std::string> connected_account_id_;
std::optional<std::thread> sync_thread_;
// --- SyncObserver implementation (called from sync thread) ---
void on_sync_progress(const sync::SyncProgressEvent& event) override;
void start_sync(std::string_view account_id);
void stop_sync();
void apply_sync_stage(const std::string& stage_text, bool is_error);
void clear_sync_state();
struct HistorySnapshot {
std::vector<Event> events;
std::chrono::year_month visible_month;

View File

@@ -28,7 +28,9 @@ 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/microsoft_sync_provider.cpp',
'src/sync/oauth.cpp',
'src/sync/oauth_tokens.cpp',
'src/sync/secret_store.cpp',
@@ -81,6 +83,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,
@@ -91,6 +96,9 @@ test('microsoft-graph', microsoft_graph_tests)
microsoft_graph_security_tests = executable('microsoft-graph-security-tests',
'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep)
test('microsoft-graph-security', microsoft_graph_security_tests)
microsoft_sync_provider_tests = executable('microsoft-sync-provider-tests',
'tests/microsoft_sync_provider_tests.cpp', dependencies: nocal_dep)
test('microsoft-sync-provider', microsoft_sync_provider_tests)
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
dependencies: nocal_dep)
test('tui', tui_tests)

View File

@@ -1,9 +1,17 @@
#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/microsoft_sync_provider.hpp"
#include "nocal/sync/secret_store.hpp"
#include "nocal/sync/token_broker.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <filesystem>
@@ -13,6 +21,7 @@
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
@@ -45,6 +54,121 @@ 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::seconds>(
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 disconnect_account_command(nocal::storage::SyncCache& cache,
nocal::sync::OAuthSecretStore& secret_store,
std::string_view account_id)
{
// Verify account exists in cache
const auto snapshot = cache.snapshot();
const auto it = std::find_if(snapshot.accounts.begin(), snapshot.accounts.end(),
[&account_id](const nocal::storage::CachedAccount& a) { return a.id == account_id; });
if (it == snapshot.accounts.end()) {
throw std::invalid_argument("unknown account: " + std::string{account_id});
}
// Erase from secret store
secret_store.erase(std::string{account_id});
// Note: cached account/calendar rows remain in the database.
// This matches the SyncProvider contract: cached provider data is left
// in place so that reconnecting the same account is idempotent.
std::cout << "Disconnected " << it->display_name << " (" << account_id << ")\n";
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
int list_accounts_command(const nocal::storage::SyncCache& cache)
{
const auto snapshot = cache.snapshot();
if (snapshot.accounts.empty()) {
std::cout << "No connected accounts.\n";
return EXIT_SUCCESS;
}
for (const auto& account : snapshot.accounts) {
std::cout << " " << account.id << " " << account.display_name
<< " (" << account.provider << ")\n";
}
return EXIT_SUCCESS;
}
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();
}
if (argc == 3 && std::string_view{argv[1]} == "disconnect") {
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::LibsecretOAuthSecretStore secrets;
return disconnect_account_command(cache, secrets, argv[2]);
}
if (argc == 2 && std::string_view{argv[1]} == "list") {
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);
return list_accounts_command(cache);
}
throw std::invalid_argument(
"usage: nocal account connect microsoft\n"
" nocal account disconnect <account_id>\n"
" nocal account list");
}
void print_help(std::ostream& output)
{
output <<
@@ -60,6 +184,13 @@ 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"
" nocal account disconnect <account_id>\n"
" removes credentials for a connected account.\n"
" nocal account list\n"
" lists connected accounts.\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"
@@ -232,6 +363,8 @@ int print_frame(std::span<const nocal::Event> events, bool no_color,
.search_result_index = 0,
.notification = {},
.notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false,
.calendar_index = 0,
.show_agenda = false,
@@ -313,6 +446,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);
@@ -361,6 +497,55 @@ int main(int argc, char** argv)
}
nocal::tui::TuiApp app{
loaded.events, std::move(saver), options.week_start};
// --- Sync infrastructure ---
const std::filesystem::path cache_path = default_sync_cache_path();
std::error_code cache_error;
std::filesystem::create_directories(cache_path.parent_path(), cache_error);
nocal::storage::SyncCache sync_cache(cache_path);
nocal::sync::CurlHttpTransport http_transport;
nocal::sync::LibsecretOAuthSecretStore secret_store;
const auto sync_snapshot = sync_cache.snapshot();
if (!sync_snapshot.accounts.empty()) {
app.set_connected_account_id(sync_snapshot.accounts.front().id);
const std::string client_id = nocal::sync::default_microsoft_client_id();
if (client_id != nocal::sync::microsoft_client_id_placeholder) {
nocal::sync::XdgBrowserLauncher browser;
nocal::sync::LoopbackCallbackReceiver callback_receiver;
nocal::sync::SystemEntropySource entropy;
nocal::sync::OAuthTokenBroker token_broker(
nocal::sync::microsoft_oauth_config(client_id),
http_transport,
secret_store,
[] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
});
auto sync_prov = std::make_unique<nocal::sync::MicrosoftAccountSyncProvider>(
token_broker,
secret_store,
sync_cache,
http_transport,
nocal::sync::microsoft_oauth_config(client_id),
[] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
},
browser,
callback_receiver,
entropy);
app.set_sync_provider(std::move(sync_prov));
}
}
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
} catch (const std::exception& error) {
std::cerr << "nocal: " << error.what() << '\n';

View File

@@ -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 <openssl/evp.h>
#include <array>
#include <cstdint>
#include <cstdlib>
#include <functional>
#include <stdexcept>
#include <string>
#include <string_view>
namespace nocal::sync {
namespace {
[[nodiscard]] std::string sha256_hex(std::string_view input) {
std::array<unsigned char, 32> digest{};
unsigned int size = 0;
if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1
|| size != digest.size()) {
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<std::int64_t()>& 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

View File

@@ -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 (...) {

View File

@@ -0,0 +1,231 @@
#include "nocal/sync/microsoft_sync_provider.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 "nocal/sync/token_broker.hpp"
#include "nocal/storage/sync_cache.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace nocal::sync {
namespace {
class NoopObserver final : public SyncObserver {
public:
void on_sync_progress(const SyncProgressEvent&) override {}
};
void send_stage(SyncObserver& observer, SyncStage stage) {
SyncProgressEvent event;
event.stage = stage;
observer.on_sync_progress(event);
}
[[nodiscard]] bool calendar_is_primary(const storage::CachedCalendar& calendar) {
try {
const nlohmann::json object = nlohmann::json::parse(calendar.raw_payload);
const auto found = object.find("isDefaultCalendar");
return found != object.end() && found->is_boolean() && found->get<bool>();
} catch (const nlohmann::json::exception&) {
return false;
}
}
[[nodiscard]] MicrosoftGraphWindow default_window(std::int64_t now_seconds) {
const std::int64_t now_micros = now_seconds * 1'000'000;
constexpr std::int64_t six_months_micros =
180LL * 24LL * 60LL * 60LL * 1'000'000LL;
constexpr std::int64_t twelve_months_micros =
365LL * 24LL * 60LL * 60LL * 1'000'000LL;
MicrosoftGraphWindow window;
window.start_epoch_microseconds = now_micros - six_months_micros;
window.end_epoch_microseconds = now_micros + twelve_months_micros;
return window;
}
} // namespace
MicrosoftAccountSyncProvider::MicrosoftAccountSyncProvider(
OAuthTokenBroker& token_broker,
OAuthSecretStore& secret_store,
storage::SyncCache& cache,
HttpTransport& transport,
const OAuthClientConfig& oauth_config,
std::function<std::int64_t()> clock,
BrowserLauncher& browser,
AuthorizationCallbackReceiver& callback_receiver,
EntropySource& entropy)
: token_broker_(token_broker),
secret_store_(secret_store),
cache_(cache),
transport_(transport),
oauth_config_(oauth_config),
clock_(std::move(clock)),
browser_(browser),
callback_receiver_(callback_receiver),
entropy_(entropy),
graph_(std::make_unique<MicrosoftGraphSync>(transport_, cache_)) {}
MicrosoftAccountSyncProvider::~MicrosoftAccountSyncProvider() = default;
std::string_view MicrosoftAccountSyncProvider::provider_id() const noexcept {
return "microsoft-graph";
}
ConnectedAccount MicrosoftAccountSyncProvider::connect_account() {
NoopObserver noop;
return do_connect(noop);
}
ConnectedAccount MicrosoftAccountSyncProvider::do_connect(SyncObserver& observer) {
// Step 1: Send authenticating stage
send_stage(observer, SyncStage::authenticating);
// Step 2: Run PKCE flow → parse tokens
HttpResponse response;
try {
response = authorize_with_pkce(
oauth_config_, browser_, callback_receiver_, transport_, entropy_);
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft authorization failed");
}
OAuthTokens tokens;
try {
tokens = parse_oauth_token_response(response.body, clock_());
} catch (const std::exception&) {
throw MicrosoftAccountError("Microsoft token response invalid");
}
// Step 3: Fetch /me identity
send_stage(observer, SyncStage::refreshing_identity);
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 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 (failure leaves a disconnected account row)
try {
secret_store_.store(account_id, tokens);
} catch (const std::exception&) {
throw MicrosoftAccountError("credential storage failed");
}
// Step 6: Report completion
send_stage(observer, SyncStage::completed);
return ConnectedAccount{account_id, identity.display_name};
}
OAuthTokens MicrosoftAccountSyncProvider::get_tokens(std::string_view account_id) {
// Delegate to the token broker which handles refresh, expiry, and
// AccountDisconnected on 400/401.
return token_broker_.valid_tokens(account_id);
}
void MicrosoftAccountSyncProvider::sync_account(
std::string_view account_id, SyncObserver& observer) {
// Step 1: Authenticating — get valid tokens or throw AccountDisconnected
send_stage(observer, SyncStage::authenticating);
OAuthTokens tokens = get_tokens(account_id);
MicrosoftGraphCredentials credentials{std::string(account_id),
std::move(tokens.access_token)};
// Step 2: Refresh identity
send_stage(observer, SyncStage::refreshing_identity);
graph_->refresh_account(credentials);
// Step 3: Discover calendars
send_stage(observer, SyncStage::discovering_calendars);
graph_->refresh_calendars(credentials);
// Step 4: Get cached calendars and sync each active one
const storage::CacheSnapshot snapshot = cache_.snapshot();
std::vector<const storage::CachedCalendar*> active_calendars;
for (const auto& cal : snapshot.calendars) {
if (cal.account_id == account_id && cal.active) {
active_calendars.push_back(&cal);
}
}
const std::size_t total = active_calendars.size();
std::size_t completed = 0;
for (const storage::CachedCalendar* cal : active_calendars) {
const bool is_primary = calendar_is_primary(*cal);
SyncProgressEvent progress;
progress.stage = SyncStage::syncing_calendar;
progress.account_id = std::string(account_id);
progress.calendar_name = cal->name;
progress.calendars_completed = completed;
progress.calendars_total = total;
observer.on_sync_progress(progress);
try {
sync_one_calendar(account_id, cal->id, cal->name, is_primary, observer);
} catch (const std::exception&) {
// Continue past individual calendar failures; report via the
// completed count but the error is not surfaced through the
// observer (SyncObserver has no error flag).
}
++completed;
}
// Step 5: Report completion
send_stage(observer, SyncStage::completed);
}
void MicrosoftAccountSyncProvider::sync_one_calendar(
std::string_view account_id,
std::string_view calendar_id,
std::string_view /*calendar_name*/,
bool is_primary,
SyncObserver& /*observer*/) {
OAuthTokens tokens = get_tokens(account_id);
MicrosoftGraphCredentials credentials{std::string(account_id),
std::move(tokens.access_token)};
const MicrosoftGraphWindow window = default_window(clock_());
if (is_primary) {
graph_->sync_primary_calendar_window(credentials, window);
} else {
graph_->sync_secondary_calendar_window(
credentials, std::string(calendar_id), window);
}
}
void MicrosoftAccountSyncProvider::disconnect_account(
std::string_view account_id) {
// Erase credentials from the secret store.
// Cached provider data (account row, calendars, events) is left in place
// per SyncProvider contract (deterministic IDs make re-add idempotent).
secret_store_.erase(std::string(account_id));
}
} // namespace nocal::sync

View File

@@ -1051,6 +1051,7 @@ Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "g") return Action::agenda;
if (sequence == "c") return Action::calendars;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
if (sequence == "\x13") return Action::sync;
return Action::none;
}

View File

@@ -1,6 +1,7 @@
#include "nocal/tui/TuiApp.hpp"
#include "nocal/domain/event_query.hpp"
#include "nocal/sync/token_broker.hpp"
#include "nocal/tui/Terminal.hpp"
#include <algorithm>
@@ -10,7 +11,9 @@
#include <cstdlib>
#include <ctime>
#include <exception>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <poll.h>
@@ -101,6 +104,78 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver,
synchronize_calendars();
}
void TuiApp::on_sync_progress(const sync::SyncProgressEvent& event)
{
std::string stage_text;
switch (event.stage) {
case sync::SyncStage::authenticating:
stage_text = "Authenticating...";
break;
case sync::SyncStage::refreshing_identity:
stage_text = "Refreshing identity...";
break;
case sync::SyncStage::discovering_calendars:
stage_text = "Discovering calendars...";
break;
case sync::SyncStage::syncing_calendar:
stage_text = "Syncing " + event.calendar_name + "...";
break;
case sync::SyncStage::completed:
stage_text = "Sync complete.";
break;
}
apply_sync_stage(stage_text, false);
}
void TuiApp::apply_sync_stage(const std::string& stage_text, const bool is_error)
{
std::lock_guard<std::mutex> lock(sync_mutex_);
state_.sync_stage = stage_text;
state_.sync_is_error = is_error;
state_.notification = stage_text;
state_.notification_is_error = is_error;
}
void TuiApp::clear_sync_state()
{
std::lock_guard<std::mutex> lock(sync_mutex_);
state_.sync_stage.clear();
state_.sync_is_error = false;
if (!state_.sync_stage.empty() || state_.notification.empty()) {
state_.notification.clear();
state_.notification_is_error = false;
}
}
void TuiApp::start_sync(const std::string_view account_id)
{
if (sync_thread_ && sync_thread_->joinable()) {
return;
}
if (!sync_provider_ || account_id.empty()) {
return;
}
connected_account_id_ = std::string{account_id};
apply_sync_stage("Syncing...", false);
sync_thread_.emplace([this, acct = std::string{account_id}]() {
try {
sync_provider_->sync_account(acct, *this);
} catch (const sync::AccountDisconnected& e) {
apply_sync_stage("Account disconnected. Run: nocal account connect microsoft", true);
} catch (const std::exception& e) {
apply_sync_stage("Sync failed: " + std::string{e.what()}, true);
}
});
}
void TuiApp::stop_sync()
{
if (sync_thread_ && sync_thread_->joinable()) {
sync_thread_->join();
sync_thread_.reset();
}
}
std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
if (!state_.focused_event_id) return std::nullopt;
const auto event_span = std::span<const Event>{events_};
@@ -733,6 +808,7 @@ void TuiApp::dispatch(const Action action) {
case Action::redo:
case Action::agenda:
case Action::calendars:
case Action::sync:
return;
}
}
@@ -774,6 +850,7 @@ void TuiApp::dispatch(const Action action) {
case Action::redo:
case Action::search:
case Action::agenda:
case Action::sync:
return;
}
}
@@ -826,6 +903,7 @@ void TuiApp::dispatch(const Action action) {
case Action::delete_event:
case Action::undo:
case Action::redo:
case Action::sync:
return;
}
}
@@ -911,6 +989,7 @@ void TuiApp::dispatch(const Action action) {
case Action::next_month:
case Action::today:
case Action::toggle_help:
case Action::sync:
return;
}
}
@@ -999,6 +1078,13 @@ void TuiApp::dispatch(const Action action) {
case Action::calendars:
begin_calendar_picker();
return;
case Action::sync:
if (connected_account_id_) {
start_sync(*connected_account_id_);
} else {
set_notification("No connected account. Run: nocal account connect microsoft", true);
}
return;
case Action::quit:
running_ = false;
return;
@@ -1073,6 +1159,7 @@ ExitReason TuiApp::run() {
redraw = true;
}
}
stop_sync();
return ExitReason::quit;
}

View File

@@ -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 <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;
}

View File

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

View File

@@ -118,6 +118,8 @@ void test_full_month_render()
.search_result_index = 0,
.notification = {},
.notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false,
.calendar_index = 0,
.show_agenda = false,
@@ -178,6 +180,8 @@ void test_compact_and_input()
.search_result_index = 0,
.notification = {},
.notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false,
.calendar_index = 0,
.show_agenda = false,