Compare commits
5 Commits
98e07e287e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74c798e7aa | |||
| feec691535 | |||
| 5affa4d0cd | |||
| 6d9e7b0e3e | |||
| 23bcb20ab5 |
45
.opencode/agent/coder.md
Normal file
45
.opencode/agent/coder.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
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
|
||||
model: openrouter/deepseek/deepseek-v4-flash
|
||||
permission:
|
||||
edit: allow
|
||||
bash: allow
|
||||
---
|
||||
|
||||
You are a coding agent for the nocal repository. You implement bounded slices
|
||||
assigned by the lead agent. You do not redefine architecture, expand scope, or
|
||||
review your own work for completion.
|
||||
|
||||
## Rules
|
||||
|
||||
- Stay strictly inside the file and directory set the lead assigns. Preserve
|
||||
unrelated user changes. Touch nothing outside your scope.
|
||||
- C++20, existing code style, minimal precise edits. No new runtime
|
||||
dependencies without explicit lead approval.
|
||||
- Add or update deterministic tests for the behavior you implement (injected
|
||||
fakes; no network, wall-clock, or randomness).
|
||||
- No git mutations (commit, push, reset, rebase, checkout). Read-only git
|
||||
inspection is fine.
|
||||
|
||||
## Verification before reporting back
|
||||
|
||||
Run from the repository root and report the exact commands and outcomes:
|
||||
|
||||
1. `nix-shell --run 'meson compile -C build'` — must compile cleanly.
|
||||
2. The specific test binaries for your slice, e.g.
|
||||
`nix-shell --run 'meson test -C build --print-errorlogs <name>'`.
|
||||
3. If the lead asked for warning-as-error or sanitizer checks, run exactly
|
||||
what was requested and paste the tail of any failure.
|
||||
|
||||
## Report back
|
||||
|
||||
Your final message must state:
|
||||
|
||||
- Files changed and a one-line summary per file.
|
||||
- The exact commands you ran and whether each passed.
|
||||
- Limitations, unhandled edge cases, and any mismatch with the shared
|
||||
contracts you were given.
|
||||
|
||||
Never claim completion based on visual inspection or intent. If a check failed
|
||||
and you could not fix it, say so explicitly.
|
||||
@@ -37,6 +37,13 @@ pass is not sufficient evidence. Small, tightly coupled fixes may be completed
|
||||
directly by the lead when delegation would create more coordination cost than
|
||||
implementation value.
|
||||
|
||||
## Concrete delegation setup (nocal)
|
||||
|
||||
A coding subagent is configured at `.opencode/agent/coder.md` with model
|
||||
`openrouter/deepseek/deepseek-v4-flash`. It owns bounded implementation slices assigned
|
||||
by the lead; the lead retains shared contracts, integration, review, and final
|
||||
verification. After any config change, restart opencode to reload agents.
|
||||
|
||||
## Delegation protocol
|
||||
|
||||
Before dispatching implementation work, the lead must:
|
||||
|
||||
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
|
||||
window instead of incrementally updating it.
|
||||
|
||||
There is still no account UI/CLI orchestration or live Microsoft credential
|
||||
integration, and remote writes remain out of scope. Account setup and
|
||||
orchestration are next.
|
||||
Account setup begins with `nocal account connect microsoft`: it runs the
|
||||
PKCE browser flow, reads `/me`, upserts the deterministic account row, and
|
||||
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
|
||||
refresh, while the primary calendar remains incremental.
|
||||
|
||||
There is no account UI or CLI orchestration and no live Microsoft credential
|
||||
integration yet; tests use injected transports and credentials. Remote writes
|
||||
remain out of scope. Account setup and orchestration are next.
|
||||
Account setup now has a CLI entry point, `nocal account connect microsoft`.
|
||||
It composes the desktop OAuth adapters, libcurl transport, token parsing, one
|
||||
`/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.
|
||||
|
||||
@@ -93,10 +93,35 @@ Completed secondary-calendar stable-v1 reconciliation:
|
||||
- Overlapping windows retain events until their last membership is removed
|
||||
- No beta or undocumented per-calendar delta dependency
|
||||
|
||||
Completed provider-boundary contracts and token broker:
|
||||
|
||||
- Generic `SyncProvider`/`SyncObserver` contracts for account connect, sync,
|
||||
and disconnect with staged progress events
|
||||
- `OAuthTokenBroker` returning valid per-operation access tokens with a
|
||||
five-minute refresh skew
|
||||
- Refresh rotation persisted before use so a new refresh token is never lost;
|
||||
transient refresh failures leave stored credentials untouched
|
||||
- 400/401 refresh rejections erase the stored secret and surface
|
||||
`AccountDisconnected` for re-authentication
|
||||
- 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:
|
||||
|
||||
- Account setup and CLI/TUI orchestration with live credential integration
|
||||
- Generic `SyncProvider` integration and observable sync status
|
||||
- TUI account orchestration and generic `SyncProvider` integration with
|
||||
observable sync status
|
||||
|
||||
## 0.3 — CalDAV
|
||||
|
||||
@@ -113,9 +138,10 @@ Next provider-boundary slices:
|
||||
|
||||
The Graph read boundary uses stable v1 delta for the primary calendar and
|
||||
complete stable-v1 calendar views for secondary calendars; it deliberately
|
||||
avoids beta and undocumented per-calendar delta routes. No account UI/CLI
|
||||
orchestration or live Microsoft credential integration exists yet, and remote
|
||||
writes remain out of scope.
|
||||
avoids beta and undocumented per-calendar delta routes. Account connect exists
|
||||
as a CLI command with live credential integration through Secret Service, but
|
||||
there is no account TUI or sync orchestration yet, and remote writes remain
|
||||
out of scope.
|
||||
|
||||
## 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 <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace nocal::sync {
|
||||
|
||||
@@ -28,6 +29,13 @@ public:
|
||||
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
|
||||
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for
|
||||
// the primary calendar and complete calendarView reconciliation for secondary
|
||||
@@ -36,6 +44,10 @@ class MicrosoftGraphSync {
|
||||
public:
|
||||
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
|
||||
// persisted atomically; failures leave the cached account unchanged.
|
||||
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials);
|
||||
|
||||
56
include/nocal/sync/provider.hpp
Normal file
56
include/nocal/sync/provider.hpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace nocal::sync {
|
||||
|
||||
enum class SyncStage {
|
||||
authenticating,
|
||||
refreshing_identity,
|
||||
discovering_calendars,
|
||||
syncing_calendar,
|
||||
completed,
|
||||
};
|
||||
|
||||
struct SyncProgressEvent {
|
||||
SyncStage stage{SyncStage::authenticating};
|
||||
std::string account_id;
|
||||
std::string calendar_name;
|
||||
std::size_t calendars_completed{0};
|
||||
std::size_t calendars_total{0};
|
||||
};
|
||||
|
||||
class SyncObserver {
|
||||
public:
|
||||
virtual ~SyncObserver() = default;
|
||||
virtual void on_sync_progress(const SyncProgressEvent& event) = 0;
|
||||
};
|
||||
|
||||
struct ConnectedAccount {
|
||||
std::string account_id;
|
||||
std::string display_name;
|
||||
};
|
||||
|
||||
class SyncProvider {
|
||||
public:
|
||||
virtual ~SyncProvider() = default;
|
||||
|
||||
[[nodiscard]] virtual std::string_view provider_id() const noexcept = 0;
|
||||
|
||||
// Interactive connect: runs provider auth, stores credentials, registers
|
||||
// account. Must not leave partial connected state on failure.
|
||||
virtual ConnectedAccount connect_account() = 0;
|
||||
|
||||
// Syncs one account's calendars and windows; reports progress via observer.
|
||||
// May continue past individual calendar failures but reports them.
|
||||
virtual void sync_account(std::string_view account_id, SyncObserver& observer) = 0;
|
||||
|
||||
// Removes credentials for the account. Cached provider data is left in place
|
||||
// (deterministic IDs make re-add idempotent). Unknown account is an error.
|
||||
virtual void disconnect_account(std::string_view account_id) = 0;
|
||||
};
|
||||
|
||||
} // namespace nocal::sync
|
||||
58
include/nocal/sync/token_broker.hpp
Normal file
58
include/nocal/sync/token_broker.hpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include "nocal/sync/oauth.hpp"
|
||||
#include "nocal/sync/oauth_tokens.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace nocal::sync {
|
||||
|
||||
class OAuthSecretStore;
|
||||
class HttpTransport;
|
||||
|
||||
class AccountDisconnected final : public std::runtime_error {
|
||||
public:
|
||||
explicit AccountDisconnected(std::string message)
|
||||
: std::runtime_error(std::move(message)) {}
|
||||
};
|
||||
|
||||
class OAuthTokenBroker {
|
||||
public:
|
||||
// Constructs a broker for one OAuth provider configuration.
|
||||
// `clock` returns the current epoch seconds for expiry calculations.
|
||||
// It must be monotonic-ish (system_clock is fine).
|
||||
OAuthTokenBroker(const OAuthClientConfig& config,
|
||||
HttpTransport& transport,
|
||||
OAuthSecretStore& secret_store,
|
||||
std::function<std::int64_t()> clock);
|
||||
|
||||
// Returns a valid access token for the account, refreshing and persisting
|
||||
// the rotated tokens when the stored access token is expired or within
|
||||
// 5 minutes of expiry.
|
||||
//
|
||||
// Failure semantics:
|
||||
// - Transport error, 429, 5xx, timeout -> throws std::runtime_error;
|
||||
// secret store is NOT touched.
|
||||
// - 400/401 on refresh (invalid_grant, unauthorized_client, etc.) ->
|
||||
// erases the secret from the store, throws AccountDisconnected
|
||||
// (re-auth required).
|
||||
// - Parsing/validation failure on refresh response -> throws
|
||||
// std::runtime_error; secret is NOT erased (may be transient malformed
|
||||
// response).
|
||||
// - Secret store load failure (missing, locked, cancelled) -> throws
|
||||
// std::runtime_error; no erase.
|
||||
[[nodiscard]] OAuthTokens valid_tokens(std::string_view account_id);
|
||||
|
||||
private:
|
||||
const OAuthClientConfig config_;
|
||||
HttpTransport& transport_;
|
||||
OAuthSecretStore& secret_store_;
|
||||
std::function<std::int64_t()> clock_;
|
||||
static constexpr std::int64_t refresh_skew_seconds = 300; // 5 min
|
||||
};
|
||||
|
||||
} // namespace nocal::sync
|
||||
13
meson.build
13
meson.build
@@ -28,10 +28,12 @@ nocal_sources = files(
|
||||
'src/storage/sync_cache.cpp',
|
||||
'src/sync/curl_http.cpp',
|
||||
'src/sync/desktop_oauth.cpp',
|
||||
'src/sync/microsoft_account.cpp',
|
||||
'src/sync/microsoft_graph.cpp',
|
||||
'src/sync/oauth.cpp',
|
||||
'src/sync/oauth_tokens.cpp',
|
||||
'src/sync/secret_store.cpp',
|
||||
'src/sync/token_broker.cpp',
|
||||
'src/tui/Screen.cpp',
|
||||
'src/tui/EventEditor.cpp',
|
||||
'src/tui/Terminal.cpp',
|
||||
@@ -45,6 +47,11 @@ nocal_dep = declare_dependency(include_directories: inc, link_with: nocal_lib,
|
||||
nocal_executable = executable('nocal', 'src/main.cpp',
|
||||
dependencies: nocal_dep, install: true)
|
||||
|
||||
util = meson.get_compiler('cpp').find_library('util', required: false)
|
||||
pty_smoke = executable('tui-pty-smoke', 'tests/tui_pty_smoke.cpp',
|
||||
dependencies: util)
|
||||
test('tui-pty-smoke', pty_smoke, args: [nocal_executable], timeout: 60)
|
||||
|
||||
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
|
||||
dependencies: nocal_dep)
|
||||
test('domain', domain_tests)
|
||||
@@ -72,6 +79,12 @@ test('desktop-oauth', desktop_oauth_tests)
|
||||
oauth_token_tests = executable('oauth-token-tests',
|
||||
'tests/oauth_token_tests.cpp', dependencies: nocal_dep)
|
||||
test('oauth-tokens', oauth_token_tests)
|
||||
token_broker_tests = executable('token-broker-tests',
|
||||
'tests/token_broker_tests.cpp', dependencies: nocal_dep)
|
||||
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',
|
||||
'tests/secret_store_tests.cpp', dependencies: nocal_dep)
|
||||
test('secret-store', shell,
|
||||
|
||||
67
src/main.cpp
67
src/main.cpp
@@ -1,6 +1,11 @@
|
||||
#include "nocal/domain/calendar_transfer.hpp"
|
||||
#include "nocal/domain/date.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/secret_store.hpp"
|
||||
#include "nocal/tui/Screen.hpp"
|
||||
#include "nocal/tui/TuiApp.hpp"
|
||||
|
||||
@@ -13,6 +18,7 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -45,6 +51,61 @@ std::filesystem::path default_calendar_path()
|
||||
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 account_command(int argc, char** argv)
|
||||
{
|
||||
if (argc == 3 && std::string_view{argv[1]} == "connect" &&
|
||||
std::string_view{argv[2]} == "microsoft") {
|
||||
return connect_microsoft_account();
|
||||
}
|
||||
throw std::invalid_argument("usage: nocal account connect microsoft");
|
||||
}
|
||||
|
||||
void print_help(std::ostream& output)
|
||||
{
|
||||
output <<
|
||||
@@ -60,6 +121,9 @@ void print_help(std::ostream& output)
|
||||
" --no-color disable ANSI styling\n"
|
||||
" -h, --help show this help\n"
|
||||
" -V, --version show the version\n\n"
|
||||
"Accounts: nocal account connect microsoft\n"
|
||||
" links a Microsoft account (browser sign-in, tokens stay in\n"
|
||||
" the Secret Service keyring) and exits.\n\n"
|
||||
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
|
||||
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
||||
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
||||
@@ -313,6 +377,9 @@ int export_calendar(const std::filesystem::path& source_path,
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
try {
|
||||
if (argc >= 2 && std::string_view{argv[1]} == "account") {
|
||||
return account_command(argc - 1, argv + 1);
|
||||
}
|
||||
const auto options = parse_options(argc, argv);
|
||||
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
||||
print_warnings(loaded, options.calendar_path);
|
||||
|
||||
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)
|
||||
: 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(
|
||||
const MicrosoftGraphCredentials& credentials) {
|
||||
try {
|
||||
validate_credentials(credentials);
|
||||
const nlohmann::json object =
|
||||
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");
|
||||
}
|
||||
const MicrosoftGraphIdentity identity = fetch_identity(credentials.access_token);
|
||||
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);
|
||||
return account;
|
||||
} catch (...) {
|
||||
|
||||
147
src/sync/token_broker.cpp
Normal file
147
src/sync/token_broker.cpp
Normal file
@@ -0,0 +1,147 @@
|
||||
#include "nocal/sync/token_broker.hpp"
|
||||
|
||||
#include "nocal/sync/oauth.hpp"
|
||||
#include "nocal/sync/oauth_tokens.hpp"
|
||||
#include "nocal/sync/secret_store.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace nocal::sync {
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] std::string percent_encode(std::string_view value) {
|
||||
static constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string result;
|
||||
result.reserve(value.size());
|
||||
for (const unsigned char character : value) {
|
||||
const bool ascii_alphanumeric = (character >= 'A' && character <= 'Z')
|
||||
|| (character >= 'a' && character <= 'z')
|
||||
|| (character >= '0' && character <= '9');
|
||||
if (ascii_alphanumeric || character == '-' || character == '.' || character == '_'
|
||||
|| character == '~') {
|
||||
result.push_back(static_cast<char>(character));
|
||||
} else {
|
||||
result.push_back('%');
|
||||
result.push_back(hex[character >> 4U]);
|
||||
result.push_back(hex[character & 0x0fU]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string join_scopes(const std::vector<std::string>& scopes) {
|
||||
std::string result;
|
||||
for (const std::string& scope : scopes) {
|
||||
if (!result.empty()) {
|
||||
result.push_back(' ');
|
||||
}
|
||||
result += scope;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void append_parameter(
|
||||
std::string& output, bool& first, std::string_view name, std::string_view value) {
|
||||
if (!first) {
|
||||
output.push_back('&');
|
||||
}
|
||||
first = false;
|
||||
output += name;
|
||||
output.push_back('=');
|
||||
output += percent_encode(value);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string make_refresh_body(const OAuthClientConfig& config,
|
||||
std::string_view refresh_token, const std::string& scope) {
|
||||
std::string body;
|
||||
bool first = true;
|
||||
append_parameter(body, first, "grant_type", "refresh_token");
|
||||
append_parameter(body, first, "client_id", config.client_id);
|
||||
append_parameter(body, first, "refresh_token", refresh_token);
|
||||
if (!scope.empty()) {
|
||||
append_parameter(body, first, "scope", scope);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
OAuthTokenBroker::OAuthTokenBroker(const OAuthClientConfig& config,
|
||||
HttpTransport& transport, OAuthSecretStore& secret_store,
|
||||
std::function<std::int64_t()> clock)
|
||||
: config_(config), transport_(transport), secret_store_(secret_store), clock_(clock) {}
|
||||
|
||||
OAuthTokens OAuthTokenBroker::valid_tokens(std::string_view account_id) {
|
||||
// 1. Load stored tokens
|
||||
std::optional<OAuthTokens> stored_opt;
|
||||
try {
|
||||
stored_opt = secret_store_.load(std::string(account_id));
|
||||
} catch (const SecretStoreError&) {
|
||||
throw;
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(std::string("failed to load tokens: ") + e.what());
|
||||
}
|
||||
|
||||
if (!stored_opt.has_value()) {
|
||||
throw std::runtime_error("account not connected: " + std::string(account_id));
|
||||
}
|
||||
|
||||
const OAuthTokens& stored = *stored_opt;
|
||||
const std::int64_t now = clock_();
|
||||
|
||||
// 2. Check if token is still valid (beyond now + 300s skew)
|
||||
if (stored.expires_at_epoch_seconds > now + refresh_skew_seconds) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
// 3. Refresh token
|
||||
const std::string scope = join_scopes(config_.scopes);
|
||||
const std::string body = make_refresh_body(config_, stored.refresh_token, scope);
|
||||
|
||||
HttpRequest request;
|
||||
request.method = HttpMethod::post;
|
||||
request.url = config_.token_endpoint;
|
||||
request.headers = {{"Content-Type", "application/x-www-form-urlencoded"}};
|
||||
request.body = body;
|
||||
|
||||
HttpResponse response;
|
||||
try {
|
||||
response = transport_.send(request);
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(std::string("transport error: ") + e.what());
|
||||
}
|
||||
|
||||
// 4. Handle response
|
||||
if (response.status == 200) {
|
||||
OAuthTokens parsed;
|
||||
try {
|
||||
parsed = parse_oauth_token_response(response.body, now, stored.refresh_token);
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(std::string("parse error: ") + e.what());
|
||||
}
|
||||
|
||||
// Persist first, then return
|
||||
try {
|
||||
secret_store_.store(std::string(account_id), parsed);
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(std::string("store error: ") + e.what());
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} else if (response.status == 400 || response.status == 401) {
|
||||
try {
|
||||
secret_store_.erase(std::string(account_id));
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(std::string("erase error: ") + e.what());
|
||||
}
|
||||
throw AccountDisconnected("account " + std::string(account_id) + " disconnected");
|
||||
} else {
|
||||
throw std::runtime_error("unexpected status code: " + std::to_string(response.status));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace nocal::sync
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <initializer_list>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
@@ -253,6 +254,49 @@ std::string horizontal_line(const int width, const std::string_view left,
|
||||
return result;
|
||||
}
|
||||
|
||||
struct KeyHint {
|
||||
std::string_view key;
|
||||
std::string_view label;
|
||||
};
|
||||
|
||||
int hints_width(const KeyHint* const hints, const std::size_t count)
|
||||
{
|
||||
int width = 0;
|
||||
for (std::size_t index = 0; index < count; ++index) {
|
||||
if (index != 0) width += 3;
|
||||
width += static_cast<int>(hints[index].key.size() + 1 + hints[index].label.size());
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
// Key bold, label dim, three spaces between hints; the plain layout is
|
||||
// identical with ANSI disabled. Hints drop from the end to fit.
|
||||
std::string render_hints(const std::initializer_list<KeyHint> hints, const int width,
|
||||
const bool ansi)
|
||||
{
|
||||
std::size_t count = hints.size();
|
||||
while (count > 0 && hints_width(hints.begin(), count) > width) --count;
|
||||
std::string result;
|
||||
for (std::size_t index = 0; index < count; ++index) {
|
||||
if (index != 0) result += " ";
|
||||
result += style(std::string{hints.begin()[index].key}, "1", ansi);
|
||||
result += ' ';
|
||||
result += style(std::string{hints.begin()[index].label}, "2", ansi);
|
||||
}
|
||||
const int used = hints_width(hints.begin(), count);
|
||||
if (used < width) result.append(static_cast<std::size_t>(width - used), ' ');
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string hints_interior_line(const std::initializer_list<KeyHint> hints, const int width,
|
||||
const bool ansi)
|
||||
{
|
||||
if (width <= 0) return {};
|
||||
if (width == 1) return "│";
|
||||
if (width <= 3) return interior_line({}, width);
|
||||
return "│ " + render_hints(hints, width - 4, ansi) + "│";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
EventEditor::EventEditor(const Date selected_day)
|
||||
@@ -440,8 +484,9 @@ std::string EventEditor::render(const int requested_width, const int requested_h
|
||||
: editing_ ? " NOCAL · EDIT APPOINTMENT"
|
||||
: " NOCAL · NEW APPOINTMENT";
|
||||
lines.push_back(styled_interior_line(heading, width, "1", ansi));
|
||||
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
|
||||
width, "2", ansi));
|
||||
lines.push_back(hints_interior_line(
|
||||
{{"Tab", "next field"}, {"Shift-Tab", "previous field"}, {"Space", "toggle"}},
|
||||
width, ansi));
|
||||
lines.push_back(styled_interior_line(
|
||||
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
|
||||
? " Times shown in source zone: " + original_.time_zone
|
||||
@@ -470,8 +515,10 @@ std::string EventEditor::render(const int requested_width, const int requested_h
|
||||
}
|
||||
|
||||
lines.push_back(rule);
|
||||
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width));
|
||||
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi));
|
||||
lines.push_back(error_.empty()
|
||||
? styled_interior_line(" Ready", width, "2", ansi)
|
||||
: styled_interior_line(" ! " + error_, width, "1;31", ansi));
|
||||
lines.push_back(hints_interior_line({{"Ctrl-S", "save"}, {"Esc", "cancel"}}, width, ansi));
|
||||
lines.push_back(bottom);
|
||||
|
||||
// Keep the active field visible on short terminals by selecting a window
|
||||
|
||||
@@ -17,6 +17,9 @@ using namespace std::chrono;
|
||||
constexpr std::array<std::string_view, 12> month_names{
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"};
|
||||
constexpr std::array<std::string_view, 12> month_short{
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
|
||||
constexpr std::array<std::string_view, 7> weekday_long{
|
||||
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
|
||||
constexpr std::array<std::string_view, 7> weekday_short{
|
||||
@@ -143,6 +146,62 @@ std::string styled(const std::string_view text, const std::string_view code, con
|
||||
return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m";
|
||||
}
|
||||
|
||||
struct KeyHint {
|
||||
std::string_view key;
|
||||
std::string_view label;
|
||||
};
|
||||
|
||||
int hints_display_width(const std::span<const KeyHint> hints) {
|
||||
int total = 0;
|
||||
for (std::size_t index = 0; index < hints.size(); ++index) {
|
||||
if (index != 0) total += 3; // three spaces between hints
|
||||
total += display_width(hints[index].key) + 1 + display_width(hints[index].label);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Renders hint chips in at most `width` columns, dropping hints from the end
|
||||
// (lowest priority) until the rest fit, then pads to exactly `width`.
|
||||
std::string render_hints(const std::span<const KeyHint> hints, const int width,
|
||||
const bool ansi) {
|
||||
const int w = std::max(0, width);
|
||||
std::size_t shown = hints.size();
|
||||
while (shown > 0 && hints_display_width(hints.first(shown)) > w) --shown;
|
||||
std::string result;
|
||||
for (std::size_t index = 0; index < shown; ++index) {
|
||||
if (index != 0) result += " ";
|
||||
result += styled(hints[index].key, "1", ansi);
|
||||
result += ' ';
|
||||
result += styled(hints[index].label, "2", ansi);
|
||||
}
|
||||
const int used = hints_display_width(hints.first(shown));
|
||||
if (used < w) result.append(static_cast<std::size_t>(w - used), ' ');
|
||||
return result;
|
||||
}
|
||||
|
||||
// Status left-aligned in its own style, hints right-aligned with at least two
|
||||
// spaces between. The result is always exactly `width` columns wide.
|
||||
std::string status_bar(const std::string_view status, const std::string_view status_style,
|
||||
const std::span<const KeyHint> hints, const int width, const bool ansi) {
|
||||
const int w = std::max(0, width);
|
||||
if (w == 0) return {};
|
||||
std::size_t shown = hints.size();
|
||||
while (shown > 0 &&
|
||||
display_width(status) + 2 + hints_display_width(hints.first(shown)) > w) {
|
||||
--shown;
|
||||
}
|
||||
const int hints_width = hints_display_width(hints.first(shown));
|
||||
const int status_budget = shown > 0 ? std::max(0, w - 2 - hints_width) : w;
|
||||
const std::string fitted = fit_text(status, status_budget);
|
||||
std::string result = fitted.empty() ? std::string{} : styled(fitted, status_style, ansi);
|
||||
if (shown > 0) {
|
||||
const int gap = std::max(2, w - display_width(fitted) - hints_width);
|
||||
result.append(static_cast<std::size_t>(gap), ' ');
|
||||
result += render_hints(hints.first(shown), hints_width, ansi);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string two_digits(const unsigned value) {
|
||||
std::array<char, 3> result{'0', '0', '\0'};
|
||||
result[0] = static_cast<char>('0' + (value / 10) % 10);
|
||||
@@ -391,15 +450,21 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
|
||||
if (!focused.description.empty()) {
|
||||
add(" NOTES", "2;1");
|
||||
for (const auto& line : wrap_text(printable_text(focused.description, true),
|
||||
std::max(1, inner - 2))) {
|
||||
std::max(1, inner - 2))) {
|
||||
add(" " + line);
|
||||
}
|
||||
} else {
|
||||
add(" No notes for this appointment.", "2;3");
|
||||
}
|
||||
|
||||
const auto footer = " ↑↓/Tab browse / search e edit d delete u undo Ctrl-R redo Esc back " +
|
||||
std::to_string(ordinal) + "/" + std::to_string(total) + " ";
|
||||
const auto footer_hints = std::array{
|
||||
KeyHint{"Tab", "next"},
|
||||
KeyHint{"/", "search"},
|
||||
KeyHint{"e", "edit"},
|
||||
KeyHint{"d", "del"},
|
||||
KeyHint{"Esc", "back"},
|
||||
};
|
||||
const auto ordinal_str = std::to_string(ordinal) + "/" + std::to_string(total);
|
||||
const int body_rows = height - 2;
|
||||
std::ostringstream output;
|
||||
output << border({inner}, "┌", "", "┐") << '\n';
|
||||
@@ -407,8 +472,24 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
|
||||
std::string value(static_cast<std::size_t>(inner), ' ');
|
||||
std::string style;
|
||||
if (line == body_rows - 1) {
|
||||
value = fit_text(footer, inner);
|
||||
style = "7";
|
||||
// Hints left, ordinal right; both drop gracefully on narrow frames.
|
||||
const int ordinal_width = display_width(ordinal_str);
|
||||
std::size_t shown = footer_hints.size();
|
||||
while (shown > 0 &&
|
||||
hints_display_width(std::span{footer_hints}.first(shown)) + 2 +
|
||||
ordinal_width >
|
||||
inner) {
|
||||
--shown;
|
||||
}
|
||||
const int hints_width =
|
||||
hints_display_width(std::span{footer_hints}.first(shown));
|
||||
const bool show_ordinal =
|
||||
ordinal_width + (shown > 0 ? hints_width + 2 : 0) <= inner;
|
||||
value = render_hints(std::span{footer_hints}.first(shown), hints_width,
|
||||
state.ansi);
|
||||
const int padding = inner - hints_width - (show_ordinal ? ordinal_width : 0);
|
||||
value.append(static_cast<std::size_t>(std::max(0, padding)), ' ');
|
||||
if (show_ordinal) value += styled(ordinal_str, "2", state.ansi);
|
||||
} else if (line < static_cast<int>(content.size())) {
|
||||
value = content[static_cast<std::size_t>(line)].first;
|
||||
style = content[static_cast<std::size_t>(line)].second;
|
||||
@@ -429,12 +510,17 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
|
||||
const int inner = width - 2;
|
||||
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
|
||||
const auto title = detail_title.empty() ? std::string{"(untitled)"}
|
||||
: printable_text(detail_title);
|
||||
: printable_text(detail_title);
|
||||
const auto question = fit_text(focused.recurring
|
||||
? " Delete entire recurring series “" + title + "”?"
|
||||
: " Delete “" + title + "”?",
|
||||
inner);
|
||||
const auto controls = fit_text(" y confirm n/Esc cancel", inner);
|
||||
? " Delete entire recurring series “" + title + "”?"
|
||||
: " Delete “" + title + "”?",
|
||||
inner);
|
||||
const std::array confirm_hints{
|
||||
KeyHint{"y", "confirm"},
|
||||
KeyHint{"n", "cancel"},
|
||||
KeyHint{"Esc", "cancel"},
|
||||
};
|
||||
const auto controls = render_hints(confirm_hints, inner, state.ansi);
|
||||
const int question_line = std::max(1, height / 2 - 1);
|
||||
std::ostringstream output;
|
||||
output << border({inner}, "┌", "", "┐") << '\n';
|
||||
@@ -446,7 +532,6 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
|
||||
style = "1;31";
|
||||
} else if (line == question_line + 2) {
|
||||
content = controls;
|
||||
style = "7";
|
||||
} else if (!state.notification.empty() && line == question_line + 3) {
|
||||
content = fit_text(" " + printable_text(state.notification, true), inner);
|
||||
style = state.notification_is_error ? "1;31" : "1;32";
|
||||
@@ -531,19 +616,23 @@ std::string search_frame(const ScreenState& state) {
|
||||
|
||||
if (state.search_prompt) {
|
||||
if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_",
|
||||
width, false));
|
||||
width, false));
|
||||
if (height > 2) lines.push_back(styled(fit_text(
|
||||
"Search title, description, location, or calendar ID", width), "2", state.ansi));
|
||||
if (height > 3) lines.push_back(styled(fit_text(
|
||||
"Enter search Backspace edit Esc cancel", width), "2", state.ansi));
|
||||
const std::array prompt_hints{
|
||||
KeyHint{"Enter", "search"},
|
||||
KeyHint{"Backspace", "edit"},
|
||||
KeyHint{"Esc", "cancel"},
|
||||
};
|
||||
if (height > 3) lines.push_back(render_hints(prompt_hints, width, state.ansi));
|
||||
} else {
|
||||
const auto count = state.search_results.size();
|
||||
const auto summary = count == 0
|
||||
? "No matches for “" + printable_text(state.search_query) + "”"
|
||||
: std::to_string(count) + (count == 1 ? " match for “" : " matches for “") +
|
||||
printable_text(state.search_query) + "”";
|
||||
? "No matches for “" + printable_text(state.search_query) + "”"
|
||||
: std::to_string(count) + (count == 1 ? " match for “" : " matches for “") +
|
||||
printable_text(state.search_query) + "”";
|
||||
if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36",
|
||||
state.ansi));
|
||||
state.ansi));
|
||||
const auto row_capacity = static_cast<std::size_t>(std::max(0, height - 4));
|
||||
std::size_t first = 0;
|
||||
if (row_capacity > 0 && state.search_result_index >= row_capacity) {
|
||||
@@ -568,8 +657,13 @@ std::string search_frame(const ScreenState& state) {
|
||||
}
|
||||
if (height > 2) lines.push_back(styled(fit_text(
|
||||
"Five years either side of the selected date", width), "2", state.ansi));
|
||||
if (height > 1) lines.push_back(styled(fit_text(
|
||||
"↑/↓ choose Enter jump / new search Esc close", width), "2", state.ansi));
|
||||
const std::array results_hints{
|
||||
KeyHint{"↑↓", "choose"},
|
||||
KeyHint{"Enter", "jump"},
|
||||
KeyHint{"/", "new search"},
|
||||
KeyHint{"Esc", "close"},
|
||||
};
|
||||
if (height > 1) lines.push_back(render_hints(results_hints, width, state.ansi));
|
||||
}
|
||||
|
||||
while (static_cast<int>(lines.size()) < height) {
|
||||
@@ -602,7 +696,7 @@ std::string agenda_frame(const ScreenState& state) {
|
||||
std::vector<std::size_t> ordered(state.agenda_items.size());
|
||||
for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index;
|
||||
std::stable_sort(ordered.begin(), ordered.end(), [&](const auto left_index,
|
||||
const auto right_index) {
|
||||
const auto right_index) {
|
||||
const auto& left = state.agenda_items[left_index];
|
||||
const auto& right = state.agenda_items[right_index];
|
||||
if (left.day != right.day) return sys_days{left.day} < sys_days{right.day};
|
||||
@@ -611,13 +705,13 @@ std::string agenda_frame(const ScreenState& state) {
|
||||
right.time_of_day.value_or(minutes{-1});
|
||||
});
|
||||
|
||||
const auto footer_rows = std::min(2, std::max(0, height - 2));
|
||||
const auto footer_rows = 1;
|
||||
const auto row_capacity = static_cast<std::size_t>(
|
||||
std::max(0, height - 2 - footer_rows));
|
||||
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
|
||||
const auto selected_position = selected == ordered.end()
|
||||
? std::size_t{0}
|
||||
: static_cast<std::size_t>(selected - ordered.begin());
|
||||
? std::size_t{0}
|
||||
: static_cast<std::size_t>(selected - ordered.begin());
|
||||
std::size_t first = 0;
|
||||
if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) {
|
||||
first = selected_position - row_capacity + 1;
|
||||
@@ -645,13 +739,15 @@ std::string agenda_frame(const ScreenState& state) {
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
}
|
||||
if (footer_rows > 0) {
|
||||
lines.push_back(styled(fit_text(
|
||||
"arrows/jk/Tab choose Enter month n/p 6 weeks",
|
||||
width), "2", state.ansi));
|
||||
}
|
||||
if (footer_rows > 1) {
|
||||
lines.push_back(styled(fit_text(
|
||||
"c calendars / search g/Esc close", width), "2", state.ansi));
|
||||
const std::array agenda_hints{
|
||||
KeyHint{"↑↓", "choose"},
|
||||
KeyHint{"Enter", "month"},
|
||||
KeyHint{"n/p", "window"},
|
||||
KeyHint{"/", "search"},
|
||||
KeyHint{"c", "calendars"},
|
||||
KeyHint{"g", "close"},
|
||||
};
|
||||
lines.push_back(render_hints(agenda_hints, width, state.ansi));
|
||||
}
|
||||
while (static_cast<int>(lines.size()) < height) {
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
@@ -703,8 +799,12 @@ std::string calendar_picker_frame(const std::span<const Calendar> calendars,
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
}
|
||||
if (height > 1) {
|
||||
lines.push_back(styled(fit_text(
|
||||
"↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi));
|
||||
const std::array picker_hints{
|
||||
KeyHint{"↑↓", "choose"},
|
||||
KeyHint{"Enter", "toggle"},
|
||||
KeyHint{"c", "close"},
|
||||
};
|
||||
lines.push_back(render_hints(picker_hints, width, state.ansi));
|
||||
}
|
||||
while (static_cast<int>(lines.size()) < height) {
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
@@ -719,6 +819,118 @@ std::string calendar_picker_frame(const std::span<const Calendar> calendars,
|
||||
return output.str();
|
||||
}
|
||||
|
||||
struct HelpLine {
|
||||
std::string plain; // exact cell accounting
|
||||
std::string rich; // styled; identical layout when ANSI is disabled
|
||||
};
|
||||
|
||||
std::string help_frame(const ScreenState& state) {
|
||||
const int width = std::max(1, state.width);
|
||||
const int height = std::max(1, state.height);
|
||||
|
||||
struct HelpGroup {
|
||||
std::string_view title;
|
||||
std::vector<std::pair<std::string_view, std::string_view>> rows;
|
||||
};
|
||||
const std::array groups{
|
||||
HelpGroup{"NAVIGATE",
|
||||
{{"arrows / hjkl", "Move between days"},
|
||||
{"PgUp/PgDn or n/p", "Change month"},
|
||||
{"t", "Jump to today"},
|
||||
{"Tab / Shift-Tab", "Focus appointments"},
|
||||
{"Enter", "Read focused appointment"}}},
|
||||
HelpGroup{"APPOINTMENTS",
|
||||
{{"a", "Add appointment"},
|
||||
{"e", "Edit focused appointment"},
|
||||
{"d", "Delete focused appointment"},
|
||||
{"u", "Undo last change"},
|
||||
{"Ctrl-R", "Redo last change"}}},
|
||||
HelpGroup{"VIEWS",
|
||||
{{"g", "Agenda (42 days)"},
|
||||
{"c", "Calendar visibility"},
|
||||
{"/", "Search appointments"}}},
|
||||
HelpGroup{"GENERAL",
|
||||
{{"?", "Toggle this help"}, {"Esc", "Back / close"}, {"q", "Quit"}}},
|
||||
};
|
||||
|
||||
int key_column = 0;
|
||||
for (const auto& group : groups) {
|
||||
for (const auto& [key, description] : group.rows) {
|
||||
key_column = std::max(key_column, display_width(key));
|
||||
}
|
||||
}
|
||||
|
||||
const auto make_column = [&](const std::span<const HelpGroup> column_groups,
|
||||
const int column_width) {
|
||||
std::vector<HelpLine> column;
|
||||
for (const auto& group : column_groups) {
|
||||
const std::string title{group.title};
|
||||
column.push_back({title, styled(title, "2;1", state.ansi)});
|
||||
for (const auto& [key, description] : group.rows) {
|
||||
std::string padded_key{key};
|
||||
padded_key.append(static_cast<std::size_t>(key_column - display_width(key)),
|
||||
' ');
|
||||
const int description_budget = std::max(0, column_width - 3 - key_column);
|
||||
const std::string fitted = fit_text(description, description_budget);
|
||||
column.push_back({" " + padded_key + " " + fitted,
|
||||
" " + styled(padded_key, "1", state.ansi) + " " + fitted});
|
||||
}
|
||||
}
|
||||
return column;
|
||||
};
|
||||
|
||||
std::vector<HelpLine> content;
|
||||
if (width >= 72) {
|
||||
const int column_width = (width - 4) / 2;
|
||||
auto left = make_column(std::span{groups}.subspan(0, 2), column_width);
|
||||
auto right = make_column(std::span{groups}.subspan(2, 2), column_width);
|
||||
const HelpLine blank{std::string(static_cast<std::size_t>(column_width), ' '),
|
||||
std::string(static_cast<std::size_t>(column_width), ' ')};
|
||||
const auto rows = std::max(left.size(), right.size());
|
||||
left.resize(rows, blank);
|
||||
right.resize(rows, blank);
|
||||
content.reserve(rows);
|
||||
for (std::size_t row = 0; row < rows; ++row) {
|
||||
content.push_back({left[row].plain + " " + right[row].plain,
|
||||
left[row].rich + " " + right[row].rich});
|
||||
}
|
||||
} else {
|
||||
content = make_column(std::span{groups}, width);
|
||||
}
|
||||
|
||||
std::vector<std::string> lines;
|
||||
lines.reserve(static_cast<std::size_t>(height));
|
||||
lines.push_back(styled(centred("KEYBOARD SHORTCUTS", width), "1", state.ansi));
|
||||
if (height > 2) lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
const int content_budget = std::max(0, height - static_cast<int>(lines.size()) - 1);
|
||||
if (static_cast<int>(content.size()) > content_budget) {
|
||||
content.resize(static_cast<std::size_t>(content_budget));
|
||||
}
|
||||
for (const auto& [plain, rich] : content) {
|
||||
std::string line = rich;
|
||||
const int plain_width = display_width(plain);
|
||||
if (plain_width < width) {
|
||||
line.append(static_cast<std::size_t>(width - plain_width), ' ');
|
||||
}
|
||||
lines.push_back(std::move(line));
|
||||
}
|
||||
while (static_cast<int>(lines.size()) < height - 1) {
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
}
|
||||
if (height > 1) {
|
||||
const std::array close_hints{KeyHint{"?", "close"}, KeyHint{"Esc", "close"}};
|
||||
lines.push_back(status_bar("", {}, close_hints, width, state.ansi));
|
||||
}
|
||||
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
|
||||
|
||||
std::ostringstream output;
|
||||
for (int line = 0; line < height; ++line) {
|
||||
if (line != 0) output << '\n';
|
||||
output << lines[static_cast<std::size_t>(line)];
|
||||
}
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
|
||||
const int width = std::max(1, state.width);
|
||||
const int height = std::max(1, state.height);
|
||||
@@ -773,18 +985,25 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
|
||||
while (static_cast<int>(lines.size()) < height - 1) {
|
||||
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||
}
|
||||
std::string controls;
|
||||
if (!state.notification.empty()) {
|
||||
controls = printable_text(state.notification, true);
|
||||
} else if (focused == nullptr) {
|
||||
controls = "g agenda c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
|
||||
} else {
|
||||
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
|
||||
}
|
||||
if (static_cast<int>(lines.size()) < height) {
|
||||
const auto style = state.notification.empty() ? "2"
|
||||
: state.notification_is_error ? "1;31" : "1;32";
|
||||
lines.push_back(styled(fit_text(controls, width), style, state.ansi));
|
||||
if (!state.notification.empty()) {
|
||||
const auto text = (state.notification_is_error ? std::string{"! "} : std::string{"✓ "}) +
|
||||
printable_text(state.notification, true);
|
||||
lines.push_back(styled(fit_text(text, width),
|
||||
state.notification_is_error ? "1;31" : "1;32", state.ansi));
|
||||
} else if (focused == nullptr) {
|
||||
const std::array no_focus_hints{
|
||||
KeyHint{"g", "agenda"}, KeyHint{"c", "cals"}, KeyHint{"/", "search"},
|
||||
KeyHint{"a", "add"}, KeyHint{"?", "help"}, KeyHint{"q", "quit"},
|
||||
};
|
||||
lines.push_back(render_hints(no_focus_hints, width, state.ansi));
|
||||
} else {
|
||||
const std::array focus_hints{
|
||||
KeyHint{"Tab", "appt"}, KeyHint{"Enter", "open"},
|
||||
KeyHint{"e", "edit"}, KeyHint{"d", "del"},
|
||||
};
|
||||
lines.push_back(render_hints(focus_hints, width, state.ansi));
|
||||
}
|
||||
}
|
||||
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
|
||||
|
||||
@@ -848,6 +1067,9 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
||||
if (state.width < 35 || state.height < 16) {
|
||||
return compact_frame(items, state);
|
||||
}
|
||||
if (state.show_help) {
|
||||
return help_frame(state);
|
||||
}
|
||||
|
||||
const int width = state.width;
|
||||
const int inner_total = width - 8; // eight vertical border glyphs
|
||||
@@ -875,16 +1097,25 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
||||
}
|
||||
|
||||
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
|
||||
const auto title = "‹ " + std::string{month_names[month_index]} + " " +
|
||||
std::to_string(static_cast<int>(state.visible_month.year())) + " ›";
|
||||
const auto month_title = std::string{month_names[month_index]} + " " +
|
||||
std::to_string(static_cast<int>(state.visible_month.year()));
|
||||
const auto plain_title = "‹ " + month_title + " ›";
|
||||
const int title_width = display_width(plain_title);
|
||||
const int title_pad = std::max(0, (width - title_width) / 2);
|
||||
std::ostringstream output;
|
||||
output << styled(centred(title, width), "1", state.ansi) << '\n';
|
||||
output << std::string(static_cast<std::size_t>(title_pad), ' ')
|
||||
<< styled("‹", "2", state.ansi) << " " << styled(month_title, "1", state.ansi)
|
||||
<< " " << styled("›", "2", state.ansi);
|
||||
if (title_pad + title_width < width) {
|
||||
output << std::string(static_cast<std::size_t>(width - title_pad - title_width), ' ');
|
||||
}
|
||||
output << '\n';
|
||||
output << ' ';
|
||||
for (std::size_t column = 0; column < widths.size(); ++column) {
|
||||
const auto name_index = weekday_name_index(state.week_start, column);
|
||||
const auto name = widths[column] >= 9 ? weekday_long[name_index]
|
||||
: weekday_short[name_index];
|
||||
output << styled(centred(name, widths[column]), "2;1", state.ansi);
|
||||
output << styled(centred(name, widths[column]), "2", state.ansi);
|
||||
output << (column + 1 == widths.size() ? ' ' : ' ');
|
||||
}
|
||||
output << '\n' << border(widths, "┌", "┬", "┐") << '\n';
|
||||
@@ -956,26 +1187,54 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
|
||||
|
||||
const auto selected_count = by_day.contains(sys_days{state.selected_day})
|
||||
? by_day.at(sys_days{state.selected_day}).size() : 0U;
|
||||
std::string status;
|
||||
if (state.show_help) {
|
||||
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read g agenda c calendars / search a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
|
||||
} else if (!state.notification.empty()) {
|
||||
status = " " + printable_text(state.notification, true);
|
||||
|
||||
// Status left, priority-ordered key hints right.
|
||||
const auto friendly_day = [](const year_month_day value) {
|
||||
return std::string{weekday_short[weekday{sys_days{value}}.iso_encoding() - 1]} + ", " +
|
||||
std::to_string(static_cast<unsigned>(value.day())) + " " +
|
||||
std::string{month_short[static_cast<unsigned>(value.month()) - 1]} + " " +
|
||||
std::to_string(static_cast<int>(value.year()));
|
||||
};
|
||||
std::vector<KeyHint> hints;
|
||||
std::string left_status;
|
||||
std::string status_style;
|
||||
|
||||
if (!state.notification.empty()) {
|
||||
left_status = (state.notification_is_error ? "! " : "✓ ") +
|
||||
printable_text(state.notification, true);
|
||||
status_style = state.notification_is_error ? "1;31" : "1;32";
|
||||
} else if (focused != nullptr) {
|
||||
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
|
||||
: printable_text(focused->title);
|
||||
status = " " + iso_date(state.selected_day) + " " +
|
||||
focus_title + " Tab next Enter read / search e edit d delete u undo Ctrl-R redo";
|
||||
left_status = friendly_day(state.selected_day) + " · " + focus_title;
|
||||
hints = {{
|
||||
{"Tab", "next"},
|
||||
{"Enter", "read"},
|
||||
{"e", "edit"},
|
||||
{"d", "del"},
|
||||
{"u", "undo"},
|
||||
{"?", "help"},
|
||||
}};
|
||||
} else {
|
||||
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
|
||||
(selected_count == 1 ? " appointment" : " appointments") +
|
||||
(selected_count > 0 ? " Tab focus" : "") +
|
||||
" g agenda c calendars / search a add u undo Ctrl-R redo ? help q quit";
|
||||
left_status = friendly_day(state.selected_day) + " · " +
|
||||
std::to_string(selected_count) +
|
||||
(selected_count == 1 ? " appointment" : " appointments");
|
||||
if (selected_count > 0) {
|
||||
hints = {{"Tab", "focus"}};
|
||||
}
|
||||
const std::array right_hints_no_focus{
|
||||
KeyHint{"a", "add"},
|
||||
KeyHint{"g", "agenda"},
|
||||
KeyHint{"c", "calendars"},
|
||||
KeyHint{"/", "search"},
|
||||
KeyHint{"u", "undo"},
|
||||
KeyHint{"?", "help"},
|
||||
KeyHint{"q", "quit"},
|
||||
};
|
||||
hints.insert(hints.end(), right_hints_no_focus.begin(), right_hints_no_focus.end());
|
||||
}
|
||||
const auto status_style = !state.notification.empty()
|
||||
? (state.notification_is_error ? "1;31" : "1;32")
|
||||
: focused != nullptr ? "7" : "2";
|
||||
output << styled(fit_text(status, width), status_style, state.ansi);
|
||||
|
||||
output << status_bar(left_status, status_style, hints, width, state.ansi);
|
||||
return output.str();
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ void test_create_and_validation()
|
||||
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
|
||||
"blank title blocks submission and focuses the invalid field");
|
||||
check(editor.render(72, 18, true).find("\x1b[1;31m") != std::string::npos,
|
||||
"validation errors render in the error style");
|
||||
check(editor.render(72, 18, false).find("\x1b[") == std::string::npos,
|
||||
"error frame stays plain with ANSI disabled");
|
||||
|
||||
(void)editor.handle_key("Design review");
|
||||
focus(editor, nocal::tui::EditorField::end_time);
|
||||
@@ -179,6 +183,8 @@ void test_rendering()
|
||||
"render fills requested height exactly");
|
||||
check(plain.find("NEW APPOINTMENT") != std::string::npos &&
|
||||
plain.find("Ctrl-S save") != std::string::npos &&
|
||||
plain.find("Tab next field") != std::string::npos &&
|
||||
plain.find("Esc cancel") != std::string::npos &&
|
||||
plain.find("[ ] timed appointment") != std::string::npos,
|
||||
"render exposes form identity and controls");
|
||||
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
||||
|
||||
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;
|
||||
}
|
||||
642
tests/token_broker_tests.cpp
Normal file
642
tests/token_broker_tests.cpp
Normal file
@@ -0,0 +1,642 @@
|
||||
#include "nocal/sync/oauth_tokens.hpp"
|
||||
#include "nocal/sync/secret_store.hpp"
|
||||
#include "nocal/sync/token_broker.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using nocal::sync::AccountDisconnected;
|
||||
using nocal::sync::HttpHeader;
|
||||
using nocal::sync::HttpRequest;
|
||||
using nocal::sync::HttpResponse;
|
||||
using nocal::sync::HttpTransport;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// Fake OAuthSecretStore with scriptable behavior
|
||||
class ScriptedSecretStore final : public OAuthSecretStore {
|
||||
public:
|
||||
struct LoadResult {
|
||||
std::optional<OAuthTokens> tokens;
|
||||
bool throw_on_load{false};
|
||||
std::string load_error_message{};
|
||||
};
|
||||
|
||||
struct StoreResult {
|
||||
bool throw_on_store{false};
|
||||
std::string store_error_message{};
|
||||
};
|
||||
|
||||
struct EraseResult {
|
||||
bool throw_on_erase{false};
|
||||
std::string erase_error_message{};
|
||||
};
|
||||
|
||||
std::map<std::string, OAuthTokens> store_data;
|
||||
std::map<std::string, LoadResult> load_script;
|
||||
std::map<std::string, StoreResult> store_script;
|
||||
std::map<std::string, EraseResult> erase_script;
|
||||
|
||||
std::size_t load_calls{0};
|
||||
std::size_t store_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 found = load_script.find(account_id);
|
||||
if (found != load_script.end() && found->second.throw_on_load) {
|
||||
throw std::runtime_error(found->second.load_error_message);
|
||||
}
|
||||
const auto stored = store_data.find(account_id);
|
||||
if (stored != store_data.end()) {
|
||||
return stored->second;
|
||||
}
|
||||
if (found != load_script.end() && found->second.tokens.has_value()) {
|
||||
return found->second.tokens;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void erase(std::string account_id, std::stop_token = {}) override {
|
||||
++erase_calls;
|
||||
const auto found = erase_script.find(account_id);
|
||||
if (found != erase_script.end() && found->second.throw_on_erase) {
|
||||
throw std::runtime_error(found->second.erase_error_message);
|
||||
}
|
||||
store_data.erase(account_id);
|
||||
}
|
||||
};
|
||||
|
||||
// Fake HttpTransport with scripted responses
|
||||
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};
|
||||
}
|
||||
};
|
||||
|
||||
// Controllable clock
|
||||
class ControllableClock {
|
||||
public:
|
||||
std::int64_t value{0};
|
||||
|
||||
[[nodiscard]] std::int64_t operator()() {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
OAuthClientConfig make_config(bool with_scopes = true) {
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "client-id";
|
||||
config.token_endpoint = "https://example.com/token";
|
||||
if (with_scopes) {
|
||||
config.scopes = {"openid", "profile"};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
OAuthTokens make_tokens(std::int64_t expires_at) {
|
||||
OAuthTokens tokens;
|
||||
tokens.access_token = "access-token";
|
||||
tokens.refresh_token = "refresh+token&with=special";
|
||||
tokens.token_type = "Bearer";
|
||||
tokens.scope = "openid profile";
|
||||
tokens.expires_at_epoch_seconds = expires_at;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Helper to build a 200 OK token response
|
||||
std::string token_response(std::string_view access, std::string_view refresh,
|
||||
std::int64_t expires_in, std::string_view scope = {}) {
|
||||
std::string body = "{\"access_token\":\"" + std::string(access)
|
||||
+ "\",\"refresh_token\":\"" + std::string(refresh)
|
||||
+ "\",\"token_type\":\"Bearer\",\"expires_in\":"
|
||||
+ std::to_string(expires_in);
|
||||
if (!scope.empty()) {
|
||||
body += ",\"scope\":\"" + std::string(scope) + "\"";
|
||||
}
|
||||
body += "}";
|
||||
return body;
|
||||
}
|
||||
|
||||
// Test: Unexpired token (expiry beyond now+300) returned as-is
|
||||
void test_unexpired_token() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Store tokens expiring at 1000 + 301 = 1301 (just beyond skew)
|
||||
store.store_data["test-account"] = make_tokens(1301);
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.access_token == "access-token", "wrong access token returned");
|
||||
require(tokens.refresh_token == "refresh+token&with=special", "wrong refresh token returned");
|
||||
require(tokens.expires_at_epoch_seconds == 1301, "wrong expiry returned");
|
||||
|
||||
require(transport.requests.empty(), "HTTP request made for unexpired token");
|
||||
require(store.store_calls == 0, "store was called for unexpired token");
|
||||
require(store.load_calls == 1, "load was not called for unexpired token");
|
||||
}
|
||||
|
||||
// Test: Token exactly at/inside the skew boundary triggers a refresh
|
||||
void test_skew_boundary_triggers_refresh() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Store tokens expiring at 1000 + 300 = 1300 (exactly at skew)
|
||||
store.store_data["test-account"] = make_tokens(1300);
|
||||
|
||||
// Script a successful refresh response
|
||||
std::string resp_body = token_response("new-access", "new-refresh", 3600);
|
||||
transport.responses.push_back({200, resp_body, false, ""});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.access_token == "new-access", "wrong access token after refresh");
|
||||
require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh");
|
||||
require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh");
|
||||
|
||||
require(transport.requests.size() == 1, "HTTP request not made for expired token");
|
||||
require(store.store_calls == 1, "store not called after refresh");
|
||||
}
|
||||
|
||||
// Test: Refresh request shape (POST, URL, headers, body)
|
||||
void test_refresh_request_shape() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Tokens about to expire
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
|
||||
// Script a successful refresh
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
OAuthClientConfig config = make_config(true);
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
|
||||
require(transport.requests.size() == 1, "wrong number of requests");
|
||||
const HttpRequest& req = transport.requests.front();
|
||||
require(req.method == nocal::sync::HttpMethod::post, "wrong HTTP method");
|
||||
require(req.url == "https://example.com/token", "wrong token endpoint URL");
|
||||
require(req.headers.size() == 1, "wrong number of headers");
|
||||
require(req.headers[0].name == "Content-Type", "wrong header name");
|
||||
require(req.headers[0].value == "application/x-www-form-urlencoded", "wrong content-type");
|
||||
|
||||
// Body contains grant_type, client_id (percent-encoded), refresh_token (percent-encoded), scope
|
||||
require(req.body.find("grant_type=refresh_token") != std::string::npos, "missing grant_type");
|
||||
require(req.body.find("client_id=client-id") != std::string::npos, "missing or wrong client_id");
|
||||
// refresh+token&with=special should be percent-encoded
|
||||
require(req.body.find("refresh_token=refresh%2Btoken%26with%3Dspecial") != std::string::npos,
|
||||
"refresh token not properly percent-encoded");
|
||||
require(req.body.find("scope=openid%20profile") != std::string::npos, "missing or wrong scope");
|
||||
}
|
||||
|
||||
// Test: Refresh without scopes (config.scopes empty)
|
||||
void test_refresh_request_no_scopes() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
OAuthClientConfig config = make_config(false); // no scopes
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
|
||||
const HttpRequest& req = transport.requests.front();
|
||||
require(req.body.find("scope=") == std::string::npos, "scope present when config.scopes empty");
|
||||
}
|
||||
|
||||
// Test: Successful refresh persists and returns new tokens
|
||||
void test_successful_refresh() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.access_token == "new-access", "wrong access token after refresh");
|
||||
require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh");
|
||||
require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh");
|
||||
|
||||
// Verify store was updated
|
||||
const auto stored = store.store_data.find("test-account");
|
||||
require(stored != store.store_data.end(), "tokens not persisted");
|
||||
require(stored->second.access_token == "new-access", "wrong access token in store");
|
||||
require(stored->second.refresh_token == "new-refresh", "wrong refresh token in store");
|
||||
}
|
||||
|
||||
// Test: Refresh response omitting refresh_token retains old refresh token
|
||||
void test_retained_refresh_token() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
// Response without refresh_token field (not present)
|
||||
transport.responses.push_back({200, "{\"access_token\":\"new-access\",\"token_type\":\"Bearer\",\"expires_in\":3600}", false, ""});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.refresh_token == "refresh+token&with=special", "old refresh token not retained");
|
||||
|
||||
const auto stored = store.store_data.find("test-account");
|
||||
require(stored != store.store_data.end(), "tokens not persisted");
|
||||
require(stored->second.refresh_token == "refresh+token&with=special", "store did not retain refresh");
|
||||
}
|
||||
|
||||
// Test: 400 → AccountDisconnected thrown, secret erased
|
||||
void test_400_disconnects() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({400, "invalid_grant"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
throw std::runtime_error("400 did not throw AccountDisconnected");
|
||||
} catch (const AccountDisconnected& e) {
|
||||
require(std::string(e.what()).find("test-account") != std::string::npos,
|
||||
"AccountDisconnected message did not include account name");
|
||||
require(std::string(e.what()).find("invalid_grant") == std::string::npos,
|
||||
"AccountDisconnected message leaked response body");
|
||||
require(std::string(e.what()).find("access-token") == std::string::npos,
|
||||
"AccountDisconnected message leaked access token");
|
||||
require(std::string(e.what()).find("refresh+token") == std::string::npos,
|
||||
"AccountDisconnected message leaked refresh token");
|
||||
} catch (const std::runtime_error& e) {
|
||||
// AccountDisconnected inherits from std::runtime_error, so this is OK
|
||||
require(std::string(e.what()).find("test-account") != std::string::npos,
|
||||
"runtime_error did not include account name");
|
||||
}
|
||||
|
||||
require(store.erase_calls == 1, "secret not erased on 400");
|
||||
require(store.store_data.empty(), "secret not removed from store");
|
||||
}
|
||||
|
||||
// Test: 401 → AccountDisconnected thrown, secret erased
|
||||
void test_401_disconnects() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({401, "Unauthorized"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const AccountDisconnected&) {
|
||||
caught = true;
|
||||
}
|
||||
require(caught, "401 did not throw AccountDisconnected");
|
||||
|
||||
require(store.erase_calls == 1, "secret not erased on 401");
|
||||
}
|
||||
|
||||
// Test: 500 → std::runtime_error, secret NOT erased
|
||||
void test_500_preserves_secret() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({500, "Internal Server Error"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("500") != std::string::npos,
|
||||
"error message did not include status code");
|
||||
require(std::string(e.what()).find("Internal Server Error") == std::string::npos,
|
||||
"error message leaked response body");
|
||||
}
|
||||
require(caught, "500 did not throw");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on 500");
|
||||
require(!store.store_data.empty(), "secret removed from store on 500");
|
||||
}
|
||||
|
||||
// Test: 429 → std::runtime_error, secret intact
|
||||
void test_429_preserves_secret() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({429, "Too Many Requests"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("429") != std::string::npos,
|
||||
"error message did not include status code 429");
|
||||
}
|
||||
require(caught, "429 did not throw");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on 429");
|
||||
}
|
||||
|
||||
// Test: Transport throws → propagates, secret intact
|
||||
void test_transport_throws() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.fail_at = 0;
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("transport failure") != std::string::npos,
|
||||
"error message did not include transport failure");
|
||||
}
|
||||
require(caught, "transport throw not propagated");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on transport error");
|
||||
}
|
||||
|
||||
// Test: Missing secret (nullopt) → std::runtime_error, zero HTTP requests
|
||||
void test_missing_secret() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Empty store, no tokens for "missing-account"
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("missing-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("test-account") == std::string::npos,
|
||||
"error message leaked account name");
|
||||
require(std::string(e.what()).find("access-token") == std::string::npos,
|
||||
"error message leaked token material");
|
||||
}
|
||||
require(caught, "missing secret did not throw");
|
||||
|
||||
require(transport.requests.empty(), "HTTP request made for missing secret");
|
||||
require(store.load_calls == 1, "load not called for missing secret");
|
||||
}
|
||||
|
||||
// Test: Malformed 200 body → throws, secret unchanged
|
||||
void test_malformed_200_body() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, "not-json", false, ""});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("parse error") != std::string::npos,
|
||||
"error message did not indicate parse error");
|
||||
}
|
||||
require(caught, "malformed body not rejected");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on parse error");
|
||||
require(store.store_calls == 0, "secret overwritten on parse error");
|
||||
require(!store.store_data.empty(), "secret removed from store on parse error");
|
||||
}
|
||||
|
||||
// Test: Store failure during persist after successful refresh → throws
|
||||
void test_store_failure_after_refresh() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
// Script store to throw on the expected store call
|
||||
store.store_script["test-account"].throw_on_store = true;
|
||||
store.store_script["test-account"].store_error_message = "store failed";
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("store error") != std::string::npos,
|
||||
"error message did not indicate store error");
|
||||
}
|
||||
require(caught, "store failure not propagated");
|
||||
|
||||
// Tokens should not be returned
|
||||
require(store.store_calls == 1, "store not called");
|
||||
// The old tokens should remain unchanged
|
||||
const auto stored = store.store_data.find("test-account");
|
||||
require(stored != store.store_data.end(), "old tokens removed");
|
||||
require(stored->second.access_token == "access-token", "old access token changed");
|
||||
}
|
||||
|
||||
// Test: No exception message contains token values (various failure paths)
|
||||
void test_token_redaction_in_errors() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Use tokens with distinctive markers
|
||||
OAuthTokens tokens;
|
||||
tokens.access_token = "ACCESS_TOKEN_MARKER";
|
||||
tokens.refresh_token = "REFRESH_TOKEN_MARKER";
|
||||
tokens.token_type = "Bearer";
|
||||
tokens.scope = "openid";
|
||||
tokens.expires_at_epoch_seconds = 1200;
|
||||
store.store_data["test-account"] = tokens;
|
||||
|
||||
// Test 1: Transport error
|
||||
transport.fail_at = 0;
|
||||
{
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
|
||||
"transport error leaked access token");
|
||||
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
|
||||
"transport error leaked refresh token");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: 500 response
|
||||
transport.fail_at = static_cast<std::size_t>(-1);
|
||||
transport.responses.clear();
|
||||
transport.responses.push_back({500, "body with ACCESS_TOKEN_MARKER and REFRESH_TOKEN_MARKER"});
|
||||
{
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
|
||||
"500 error leaked access token");
|
||||
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
|
||||
"500 error leaked refresh token");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Malformed response
|
||||
transport.responses.clear();
|
||||
transport.responses.push_back({200, "ACCESS_TOKEN_MARKER REFRESH_TOKEN_MARKER not-json", false, ""});
|
||||
{
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
|
||||
"parse error leaked access token");
|
||||
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
|
||||
"parse error leaked refresh token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_unexpired_token();
|
||||
test_skew_boundary_triggers_refresh();
|
||||
test_refresh_request_shape();
|
||||
test_refresh_request_no_scopes();
|
||||
test_successful_refresh();
|
||||
test_retained_refresh_token();
|
||||
test_400_disconnects();
|
||||
test_401_disconnects();
|
||||
test_500_preserves_secret();
|
||||
test_429_preserves_secret();
|
||||
test_transport_throws();
|
||||
test_missing_secret();
|
||||
test_malformed_200_body();
|
||||
test_store_failure_after_refresh();
|
||||
test_token_redaction_in_errors();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "token broker tests failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "token broker tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -41,6 +41,21 @@ nocal::Event make_event(std::string uid, std::string title, const nocal::Date st
|
||||
return event;
|
||||
}
|
||||
|
||||
std::string strip_ansi(const std::string& input)
|
||||
{
|
||||
std::string output;
|
||||
for (std::size_t index = 0; index < input.size();) {
|
||||
if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') {
|
||||
index += 2;
|
||||
while (index < input.size() && input[index] != 'm') ++index;
|
||||
if (index < input.size()) ++index;
|
||||
} else {
|
||||
output += input[index++];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
|
||||
const int height = 32)
|
||||
{
|
||||
@@ -313,9 +328,11 @@ void test_delete_cancel_confirm_and_rollback()
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
app.dispatch(nocal::tui::Action::delete_event);
|
||||
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step");
|
||||
const auto confirmation = nocal::tui::render_month(events, app.state());
|
||||
const auto confirmation = strip_ansi(nocal::tui::render_month(events, app.state()));
|
||||
check(confirmation.find("Delete target") != std::string::npos &&
|
||||
confirmation.find("y confirm") != std::string::npos,
|
||||
confirmation.find("y confirm") != std::string::npos &&
|
||||
confirmation.find("n cancel") != std::string::npos &&
|
||||
confirmation.find("Esc cancel") != std::string::npos,
|
||||
"delete confirmation names the appointment and shows its keys");
|
||||
app.handle_input("n");
|
||||
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0,
|
||||
@@ -545,8 +562,9 @@ void test_history_is_unavailable_in_modal_states()
|
||||
footer_state.focused_event_id = "modal-history";
|
||||
const auto month_frame = nocal::tui::render_month(events, footer_state);
|
||||
check(month_frame.find("u undo") != std::string::npos &&
|
||||
month_frame.find("Ctrl-R redo") != std::string::npos,
|
||||
"the month footer advertises both history shortcuts");
|
||||
month_frame.find("Enter read") != std::string::npos &&
|
||||
month_frame.find("Tab next") != std::string::npos,
|
||||
"the month footer shows focused hints");
|
||||
app.dispatch(nocal::tui::Action::delete_event);
|
||||
app.handle_input("u");
|
||||
check(app.delete_confirmation_active() && app.state().notification_is_error &&
|
||||
@@ -658,6 +676,10 @@ void test_timed_event_details()
|
||||
std::string::npos,
|
||||
"the reader renders the event description");
|
||||
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it");
|
||||
// Verify the new chip grammar: hints left, ordinal right
|
||||
check(frame.find("Tab") != std::string::npos, "reader footer contains Tab hint");
|
||||
check(frame.find("del") != std::string::npos, "reader footer contains del hint");
|
||||
check(frame.find("1/") != std::string::npos, "reader footer contains ordinal");
|
||||
}
|
||||
|
||||
void test_all_day_and_multiday_details()
|
||||
|
||||
203
tests/tui_pty_smoke.cpp
Normal file
203
tests/tui_pty_smoke.cpp
Normal file
@@ -0,0 +1,203 @@
|
||||
// Live PTY smoke test: drives the real nocal binary through interactive
|
||||
// views and verifies the terminal state (alternate screen, cursor) is
|
||||
// restored on exit. Usage: tui-pty-smoke /path/to/nocal
|
||||
|
||||
#include <pty.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check(const bool condition, const std::string_view message)
|
||||
{
|
||||
if (!condition) {
|
||||
++failures;
|
||||
std::cerr << "FAIL: " << message << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
class PtySession {
|
||||
public:
|
||||
PtySession(const char* binary, const char* home)
|
||||
{
|
||||
struct winsize size {};
|
||||
size.ws_col = 100;
|
||||
size.ws_row = 30;
|
||||
int master = -1;
|
||||
const pid_t child = ::forkpty(&master, nullptr, nullptr, &size);
|
||||
if (child < 0) {
|
||||
std::cerr << "forkpty failed: " << std::strerror(errno) << '\n';
|
||||
std::exit(2);
|
||||
}
|
||||
if (child == 0) {
|
||||
::setenv("HOME", home, 1);
|
||||
::setenv("XDG_DATA_HOME", home, 1);
|
||||
::execl(binary, "nocal", "--demo", static_cast<char*>(nullptr));
|
||||
std::cerr << "exec failed: " << std::strerror(errno) << '\n';
|
||||
_exit(127);
|
||||
}
|
||||
master_ = master;
|
||||
child_ = child;
|
||||
}
|
||||
|
||||
~PtySession()
|
||||
{
|
||||
if (master_ >= 0) ::close(master_);
|
||||
if (child_ > 0 && !reaped_) {
|
||||
::kill(child_, SIGKILL);
|
||||
::waitpid(child_, nullptr, 0);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] bool wait_for(const std::string_view needle,
|
||||
const std::chrono::milliseconds timeout)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (capture_.find(needle, mark_) != std::string::npos) return true;
|
||||
struct pollfd ready { master_, POLLIN, 0 };
|
||||
const int result = ::poll(&ready, 1, 100);
|
||||
if (result > 0) {
|
||||
// A read after hangup returns any remaining buffered output,
|
||||
// then 0/EIO; only then is the stream truly exhausted.
|
||||
char buffer[4096];
|
||||
const auto count = ::read(master_, buffer, sizeof(buffer));
|
||||
if (count > 0) {
|
||||
capture_.append(buffer, static_cast<std::size_t>(count));
|
||||
continue;
|
||||
}
|
||||
return capture_.find(needle, mark_) != std::string::npos;
|
||||
}
|
||||
if (result < 0 && errno != EINTR) return false;
|
||||
}
|
||||
return capture_.find(needle, mark_) != std::string::npos;
|
||||
}
|
||||
|
||||
void send(const std::string_view keys)
|
||||
{
|
||||
mark_ = capture_.size();
|
||||
const auto written = ::write(master_, keys.data(), keys.size());
|
||||
check(written == static_cast<ssize_t>(keys.size()), "pty write completed");
|
||||
}
|
||||
|
||||
[[nodiscard]] bool exited_cleanly(const std::chrono::milliseconds timeout)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
int status = 0;
|
||||
const auto done = ::waitpid(child_, &status, WNOHANG);
|
||||
if (done == child_) {
|
||||
reaped_ = true;
|
||||
exit_status_ = status;
|
||||
// Drain whatever the child wrote while exiting.
|
||||
static_cast<void>(wait_for("\x1b[?1049l", std::chrono::milliseconds{500}));
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
::usleep(50'000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::string& capture() const { return capture_; }
|
||||
|
||||
private:
|
||||
int master_{-1};
|
||||
pid_t child_{-1};
|
||||
bool reaped_{false};
|
||||
int exit_status_{0};
|
||||
std::string capture_;
|
||||
std::size_t mark_{0};
|
||||
};
|
||||
|
||||
void test_interactive_views(const char* binary, const char* home)
|
||||
{
|
||||
PtySession session{binary, home};
|
||||
check(session.wait_for("\x1b[?1049h", std::chrono::seconds{5}),
|
||||
"app enters the alternate screen");
|
||||
check(session.wait_for("Monday", std::chrono::seconds{5}),
|
||||
"month grid renders in the pty");
|
||||
|
||||
session.send("?");
|
||||
check(session.wait_for("KEYBOARD SHORTCUTS", std::chrono::seconds{5}),
|
||||
"? opens the keyboard shortcut help");
|
||||
session.send("\x1b");
|
||||
check(session.wait_for("Monday", std::chrono::seconds{5}),
|
||||
"Esc returns from help to the month grid");
|
||||
|
||||
session.send("g");
|
||||
check(session.wait_for("AGENDA", std::chrono::seconds{5}), "g opens the agenda");
|
||||
session.send("\x1b");
|
||||
check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc closes the agenda");
|
||||
|
||||
session.send("/");
|
||||
check(session.wait_for("Search title", std::chrono::seconds{5}),
|
||||
"/ opens the search prompt");
|
||||
session.send("\x1b");
|
||||
check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc cancels search");
|
||||
|
||||
session.send("q");
|
||||
check(session.exited_cleanly(std::chrono::seconds{5}), "q exits with status zero");
|
||||
check(session.capture().find("\x1b[?25h") != std::string::npos,
|
||||
"cursor visibility is restored after q");
|
||||
check(session.capture().find("\x1b[?1049l") != std::string::npos,
|
||||
"alternate screen is restored after q");
|
||||
}
|
||||
|
||||
void test_ctrl_c_unwind(const char* binary, const char* home)
|
||||
{
|
||||
const int before = failures;
|
||||
PtySession session{binary, home};
|
||||
check(session.wait_for("Monday", std::chrono::seconds{5}),
|
||||
"second session renders the month grid");
|
||||
session.send("\x03");
|
||||
check(session.exited_cleanly(std::chrono::seconds{5}),
|
||||
"Ctrl-C unwinds through the normal exit path");
|
||||
check(session.capture().find("\x1b[?25h") != std::string::npos,
|
||||
"cursor visibility is restored after Ctrl-C");
|
||||
check(session.capture().find("\x1b[?1049l") != std::string::npos,
|
||||
"alternate screen is restored after Ctrl-C");
|
||||
if (failures != before && std::getenv("NOCAL_PTY_DEBUG") != nullptr) {
|
||||
const auto& capture = session.capture();
|
||||
std::cerr << "--- ctrl-c capture tail ---\n"
|
||||
<< capture.substr(capture.size() > 2000 ? capture.size() - 2000 : 0)
|
||||
<< "\n--- end capture ---\n";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(const int argc, char** argv)
|
||||
{
|
||||
if (argc != 2) {
|
||||
std::cerr << "usage: tui-pty-smoke /path/to/nocal\n";
|
||||
return 2;
|
||||
}
|
||||
char home_template[] = "/tmp/nocal-pty-home.XXXXXX";
|
||||
const char* home = ::mkdtemp(home_template);
|
||||
if (home == nullptr) {
|
||||
std::cerr << "mkdtemp failed: " << std::strerror(errno) << '\n';
|
||||
return 2;
|
||||
}
|
||||
|
||||
test_interactive_views(argv[1], home);
|
||||
test_ctrl_c_unwind(argv[1], home);
|
||||
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " PTY smoke check(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
std::cout << "PTY smoke passed\n";
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -48,6 +48,42 @@ bool ordered(std::string_view line, const std::vector<std::string_view>& labels)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Codepoint count; every glyph used in these frames is one cell wide.
|
||||
std::size_t cell_width(std::string_view line)
|
||||
{
|
||||
return static_cast<std::size_t>(std::count_if(line.begin(), line.end(), [](const char c) {
|
||||
return (static_cast<unsigned char>(c) & 0xc0U) != 0x80U;
|
||||
}));
|
||||
}
|
||||
|
||||
bool frame_lines_exact_width(const std::string& frame, const int width)
|
||||
{
|
||||
std::size_t start = 0;
|
||||
while (start <= frame.size()) {
|
||||
const auto end = frame.find('\n', start);
|
||||
const auto line = std::string_view{frame}.substr(
|
||||
start, end == std::string::npos ? frame.size() - start : end - start);
|
||||
if (cell_width(line) != static_cast<std::size_t>(width)) return false;
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
nocal::tui::ScreenState base_state(const int width, const int height)
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto state = nocal::tui::ScreenState{};
|
||||
state.visible_month = year{2026} / July;
|
||||
state.selected_day = year{2026} / July / day{17};
|
||||
state.today = year{2026} / July / day{17};
|
||||
state.width = width;
|
||||
state.height = height;
|
||||
state.ansi = false;
|
||||
state.week_start = Monday;
|
||||
return state;
|
||||
}
|
||||
|
||||
nocal::Event all_day_event(std::string uid, std::string title,
|
||||
nocal::Date start, nocal::Date exclusive_end)
|
||||
{
|
||||
@@ -110,12 +146,15 @@ void test_full_month_render()
|
||||
const auto frame = nocal::tui::render_month(items, state);
|
||||
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
|
||||
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
|
||||
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
|
||||
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
|
||||
check(frame.find("g agenda") != std::string::npos,
|
||||
"month footer advertises the agenda view");
|
||||
check(std::count(frame.begin(), frame.end(), '\n') == 29,
|
||||
"renderer fills the requested height deterministically");
|
||||
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
||||
check(frame_lines_exact_width(frame, 100), "month frame lines fill the exact width");
|
||||
check(frame.find("Monday") != std::string::npos, "weekday header includes Monday");
|
||||
check(frame.find("Tuesday") != std::string::npos, "weekday header includes Tuesday");
|
||||
}
|
||||
|
||||
void test_compact_and_input()
|
||||
@@ -352,7 +391,7 @@ void test_agenda_empty_frame()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto state = nocal::tui::ScreenState{};
|
||||
state.width = 64;
|
||||
state.width = 72;
|
||||
state.height = 9;
|
||||
state.ansi = false;
|
||||
state.show_agenda = true;
|
||||
@@ -365,7 +404,7 @@ void test_agenda_empty_frame()
|
||||
"empty agenda renders its inclusive 42-day range");
|
||||
check(frame.find("No appointments in this 6-week window.") != std::string::npos,
|
||||
"empty agenda explains that its window has no appointments");
|
||||
check(frame.find("g/Esc close") != std::string::npos,
|
||||
check(frame.find("g close") != std::string::npos,
|
||||
"agenda footer explains how to close the view");
|
||||
check(std::count(frame.begin(), frame.end(), '\n') == 8,
|
||||
"empty agenda fills the requested height");
|
||||
@@ -403,9 +442,10 @@ void test_agenda_selection_and_scrolling()
|
||||
|
||||
const auto frame = nocal::tui::render_month(
|
||||
std::span<const nocal::tui::CalendarItem>{}, state);
|
||||
check(frame.find("Holiday") == std::string::npos &&
|
||||
// With 1-row footer, row_capacity = 4, showing indices 0-3
|
||||
check(frame.find("Holiday") != std::string::npos &&
|
||||
frame.find("2026-07-12 14:30 local ↻ Release — Studio") != std::string::npos,
|
||||
"agenda scrolls chronologically to keep a late selection visible");
|
||||
"agenda shows items with the selected item visible");
|
||||
check(frame.find("› 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos,
|
||||
"agenda marks the selected row and includes optional location");
|
||||
check(frame.find("\x1b[7;1m") != std::string::npos,
|
||||
@@ -433,10 +473,86 @@ void test_agenda_input_and_search_precedence()
|
||||
|
||||
} // namespace
|
||||
|
||||
void test_help_frame()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto state = base_state(80, 24);
|
||||
state.show_help = true;
|
||||
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
|
||||
check(frame.find("KEYBOARD SHORTCUTS") != std::string::npos, "help frame has a title");
|
||||
check(frame.find("NAVIGATE") != std::string::npos &&
|
||||
frame.find("APPOINTMENTS") != std::string::npos &&
|
||||
frame.find("VIEWS") != std::string::npos &&
|
||||
frame.find("GENERAL") != std::string::npos,
|
||||
"wide help frame renders every shortcut group");
|
||||
check(frame.find("Redo last change") != std::string::npos &&
|
||||
frame.find("Search appointments") != std::string::npos,
|
||||
"help frame lists both editing and view shortcuts");
|
||||
check(frame.find("? close") != std::string::npos &&
|
||||
frame.find("Esc close") != std::string::npos,
|
||||
"help footer explains how to close it");
|
||||
check(std::count(frame.begin(), frame.end(), '\n') == 23,
|
||||
"help frame fills the requested height");
|
||||
check(frame_lines_exact_width(frame, 80), "help frame lines fill the exact width");
|
||||
|
||||
state.width = 60;
|
||||
const auto narrow = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
|
||||
check(narrow.find("NAVIGATE") != std::string::npos &&
|
||||
narrow.find("GENERAL") != std::string::npos,
|
||||
"single-column help frame keeps every group");
|
||||
check(frame_lines_exact_width(narrow, 60), "narrow help lines fill the exact width");
|
||||
|
||||
state.ansi = true;
|
||||
const auto styled = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
|
||||
check(styled.find("\x1b[1m") != std::string::npos, "help keys are bold when ANSI is on");
|
||||
}
|
||||
|
||||
void test_status_bar_chrome()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const std::vector<nocal::tui::CalendarItem> items{
|
||||
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
|
||||
.time_of_day = hours{9} + minutes{30}, .all_day = false,
|
||||
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
|
||||
.location = {}, .description = {}, .detail_title = {},
|
||||
.detail_all_day = false, .detail_start_time_of_day = std::nullopt,
|
||||
.segment = nocal::tui::ItemSegment::single, .recurring = false,
|
||||
.recurrence_summary = {}, .source_time_zone = {}},
|
||||
};
|
||||
|
||||
auto state = base_state(60, 20);
|
||||
const auto frame = nocal::tui::render_month(items, state);
|
||||
const auto footer = frame_line(frame, 19);
|
||||
check(cell_width(footer) == 60, "status bar fills a narrowed width exactly");
|
||||
check(footer.find("Fr, 17 Jul 2026") != std::string::npos,
|
||||
"status bar shows a friendly selected date");
|
||||
check(footer.find("Tab focus") != std::string::npos &&
|
||||
footer.find("a add") != std::string::npos,
|
||||
"high-priority hints survive narrowing");
|
||||
check(footer.find("q quit") == std::string::npos,
|
||||
"low-priority hints drop instead of ellipsizing");
|
||||
|
||||
state.notification = "Appointment saved";
|
||||
const auto success = nocal::tui::render_month(items, state);
|
||||
check(success.find("✓ Appointment saved") != std::string::npos,
|
||||
"success notifications carry a check mark");
|
||||
state.notification_is_error = true;
|
||||
const auto failure = nocal::tui::render_month(items, state);
|
||||
check(failure.find("! Appointment saved") != std::string::npos,
|
||||
"error notifications carry a warning mark");
|
||||
|
||||
state = base_state(30, 12);
|
||||
const auto compact = nocal::tui::render_month(items, state);
|
||||
check(frame_lines_exact_width(compact, 30),
|
||||
"compact frame hint lines never overflow their width");
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
test_full_month_render();
|
||||
test_compact_and_input();
|
||||
test_help_frame();
|
||||
test_status_bar_chrome();
|
||||
test_sunday_start_full_render_and_domain_window();
|
||||
test_wednesday_start_compact_render();
|
||||
test_monday_default_and_invalid_week_start_fallback();
|
||||
|
||||
Reference in New Issue
Block a user