diff --git a/include/nocal/sync/microsoft_sync_provider.hpp b/include/nocal/sync/microsoft_sync_provider.hpp new file mode 100644 index 0000000..aeefc58 --- /dev/null +++ b/include/nocal/sync/microsoft_sync_provider.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include "nocal/sync/oauth.hpp" +#include "nocal/sync/oauth_tokens.hpp" +#include "nocal/sync/provider.hpp" + +#include +#include +#include +#include +#include + +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 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 clock_; + BrowserLauncher& browser_; + AuthorizationCallbackReceiver& callback_receiver_; + EntropySource& entropy_; + std::unique_ptr 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 diff --git a/include/nocal/tui/Screen.hpp b/include/nocal/tui/Screen.hpp index 4582014..03ea906 100644 --- a/include/nocal/tui/Screen.hpp +++ b/include/nocal/tui/Screen.hpp @@ -82,6 +82,8 @@ struct ScreenState { std::size_t search_result_index{0}; std::string notification; bool notification_is_error{false}; + std::string sync_stage; + bool sync_is_error{false}; bool show_calendar_picker{false}; std::size_t calendar_index{0}; bool show_agenda{false}; @@ -136,6 +138,7 @@ enum class Action { search, agenda, calendars, + sync, quit, }; diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp index 00f36bd..238f62b 100644 --- a/include/nocal/tui/TuiApp.hpp +++ b/include/nocal/tui/TuiApp.hpp @@ -1,13 +1,17 @@ #pragma once #include +#include +#include #include #include #include #include +#include #include #include "nocal/domain/event.hpp" +#include "nocal/sync/provider.hpp" #include "nocal/tui/EventEditor.hpp" #include "nocal/tui/Screen.hpp" @@ -21,7 +25,7 @@ enum class ExitReason { // The Event vector is retained by reference so future editor commands can // update the caller's model without changing the application boundary. -class TuiApp { +class TuiApp : public sync::SyncObserver { public: using SaveCallback = std::function)>; @@ -57,7 +61,28 @@ public: return calendars_; } + void set_sync_provider(std::unique_ptr provider) noexcept { + sync_provider_ = std::move(provider); + } + void set_connected_account_id(std::string id) noexcept { + connected_account_id_ = std::move(id); + } + private: + // Sync state (protected by sync_mutex_) + std::mutex sync_mutex_; + std::unique_ptr sync_provider_; + std::optional connected_account_id_; + std::optional sync_thread_; + + // --- SyncObserver implementation (called from sync thread) --- + void on_sync_progress(const sync::SyncProgressEvent& event) override; + + void start_sync(std::string_view account_id); + void stop_sync(); + void apply_sync_stage(const std::string& stage_text, bool is_error); + void clear_sync_state(); + struct HistorySnapshot { std::vector events; std::chrono::year_month visible_month; diff --git a/meson.build b/meson.build index 88a26b9..0785931 100644 --- a/meson.build +++ b/meson.build @@ -30,6 +30,7 @@ nocal_sources = files( 'src/sync/desktop_oauth.cpp', 'src/sync/microsoft_account.cpp', 'src/sync/microsoft_graph.cpp', + 'src/sync/microsoft_sync_provider.cpp', 'src/sync/oauth.cpp', 'src/sync/oauth_tokens.cpp', 'src/sync/secret_store.cpp', @@ -95,6 +96,9 @@ test('microsoft-graph', microsoft_graph_tests) microsoft_graph_security_tests = executable('microsoft-graph-security-tests', 'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep) test('microsoft-graph-security', microsoft_graph_security_tests) +microsoft_sync_provider_tests = executable('microsoft-sync-provider-tests', + 'tests/microsoft_sync_provider_tests.cpp', dependencies: nocal_dep) +test('microsoft-sync-provider', microsoft_sync_provider_tests) tui_tests = executable('tui-tests', 'tests/tui_tests.cpp', dependencies: nocal_dep) test('tui', tui_tests) diff --git a/src/main.cpp b/src/main.cpp index 408dde0..bec76a6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,10 +5,13 @@ #include "nocal/sync/curl_http.hpp" #include "nocal/sync/desktop_oauth.hpp" #include "nocal/sync/microsoft_account.hpp" +#include "nocal/sync/microsoft_sync_provider.hpp" #include "nocal/sync/secret_store.hpp" +#include "nocal/sync/token_broker.hpp" #include "nocal/tui/Screen.hpp" #include "nocal/tui/TuiApp.hpp" +#include #include #include #include @@ -97,13 +100,73 @@ int connect_microsoft_account() 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(); } - throw std::invalid_argument("usage: nocal account connect microsoft"); + 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 \n" + " nocal account list"); } void print_help(std::ostream& output) @@ -121,9 +184,13 @@ void print_help(std::ostream& output) " --no-color disable ANSI styling\n" " -h, --help show this help\n" " -V, --version show the version\n\n" - "Accounts: nocal account connect microsoft\n" - " links a Microsoft account (browser sign-in, tokens stay in\n" - " the Secret Service keyring) and exits.\n\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 \n" + " removes credentials for a connected account.\n" + " nocal account list\n" + " lists connected accounts.\n\n" "Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n" " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n" @@ -296,6 +363,8 @@ int print_frame(std::span events, bool no_color, .search_result_index = 0, .notification = {}, .notification_is_error = false, + .sync_stage = {}, + .sync_is_error = false, .show_calendar_picker = false, .calendar_index = 0, .show_agenda = false, @@ -428,6 +497,55 @@ int main(int argc, char** argv) } nocal::tui::TuiApp app{ loaded.events, std::move(saver), options.week_start}; + + // --- Sync infrastructure --- + const std::filesystem::path cache_path = default_sync_cache_path(); + std::error_code cache_error; + std::filesystem::create_directories(cache_path.parent_path(), cache_error); + + nocal::storage::SyncCache sync_cache(cache_path); + nocal::sync::CurlHttpTransport http_transport; + nocal::sync::LibsecretOAuthSecretStore secret_store; + + const auto sync_snapshot = sync_cache.snapshot(); + if (!sync_snapshot.accounts.empty()) { + app.set_connected_account_id(sync_snapshot.accounts.front().id); + + const std::string client_id = nocal::sync::default_microsoft_client_id(); + if (client_id != nocal::sync::microsoft_client_id_placeholder) { + nocal::sync::XdgBrowserLauncher browser; + nocal::sync::LoopbackCallbackReceiver callback_receiver; + nocal::sync::SystemEntropySource entropy; + + nocal::sync::OAuthTokenBroker token_broker( + nocal::sync::microsoft_oauth_config(client_id), + http_transport, + secret_store, + [] { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + }); + + auto sync_prov = std::make_unique( + token_broker, + secret_store, + sync_cache, + http_transport, + nocal::sync::microsoft_oauth_config(client_id), + [] { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + }, + browser, + callback_receiver, + entropy); + + app.set_sync_provider(std::move(sync_prov)); + } + } + return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS; } catch (const std::exception& error) { std::cerr << "nocal: " << error.what() << '\n'; diff --git a/src/sync/microsoft_sync_provider.cpp b/src/sync/microsoft_sync_provider.cpp new file mode 100644 index 0000000..29fc783 --- /dev/null +++ b/src/sync/microsoft_sync_provider.cpp @@ -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 + +#include +#include +#include +#include +#include +#include + +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(); + } 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 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(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 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 diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 03be898..0e6e658 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -1051,6 +1051,7 @@ Action decode_key(const std::string_view sequence) noexcept { if (sequence == "g") return Action::agenda; if (sequence == "c") return Action::calendars; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; + if (sequence == "\x13") return Action::sync; return Action::none; } diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index 86e2c1c..1bb19dc 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -1,6 +1,7 @@ #include "nocal/tui/TuiApp.hpp" #include "nocal/domain/event_query.hpp" +#include "nocal/sync/token_broker.hpp" #include "nocal/tui/Terminal.hpp" #include @@ -10,7 +11,9 @@ #include #include #include +#include #include +#include #include #include @@ -101,6 +104,78 @@ TuiApp::TuiApp(std::vector& events, SaveCallback saver, synchronize_calendars(); } +void TuiApp::on_sync_progress(const sync::SyncProgressEvent& event) +{ + std::string stage_text; + switch (event.stage) { + case sync::SyncStage::authenticating: + stage_text = "Authenticating..."; + break; + case sync::SyncStage::refreshing_identity: + stage_text = "Refreshing identity..."; + break; + case sync::SyncStage::discovering_calendars: + stage_text = "Discovering calendars..."; + break; + case sync::SyncStage::syncing_calendar: + stage_text = "Syncing " + event.calendar_name + "..."; + break; + case sync::SyncStage::completed: + stage_text = "Sync complete."; + break; + } + apply_sync_stage(stage_text, false); +} + +void TuiApp::apply_sync_stage(const std::string& stage_text, const bool is_error) +{ + std::lock_guard 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 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 TuiApp::focused_occurrence() const { if (!state_.focused_event_id) return std::nullopt; const auto event_span = std::span{events_}; @@ -733,6 +808,7 @@ void TuiApp::dispatch(const Action action) { case Action::redo: case Action::agenda: case Action::calendars: + case Action::sync: return; } } @@ -774,6 +850,7 @@ void TuiApp::dispatch(const Action action) { case Action::redo: case Action::search: case Action::agenda: + case Action::sync: return; } } @@ -826,6 +903,7 @@ void TuiApp::dispatch(const Action action) { case Action::delete_event: case Action::undo: case Action::redo: + case Action::sync: return; } } @@ -911,6 +989,7 @@ void TuiApp::dispatch(const Action action) { case Action::next_month: case Action::today: case Action::toggle_help: + case Action::sync: return; } } @@ -999,6 +1078,13 @@ void TuiApp::dispatch(const Action action) { case Action::calendars: begin_calendar_picker(); return; + case Action::sync: + if (connected_account_id_) { + start_sync(*connected_account_id_); + } else { + set_notification("No connected account. Run: nocal account connect microsoft", true); + } + return; case Action::quit: running_ = false; return; @@ -1073,6 +1159,7 @@ ExitReason TuiApp::run() { redraw = true; } } + stop_sync(); return ExitReason::quit; } diff --git a/tests/microsoft_sync_provider_tests.cpp b/tests/microsoft_sync_provider_tests.cpp new file mode 100644 index 0000000..a5eff7b --- /dev/null +++ b/tests/microsoft_sync_provider_tests.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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> sc) + : values(std::move(sc)) {} + + std::vector> values; + std::size_t calls{0}; + + void fill(std::span output) override { + require(output.size() == 32, "entropy request was not exactly 32 bytes"); + 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 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 requests; + std::vector responses; + std::size_t fail_at{static_cast(-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 store_data; + std::map 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 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 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 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& remote_ids, + const std::vector& names, + const std::vector& 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>{}}; + 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>{}}; + 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; +} diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp index 9bf503d..e7f1afc 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -118,6 +118,8 @@ void test_full_month_render() .search_result_index = 0, .notification = {}, .notification_is_error = false, + .sync_stage = {}, + .sync_is_error = false, .show_calendar_picker = false, .calendar_index = 0, .show_agenda = false, @@ -178,6 +180,8 @@ void test_compact_and_input() .search_result_index = 0, .notification = {}, .notification_is_error = false, + .sync_stage = {}, + .sync_is_error = false, .show_calendar_picker = false, .calendar_index = 0, .show_agenda = false,