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. 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 mode: subagent
model: openrouter/qwen/qwen3-coder-next model: openrouter/deepseek/deepseek-v4-flash
permission: permission:
edit: allow edit: allow
bash: allow bash: allow

View File

@@ -40,7 +40,7 @@ implementation value.
## Concrete delegation setup (nocal) ## Concrete delegation setup (nocal)
A coding subagent is configured at `.opencode/agent/coder.md` with model 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 by the lead; the lead retains shared contracts, integration, review, and final
verification. After any config change, restart opencode to reload agents. 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 beta and undocumented delta routes, at the cost of refetching each secondary
window instead of incrementally updating it. window instead of incrementally updating it.
There is still no account UI/CLI orchestration or live Microsoft credential Account setup begins with `nocal account connect microsoft`: it runs the
integration, and remote writes remain out of scope. Account setup and PKCE browser flow, reads `/me`, upserts the deterministic account row, and
orchestration are next. 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 tradeoff is bandwidth: every secondary window is fetched completely on each
refresh, while the primary calendar remains incremental. refresh, while the primary calendar remains incremental.
There is no account UI or CLI orchestration and no live Microsoft credential Account setup now has a CLI entry point, `nocal account connect microsoft`.
integration yet; tests use injected transports and credentials. Remote writes It composes the desktop OAuth adapters, libcurl transport, token parsing, one
remain out of scope. Account setup and orchestration are next. `/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 `AccountDisconnected` for re-authentication
- Credential material never appears in exception messages - 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: Next provider-boundary slices:
- Account setup and CLI/TUI orchestration with live credential integration - TUI account orchestration and generic `SyncProvider` integration with
- Generic `SyncProvider` integration and observable sync status observable sync status
## 0.3 — CalDAV ## 0.3 — CalDAV
@@ -125,9 +138,10 @@ Next provider-boundary slices:
The Graph read boundary uses stable v1 delta for the primary calendar and The Graph read boundary uses stable v1 delta for the primary calendar and
complete stable-v1 calendar views for secondary calendars; it deliberately complete stable-v1 calendar views for secondary calendars; it deliberately
avoids beta and undocumented per-calendar delta routes. No account UI/CLI avoids beta and undocumented per-calendar delta routes. Account connect exists
orchestration or live Microsoft credential integration exists yet, and remote as a CLI command with live credential integration through Secret Service, but
writes remain out of scope. there is no account TUI or sync orchestration yet, and remote writes remain
out of scope.
## 1.0 — distribution quality ## 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 <cstdint>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <string_view>
namespace nocal::sync { namespace nocal::sync {
@@ -28,6 +29,13 @@ public:
using std::runtime_error::runtime_error; 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 // Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for // every calendar returned by /me/calendars. Stable v1.0 event delta is used for
// the primary calendar and complete calendarView reconciliation for secondary // the primary calendar and complete calendarView reconciliation for secondary
@@ -36,6 +44,10 @@ class MicrosoftGraphSync {
public: public:
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache); 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 // Requires delegated User.Read. A complete and validated /me response is
// persisted atomically; failures leave the cached account unchanged. // persisted atomically; failures leave the cached account unchanged.
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials); 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::size_t search_result_index{0};
std::string notification; std::string notification;
bool notification_is_error{false}; bool notification_is_error{false};
std::string sync_stage;
bool sync_is_error{false};
bool show_calendar_picker{false}; bool show_calendar_picker{false};
std::size_t calendar_index{0}; std::size_t calendar_index{0};
bool show_agenda{false}; bool show_agenda{false};
@@ -136,6 +138,7 @@ enum class Action {
search, search,
agenda, agenda,
calendars, calendars,
sync,
quit, quit,
}; };

View File

@@ -1,13 +1,17 @@
#pragma once #pragma once
#include <functional> #include <functional>
#include <memory>
#include <mutex>
#include <optional> #include <optional>
#include <span> #include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <thread>
#include <vector> #include <vector>
#include "nocal/domain/event.hpp" #include "nocal/domain/event.hpp"
#include "nocal/sync/provider.hpp"
#include "nocal/tui/EventEditor.hpp" #include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/Screen.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 // The Event vector is retained by reference so future editor commands can
// update the caller's model without changing the application boundary. // update the caller's model without changing the application boundary.
class TuiApp { class TuiApp : public sync::SyncObserver {
public: public:
using SaveCallback = std::function<void(std::span<const Event>)>; using SaveCallback = std::function<void(std::span<const Event>)>;
@@ -57,7 +61,28 @@ public:
return calendars_; 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: 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 { struct HistorySnapshot {
std::vector<Event> events; std::vector<Event> events;
std::chrono::year_month visible_month; std::chrono::year_month visible_month;

View File

@@ -28,7 +28,9 @@ nocal_sources = files(
'src/storage/sync_cache.cpp', 'src/storage/sync_cache.cpp',
'src/sync/curl_http.cpp', 'src/sync/curl_http.cpp',
'src/sync/desktop_oauth.cpp', 'src/sync/desktop_oauth.cpp',
'src/sync/microsoft_account.cpp',
'src/sync/microsoft_graph.cpp', 'src/sync/microsoft_graph.cpp',
'src/sync/microsoft_sync_provider.cpp',
'src/sync/oauth.cpp', 'src/sync/oauth.cpp',
'src/sync/oauth_tokens.cpp', 'src/sync/oauth_tokens.cpp',
'src/sync/secret_store.cpp', 'src/sync/secret_store.cpp',
@@ -81,6 +83,9 @@ test('oauth-tokens', oauth_token_tests)
token_broker_tests = executable('token-broker-tests', token_broker_tests = executable('token-broker-tests',
'tests/token_broker_tests.cpp', dependencies: nocal_dep) 'tests/token_broker_tests.cpp', dependencies: nocal_dep)
test('token-broker', token_broker_tests) 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', secret_store_tests = executable('secret-store-tests',
'tests/secret_store_tests.cpp', dependencies: nocal_dep) 'tests/secret_store_tests.cpp', dependencies: nocal_dep)
test('secret-store', shell, test('secret-store', shell,
@@ -91,6 +96,9 @@ test('microsoft-graph', microsoft_graph_tests)
microsoft_graph_security_tests = executable('microsoft-graph-security-tests', microsoft_graph_security_tests = executable('microsoft-graph-security-tests',
'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep) 'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep)
test('microsoft-graph-security', microsoft_graph_security_tests) 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', tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('tui', tui_tests) test('tui', tui_tests)

View File

@@ -1,9 +1,17 @@
#include "nocal/domain/calendar_transfer.hpp" #include "nocal/domain/calendar_transfer.hpp"
#include "nocal/domain/date.hpp" #include "nocal/domain/date.hpp"
#include "nocal/storage/ics_store.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/Screen.hpp"
#include "nocal/tui/TuiApp.hpp" #include "nocal/tui/TuiApp.hpp"
#include <algorithm>
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
@@ -13,6 +21,7 @@
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <system_error>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -45,6 +54,121 @@ std::filesystem::path default_calendar_path()
return "calendar.ics"; 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) void print_help(std::ostream& output)
{ {
output << output <<
@@ -60,6 +184,13 @@ void print_help(std::ostream& output)
" --no-color disable ANSI styling\n" " --no-color disable ANSI styling\n"
" -h, --help show this help\n" " -h, --help show this help\n"
" -V, --version show the version\n\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" "Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\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, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false, .show_calendar_picker = false,
.calendar_index = 0, .calendar_index = 0,
.show_agenda = false, .show_agenda = false,
@@ -313,6 +446,9 @@ int export_calendar(const std::filesystem::path& source_path,
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
try { try {
if (argc >= 2 && std::string_view{argv[1]} == "account") {
return account_command(argc - 1, argv + 1);
}
const auto options = parse_options(argc, argv); const auto options = parse_options(argc, argv);
auto loaded = nocal::storage::IcsStore::load(options.calendar_path); auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
print_warnings(loaded, options.calendar_path); print_warnings(loaded, options.calendar_path);
@@ -361,6 +497,55 @@ int main(int argc, char** argv)
} }
nocal::tui::TuiApp app{ nocal::tui::TuiApp app{
loaded.events, std::move(saver), options.week_start}; 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; return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
} catch (const std::exception& error) { } catch (const std::exception& error) {
std::cerr << "nocal: " << error.what() << '\n'; 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) MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache)
: transport_(transport), cache_(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( CachedAccount MicrosoftGraphSync::refresh_account(
const MicrosoftGraphCredentials& credentials) { const MicrosoftGraphCredentials& credentials) {
try { try {
validate_credentials(credentials); validate_credentials(credentials);
const nlohmann::json object = const MicrosoftGraphIdentity identity = fetch_identity(credentials.access_token);
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");
}
CachedAccount account{ 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); cache_.upsert_account(account);
return account; return account;
} catch (...) { } 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 == "g") return Action::agenda;
if (sequence == "c") return Action::calendars; if (sequence == "c") return Action::calendars;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
if (sequence == "\x13") return Action::sync;
return Action::none; return Action::none;
} }

View File

@@ -1,6 +1,7 @@
#include "nocal/tui/TuiApp.hpp" #include "nocal/tui/TuiApp.hpp"
#include "nocal/domain/event_query.hpp" #include "nocal/domain/event_query.hpp"
#include "nocal/sync/token_broker.hpp"
#include "nocal/tui/Terminal.hpp" #include "nocal/tui/Terminal.hpp"
#include <algorithm> #include <algorithm>
@@ -10,7 +11,9 @@
#include <cstdlib> #include <cstdlib>
#include <ctime> #include <ctime>
#include <exception> #include <exception>
#include <mutex>
#include <string> #include <string>
#include <thread>
#include <utility> #include <utility>
#include <poll.h> #include <poll.h>
@@ -101,6 +104,78 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver,
synchronize_calendars(); 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 { std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
if (!state_.focused_event_id) return std::nullopt; if (!state_.focused_event_id) return std::nullopt;
const auto event_span = std::span<const Event>{events_}; const auto event_span = std::span<const Event>{events_};
@@ -733,6 +808,7 @@ void TuiApp::dispatch(const Action action) {
case Action::redo: case Action::redo:
case Action::agenda: case Action::agenda:
case Action::calendars: case Action::calendars:
case Action::sync:
return; return;
} }
} }
@@ -774,6 +850,7 @@ void TuiApp::dispatch(const Action action) {
case Action::redo: case Action::redo:
case Action::search: case Action::search:
case Action::agenda: case Action::agenda:
case Action::sync:
return; return;
} }
} }
@@ -826,6 +903,7 @@ void TuiApp::dispatch(const Action action) {
case Action::delete_event: case Action::delete_event:
case Action::undo: case Action::undo:
case Action::redo: case Action::redo:
case Action::sync:
return; return;
} }
} }
@@ -911,6 +989,7 @@ void TuiApp::dispatch(const Action action) {
case Action::next_month: case Action::next_month:
case Action::today: case Action::today:
case Action::toggle_help: case Action::toggle_help:
case Action::sync:
return; return;
} }
} }
@@ -999,6 +1078,13 @@ void TuiApp::dispatch(const Action action) {
case Action::calendars: case Action::calendars:
begin_calendar_picker(); begin_calendar_picker();
return; 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: case Action::quit:
running_ = false; running_ = false;
return; return;
@@ -1073,6 +1159,7 @@ ExitReason TuiApp::run() {
redraw = true; redraw = true;
} }
} }
stop_sync();
return ExitReason::quit; 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, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false, .show_calendar_picker = false,
.calendar_index = 0, .calendar_index = 0,
.show_agenda = false, .show_agenda = false,
@@ -178,6 +180,8 @@ void test_compact_and_input()
.search_result_index = 0, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
.sync_stage = {},
.sync_is_error = false,
.show_calendar_picker = false, .show_calendar_picker = false,
.calendar_index = 0, .calendar_index = 0,
.show_agenda = false, .show_agenda = false,