Compare commits
3 Commits
5affa4d0cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 725e48569e | |||
| 74c798e7aa | |||
| feec691535 |
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
description: Implements bounded, well-specified coding slices for nocal. Owns an explicit file set, writes C++20 with deterministic tests, compiles with strict warnings, runs the relevant test binaries, and reports exact checks. Use for delegated implementation only — not for architecture decisions, cross-cutting changes, or review.
|
description: Implements bounded, well-specified coding slices for nocal. Owns an explicit file set, writes C++20 with deterministic tests, compiles with strict warnings, runs the relevant test binaries, and reports exact checks. Use for delegated implementation only — not for architecture decisions, cross-cutting changes, or review.
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: openrouter/qwen/qwen3-coder-next
|
model: openrouter/deepseek/deepseek-v4-flash
|
||||||
permission:
|
permission:
|
||||||
edit: allow
|
edit: allow
|
||||||
bash: allow
|
bash: allow
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ implementation value.
|
|||||||
## Concrete delegation setup (nocal)
|
## Concrete delegation setup (nocal)
|
||||||
|
|
||||||
A coding subagent is configured at `.opencode/agent/coder.md` with model
|
A coding subagent is configured at `.opencode/agent/coder.md` with model
|
||||||
`openrouter/qwen/qwen3-coder-next`. It owns bounded implementation slices assigned
|
`openrouter/deepseek/deepseek-v4-flash`. It owns bounded implementation slices assigned
|
||||||
by the lead; the lead retains shared contracts, integration, review, and final
|
by the lead; the lead retains shared contracts, integration, review, and final
|
||||||
verification. After any config change, restart opencode to reload agents.
|
verification. After any config change, restart opencode to reload agents.
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -182,6 +182,10 @@ them; omission is not mislabeled as a remote deletion tombstone. This avoids
|
|||||||
beta and undocumented delta routes, at the cost of refetching each secondary
|
beta and undocumented delta routes, at the cost of refetching each secondary
|
||||||
window instead of incrementally updating it.
|
window instead of incrementally updating it.
|
||||||
|
|
||||||
There is still no account UI/CLI orchestration or live Microsoft credential
|
Account setup begins with `nocal account connect microsoft`: it runs the
|
||||||
integration, and remote writes remain out of scope. Account setup and
|
PKCE browser flow, reads `/me`, upserts the deterministic account row, and
|
||||||
orchestration are next.
|
stores the tokens in the Secret Service keyring as the final connected-state
|
||||||
|
gate. This build ships a placeholder client ID; live connect requires a real
|
||||||
|
mobile/desktop Entra registration supplied via `NOCAL_MICROSOFT_CLIENT_ID`.
|
||||||
|
There is still no account TUI or sync orchestration, and remote writes remain
|
||||||
|
out of scope.
|
||||||
|
|||||||
@@ -288,6 +288,16 @@ This design avoids beta and undocumented per-calendar delta routes. Its
|
|||||||
tradeoff is bandwidth: every secondary window is fetched completely on each
|
tradeoff is bandwidth: every secondary window is fetched completely on each
|
||||||
refresh, while the primary calendar remains incremental.
|
refresh, while the primary calendar remains incremental.
|
||||||
|
|
||||||
There is no account UI or CLI orchestration and no live Microsoft credential
|
Account setup now has a CLI entry point, `nocal account connect microsoft`.
|
||||||
integration yet; tests use injected transports and credentials. Remote writes
|
It composes the desktop OAuth adapters, libcurl transport, token parsing, one
|
||||||
remain out of scope. Account setup and orchestration are next.
|
`/me` identity read, the sync cache, and the Secret Service store in a fixed
|
||||||
|
order: the account row is upserted first, and the versioned secret payload is
|
||||||
|
stored last as the connected-state gate. A secret-store failure therefore
|
||||||
|
leaves the same coherent state as a disconnect, and reconnecting is idempotent
|
||||||
|
because the local account id is a deterministic hash of the remote subject.
|
||||||
|
This build carries a placeholder client ID; a real Entra registration is
|
||||||
|
supplied through the `NOCAL_MICROSOFT_CLIENT_ID` environment variable (a
|
||||||
|
public client ID, never a secret). There is no account TUI and no sync
|
||||||
|
orchestration yet; tests use injected transports and credentials. Remote
|
||||||
|
writes remain out of scope. Generic `SyncProvider` integration and observable
|
||||||
|
sync status are next.
|
||||||
|
|||||||
@@ -105,10 +105,23 @@ Completed provider-boundary contracts and token broker:
|
|||||||
`AccountDisconnected` for re-authentication
|
`AccountDisconnected` for re-authentication
|
||||||
- Credential material never appears in exception messages
|
- Credential material never appears in exception messages
|
||||||
|
|
||||||
|
Completed account setup (CLI):
|
||||||
|
|
||||||
|
- `nocal account connect microsoft` runs the PKCE browser flow, reads `/me`,
|
||||||
|
upserts the deterministic account row, and persists tokens to Secret
|
||||||
|
Service as the final connected-state gate
|
||||||
|
- Deterministic local account IDs hashed from the remote subject make
|
||||||
|
reconnect idempotent; a secret-store failure leaves the same coherent state
|
||||||
|
as a disconnect
|
||||||
|
- Placeholder client ID with `NOCAL_MICROSOFT_CLIENT_ID` override until a
|
||||||
|
real Entra mobile/desktop registration exists
|
||||||
|
- Deterministic failure-path tests: rejection, token errors, identity
|
||||||
|
failure, storage failure, and credential redaction in error messages
|
||||||
|
|
||||||
Next provider-boundary slices:
|
Next provider-boundary slices:
|
||||||
|
|
||||||
- Account setup and CLI/TUI orchestration with live credential integration
|
- TUI account orchestration and generic `SyncProvider` integration with
|
||||||
- Generic `SyncProvider` integration and observable sync status
|
observable sync status
|
||||||
|
|
||||||
## 0.3 — CalDAV
|
## 0.3 — CalDAV
|
||||||
|
|
||||||
@@ -125,9 +138,10 @@ Next provider-boundary slices:
|
|||||||
|
|
||||||
The Graph read boundary uses stable v1 delta for the primary calendar and
|
The Graph read boundary uses stable v1 delta for the primary calendar and
|
||||||
complete stable-v1 calendar views for secondary calendars; it deliberately
|
complete stable-v1 calendar views for secondary calendars; it deliberately
|
||||||
avoids beta and undocumented per-calendar delta routes. No account UI/CLI
|
avoids beta and undocumented per-calendar delta routes. Account connect exists
|
||||||
orchestration or live Microsoft credential integration exists yet, and remote
|
as a CLI command with live credential integration through Secret Service, but
|
||||||
writes remain out of scope.
|
there is no account TUI or sync orchestration yet, and remote writes remain
|
||||||
|
out of scope.
|
||||||
|
|
||||||
## 1.0 — distribution quality
|
## 1.0 — distribution quality
|
||||||
|
|
||||||
|
|||||||
70
include/nocal/sync/microsoft_account.hpp
Normal file
70
include/nocal/sync/microsoft_account.hpp
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/storage/sync_cache.hpp"
|
||||||
|
#include "nocal/sync/oauth.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace nocal::sync {
|
||||||
|
|
||||||
|
class HttpTransport;
|
||||||
|
class OAuthSecretStore;
|
||||||
|
|
||||||
|
// Placeholder used when no real Entra application registration is available.
|
||||||
|
// It is not a registered client; interactive connect with it fails at the
|
||||||
|
// authorization endpoint. Override with the NOCAL_MICROSOFT_CLIENT_ID
|
||||||
|
// environment variable. A client ID is public configuration, never a secret.
|
||||||
|
inline constexpr std::string_view microsoft_client_id_placeholder =
|
||||||
|
"nocal-unregistered-client-id";
|
||||||
|
|
||||||
|
// Client ID used for interactive connect: the NOCAL_MICROSOFT_CLIENT_ID
|
||||||
|
// environment variable when set and non-empty, otherwise the placeholder.
|
||||||
|
[[nodiscard]] std::string default_microsoft_client_id();
|
||||||
|
|
||||||
|
// Public-desktop Microsoft OAuth configuration for the multitenant
|
||||||
|
// organizational and personal Microsoft account audience with delegated
|
||||||
|
// read-only scopes. The open-source binary never embeds a client secret.
|
||||||
|
[[nodiscard]] OAuthClientConfig microsoft_oauth_config(std::string client_id);
|
||||||
|
|
||||||
|
class MicrosoftAccountError final : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic local account id derived from the stable remote subject, so
|
||||||
|
// reconnecting the same Microsoft account upserts the same cache row and
|
||||||
|
// reuses the same Secret Service key.
|
||||||
|
[[nodiscard]] std::string microsoft_account_id(std::string_view remote_subject);
|
||||||
|
|
||||||
|
// Runs one interactive Microsoft account connection:
|
||||||
|
// 1. authorization-code flow with PKCE S256 (one browser launch, one
|
||||||
|
// loopback callback, one token request) via authorize_with_pkce;
|
||||||
|
// 2. strict token parsing that requires an initial refresh token;
|
||||||
|
// 3. one /me identity read for the remote subject and display name;
|
||||||
|
// 4. the account row upserted into the sync cache;
|
||||||
|
// 5. the versioned token payload persisted to the Secret Service store.
|
||||||
|
//
|
||||||
|
// Invariants and failure semantics:
|
||||||
|
// - The account is connected only after step 5 succeeds; there is no
|
||||||
|
// plaintext fallback and no partial connected state.
|
||||||
|
// - Failure before step 4 leaves the cache and the secret store untouched.
|
||||||
|
// - Failure in step 5 leaves a cached account without credentials — the same
|
||||||
|
// coherent state that disconnecting produces. Reconnecting is idempotent
|
||||||
|
// because the account id is deterministic.
|
||||||
|
// - The access token is never retained after return, and error messages
|
||||||
|
// never contain credential material.
|
||||||
|
[[nodiscard]] storage::CachedAccount connect_microsoft_account(
|
||||||
|
const OAuthClientConfig& config,
|
||||||
|
BrowserLauncher& browser,
|
||||||
|
AuthorizationCallbackReceiver& receiver,
|
||||||
|
HttpTransport& transport,
|
||||||
|
EntropySource& entropy,
|
||||||
|
OAuthSecretStore& secret_store,
|
||||||
|
storage::SyncCache& cache,
|
||||||
|
const std::function<std::int64_t()>& clock);
|
||||||
|
|
||||||
|
} // namespace nocal::sync
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
namespace nocal::sync {
|
namespace nocal::sync {
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ public:
|
|||||||
using std::runtime_error::runtime_error;
|
using std::runtime_error::runtime_error;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validated /me identity. Fetched without persisting anything so account
|
||||||
|
// connect can derive the deterministic local account id before any cache write.
|
||||||
|
struct MicrosoftGraphIdentity {
|
||||||
|
std::string remote_subject;
|
||||||
|
std::string display_name;
|
||||||
|
};
|
||||||
|
|
||||||
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
|
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
|
||||||
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for
|
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for
|
||||||
// the primary calendar and complete calendarView reconciliation for secondary
|
// the primary calendar and complete calendarView reconciliation for secondary
|
||||||
@@ -36,6 +44,10 @@ class MicrosoftGraphSync {
|
|||||||
public:
|
public:
|
||||||
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
|
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
|
||||||
|
|
||||||
|
// Requires delegated User.Read. Reads /me and returns the validated
|
||||||
|
// identity without touching the cache; the access token is not retained.
|
||||||
|
[[nodiscard]] MicrosoftGraphIdentity fetch_identity(std::string_view access_token) const;
|
||||||
|
|
||||||
// Requires delegated User.Read. A complete and validated /me response is
|
// Requires delegated User.Read. A complete and validated /me response is
|
||||||
// persisted atomically; failures leave the cached account unchanged.
|
// persisted atomically; failures leave the cached account unchanged.
|
||||||
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials);
|
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials);
|
||||||
|
|||||||
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::size_t search_result_index{0};
|
||||||
std::string notification;
|
std::string notification;
|
||||||
bool notification_is_error{false};
|
bool notification_is_error{false};
|
||||||
|
std::string sync_stage;
|
||||||
|
bool sync_is_error{false};
|
||||||
bool show_calendar_picker{false};
|
bool show_calendar_picker{false};
|
||||||
std::size_t calendar_index{0};
|
std::size_t calendar_index{0};
|
||||||
bool show_agenda{false};
|
bool show_agenda{false};
|
||||||
@@ -136,6 +138,7 @@ enum class Action {
|
|||||||
search,
|
search,
|
||||||
agenda,
|
agenda,
|
||||||
calendars,
|
calendars,
|
||||||
|
sync,
|
||||||
quit,
|
quit,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <span>
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "nocal/domain/event.hpp"
|
#include "nocal/domain/event.hpp"
|
||||||
|
#include "nocal/sync/provider.hpp"
|
||||||
#include "nocal/tui/EventEditor.hpp"
|
#include "nocal/tui/EventEditor.hpp"
|
||||||
#include "nocal/tui/Screen.hpp"
|
#include "nocal/tui/Screen.hpp"
|
||||||
|
|
||||||
@@ -21,7 +25,7 @@ enum class ExitReason {
|
|||||||
|
|
||||||
// The Event vector is retained by reference so future editor commands can
|
// The Event vector is retained by reference so future editor commands can
|
||||||
// update the caller's model without changing the application boundary.
|
// update the caller's model without changing the application boundary.
|
||||||
class TuiApp {
|
class TuiApp : public sync::SyncObserver {
|
||||||
public:
|
public:
|
||||||
using SaveCallback = std::function<void(std::span<const Event>)>;
|
using SaveCallback = std::function<void(std::span<const Event>)>;
|
||||||
|
|
||||||
@@ -57,7 +61,28 @@ public:
|
|||||||
return calendars_;
|
return calendars_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void set_sync_provider(std::unique_ptr<sync::SyncProvider> provider) noexcept {
|
||||||
|
sync_provider_ = std::move(provider);
|
||||||
|
}
|
||||||
|
void set_connected_account_id(std::string id) noexcept {
|
||||||
|
connected_account_id_ = std::move(id);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Sync state (protected by sync_mutex_)
|
||||||
|
std::mutex sync_mutex_;
|
||||||
|
std::unique_ptr<sync::SyncProvider> sync_provider_;
|
||||||
|
std::optional<std::string> connected_account_id_;
|
||||||
|
std::optional<std::thread> sync_thread_;
|
||||||
|
|
||||||
|
// --- SyncObserver implementation (called from sync thread) ---
|
||||||
|
void on_sync_progress(const sync::SyncProgressEvent& event) override;
|
||||||
|
|
||||||
|
void start_sync(std::string_view account_id);
|
||||||
|
void stop_sync();
|
||||||
|
void apply_sync_stage(const std::string& stage_text, bool is_error);
|
||||||
|
void clear_sync_state();
|
||||||
|
|
||||||
struct HistorySnapshot {
|
struct HistorySnapshot {
|
||||||
std::vector<Event> events;
|
std::vector<Event> events;
|
||||||
std::chrono::year_month visible_month;
|
std::chrono::year_month visible_month;
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ nocal_sources = files(
|
|||||||
'src/storage/sync_cache.cpp',
|
'src/storage/sync_cache.cpp',
|
||||||
'src/sync/curl_http.cpp',
|
'src/sync/curl_http.cpp',
|
||||||
'src/sync/desktop_oauth.cpp',
|
'src/sync/desktop_oauth.cpp',
|
||||||
|
'src/sync/microsoft_account.cpp',
|
||||||
'src/sync/microsoft_graph.cpp',
|
'src/sync/microsoft_graph.cpp',
|
||||||
|
'src/sync/microsoft_sync_provider.cpp',
|
||||||
'src/sync/oauth.cpp',
|
'src/sync/oauth.cpp',
|
||||||
'src/sync/oauth_tokens.cpp',
|
'src/sync/oauth_tokens.cpp',
|
||||||
'src/sync/secret_store.cpp',
|
'src/sync/secret_store.cpp',
|
||||||
@@ -81,6 +83,9 @@ test('oauth-tokens', oauth_token_tests)
|
|||||||
token_broker_tests = executable('token-broker-tests',
|
token_broker_tests = executable('token-broker-tests',
|
||||||
'tests/token_broker_tests.cpp', dependencies: nocal_dep)
|
'tests/token_broker_tests.cpp', dependencies: nocal_dep)
|
||||||
test('token-broker', token_broker_tests)
|
test('token-broker', token_broker_tests)
|
||||||
|
microsoft_account_tests = executable('microsoft-account-tests',
|
||||||
|
'tests/microsoft_account_tests.cpp', dependencies: nocal_dep)
|
||||||
|
test('microsoft-account', microsoft_account_tests)
|
||||||
secret_store_tests = executable('secret-store-tests',
|
secret_store_tests = executable('secret-store-tests',
|
||||||
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
||||||
test('secret-store', shell,
|
test('secret-store', shell,
|
||||||
@@ -91,6 +96,9 @@ test('microsoft-graph', microsoft_graph_tests)
|
|||||||
microsoft_graph_security_tests = executable('microsoft-graph-security-tests',
|
microsoft_graph_security_tests = executable('microsoft-graph-security-tests',
|
||||||
'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep)
|
'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep)
|
||||||
test('microsoft-graph-security', microsoft_graph_security_tests)
|
test('microsoft-graph-security', microsoft_graph_security_tests)
|
||||||
|
microsoft_sync_provider_tests = executable('microsoft-sync-provider-tests',
|
||||||
|
'tests/microsoft_sync_provider_tests.cpp', dependencies: nocal_dep)
|
||||||
|
test('microsoft-sync-provider', microsoft_sync_provider_tests)
|
||||||
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
|
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
|
||||||
dependencies: nocal_dep)
|
dependencies: nocal_dep)
|
||||||
test('tui', tui_tests)
|
test('tui', tui_tests)
|
||||||
|
|||||||
185
src/main.cpp
185
src/main.cpp
@@ -1,9 +1,17 @@
|
|||||||
#include "nocal/domain/calendar_transfer.hpp"
|
#include "nocal/domain/calendar_transfer.hpp"
|
||||||
#include "nocal/domain/date.hpp"
|
#include "nocal/domain/date.hpp"
|
||||||
#include "nocal/storage/ics_store.hpp"
|
#include "nocal/storage/ics_store.hpp"
|
||||||
|
#include "nocal/storage/sync_cache.hpp"
|
||||||
|
#include "nocal/sync/curl_http.hpp"
|
||||||
|
#include "nocal/sync/desktop_oauth.hpp"
|
||||||
|
#include "nocal/sync/microsoft_account.hpp"
|
||||||
|
#include "nocal/sync/microsoft_sync_provider.hpp"
|
||||||
|
#include "nocal/sync/secret_store.hpp"
|
||||||
|
#include "nocal/sync/token_broker.hpp"
|
||||||
#include "nocal/tui/Screen.hpp"
|
#include "nocal/tui/Screen.hpp"
|
||||||
#include "nocal/tui/TuiApp.hpp"
|
#include "nocal/tui/TuiApp.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -13,6 +21,7 @@
|
|||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
#include <system_error>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -45,6 +54,121 @@ std::filesystem::path default_calendar_path()
|
|||||||
return "calendar.ics";
|
return "calendar.ics";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::filesystem::path default_sync_cache_path()
|
||||||
|
{
|
||||||
|
if (const char* data_home = std::getenv("XDG_DATA_HOME");
|
||||||
|
data_home != nullptr && *data_home != '\0') {
|
||||||
|
return std::filesystem::path{data_home} / "nocal" / "sync.db";
|
||||||
|
}
|
||||||
|
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
|
||||||
|
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "sync.db";
|
||||||
|
}
|
||||||
|
return "sync.db";
|
||||||
|
}
|
||||||
|
|
||||||
|
int connect_microsoft_account()
|
||||||
|
{
|
||||||
|
const std::string client_id = nocal::sync::default_microsoft_client_id();
|
||||||
|
if (client_id == nocal::sync::microsoft_client_id_placeholder) {
|
||||||
|
std::cerr <<
|
||||||
|
"nocal: this build has no registered Microsoft application client ID.\n"
|
||||||
|
"Register a mobile and desktop public client with redirect URI\n"
|
||||||
|
"http://localhost/nocal/oauth/callback, then set NOCAL_MICROSOFT_CLIENT_ID.\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
const std::filesystem::path cache_path = default_sync_cache_path();
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::create_directories(cache_path.parent_path(), error);
|
||||||
|
if (error) {
|
||||||
|
throw std::runtime_error("cannot create " + cache_path.parent_path().string() +
|
||||||
|
": " + error.message());
|
||||||
|
}
|
||||||
|
nocal::storage::SyncCache cache(cache_path);
|
||||||
|
nocal::sync::CurlHttpTransport transport;
|
||||||
|
nocal::sync::XdgBrowserLauncher browser;
|
||||||
|
nocal::sync::LoopbackCallbackReceiver receiver;
|
||||||
|
nocal::sync::SystemEntropySource entropy;
|
||||||
|
nocal::sync::LibsecretOAuthSecretStore secrets;
|
||||||
|
const auto account = nocal::sync::connect_microsoft_account(
|
||||||
|
nocal::sync::microsoft_oauth_config(client_id), browser, receiver, transport,
|
||||||
|
entropy, secrets, cache, [] {
|
||||||
|
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
});
|
||||||
|
std::cout << "Connected " << account.display_name << " (" << account.id << ")\n";
|
||||||
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int disconnect_account_command(nocal::storage::SyncCache& cache,
|
||||||
|
nocal::sync::OAuthSecretStore& secret_store,
|
||||||
|
std::string_view account_id)
|
||||||
|
{
|
||||||
|
// Verify account exists in cache
|
||||||
|
const auto snapshot = cache.snapshot();
|
||||||
|
const auto it = std::find_if(snapshot.accounts.begin(), snapshot.accounts.end(),
|
||||||
|
[&account_id](const nocal::storage::CachedAccount& a) { return a.id == account_id; });
|
||||||
|
if (it == snapshot.accounts.end()) {
|
||||||
|
throw std::invalid_argument("unknown account: " + std::string{account_id});
|
||||||
|
}
|
||||||
|
// Erase from secret store
|
||||||
|
secret_store.erase(std::string{account_id});
|
||||||
|
// Note: cached account/calendar rows remain in the database.
|
||||||
|
// This matches the SyncProvider contract: cached provider data is left
|
||||||
|
// in place so that reconnecting the same account is idempotent.
|
||||||
|
std::cout << "Disconnected " << it->display_name << " (" << account_id << ")\n";
|
||||||
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int list_accounts_command(const nocal::storage::SyncCache& cache)
|
||||||
|
{
|
||||||
|
const auto snapshot = cache.snapshot();
|
||||||
|
if (snapshot.accounts.empty()) {
|
||||||
|
std::cout << "No connected accounts.\n";
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
for (const auto& account : snapshot.accounts) {
|
||||||
|
std::cout << " " << account.id << " " << account.display_name
|
||||||
|
<< " (" << account.provider << ")\n";
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
int account_command(int argc, char** argv)
|
||||||
|
{
|
||||||
|
if (argc == 3 && std::string_view{argv[1]} == "connect" &&
|
||||||
|
std::string_view{argv[2]} == "microsoft") {
|
||||||
|
return connect_microsoft_account();
|
||||||
|
}
|
||||||
|
if (argc == 3 && std::string_view{argv[1]} == "disconnect") {
|
||||||
|
const std::filesystem::path cache_path = default_sync_cache_path();
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::create_directories(cache_path.parent_path(), error);
|
||||||
|
if (error) {
|
||||||
|
throw std::runtime_error("cannot create " + cache_path.parent_path().string() +
|
||||||
|
": " + error.message());
|
||||||
|
}
|
||||||
|
nocal::storage::SyncCache cache(cache_path);
|
||||||
|
nocal::sync::LibsecretOAuthSecretStore secrets;
|
||||||
|
return disconnect_account_command(cache, secrets, argv[2]);
|
||||||
|
}
|
||||||
|
if (argc == 2 && std::string_view{argv[1]} == "list") {
|
||||||
|
const std::filesystem::path cache_path = default_sync_cache_path();
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::create_directories(cache_path.parent_path(), error);
|
||||||
|
if (error) {
|
||||||
|
throw std::runtime_error("cannot create " + cache_path.parent_path().string() +
|
||||||
|
": " + error.message());
|
||||||
|
}
|
||||||
|
nocal::storage::SyncCache cache(cache_path);
|
||||||
|
return list_accounts_command(cache);
|
||||||
|
}
|
||||||
|
throw std::invalid_argument(
|
||||||
|
"usage: nocal account connect microsoft\n"
|
||||||
|
" nocal account disconnect <account_id>\n"
|
||||||
|
" nocal account list");
|
||||||
|
}
|
||||||
|
|
||||||
void print_help(std::ostream& output)
|
void print_help(std::ostream& output)
|
||||||
{
|
{
|
||||||
output <<
|
output <<
|
||||||
@@ -60,6 +184,13 @@ void print_help(std::ostream& output)
|
|||||||
" --no-color disable ANSI styling\n"
|
" --no-color disable ANSI styling\n"
|
||||||
" -h, --help show this help\n"
|
" -h, --help show this help\n"
|
||||||
" -V, --version show the version\n\n"
|
" -V, --version show the version\n\n"
|
||||||
|
"Accounts: nocal account connect microsoft\n"
|
||||||
|
" links a Microsoft account (browser sign-in, tokens stay in\n"
|
||||||
|
" the Secret Service keyring) and exits.\n"
|
||||||
|
" nocal account disconnect <account_id>\n"
|
||||||
|
" removes credentials for a connected account.\n"
|
||||||
|
" nocal account list\n"
|
||||||
|
" lists connected accounts.\n\n"
|
||||||
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
|
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
|
||||||
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
||||||
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
||||||
@@ -232,6 +363,8 @@ int print_frame(std::span<const nocal::Event> events, bool no_color,
|
|||||||
.search_result_index = 0,
|
.search_result_index = 0,
|
||||||
.notification = {},
|
.notification = {},
|
||||||
.notification_is_error = false,
|
.notification_is_error = false,
|
||||||
|
.sync_stage = {},
|
||||||
|
.sync_is_error = false,
|
||||||
.show_calendar_picker = false,
|
.show_calendar_picker = false,
|
||||||
.calendar_index = 0,
|
.calendar_index = 0,
|
||||||
.show_agenda = false,
|
.show_agenda = false,
|
||||||
@@ -313,6 +446,9 @@ int export_calendar(const std::filesystem::path& source_path,
|
|||||||
int main(int argc, char** argv)
|
int main(int argc, char** argv)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
if (argc >= 2 && std::string_view{argv[1]} == "account") {
|
||||||
|
return account_command(argc - 1, argv + 1);
|
||||||
|
}
|
||||||
const auto options = parse_options(argc, argv);
|
const auto options = parse_options(argc, argv);
|
||||||
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
||||||
print_warnings(loaded, options.calendar_path);
|
print_warnings(loaded, options.calendar_path);
|
||||||
@@ -361,6 +497,55 @@ int main(int argc, char** argv)
|
|||||||
}
|
}
|
||||||
nocal::tui::TuiApp app{
|
nocal::tui::TuiApp app{
|
||||||
loaded.events, std::move(saver), options.week_start};
|
loaded.events, std::move(saver), options.week_start};
|
||||||
|
|
||||||
|
// --- Sync infrastructure ---
|
||||||
|
const std::filesystem::path cache_path = default_sync_cache_path();
|
||||||
|
std::error_code cache_error;
|
||||||
|
std::filesystem::create_directories(cache_path.parent_path(), cache_error);
|
||||||
|
|
||||||
|
nocal::storage::SyncCache sync_cache(cache_path);
|
||||||
|
nocal::sync::CurlHttpTransport http_transport;
|
||||||
|
nocal::sync::LibsecretOAuthSecretStore secret_store;
|
||||||
|
|
||||||
|
const auto sync_snapshot = sync_cache.snapshot();
|
||||||
|
if (!sync_snapshot.accounts.empty()) {
|
||||||
|
app.set_connected_account_id(sync_snapshot.accounts.front().id);
|
||||||
|
|
||||||
|
const std::string client_id = nocal::sync::default_microsoft_client_id();
|
||||||
|
if (client_id != nocal::sync::microsoft_client_id_placeholder) {
|
||||||
|
nocal::sync::XdgBrowserLauncher browser;
|
||||||
|
nocal::sync::LoopbackCallbackReceiver callback_receiver;
|
||||||
|
nocal::sync::SystemEntropySource entropy;
|
||||||
|
|
||||||
|
nocal::sync::OAuthTokenBroker token_broker(
|
||||||
|
nocal::sync::microsoft_oauth_config(client_id),
|
||||||
|
http_transport,
|
||||||
|
secret_store,
|
||||||
|
[] {
|
||||||
|
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
});
|
||||||
|
|
||||||
|
auto sync_prov = std::make_unique<nocal::sync::MicrosoftAccountSyncProvider>(
|
||||||
|
token_broker,
|
||||||
|
secret_store,
|
||||||
|
sync_cache,
|
||||||
|
http_transport,
|
||||||
|
nocal::sync::microsoft_oauth_config(client_id),
|
||||||
|
[] {
|
||||||
|
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
},
|
||||||
|
browser,
|
||||||
|
callback_receiver,
|
||||||
|
entropy);
|
||||||
|
|
||||||
|
app.set_sync_provider(std::move(sync_prov));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
|
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << "nocal: " << error.what() << '\n';
|
std::cerr << "nocal: " << error.what() << '\n';
|
||||||
|
|||||||
117
src/sync/microsoft_account.cpp
Normal file
117
src/sync/microsoft_account.cpp
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#include "nocal/sync/microsoft_account.hpp"
|
||||||
|
|
||||||
|
#include "nocal/sync/microsoft_graph.hpp"
|
||||||
|
#include "nocal/sync/oauth.hpp"
|
||||||
|
#include "nocal/sync/oauth_tokens.hpp"
|
||||||
|
#include "nocal/sync/secret_store.hpp"
|
||||||
|
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <functional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace nocal::sync {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
[[nodiscard]] std::string sha256_hex(std::string_view input) {
|
||||||
|
std::array<unsigned char, 32> digest{};
|
||||||
|
unsigned int size = 0;
|
||||||
|
if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1
|
||||||
|
|| size != digest.size()) {
|
||||||
|
throw std::runtime_error("cryptographic failure");
|
||||||
|
}
|
||||||
|
static constexpr char hexadecimal[] = "0123456789abcdef";
|
||||||
|
std::string output;
|
||||||
|
output.reserve(digest.size() * 2U);
|
||||||
|
for (const unsigned char byte : digest) {
|
||||||
|
output.push_back(hexadecimal[byte >> 4U]);
|
||||||
|
output.push_back(hexadecimal[byte & 0x0fU]);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string default_microsoft_client_id() {
|
||||||
|
const char* value = std::getenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||||
|
if (value != nullptr && value[0] != '\0') {
|
||||||
|
return std::string(value);
|
||||||
|
}
|
||||||
|
return std::string(microsoft_client_id_placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
OAuthClientConfig microsoft_oauth_config(std::string client_id) {
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = std::move(client_id);
|
||||||
|
config.authorization_endpoint =
|
||||||
|
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
||||||
|
config.token_endpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string microsoft_account_id(std::string_view remote_subject) {
|
||||||
|
std::string input("account");
|
||||||
|
input.push_back('\0');
|
||||||
|
input += "microsoft-graph";
|
||||||
|
input.push_back('\0');
|
||||||
|
input += remote_subject;
|
||||||
|
return std::string("graph-account-") + sha256_hex(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
storage::CachedAccount connect_microsoft_account(const OAuthClientConfig& config,
|
||||||
|
BrowserLauncher& browser, AuthorizationCallbackReceiver& receiver,
|
||||||
|
HttpTransport& transport, EntropySource& entropy, OAuthSecretStore& secret_store,
|
||||||
|
storage::SyncCache& cache, const std::function<std::int64_t()>& clock) {
|
||||||
|
// Step 1: authorization-code flow with PKCE S256
|
||||||
|
HttpResponse response;
|
||||||
|
try {
|
||||||
|
response = authorize_with_pkce(config, browser, receiver, transport, entropy);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw MicrosoftAccountError("Microsoft authorization failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: strict token parsing (requires initial refresh token)
|
||||||
|
OAuthTokens tokens;
|
||||||
|
try {
|
||||||
|
tokens = parse_oauth_token_response(response.body, clock());
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw MicrosoftAccountError("Microsoft token response invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: one /me identity read
|
||||||
|
MicrosoftGraphSync graph{transport, cache};
|
||||||
|
MicrosoftGraphIdentity identity;
|
||||||
|
try {
|
||||||
|
identity = graph.fetch_identity(tokens.access_token);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw MicrosoftAccountError("Microsoft identity read failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: upsert account into the sync cache
|
||||||
|
const std::string account_id = microsoft_account_id(identity.remote_subject);
|
||||||
|
const storage::CachedAccount account{
|
||||||
|
account_id, "microsoft-graph", identity.remote_subject, identity.display_name};
|
||||||
|
try {
|
||||||
|
cache.upsert_account(account);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw MicrosoftAccountError("sync cache update failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: persist credentials to the secret store
|
||||||
|
// On failure the cached account row remains (documented disconnected state).
|
||||||
|
try {
|
||||||
|
secret_store.store(account_id, tokens);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw MicrosoftAccountError("credential storage failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::sync
|
||||||
@@ -533,19 +533,32 @@ struct DeltaContinuation {
|
|||||||
MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache)
|
MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache)
|
||||||
: transport_(transport), cache_(cache) {}
|
: transport_(transport), cache_(cache) {}
|
||||||
|
|
||||||
|
MicrosoftGraphIdentity MicrosoftGraphSync::fetch_identity(
|
||||||
|
std::string_view access_token) const {
|
||||||
|
try {
|
||||||
|
const MicrosoftGraphCredentials credentials{"identity", std::string(access_token)};
|
||||||
|
validate_credentials(credentials);
|
||||||
|
const nlohmann::json object = parse_json_object(
|
||||||
|
send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
|
||||||
|
MicrosoftGraphIdentity identity{required_string(object, "id"),
|
||||||
|
optional_string(object, "displayName")};
|
||||||
|
if (identity.display_name.empty()) {
|
||||||
|
identity.display_name = required_string(object, "userPrincipalName");
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
} catch (...) {
|
||||||
|
throw MicrosoftGraphError("Microsoft Graph identity read failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CachedAccount MicrosoftGraphSync::refresh_account(
|
CachedAccount MicrosoftGraphSync::refresh_account(
|
||||||
const MicrosoftGraphCredentials& credentials) {
|
const MicrosoftGraphCredentials& credentials) {
|
||||||
try {
|
try {
|
||||||
validate_credentials(credentials);
|
validate_credentials(credentials);
|
||||||
const nlohmann::json object =
|
const MicrosoftGraphIdentity identity = fetch_identity(credentials.access_token);
|
||||||
parse_json_object(send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
|
|
||||||
const std::string remote_subject = required_string(object, "id");
|
|
||||||
std::string display_name = optional_string(object, "displayName");
|
|
||||||
if (display_name.empty()) {
|
|
||||||
display_name = required_string(object, "userPrincipalName");
|
|
||||||
}
|
|
||||||
CachedAccount account{
|
CachedAccount account{
|
||||||
credentials.account_id, "microsoft-graph", remote_subject, std::move(display_name)};
|
credentials.account_id, "microsoft-graph", identity.remote_subject,
|
||||||
|
identity.display_name};
|
||||||
cache_.upsert_account(account);
|
cache_.upsert_account(account);
|
||||||
return account;
|
return account;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
|
|||||||
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 == "g") return Action::agenda;
|
||||||
if (sequence == "c") return Action::calendars;
|
if (sequence == "c") return Action::calendars;
|
||||||
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
|
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
|
||||||
|
if (sequence == "\x13") return Action::sync;
|
||||||
return Action::none;
|
return Action::none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "nocal/tui/TuiApp.hpp"
|
#include "nocal/tui/TuiApp.hpp"
|
||||||
|
|
||||||
#include "nocal/domain/event_query.hpp"
|
#include "nocal/domain/event_query.hpp"
|
||||||
|
#include "nocal/sync/token_broker.hpp"
|
||||||
#include "nocal/tui/Terminal.hpp"
|
#include "nocal/tui/Terminal.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -10,7 +11,9 @@
|
|||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <poll.h>
|
#include <poll.h>
|
||||||
@@ -101,6 +104,78 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver,
|
|||||||
synchronize_calendars();
|
synchronize_calendars();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TuiApp::on_sync_progress(const sync::SyncProgressEvent& event)
|
||||||
|
{
|
||||||
|
std::string stage_text;
|
||||||
|
switch (event.stage) {
|
||||||
|
case sync::SyncStage::authenticating:
|
||||||
|
stage_text = "Authenticating...";
|
||||||
|
break;
|
||||||
|
case sync::SyncStage::refreshing_identity:
|
||||||
|
stage_text = "Refreshing identity...";
|
||||||
|
break;
|
||||||
|
case sync::SyncStage::discovering_calendars:
|
||||||
|
stage_text = "Discovering calendars...";
|
||||||
|
break;
|
||||||
|
case sync::SyncStage::syncing_calendar:
|
||||||
|
stage_text = "Syncing " + event.calendar_name + "...";
|
||||||
|
break;
|
||||||
|
case sync::SyncStage::completed:
|
||||||
|
stage_text = "Sync complete.";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
apply_sync_stage(stage_text, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::apply_sync_stage(const std::string& stage_text, const bool is_error)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(sync_mutex_);
|
||||||
|
state_.sync_stage = stage_text;
|
||||||
|
state_.sync_is_error = is_error;
|
||||||
|
state_.notification = stage_text;
|
||||||
|
state_.notification_is_error = is_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::clear_sync_state()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(sync_mutex_);
|
||||||
|
state_.sync_stage.clear();
|
||||||
|
state_.sync_is_error = false;
|
||||||
|
if (!state_.sync_stage.empty() || state_.notification.empty()) {
|
||||||
|
state_.notification.clear();
|
||||||
|
state_.notification_is_error = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::start_sync(const std::string_view account_id)
|
||||||
|
{
|
||||||
|
if (sync_thread_ && sync_thread_->joinable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sync_provider_ || account_id.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connected_account_id_ = std::string{account_id};
|
||||||
|
apply_sync_stage("Syncing...", false);
|
||||||
|
sync_thread_.emplace([this, acct = std::string{account_id}]() {
|
||||||
|
try {
|
||||||
|
sync_provider_->sync_account(acct, *this);
|
||||||
|
} catch (const sync::AccountDisconnected& e) {
|
||||||
|
apply_sync_stage("Account disconnected. Run: nocal account connect microsoft", true);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
apply_sync_stage("Sync failed: " + std::string{e.what()}, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::stop_sync()
|
||||||
|
{
|
||||||
|
if (sync_thread_ && sync_thread_->joinable()) {
|
||||||
|
sync_thread_->join();
|
||||||
|
sync_thread_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
|
std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
|
||||||
if (!state_.focused_event_id) return std::nullopt;
|
if (!state_.focused_event_id) return std::nullopt;
|
||||||
const auto event_span = std::span<const Event>{events_};
|
const auto event_span = std::span<const Event>{events_};
|
||||||
@@ -733,6 +808,7 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::redo:
|
case Action::redo:
|
||||||
case Action::agenda:
|
case Action::agenda:
|
||||||
case Action::calendars:
|
case Action::calendars:
|
||||||
|
case Action::sync:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -774,6 +850,7 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::redo:
|
case Action::redo:
|
||||||
case Action::search:
|
case Action::search:
|
||||||
case Action::agenda:
|
case Action::agenda:
|
||||||
|
case Action::sync:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -826,6 +903,7 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::delete_event:
|
case Action::delete_event:
|
||||||
case Action::undo:
|
case Action::undo:
|
||||||
case Action::redo:
|
case Action::redo:
|
||||||
|
case Action::sync:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -911,6 +989,7 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::next_month:
|
case Action::next_month:
|
||||||
case Action::today:
|
case Action::today:
|
||||||
case Action::toggle_help:
|
case Action::toggle_help:
|
||||||
|
case Action::sync:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -999,6 +1078,13 @@ void TuiApp::dispatch(const Action action) {
|
|||||||
case Action::calendars:
|
case Action::calendars:
|
||||||
begin_calendar_picker();
|
begin_calendar_picker();
|
||||||
return;
|
return;
|
||||||
|
case Action::sync:
|
||||||
|
if (connected_account_id_) {
|
||||||
|
start_sync(*connected_account_id_);
|
||||||
|
} else {
|
||||||
|
set_notification("No connected account. Run: nocal account connect microsoft", true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
case Action::quit:
|
case Action::quit:
|
||||||
running_ = false;
|
running_ = false;
|
||||||
return;
|
return;
|
||||||
@@ -1073,6 +1159,7 @@ ExitReason TuiApp::run() {
|
|||||||
redraw = true;
|
redraw = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stop_sync();
|
||||||
return ExitReason::quit;
|
return ExitReason::quit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
886
tests/microsoft_account_tests.cpp
Normal file
886
tests/microsoft_account_tests.cpp
Normal file
@@ -0,0 +1,886 @@
|
|||||||
|
#include "nocal/storage/sync_cache.hpp"
|
||||||
|
#include "nocal/sync/microsoft_account.hpp"
|
||||||
|
#include "nocal/sync/microsoft_graph.hpp"
|
||||||
|
#include "nocal/sync/oauth.hpp"
|
||||||
|
#include "nocal/sync/oauth_tokens.hpp"
|
||||||
|
#include "nocal/sync/secret_store.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <exception>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
|
#include <span>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using nocal::storage::CachedAccount;
|
||||||
|
using nocal::storage::SyncCache;
|
||||||
|
using nocal::sync::AuthorizationCallback;
|
||||||
|
using nocal::sync::AuthorizationCallbackReceiver;
|
||||||
|
using nocal::sync::BrowserLauncher;
|
||||||
|
using nocal::sync::EntropySource;
|
||||||
|
using nocal::sync::HttpMethod;
|
||||||
|
using nocal::sync::HttpRequest;
|
||||||
|
using nocal::sync::HttpResponse;
|
||||||
|
using nocal::sync::HttpTransport;
|
||||||
|
using nocal::sync::MicrosoftAccountError;
|
||||||
|
using nocal::sync::OAuthClientConfig;
|
||||||
|
using nocal::sync::OAuthSecretStore;
|
||||||
|
using nocal::sync::OAuthTokens;
|
||||||
|
|
||||||
|
void require(bool condition, std::string_view message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw std::runtime_error(std::string{message});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// TempDirectory for a real SyncCache
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
class TempDirectory {
|
||||||
|
public:
|
||||||
|
TempDirectory() {
|
||||||
|
std::string pattern =
|
||||||
|
(std::filesystem::temp_directory_path() / "nocal-microsoft-account-tests.XXXXXX")
|
||||||
|
.string();
|
||||||
|
std::vector<char> writable(pattern.begin(), pattern.end());
|
||||||
|
writable.push_back('\0');
|
||||||
|
const char* created = ::mkdtemp(writable.data());
|
||||||
|
if (created == nullptr) {
|
||||||
|
throw std::runtime_error("mkdtemp failed");
|
||||||
|
}
|
||||||
|
path_ = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
TempDirectory(const TempDirectory&) = delete;
|
||||||
|
TempDirectory& operator=(const TempDirectory&) = delete;
|
||||||
|
|
||||||
|
~TempDirectory() {
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove_all(path_, ignored);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::filesystem::path path_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ScriptedEntropy – fixed deterministic values
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
class ScriptedEntropy final : public EntropySource {
|
||||||
|
public:
|
||||||
|
explicit ScriptedEntropy(std::vector<std::array<std::byte, 32>> scripted_values)
|
||||||
|
: values(std::move(scripted_values)) {}
|
||||||
|
|
||||||
|
std::vector<std::array<std::byte, 32>> values;
|
||||||
|
std::size_t calls{0};
|
||||||
|
|
||||||
|
void fill(std::span<std::byte> output) override {
|
||||||
|
require(output.size() == 32, "entropy request was not exactly 32 bytes");
|
||||||
|
const std::size_t call = calls++;
|
||||||
|
require(call < values.size(), "unexpected entropy request");
|
||||||
|
std::copy(values[call].begin(), values[call].end(), output.begin());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ScriptedBrowser
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
class ScriptedBrowser final : public BrowserLauncher {
|
||||||
|
public:
|
||||||
|
std::vector<std::string> urls;
|
||||||
|
|
||||||
|
void open(const std::string& url) override { urls.push_back(url); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ScriptedReceiver – copies state from browser URL if needed
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
class ScriptedReceiver final : public AuthorizationCallbackReceiver {
|
||||||
|
public:
|
||||||
|
std::string redirect{"http://127.0.0.1:43123/callback"};
|
||||||
|
AuthorizationCallback callback;
|
||||||
|
std::size_t receives{0};
|
||||||
|
bool copy_browser_state{true};
|
||||||
|
ScriptedBrowser* browser{nullptr};
|
||||||
|
|
||||||
|
[[nodiscard]] std::string redirect_uri() const override { return redirect; }
|
||||||
|
|
||||||
|
AuthorizationCallback receive() override {
|
||||||
|
++receives;
|
||||||
|
if (copy_browser_state && browser != nullptr && !browser->urls.empty()) {
|
||||||
|
// Extract state from the authorization URL
|
||||||
|
const std::string& url = browser->urls.front();
|
||||||
|
const std::size_t state_pos = url.find("state=");
|
||||||
|
if (state_pos != std::string::npos) {
|
||||||
|
const std::size_t value_start = state_pos + 6;
|
||||||
|
const std::size_t amp = url.find('&', value_start);
|
||||||
|
const std::size_t len = (amp == std::string::npos) ? url.size() - value_start
|
||||||
|
: amp - value_start;
|
||||||
|
AuthorizationCallback result = callback;
|
||||||
|
result.state = url.substr(value_start, len);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return callback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ScriptedTransport – scripted responses for token exchange then /me
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
class ScriptedTransport final : public HttpTransport {
|
||||||
|
public:
|
||||||
|
struct ResponseSpec {
|
||||||
|
int status{200};
|
||||||
|
std::string body;
|
||||||
|
bool throw_on_send{false};
|
||||||
|
std::string send_error_message{};
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<HttpRequest> requests;
|
||||||
|
std::vector<ResponseSpec> responses;
|
||||||
|
|
||||||
|
HttpResponse send(const HttpRequest& request) override {
|
||||||
|
requests.push_back(request);
|
||||||
|
const std::size_t index = requests.size() - 1;
|
||||||
|
if (index >= responses.size()) {
|
||||||
|
return {200, {}, "{}"};
|
||||||
|
}
|
||||||
|
const ResponseSpec& spec = responses[index];
|
||||||
|
if (spec.throw_on_send) {
|
||||||
|
throw std::runtime_error(spec.send_error_message);
|
||||||
|
}
|
||||||
|
return {spec.status, {}, spec.body};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ScriptedSecretStore – in-memory with injectable store failure
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
class ScriptedSecretStore final : public OAuthSecretStore {
|
||||||
|
public:
|
||||||
|
std::map<std::string, OAuthTokens> store_data;
|
||||||
|
bool throw_on_store{false};
|
||||||
|
std::string store_error_message{};
|
||||||
|
std::size_t store_calls{0};
|
||||||
|
std::size_t load_calls{0};
|
||||||
|
std::size_t erase_calls{0};
|
||||||
|
|
||||||
|
void store(std::string account_id, const OAuthTokens& tokens, std::stop_token = {}) override {
|
||||||
|
++store_calls;
|
||||||
|
if (throw_on_store) {
|
||||||
|
throw std::runtime_error(store_error_message);
|
||||||
|
}
|
||||||
|
store_data[std::move(account_id)] = tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<OAuthTokens> load(
|
||||||
|
std::string account_id, std::stop_token = {}) override {
|
||||||
|
++load_calls;
|
||||||
|
const auto found = store_data.find(account_id);
|
||||||
|
if (found != store_data.end()) {
|
||||||
|
return found->second;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void erase(std::string account_id, std::stop_token = {}) override {
|
||||||
|
++erase_calls;
|
||||||
|
store_data.erase(account_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Controllable clock
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
class ControllableClock {
|
||||||
|
public:
|
||||||
|
std::int64_t value{1000};
|
||||||
|
|
||||||
|
[[nodiscard]] std::int64_t operator()() { return value; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[[nodiscard]] std::array<std::byte, 32> zeros() { return {}; }
|
||||||
|
|
||||||
|
[[nodiscard]] std::string token_response_body(std::string_view access_token,
|
||||||
|
std::string_view refresh_token, std::int64_t expires_in) {
|
||||||
|
return "{\"access_token\":\"" + std::string(access_token)
|
||||||
|
+ "\",\"refresh_token\":\"" + std::string(refresh_token)
|
||||||
|
+ "\",\"token_type\":\"Bearer\",\"expires_in\":" + std::to_string(expires_in) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string me_response_body(
|
||||||
|
std::string_view id, std::string_view display_name, std::string_view user_principal_name) {
|
||||||
|
std::string body = "{\"id\":\"" + std::string(id) + "\"";
|
||||||
|
if (!display_name.empty()) {
|
||||||
|
body += ",\"displayName\":\"" + std::string(display_name) + "\"";
|
||||||
|
}
|
||||||
|
if (!user_principal_name.empty()) {
|
||||||
|
body += ",\"userPrincipalName\":\"" + std::string(user_principal_name) + "\"";
|
||||||
|
}
|
||||||
|
body += "}";
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Happy path
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_happy_path() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"auth-code", "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, token_response_body("access-token-value", "refresh-token-value", 3600)});
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
const CachedAccount account = nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
|
||||||
|
// Verify returned account
|
||||||
|
require(account.provider == "microsoft-graph", "wrong provider");
|
||||||
|
|
||||||
|
// Compute expected account id
|
||||||
|
const std::string expected_id =
|
||||||
|
nocal::sync::microsoft_account_id("remote-subject");
|
||||||
|
require(account.id == expected_id, "account id mismatch");
|
||||||
|
require(account.remote_subject == "remote-subject", "remote subject mismatch");
|
||||||
|
require(account.display_name == "Display Name", "display name mismatch");
|
||||||
|
|
||||||
|
// Cache snapshot has exactly one account
|
||||||
|
const auto snapshot = cache.snapshot();
|
||||||
|
require(snapshot.accounts.size() == 1, "expected exactly one cached account");
|
||||||
|
require(snapshot.accounts.front() == account, "cached account differs from returned");
|
||||||
|
|
||||||
|
// Secret store holds the parsed tokens under the same id
|
||||||
|
const auto stored_tokens = secret_store.store_data.find(expected_id);
|
||||||
|
require(stored_tokens != secret_store.store_data.end(), "tokens not stored");
|
||||||
|
require(stored_tokens->second.access_token == "access-token-value",
|
||||||
|
"wrong stored access token");
|
||||||
|
require(stored_tokens->second.refresh_token == "refresh-token-value",
|
||||||
|
"wrong stored refresh token");
|
||||||
|
require(stored_tokens->second.token_type == "Bearer", "wrong stored token type");
|
||||||
|
|
||||||
|
// Exactly one browser URL, one token request, one /me GET with Bearer token
|
||||||
|
require(browser.urls.size() == 1, "expected exactly one browser URL");
|
||||||
|
require(transport.requests.size() == 2, "expected exactly two HTTP requests");
|
||||||
|
require(transport.requests[0].method == HttpMethod::post,
|
||||||
|
"first request was not POST (token)");
|
||||||
|
require(transport.requests[0].url == "https://login.example/token",
|
||||||
|
"first request URL mismatch");
|
||||||
|
require(transport.requests[1].method == HttpMethod::get,
|
||||||
|
"second request was not GET (/me)");
|
||||||
|
require(transport.requests[1].url.find("graph.microsoft.com") != std::string::npos,
|
||||||
|
"second request URL does not contain graph.microsoft.com");
|
||||||
|
bool has_bearer = false;
|
||||||
|
for (const auto& header : transport.requests[1].headers) {
|
||||||
|
if (header.name == "Authorization") {
|
||||||
|
has_bearer = header.value.find("Bearer access-token-value") != std::string::npos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(has_bearer, "/me request did not carry Bearer token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Idempotent reconnect
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_idempotent_reconnect() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
auto do_connect = [&]() -> std::string {
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"auth-code", "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, token_response_body("access-token-" + std::to_string(transport.requests.size()),
|
||||||
|
"refresh-token-" + std::to_string(transport.requests.size()), 3600)});
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, me_response_body("same-subject", "Same User", "same@example.com")});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
const CachedAccount account = nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
return account.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::string first_id = do_connect();
|
||||||
|
const std::string second_id = do_connect();
|
||||||
|
|
||||||
|
// Same account id
|
||||||
|
require(first_id == second_id, "reconnect produced different account id");
|
||||||
|
|
||||||
|
// Cache still has exactly one account
|
||||||
|
const auto snapshot = cache.snapshot();
|
||||||
|
require(snapshot.accounts.size() == 1, "expected exactly one account after reconnect");
|
||||||
|
require(snapshot.accounts.front().id == first_id, "account id mismatch in cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Authorization rejected
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_authorization_rejected() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"", "", "access_denied", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const MicrosoftAccountError& e) {
|
||||||
|
caught = true;
|
||||||
|
require(std::string(e.what()) == "Microsoft authorization failed",
|
||||||
|
"wrong error message on authorization rejection");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
std::string("wrong exception type: ") + e.what());
|
||||||
|
}
|
||||||
|
require(caught, "expected MicrosoftAccountError");
|
||||||
|
|
||||||
|
// Cache and secret store both untouched
|
||||||
|
require(cache.snapshot().accounts.empty(), "cache was modified on authorization rejection");
|
||||||
|
require(secret_store.store_calls == 0, "secret store was called on authorization rejection");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Token endpoint returns non-2xx
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_token_endpoint_non2xx() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"auth-code", "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back({400, "{\"error\":\"invalid_grant\"}"});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const MicrosoftAccountError& e) {
|
||||||
|
caught = true;
|
||||||
|
require(std::string(e.what()) == "Microsoft authorization failed",
|
||||||
|
"wrong error message on token rejection");
|
||||||
|
}
|
||||||
|
require(caught, "expected MicrosoftAccountError");
|
||||||
|
|
||||||
|
// Nothing stored
|
||||||
|
require(cache.snapshot().accounts.empty(), "cache was modified on token error");
|
||||||
|
require(secret_store.store_calls == 0, "secret store was called on token error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Token response missing refresh_token
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_token_response_missing_refresh() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"auth-code", "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
// No refresh_token in response — parse_oauth_token_response will reject
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, "{\"access_token\":\"acc\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const MicrosoftAccountError& e) {
|
||||||
|
caught = true;
|
||||||
|
require(std::string(e.what()) == "Microsoft token response invalid",
|
||||||
|
"wrong error message on missing refresh token");
|
||||||
|
}
|
||||||
|
require(caught, "expected MicrosoftAccountError");
|
||||||
|
|
||||||
|
// Nothing stored
|
||||||
|
require(cache.snapshot().accounts.empty(), "cache was modified on parse error");
|
||||||
|
require(secret_store.store_calls == 0, "secret store was called on parse error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: /me failure
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_me_failure() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"auth-code", "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
// Successful token response
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, token_response_body("acc-token", "ref-token", 3600)});
|
||||||
|
// /me returns garbage (malformed JSON)
|
||||||
|
transport.responses.push_back({200, "not-valid-json"});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const MicrosoftAccountError& e) {
|
||||||
|
caught = true;
|
||||||
|
require(std::string(e.what()) == "Microsoft identity read failed",
|
||||||
|
"wrong error message on /me failure");
|
||||||
|
}
|
||||||
|
require(caught, "expected MicrosoftAccountError");
|
||||||
|
|
||||||
|
// Cache and secret store untouched
|
||||||
|
require(cache.snapshot().accounts.empty(), "cache was modified on /me failure");
|
||||||
|
require(secret_store.store_calls == 0, "secret store was called on /me failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Secret-store store() failure — cache retains the account row
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_secret_store_failure() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"auth-code", "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, token_response_body("acc-token", "ref-token", 3600)});
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
secret_store.throw_on_store = true;
|
||||||
|
secret_store.store_error_message = "secret service unavailable";
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const MicrosoftAccountError& e) {
|
||||||
|
caught = true;
|
||||||
|
require(std::string(e.what()) == "credential storage failed",
|
||||||
|
"wrong error message on store failure");
|
||||||
|
}
|
||||||
|
require(caught, "expected MicrosoftAccountError");
|
||||||
|
|
||||||
|
// Cache DOES retain the account row (documented disconnected state)
|
||||||
|
const auto snapshot = cache.snapshot();
|
||||||
|
require(snapshot.accounts.size() == 1,
|
||||||
|
"cache should retain the account row even when secret store fails");
|
||||||
|
require(snapshot.accounts.front().remote_subject == "remote-subject",
|
||||||
|
"cached account has wrong remote subject");
|
||||||
|
|
||||||
|
// Verify secret store does NOT have the credentials
|
||||||
|
const std::string expected_id =
|
||||||
|
nocal::sync::microsoft_account_id("remote-subject");
|
||||||
|
const auto stored = secret_store.store_data.find(expected_id);
|
||||||
|
require(stored == secret_store.store_data.end(),
|
||||||
|
"secret store should not contain credentials after store failure");
|
||||||
|
require(secret_store.store_calls == 1, "store should have been attempted once");
|
||||||
|
|
||||||
|
// A subsequent successful connect should complete (idempotent)
|
||||||
|
ScriptedEntropy entropy2{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser2;
|
||||||
|
ScriptedReceiver receiver2;
|
||||||
|
receiver2.browser = &browser2;
|
||||||
|
receiver2.callback = {"auth-code-2", "", "", ""};
|
||||||
|
ScriptedTransport transport2;
|
||||||
|
transport2.responses.push_back(
|
||||||
|
{200, token_response_body("acc-token-2", "ref-token-2", 3600)});
|
||||||
|
transport2.responses.push_back(
|
||||||
|
{200, me_response_body("remote-subject", "Display Name", "user@example.com")});
|
||||||
|
ScriptedSecretStore secret_store2;
|
||||||
|
ControllableClock clock2;
|
||||||
|
|
||||||
|
const CachedAccount retry_account = nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser2, receiver2, transport2, entropy2, secret_store2, cache,
|
||||||
|
[&clock2]() { return clock2(); });
|
||||||
|
require(retry_account.id == expected_id,
|
||||||
|
"retry account id should match deterministic id");
|
||||||
|
require(cache.snapshot().accounts.size() == 1,
|
||||||
|
"cache should still have exactly one account after successful retry");
|
||||||
|
|
||||||
|
const auto stored2 = secret_store2.store_data.find(expected_id);
|
||||||
|
require(stored2 != secret_store2.store_data.end(),
|
||||||
|
"credentials should be stored after successful retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Empty displayName falls back to userPrincipalName
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_empty_display_name_fallback() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {"auth-code", "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, token_response_body("acc-token", "ref-token", 3600)});
|
||||||
|
// No displayName in /me response
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, me_response_body("remote-subject", "", "fallback@example.com")});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
const CachedAccount account = nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
|
||||||
|
require(account.display_name == "fallback@example.com",
|
||||||
|
"display name should fall back to userPrincipalName");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: default_microsoft_client_id with env unset and set
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_default_client_id() {
|
||||||
|
// Save original env state and unset
|
||||||
|
const char* original = std::getenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||||
|
|
||||||
|
// Test with env unset
|
||||||
|
if (original != nullptr) {
|
||||||
|
::unsetenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||||
|
}
|
||||||
|
const std::string unset_result = nocal::sync::default_microsoft_client_id();
|
||||||
|
require(unset_result == nocal::sync::microsoft_client_id_placeholder,
|
||||||
|
"default client id should be placeholder when env is unset");
|
||||||
|
|
||||||
|
// Test with env set
|
||||||
|
::setenv("NOCAL_MICROSOFT_CLIENT_ID", "my-test-client-id", 1);
|
||||||
|
const std::string set_result = nocal::sync::default_microsoft_client_id();
|
||||||
|
require(set_result == "my-test-client-id",
|
||||||
|
"default client id should match env when set");
|
||||||
|
|
||||||
|
// Restore original
|
||||||
|
if (original != nullptr) {
|
||||||
|
::setenv("NOCAL_MICROSOFT_CLIENT_ID", original, 1);
|
||||||
|
} else {
|
||||||
|
::unsetenv("NOCAL_MICROSOFT_CLIENT_ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: microsoft_oauth_config
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_microsoft_oauth_config() {
|
||||||
|
const OAuthClientConfig config = nocal::sync::microsoft_oauth_config("my-client-id");
|
||||||
|
|
||||||
|
require(config.client_id == "my-client-id", "client_id mismatch");
|
||||||
|
require(config.authorization_endpoint
|
||||||
|
== "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||||
|
"wrong authorization endpoint");
|
||||||
|
require(config.token_endpoint
|
||||||
|
== "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||||
|
"wrong token endpoint");
|
||||||
|
|
||||||
|
require(config.scopes.size() == 3, "expected exactly 3 scopes");
|
||||||
|
require(config.scopes[0] == "offline_access", "scope[0] mismatch");
|
||||||
|
require(config.scopes[1] == "User.Read", "scope[1] mismatch");
|
||||||
|
require(config.scopes[2] == "Calendars.Read", "scope[2] mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Error messages never contain credential material
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_error_message_redaction() {
|
||||||
|
TempDirectory temp;
|
||||||
|
SyncCache cache(temp.path() / "cache.db");
|
||||||
|
|
||||||
|
const std::string access_marker = "ACCESS_CRED_MARKER_12345";
|
||||||
|
const std::string refresh_marker = "REFRESH_CRED_MARKER_67890";
|
||||||
|
const std::string auth_code_marker = "AUTH_CODE_MARKER_ABCDE";
|
||||||
|
|
||||||
|
// Test 1: Token endpoint error (400)
|
||||||
|
{
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {auth_code_marker, "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back({400, access_marker + refresh_marker});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
const std::string msg = e.what();
|
||||||
|
require(msg.find(access_marker) == std::string::npos,
|
||||||
|
"error leaked access token on 400");
|
||||||
|
require(msg.find(refresh_marker) == std::string::npos,
|
||||||
|
"error leaked refresh token on 400");
|
||||||
|
require(msg.find(auth_code_marker) == std::string::npos,
|
||||||
|
"error leaked auth code on 400");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Parse error (missing refresh_token)
|
||||||
|
{
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {auth_code_marker, "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, "{\"access_token\":\"" + access_marker
|
||||||
|
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
const std::string msg = e.what();
|
||||||
|
require(msg.find(access_marker) == std::string::npos,
|
||||||
|
"error leaked access token on parse failure");
|
||||||
|
require(msg.find(auth_code_marker) == std::string::npos,
|
||||||
|
"error leaked auth code on parse failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: /me failure
|
||||||
|
{
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {auth_code_marker, "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, "{\"access_token\":\"" + access_marker
|
||||||
|
+ "\",\"refresh_token\":\"" + refresh_marker
|
||||||
|
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, "{\"bad_field\":" + access_marker + "}"});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
const std::string msg = e.what();
|
||||||
|
require(msg.find(access_marker) == std::string::npos,
|
||||||
|
"error leaked access token on /me failure");
|
||||||
|
require(msg.find(refresh_marker) == std::string::npos,
|
||||||
|
"error leaked refresh token on /me failure");
|
||||||
|
require(msg.find(auth_code_marker) == std::string::npos,
|
||||||
|
"error leaked auth code on /me failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Secret store failure
|
||||||
|
{
|
||||||
|
ScriptedEntropy entropy{{zeros(), zeros()}};
|
||||||
|
ScriptedBrowser browser;
|
||||||
|
ScriptedReceiver receiver;
|
||||||
|
receiver.browser = &browser;
|
||||||
|
receiver.callback = {auth_code_marker, "", "", ""};
|
||||||
|
ScriptedTransport transport;
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, "{\"access_token\":\"" + access_marker
|
||||||
|
+ "\",\"refresh_token\":\"" + refresh_marker
|
||||||
|
+ "\",\"token_type\":\"Bearer\",\"expires_in\":3600}"});
|
||||||
|
transport.responses.push_back(
|
||||||
|
{200, me_response_body("remote-subject", "Display", "user@example.com")});
|
||||||
|
ScriptedSecretStore secret_store;
|
||||||
|
secret_store.throw_on_store = true;
|
||||||
|
ControllableClock clock;
|
||||||
|
|
||||||
|
OAuthClientConfig config;
|
||||||
|
config.client_id = "test-client-id";
|
||||||
|
config.authorization_endpoint = "https://login.example/auth";
|
||||||
|
config.token_endpoint = "https://login.example/token";
|
||||||
|
config.scopes = {"offline_access", "User.Read", "Calendars.Read"};
|
||||||
|
|
||||||
|
try {
|
||||||
|
(void)nocal::sync::connect_microsoft_account(
|
||||||
|
config, browser, receiver, transport, entropy, secret_store, cache,
|
||||||
|
[&clock]() { return clock(); });
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
const std::string msg = e.what();
|
||||||
|
require(msg.find(access_marker) == std::string::npos,
|
||||||
|
"error leaked access token on store failure");
|
||||||
|
require(msg.find(refresh_marker) == std::string::npos,
|
||||||
|
"error leaked refresh token on store failure");
|
||||||
|
require(msg.find(auth_code_marker) == std::string::npos,
|
||||||
|
"error leaked auth code on store failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: microsoft_account_id is deterministic
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
void test_microsoft_account_id_deterministic() {
|
||||||
|
const std::string first = nocal::sync::microsoft_account_id("user@example.com");
|
||||||
|
const std::string second = nocal::sync::microsoft_account_id("user@example.com");
|
||||||
|
require(
|
||||||
|
first == second, "microsoft_account_id is not deterministic for same input");
|
||||||
|
|
||||||
|
const std::string different =
|
||||||
|
nocal::sync::microsoft_account_id("other@example.com");
|
||||||
|
require(first != different,
|
||||||
|
"microsoft_account_id should differ for different remote subjects");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
test_happy_path();
|
||||||
|
test_idempotent_reconnect();
|
||||||
|
test_authorization_rejected();
|
||||||
|
test_token_endpoint_non2xx();
|
||||||
|
test_token_response_missing_refresh();
|
||||||
|
test_me_failure();
|
||||||
|
test_secret_store_failure();
|
||||||
|
test_empty_display_name_fallback();
|
||||||
|
test_default_client_id();
|
||||||
|
test_microsoft_oauth_config();
|
||||||
|
test_error_message_redaction();
|
||||||
|
test_microsoft_account_id_deterministic();
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << "microsoft account tests failed: " << error.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::cout << "microsoft account tests passed\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
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,
|
.search_result_index = 0,
|
||||||
.notification = {},
|
.notification = {},
|
||||||
.notification_is_error = false,
|
.notification_is_error = false,
|
||||||
|
.sync_stage = {},
|
||||||
|
.sync_is_error = false,
|
||||||
.show_calendar_picker = false,
|
.show_calendar_picker = false,
|
||||||
.calendar_index = 0,
|
.calendar_index = 0,
|
||||||
.show_agenda = false,
|
.show_agenda = false,
|
||||||
@@ -178,6 +180,8 @@ void test_compact_and_input()
|
|||||||
.search_result_index = 0,
|
.search_result_index = 0,
|
||||||
.notification = {},
|
.notification = {},
|
||||||
.notification_is_error = false,
|
.notification_is_error = false,
|
||||||
|
.sync_stage = {},
|
||||||
|
.sync_is_error = false,
|
||||||
.show_calendar_picker = false,
|
.show_calendar_picker = false,
|
||||||
.calendar_index = 0,
|
.calendar_index = 0,
|
||||||
.show_agenda = false,
|
.show_agenda = false,
|
||||||
|
|||||||
Reference in New Issue
Block a user