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:
2026-07-22 21:49:26 +01:00
parent 74c798e7aa
commit 725e48569e
10 changed files with 1257 additions and 5 deletions

View File

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