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
This commit is contained in:
74
include/nocal/sync/microsoft_sync_provider.hpp
Normal file
74
include/nocal/sync/microsoft_sync_provider.hpp
Normal 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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "nocal/domain/event.hpp"
|
||||
#include "nocal/sync/provider.hpp"
|
||||
#include "nocal/tui/EventEditor.hpp"
|
||||
#include "nocal/tui/Screen.hpp"
|
||||
|
||||
@@ -21,7 +25,7 @@ enum class ExitReason {
|
||||
|
||||
// The Event vector is retained by reference so future editor commands can
|
||||
// update the caller's model without changing the application boundary.
|
||||
class TuiApp {
|
||||
class TuiApp : public sync::SyncObserver {
|
||||
public:
|
||||
using SaveCallback = std::function<void(std::span<const Event>)>;
|
||||
|
||||
@@ -57,7 +61,28 @@ public:
|
||||
return calendars_;
|
||||
}
|
||||
|
||||
void set_sync_provider(std::unique_ptr<sync::SyncProvider> provider) noexcept {
|
||||
sync_provider_ = std::move(provider);
|
||||
}
|
||||
void set_connected_account_id(std::string id) noexcept {
|
||||
connected_account_id_ = std::move(id);
|
||||
}
|
||||
|
||||
private:
|
||||
// Sync state (protected by sync_mutex_)
|
||||
std::mutex sync_mutex_;
|
||||
std::unique_ptr<sync::SyncProvider> sync_provider_;
|
||||
std::optional<std::string> connected_account_id_;
|
||||
std::optional<std::thread> sync_thread_;
|
||||
|
||||
// --- SyncObserver implementation (called from sync thread) ---
|
||||
void on_sync_progress(const sync::SyncProgressEvent& event) override;
|
||||
|
||||
void start_sync(std::string_view account_id);
|
||||
void stop_sync();
|
||||
void apply_sync_stage(const std::string& stage_text, bool is_error);
|
||||
void clear_sync_state();
|
||||
|
||||
struct HistorySnapshot {
|
||||
std::vector<Event> events;
|
||||
std::chrono::year_month visible_month;
|
||||
|
||||
@@ -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)
|
||||
|
||||
122
src/main.cpp
122
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 <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
@@ -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 <account_id>\n"
|
||||
" nocal account list");
|
||||
}
|
||||
|
||||
void print_help(std::ostream& output)
|
||||
@@ -123,7 +186,11 @@ void print_help(std::ostream& output)
|
||||
" -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"
|
||||
" the Secret Service keyring) and exits.\n"
|
||||
" nocal account disconnect <account_id>\n"
|
||||
" removes credentials for a connected account.\n"
|
||||
" nocal account list\n"
|
||||
" lists connected accounts.\n\n"
|
||||
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
|
||||
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
||||
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
||||
@@ -296,6 +363,8 @@ int print_frame(std::span<const nocal::Event> events, bool no_color,
|
||||
.search_result_index = 0,
|
||||
.notification = {},
|
||||
.notification_is_error = false,
|
||||
.sync_stage = {},
|
||||
.sync_is_error = false,
|
||||
.show_calendar_picker = false,
|
||||
.calendar_index = 0,
|
||||
.show_agenda = false,
|
||||
@@ -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::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
});
|
||||
|
||||
auto sync_prov = std::make_unique<nocal::sync::MicrosoftAccountSyncProvider>(
|
||||
token_broker,
|
||||
secret_store,
|
||||
sync_cache,
|
||||
http_transport,
|
||||
nocal::sync::microsoft_oauth_config(client_id),
|
||||
[] {
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
},
|
||||
browser,
|
||||
callback_receiver,
|
||||
entropy);
|
||||
|
||||
app.set_sync_provider(std::move(sync_prov));
|
||||
}
|
||||
}
|
||||
|
||||
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "nocal: " << error.what() << '\n';
|
||||
|
||||
231
src/sync/microsoft_sync_provider.cpp
Normal file
231
src/sync/microsoft_sync_provider.cpp
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "nocal/tui/TuiApp.hpp"
|
||||
|
||||
#include "nocal/domain/event_query.hpp"
|
||||
#include "nocal/sync/token_broker.hpp"
|
||||
#include "nocal/tui/Terminal.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -10,7 +11,9 @@
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <exception>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include <poll.h>
|
||||
@@ -101,6 +104,78 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver,
|
||||
synchronize_calendars();
|
||||
}
|
||||
|
||||
void TuiApp::on_sync_progress(const sync::SyncProgressEvent& event)
|
||||
{
|
||||
std::string stage_text;
|
||||
switch (event.stage) {
|
||||
case sync::SyncStage::authenticating:
|
||||
stage_text = "Authenticating...";
|
||||
break;
|
||||
case sync::SyncStage::refreshing_identity:
|
||||
stage_text = "Refreshing identity...";
|
||||
break;
|
||||
case sync::SyncStage::discovering_calendars:
|
||||
stage_text = "Discovering calendars...";
|
||||
break;
|
||||
case sync::SyncStage::syncing_calendar:
|
||||
stage_text = "Syncing " + event.calendar_name + "...";
|
||||
break;
|
||||
case sync::SyncStage::completed:
|
||||
stage_text = "Sync complete.";
|
||||
break;
|
||||
}
|
||||
apply_sync_stage(stage_text, false);
|
||||
}
|
||||
|
||||
void TuiApp::apply_sync_stage(const std::string& stage_text, const bool is_error)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(sync_mutex_);
|
||||
state_.sync_stage = stage_text;
|
||||
state_.sync_is_error = is_error;
|
||||
state_.notification = stage_text;
|
||||
state_.notification_is_error = is_error;
|
||||
}
|
||||
|
||||
void TuiApp::clear_sync_state()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(sync_mutex_);
|
||||
state_.sync_stage.clear();
|
||||
state_.sync_is_error = false;
|
||||
if (!state_.sync_stage.empty() || state_.notification.empty()) {
|
||||
state_.notification.clear();
|
||||
state_.notification_is_error = false;
|
||||
}
|
||||
}
|
||||
|
||||
void TuiApp::start_sync(const std::string_view account_id)
|
||||
{
|
||||
if (sync_thread_ && sync_thread_->joinable()) {
|
||||
return;
|
||||
}
|
||||
if (!sync_provider_ || account_id.empty()) {
|
||||
return;
|
||||
}
|
||||
connected_account_id_ = std::string{account_id};
|
||||
apply_sync_stage("Syncing...", false);
|
||||
sync_thread_.emplace([this, acct = std::string{account_id}]() {
|
||||
try {
|
||||
sync_provider_->sync_account(acct, *this);
|
||||
} catch (const sync::AccountDisconnected& e) {
|
||||
apply_sync_stage("Account disconnected. Run: nocal account connect microsoft", true);
|
||||
} catch (const std::exception& e) {
|
||||
apply_sync_stage("Sync failed: " + std::string{e.what()}, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void TuiApp::stop_sync()
|
||||
{
|
||||
if (sync_thread_ && sync_thread_->joinable()) {
|
||||
sync_thread_->join();
|
||||
sync_thread_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
|
||||
if (!state_.focused_event_id) return std::nullopt;
|
||||
const auto event_span = std::span<const Event>{events_};
|
||||
@@ -733,6 +808,7 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::redo:
|
||||
case Action::agenda:
|
||||
case Action::calendars:
|
||||
case Action::sync:
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -774,6 +850,7 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::redo:
|
||||
case Action::search:
|
||||
case Action::agenda:
|
||||
case Action::sync:
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -826,6 +903,7 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::delete_event:
|
||||
case Action::undo:
|
||||
case Action::redo:
|
||||
case Action::sync:
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -911,6 +989,7 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::next_month:
|
||||
case Action::today:
|
||||
case Action::toggle_help:
|
||||
case Action::sync:
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -999,6 +1078,13 @@ void TuiApp::dispatch(const Action action) {
|
||||
case Action::calendars:
|
||||
begin_calendar_picker();
|
||||
return;
|
||||
case Action::sync:
|
||||
if (connected_account_id_) {
|
||||
start_sync(*connected_account_id_);
|
||||
} else {
|
||||
set_notification("No connected account. Run: nocal account connect microsoft", true);
|
||||
}
|
||||
return;
|
||||
case Action::quit:
|
||||
running_ = false;
|
||||
return;
|
||||
@@ -1073,6 +1159,7 @@ ExitReason TuiApp::run() {
|
||||
redraw = true;
|
||||
}
|
||||
}
|
||||
stop_sync();
|
||||
return ExitReason::quit;
|
||||
}
|
||||
|
||||
|
||||
705
tests/microsoft_sync_provider_tests.cpp
Normal file
705
tests/microsoft_sync_provider_tests.cpp
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user