Compare commits

...

5 Commits

Author SHA1 Message Date
74c798e7aa feat(sync): add Microsoft account connect 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. Account IDs are hashed from
the remote subject so reconnect is idempotent, and a secret-store
failure leaves the same coherent state as a disconnect. Ships a
placeholder client ID with NOCAL_MICROSOFT_CLIENT_ID override until a
real Entra registration exists.
2026-07-21 18:44:47 +01:00
feec691535 docs: switch coder subagent to deepseek-v4-flash 2026-07-20 23:00:03 +01:00
5affa4d0cd docs: document coder subagent delegation setup 2026-07-20 22:51:28 +01:00
6d9e7b0e3e feat(tui): redesign chrome with unified key hints and help overlay
Introduce one key-hint grammar across every view: bold key, dim label,
priority-ordered hints that drop from the end instead of ellipsizing.
The month footer becomes a single split status bar with a friendly
selected date, the '?' mega-line becomes a real keyboard shortcut
panel, and the editor, agenda, search, picker, detail, and delete
frames share the same chips. Notifications carry check/warning marks.
Adds a live PTY smoke test covering view interaction and terminal
state restoration on q and Ctrl-C exits.
2026-07-20 22:51:28 +01:00
23bcb20ab5 feat(sync): add provider contracts and OAuth token broker
Draft the generic SyncProvider/SyncObserver contracts and implement
OAuthTokenBroker: valid access tokens with a five-minute refresh skew,
rotation persisted before use so a new refresh token is never lost,
400/401 refresh rejections erase the secret and surface
AccountDisconnected, and no credential material in error messages.
2026-07-20 22:50:54 +01:00
22 changed files with 2921 additions and 95 deletions

45
.opencode/agent/coder.md Normal file
View 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.

View File

@@ -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 directly by the lead when delegation would create more coordination cost than
implementation value. 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 ## Delegation protocol
Before dispatching implementation work, the lead must: Before dispatching implementation work, the lead must:

View File

@@ -182,6 +182,10 @@ them; omission is not mislabeled as a remote deletion tombstone. This avoids
beta and undocumented delta routes, at the cost of refetching each secondary beta and undocumented delta routes, at the cost of refetching each secondary
window instead of incrementally updating it. window instead of incrementally updating it.
There is still no account UI/CLI orchestration or live Microsoft credential Account setup begins with `nocal account connect microsoft`: it runs the
integration, and remote writes remain out of scope. Account setup and PKCE browser flow, reads `/me`, upserts the deterministic account row, and
orchestration are next. stores the tokens in the Secret Service keyring as the final connected-state
gate. This build ships a placeholder client ID; live connect requires a real
mobile/desktop Entra registration supplied via `NOCAL_MICROSOFT_CLIENT_ID`.
There is still no account TUI or sync orchestration, and remote writes remain
out of scope.

View File

@@ -288,6 +288,16 @@ This design avoids beta and undocumented per-calendar delta routes. Its
tradeoff is bandwidth: every secondary window is fetched completely on each tradeoff is bandwidth: every secondary window is fetched completely on each
refresh, while the primary calendar remains incremental. refresh, while the primary calendar remains incremental.
There is no account UI or CLI orchestration and no live Microsoft credential Account setup now has a CLI entry point, `nocal account connect microsoft`.
integration yet; tests use injected transports and credentials. Remote writes It composes the desktop OAuth adapters, libcurl transport, token parsing, one
remain out of scope. Account setup and orchestration are next. `/me` identity read, the sync cache, and the Secret Service store in a fixed
order: the account row is upserted first, and the versioned secret payload is
stored last as the connected-state gate. A secret-store failure therefore
leaves the same coherent state as a disconnect, and reconnecting is idempotent
because the local account id is a deterministic hash of the remote subject.
This build carries a placeholder client ID; a real Entra registration is
supplied through the `NOCAL_MICROSOFT_CLIENT_ID` environment variable (a
public client ID, never a secret). There is no account TUI and no sync
orchestration yet; tests use injected transports and credentials. Remote
writes remain out of scope. Generic `SyncProvider` integration and observable
sync status are next.

View File

@@ -93,10 +93,35 @@ Completed secondary-calendar stable-v1 reconciliation:
- Overlapping windows retain events until their last membership is removed - Overlapping windows retain events until their last membership is removed
- No beta or undocumented per-calendar delta dependency - 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: Next provider-boundary slices:
- Account setup and CLI/TUI orchestration with live credential integration - TUI account orchestration and generic `SyncProvider` integration with
- Generic `SyncProvider` integration and observable sync status observable sync status
## 0.3 — CalDAV ## 0.3 — CalDAV
@@ -113,9 +138,10 @@ Next provider-boundary slices:
The Graph read boundary uses stable v1 delta for the primary calendar and The Graph read boundary uses stable v1 delta for the primary calendar and
complete stable-v1 calendar views for secondary calendars; it deliberately complete stable-v1 calendar views for secondary calendars; it deliberately
avoids beta and undocumented per-calendar delta routes. No account UI/CLI avoids beta and undocumented per-calendar delta routes. Account connect exists
orchestration or live Microsoft credential integration exists yet, and remote as a CLI command with live credential integration through Secret Service, but
writes remain out of scope. there is no account TUI or sync orchestration yet, and remote writes remain
out of scope.
## 1.0 — distribution quality ## 1.0 — distribution quality

View 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

View File

@@ -6,6 +6,7 @@
#include <cstdint> #include <cstdint>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <string_view>
namespace nocal::sync { namespace nocal::sync {
@@ -28,6 +29,13 @@ public:
using std::runtime_error::runtime_error; using std::runtime_error::runtime_error;
}; };
// Validated /me identity. Fetched without persisting anything so account
// connect can derive the deterministic local account id before any cache write.
struct MicrosoftGraphIdentity {
std::string remote_subject;
std::string display_name;
};
// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers // Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers
// every calendar returned by /me/calendars. Stable v1.0 event delta is used for // every calendar returned by /me/calendars. Stable v1.0 event delta is used for
// the primary calendar and complete calendarView reconciliation for secondary // the primary calendar and complete calendarView reconciliation for secondary
@@ -36,6 +44,10 @@ class MicrosoftGraphSync {
public: public:
MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache); MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache);
// Requires delegated User.Read. Reads /me and returns the validated
// identity without touching the cache; the access token is not retained.
[[nodiscard]] MicrosoftGraphIdentity fetch_identity(std::string_view access_token) const;
// Requires delegated User.Read. A complete and validated /me response is // Requires delegated User.Read. A complete and validated /me response is
// persisted atomically; failures leave the cached account unchanged. // persisted atomically; failures leave the cached account unchanged.
storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials); storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials);

View 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

View 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

View File

@@ -28,10 +28,12 @@ nocal_sources = files(
'src/storage/sync_cache.cpp', 'src/storage/sync_cache.cpp',
'src/sync/curl_http.cpp', 'src/sync/curl_http.cpp',
'src/sync/desktop_oauth.cpp', 'src/sync/desktop_oauth.cpp',
'src/sync/microsoft_account.cpp',
'src/sync/microsoft_graph.cpp', 'src/sync/microsoft_graph.cpp',
'src/sync/oauth.cpp', 'src/sync/oauth.cpp',
'src/sync/oauth_tokens.cpp', 'src/sync/oauth_tokens.cpp',
'src/sync/secret_store.cpp', 'src/sync/secret_store.cpp',
'src/sync/token_broker.cpp',
'src/tui/Screen.cpp', 'src/tui/Screen.cpp',
'src/tui/EventEditor.cpp', 'src/tui/EventEditor.cpp',
'src/tui/Terminal.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', nocal_executable = executable('nocal', 'src/main.cpp',
dependencies: nocal_dep, install: true) 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', domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('domain', domain_tests) test('domain', domain_tests)
@@ -72,6 +79,12 @@ test('desktop-oauth', desktop_oauth_tests)
oauth_token_tests = executable('oauth-token-tests', oauth_token_tests = executable('oauth-token-tests',
'tests/oauth_token_tests.cpp', dependencies: nocal_dep) 'tests/oauth_token_tests.cpp', dependencies: nocal_dep)
test('oauth-tokens', oauth_token_tests) 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', secret_store_tests = executable('secret-store-tests',
'tests/secret_store_tests.cpp', dependencies: nocal_dep) 'tests/secret_store_tests.cpp', dependencies: nocal_dep)
test('secret-store', shell, test('secret-store', shell,

View File

@@ -1,6 +1,11 @@
#include "nocal/domain/calendar_transfer.hpp" #include "nocal/domain/calendar_transfer.hpp"
#include "nocal/domain/date.hpp" #include "nocal/domain/date.hpp"
#include "nocal/storage/ics_store.hpp" #include "nocal/storage/ics_store.hpp"
#include "nocal/storage/sync_cache.hpp"
#include "nocal/sync/curl_http.hpp"
#include "nocal/sync/desktop_oauth.hpp"
#include "nocal/sync/microsoft_account.hpp"
#include "nocal/sync/secret_store.hpp"
#include "nocal/tui/Screen.hpp" #include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp" #include "nocal/tui/TuiApp.hpp"
@@ -13,6 +18,7 @@
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <system_error>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -45,6 +51,61 @@ std::filesystem::path default_calendar_path()
return "calendar.ics"; return "calendar.ics";
} }
std::filesystem::path default_sync_cache_path()
{
if (const char* data_home = std::getenv("XDG_DATA_HOME");
data_home != nullptr && *data_home != '\0') {
return std::filesystem::path{data_home} / "nocal" / "sync.db";
}
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "sync.db";
}
return "sync.db";
}
int connect_microsoft_account()
{
const std::string client_id = nocal::sync::default_microsoft_client_id();
if (client_id == nocal::sync::microsoft_client_id_placeholder) {
std::cerr <<
"nocal: this build has no registered Microsoft application client ID.\n"
"Register a mobile and desktop public client with redirect URI\n"
"http://localhost/nocal/oauth/callback, then set NOCAL_MICROSOFT_CLIENT_ID.\n";
return EXIT_FAILURE;
}
const std::filesystem::path cache_path = default_sync_cache_path();
std::error_code error;
std::filesystem::create_directories(cache_path.parent_path(), error);
if (error) {
throw std::runtime_error("cannot create " + cache_path.parent_path().string() +
": " + error.message());
}
nocal::storage::SyncCache cache(cache_path);
nocal::sync::CurlHttpTransport transport;
nocal::sync::XdgBrowserLauncher browser;
nocal::sync::LoopbackCallbackReceiver receiver;
nocal::sync::SystemEntropySource entropy;
nocal::sync::LibsecretOAuthSecretStore secrets;
const auto account = nocal::sync::connect_microsoft_account(
nocal::sync::microsoft_oauth_config(client_id), browser, receiver, transport,
entropy, secrets, cache, [] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
});
std::cout << "Connected " << account.display_name << " (" << account.id << ")\n";
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
int 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) void print_help(std::ostream& output)
{ {
output << output <<
@@ -60,6 +121,9 @@ void print_help(std::ostream& output)
" --no-color disable ANSI styling\n" " --no-color disable ANSI styling\n"
" -h, --help show this help\n" " -h, --help show this help\n"
" -V, --version show the version\n\n" " -V, --version show the version\n\n"
"Accounts: nocal account connect microsoft\n"
" links a Microsoft account (browser sign-in, tokens stay in\n"
" the Secret Service keyring) and exits.\n\n"
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n" "Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n" " a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
@@ -313,6 +377,9 @@ int export_calendar(const std::filesystem::path& source_path,
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
try { try {
if (argc >= 2 && std::string_view{argv[1]} == "account") {
return account_command(argc - 1, argv + 1);
}
const auto options = parse_options(argc, argv); const auto options = parse_options(argc, argv);
auto loaded = nocal::storage::IcsStore::load(options.calendar_path); auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
print_warnings(loaded, options.calendar_path); print_warnings(loaded, options.calendar_path);

View 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

View File

@@ -533,19 +533,32 @@ struct DeltaContinuation {
MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache) MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache)
: transport_(transport), cache_(cache) {} : transport_(transport), cache_(cache) {}
MicrosoftGraphIdentity MicrosoftGraphSync::fetch_identity(
std::string_view access_token) const {
try {
const MicrosoftGraphCredentials credentials{"identity", std::string(access_token)};
validate_credentials(credentials);
const nlohmann::json object = parse_json_object(
send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
MicrosoftGraphIdentity identity{required_string(object, "id"),
optional_string(object, "displayName")};
if (identity.display_name.empty()) {
identity.display_name = required_string(object, "userPrincipalName");
}
return identity;
} catch (...) {
throw MicrosoftGraphError("Microsoft Graph identity read failed");
}
}
CachedAccount MicrosoftGraphSync::refresh_account( CachedAccount MicrosoftGraphSync::refresh_account(
const MicrosoftGraphCredentials& credentials) { const MicrosoftGraphCredentials& credentials) {
try { try {
validate_credentials(credentials); validate_credentials(credentials);
const nlohmann::json object = const MicrosoftGraphIdentity identity = fetch_identity(credentials.access_token);
parse_json_object(send_graph(transport_, std::string(graph_v1) + "/me", credentials).body);
const std::string remote_subject = required_string(object, "id");
std::string display_name = optional_string(object, "displayName");
if (display_name.empty()) {
display_name = required_string(object, "userPrincipalName");
}
CachedAccount account{ CachedAccount account{
credentials.account_id, "microsoft-graph", remote_subject, std::move(display_name)}; credentials.account_id, "microsoft-graph", identity.remote_subject,
identity.display_name};
cache_.upsert_account(account); cache_.upsert_account(account);
return account; return account;
} catch (...) { } catch (...) {

147
src/sync/token_broker.cpp Normal file
View 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

View File

@@ -7,6 +7,7 @@
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <ctime> #include <ctime>
#include <initializer_list>
#include <random> #include <random>
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
@@ -253,6 +254,49 @@ std::string horizontal_line(const int width, const std::string_view left,
return result; 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 } // namespace
EventEditor::EventEditor(const Date selected_day) 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" : editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT"; : " NOCAL · NEW APPOINTMENT";
lines.push_back(styled_interior_line(heading, width, "1", ansi)); lines.push_back(styled_interior_line(heading, width, "1", ansi));
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle", lines.push_back(hints_interior_line(
width, "2", ansi)); {{"Tab", "next field"}, {"Shift-Tab", "previous field"}, {"Space", "toggle"}},
width, ansi));
lines.push_back(styled_interior_line( lines.push_back(styled_interior_line(
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty() original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
? " Times shown in source zone: " + original_.time_zone ? " 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(rule);
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width)); lines.push_back(error_.empty()
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi)); ? 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); lines.push_back(bottom);
// Keep the active field visible on short terminals by selecting a window // Keep the active field visible on short terminals by selecting a window

View File

@@ -17,6 +17,9 @@ using namespace std::chrono;
constexpr std::array<std::string_view, 12> month_names{ constexpr std::array<std::string_view, 12> month_names{
"January", "February", "March", "April", "May", "June", "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"}; "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{ constexpr std::array<std::string_view, 7> weekday_long{
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}; "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
constexpr std::array<std::string_view, 7> weekday_short{ 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"; 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::string two_digits(const unsigned value) {
std::array<char, 3> result{'0', '0', '\0'}; std::array<char, 3> result{'0', '0', '\0'};
result[0] = static_cast<char>('0' + (value / 10) % 10); 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()) { if (!focused.description.empty()) {
add(" NOTES", "2;1"); add(" NOTES", "2;1");
for (const auto& line : wrap_text(printable_text(focused.description, true), for (const auto& line : wrap_text(printable_text(focused.description, true),
std::max(1, inner - 2))) { std::max(1, inner - 2))) {
add(" " + line); add(" " + line);
} }
} else { } else {
add(" No notes for this appointment.", "2;3"); 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 " + const auto footer_hints = std::array{
std::to_string(ordinal) + "/" + std::to_string(total) + " "; 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; const int body_rows = height - 2;
std::ostringstream output; std::ostringstream output;
output << border({inner}, "", "", "") << '\n'; 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 value(static_cast<std::size_t>(inner), ' ');
std::string style; std::string style;
if (line == body_rows - 1) { if (line == body_rows - 1) {
value = fit_text(footer, inner); // Hints left, ordinal right; both drop gracefully on narrow frames.
style = "7"; 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())) { } else if (line < static_cast<int>(content.size())) {
value = content[static_cast<std::size_t>(line)].first; value = content[static_cast<std::size_t>(line)].first;
style = content[static_cast<std::size_t>(line)].second; 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 int inner = width - 2;
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title; const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
const auto title = detail_title.empty() ? std::string{"(untitled)"} const auto title = detail_title.empty() ? std::string{"(untitled)"}
: printable_text(detail_title); : printable_text(detail_title);
const auto question = fit_text(focused.recurring const auto question = fit_text(focused.recurring
? " Delete entire recurring series “" + title + "”?" ? " Delete entire recurring series “" + title + "”?"
: " Delete “" + title + "”?", : " Delete “" + title + "”?",
inner); inner);
const auto controls = fit_text(" y confirm n/Esc cancel", 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); const int question_line = std::max(1, height / 2 - 1);
std::ostringstream output; std::ostringstream output;
output << border({inner}, "", "", "") << '\n'; output << border({inner}, "", "", "") << '\n';
@@ -446,7 +532,6 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
style = "1;31"; style = "1;31";
} else if (line == question_line + 2) { } else if (line == question_line + 2) {
content = controls; content = controls;
style = "7";
} else if (!state.notification.empty() && line == question_line + 3) { } else if (!state.notification.empty() && line == question_line + 3) {
content = fit_text(" " + printable_text(state.notification, true), inner); content = fit_text(" " + printable_text(state.notification, true), inner);
style = state.notification_is_error ? "1;31" : "1;32"; 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 (state.search_prompt) {
if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_", 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( if (height > 2) lines.push_back(styled(fit_text(
"Search title, description, location, or calendar ID", width), "2", state.ansi)); "Search title, description, location, or calendar ID", width), "2", state.ansi));
if (height > 3) lines.push_back(styled(fit_text( const std::array prompt_hints{
"Enter search Backspace edit Esc cancel", width), "2", state.ansi)); KeyHint{"Enter", "search"},
KeyHint{"Backspace", "edit"},
KeyHint{"Esc", "cancel"},
};
if (height > 3) lines.push_back(render_hints(prompt_hints, width, state.ansi));
} else { } else {
const auto count = state.search_results.size(); const auto count = state.search_results.size();
const auto summary = count == 0 const auto summary = count == 0
? "No 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 “") + : std::to_string(count) + (count == 1 ? " match for “" : " matches for “") +
printable_text(state.search_query) + ""; printable_text(state.search_query) + "";
if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36", 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)); const auto row_capacity = static_cast<std::size_t>(std::max(0, height - 4));
std::size_t first = 0; std::size_t first = 0;
if (row_capacity > 0 && state.search_result_index >= row_capacity) { 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( if (height > 2) lines.push_back(styled(fit_text(
"Five years either side of the selected date", width), "2", state.ansi)); "Five years either side of the selected date", width), "2", state.ansi));
if (height > 1) lines.push_back(styled(fit_text( const std::array results_hints{
"↑/↓ choose Enter jump / new search Esc close", width), "2", state.ansi)); 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) { 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()); std::vector<std::size_t> ordered(state.agenda_items.size());
for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index; for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index;
std::stable_sort(ordered.begin(), ordered.end(), [&](const auto left_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& left = state.agenda_items[left_index];
const auto& right = state.agenda_items[right_index]; const auto& right = state.agenda_items[right_index];
if (left.day != right.day) return sys_days{left.day} < sys_days{right.day}; 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}); 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>( const auto row_capacity = static_cast<std::size_t>(
std::max(0, height - 2 - footer_rows)); std::max(0, height - 2 - footer_rows));
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index); const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
const auto selected_position = selected == ordered.end() const auto selected_position = selected == ordered.end()
? std::size_t{0} ? std::size_t{0}
: static_cast<std::size_t>(selected - ordered.begin()); : static_cast<std::size_t>(selected - ordered.begin());
std::size_t first = 0; std::size_t first = 0;
if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) { if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) {
first = selected_position - row_capacity + 1; 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), ' '); lines.emplace_back(static_cast<std::size_t>(width), ' ');
} }
if (footer_rows > 0) { if (footer_rows > 0) {
lines.push_back(styled(fit_text( const std::array agenda_hints{
"arrows/jk/Tab choose Enter month n/p 6 weeks", KeyHint{"↑↓", "choose"},
width), "2", state.ansi)); KeyHint{"Enter", "month"},
} KeyHint{"n/p", "window"},
if (footer_rows > 1) { KeyHint{"/", "search"},
lines.push_back(styled(fit_text( KeyHint{"c", "calendars"},
"c calendars / search g/Esc close", width), "2", state.ansi)); KeyHint{"g", "close"},
};
lines.push_back(render_hints(agenda_hints, width, state.ansi));
} }
while (static_cast<int>(lines.size()) < height) { while (static_cast<int>(lines.size()) < height) {
lines.emplace_back(static_cast<std::size_t>(width), ' '); 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), ' '); lines.emplace_back(static_cast<std::size_t>(width), ' ');
} }
if (height > 1) { if (height > 1) {
lines.push_back(styled(fit_text( const std::array picker_hints{
"↑/↓ choose Enter toggle c/Esc close", width), "2", state.ansi)); 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) { while (static_cast<int>(lines.size()) < height) {
lines.emplace_back(static_cast<std::size_t>(width), ' '); 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(); 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) { std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width); const int width = std::max(1, state.width);
const int height = std::max(1, state.height); 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) { while (static_cast<int>(lines.size()) < height - 1) {
lines.emplace_back(static_cast<std::size_t>(width), ' '); 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) { if (static_cast<int>(lines.size()) < height) {
const auto style = state.notification.empty() ? "2" if (!state.notification.empty()) {
: state.notification_is_error ? "1;31" : "1;32"; const auto text = (state.notification_is_error ? std::string{"! "} : std::string{""}) +
lines.push_back(styled(fit_text(controls, width), style, state.ansi)); 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)); 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) { if (state.width < 35 || state.height < 16) {
return compact_frame(items, state); return compact_frame(items, state);
} }
if (state.show_help) {
return help_frame(state);
}
const int width = state.width; const int width = state.width;
const int inner_total = width - 8; // eight vertical border glyphs 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 month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
const auto title = " " + std::string{month_names[month_index]} + " " + const auto month_title = std::string{month_names[month_index]} + " " +
std::to_string(static_cast<int>(state.visible_month.year())) + " "; 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; 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 << ' '; output << ' ';
for (std::size_t column = 0; column < widths.size(); ++column) { for (std::size_t column = 0; column < widths.size(); ++column) {
const auto name_index = weekday_name_index(state.week_start, column); const auto name_index = weekday_name_index(state.week_start, column);
const auto name = widths[column] >= 9 ? weekday_long[name_index] const auto name = widths[column] >= 9 ? weekday_long[name_index]
: weekday_short[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 << (column + 1 == widths.size() ? ' ' : ' ');
} }
output << '\n' << border(widths, "", "", "") << '\n'; 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}) const auto selected_count = by_day.contains(sys_days{state.selected_day})
? by_day.at(sys_days{state.selected_day}).size() : 0U; ? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status;
if (state.show_help) { // Status left, priority-ordered key hints right.
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"; const auto friendly_day = [](const year_month_day value) {
} else if (!state.notification.empty()) { return std::string{weekday_short[weekday{sys_days{value}}.iso_encoding() - 1]} + ", " +
status = " " + printable_text(state.notification, true); 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) { } else if (focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"} const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title); : printable_text(focused->title);
status = " " + iso_date(state.selected_day) + " " + left_status = friendly_day(state.selected_day) + " · " + focus_title;
focus_title + " Tab next Enter read / search e edit d delete u undo Ctrl-R redo"; hints = {{
{"Tab", "next"},
{"Enter", "read"},
{"e", "edit"},
{"d", "del"},
{"u", "undo"},
{"?", "help"},
}};
} else { } else {
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) + left_status = friendly_day(state.selected_day) + " · " +
(selected_count == 1 ? " appointment" : " appointments") + std::to_string(selected_count) +
(selected_count > 0 ? " Tab focus" : "") + (selected_count == 1 ? " appointment" : " appointments");
" g agenda c calendars / search a add u undo Ctrl-R redo ? help q quit"; 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") output << status_bar(left_status, status_style, hints, width, state.ansi);
: focused != nullptr ? "7" : "2";
output << styled(fit_text(status, width), status_style, state.ansi);
return output.str(); return output.str();
} }

View File

@@ -47,6 +47,10 @@ void test_create_and_validation()
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(), editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
"blank title blocks submission and focuses the invalid field"); "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"); (void)editor.handle_key("Design review");
focus(editor, nocal::tui::EditorField::end_time); focus(editor, nocal::tui::EditorField::end_time);
@@ -179,6 +183,8 @@ void test_rendering()
"render fills requested height exactly"); "render fills requested height exactly");
check(plain.find("NEW APPOINTMENT") != std::string::npos && check(plain.find("NEW APPOINTMENT") != std::string::npos &&
plain.find("Ctrl-S save") != 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, plain.find("[ ] timed appointment") != std::string::npos,
"render exposes form identity and controls"); "render exposes form identity and controls");
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled"); check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");

View 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;
}

View 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;
}

View File

@@ -41,6 +41,21 @@ nocal::Event make_event(std::string uid, std::string title, const nocal::Date st
return event; 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, nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
const int height = 32) 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::next_event);
app.dispatch(nocal::tui::Action::delete_event); app.dispatch(nocal::tui::Action::delete_event);
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step"); 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 && 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"); "delete confirmation names the appointment and shows its keys");
app.handle_input("n"); app.handle_input("n");
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0, 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"; footer_state.focused_event_id = "modal-history";
const auto month_frame = nocal::tui::render_month(events, footer_state); const auto month_frame = nocal::tui::render_month(events, footer_state);
check(month_frame.find("u undo") != std::string::npos && check(month_frame.find("u undo") != std::string::npos &&
month_frame.find("Ctrl-R redo") != std::string::npos, month_frame.find("Enter read") != std::string::npos &&
"the month footer advertises both history shortcuts"); month_frame.find("Tab next") != std::string::npos,
"the month footer shows focused hints");
app.dispatch(nocal::tui::Action::delete_event); app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("u"); app.handle_input("u");
check(app.delete_confirmation_active() && app.state().notification_is_error && check(app.delete_confirmation_active() && app.state().notification_is_error &&
@@ -658,6 +676,10 @@ void test_timed_event_details()
std::string::npos, std::string::npos,
"the reader renders the event description"); "the reader renders the event description");
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it"); 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() void test_all_day_and_multiday_details()

203
tests/tui_pty_smoke.cpp Normal file
View 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;
}

View File

@@ -48,6 +48,42 @@ bool ordered(std::string_view line, const std::vector<std::string_view>& labels)
return true; 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::Event all_day_event(std::string uid, std::string title,
nocal::Date start, nocal::Date exclusive_end) 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); 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("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("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, check(frame.find("g agenda") != std::string::npos,
"month footer advertises the agenda view"); "month footer advertises the agenda view");
check(std::count(frame.begin(), frame.end(), '\n') == 29, check(std::count(frame.begin(), frame.end(), '\n') == 29,
"renderer fills the requested height deterministically"); "renderer fills the requested height deterministically");
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled"); 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() void test_compact_and_input()
@@ -352,7 +391,7 @@ void test_agenda_empty_frame()
{ {
using namespace std::chrono; using namespace std::chrono;
auto state = nocal::tui::ScreenState{}; auto state = nocal::tui::ScreenState{};
state.width = 64; state.width = 72;
state.height = 9; state.height = 9;
state.ansi = false; state.ansi = false;
state.show_agenda = true; state.show_agenda = true;
@@ -365,7 +404,7 @@ void test_agenda_empty_frame()
"empty agenda renders its inclusive 42-day range"); "empty agenda renders its inclusive 42-day range");
check(frame.find("No appointments in this 6-week window.") != std::string::npos, check(frame.find("No appointments in this 6-week window.") != std::string::npos,
"empty agenda explains that its window has no appointments"); "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"); "agenda footer explains how to close the view");
check(std::count(frame.begin(), frame.end(), '\n') == 8, check(std::count(frame.begin(), frame.end(), '\n') == 8,
"empty agenda fills the requested height"); "empty agenda fills the requested height");
@@ -403,9 +442,10 @@ void test_agenda_selection_and_scrolling()
const auto frame = nocal::tui::render_month( const auto frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, state); 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, 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, check(frame.find(" 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos,
"agenda marks the selected row and includes optional location"); "agenda marks the selected row and includes optional location");
check(frame.find("\x1b[7;1m") != std::string::npos, check(frame.find("\x1b[7;1m") != std::string::npos,
@@ -433,10 +473,86 @@ void test_agenda_input_and_search_precedence()
} // namespace } // 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() int main()
{ {
test_full_month_render(); test_full_month_render();
test_compact_and_input(); test_compact_and_input();
test_help_frame();
test_status_bar_chrome();
test_sunday_start_full_render_and_domain_window(); test_sunday_start_full_render_and_domain_window();
test_wednesday_start_compact_render(); test_wednesday_start_compact_render();
test_monday_default_and_invalid_week_start_fallback(); test_monday_default_and_invalid_week_start_fallback();