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.
This commit is contained in:
642
tests/token_broker_tests.cpp
Normal file
642
tests/token_broker_tests.cpp
Normal file
@@ -0,0 +1,642 @@
|
||||
#include "nocal/sync/oauth_tokens.hpp"
|
||||
#include "nocal/sync/secret_store.hpp"
|
||||
#include "nocal/sync/token_broker.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using nocal::sync::AccountDisconnected;
|
||||
using nocal::sync::HttpHeader;
|
||||
using nocal::sync::HttpRequest;
|
||||
using nocal::sync::HttpResponse;
|
||||
using nocal::sync::HttpTransport;
|
||||
using nocal::sync::OAuthClientConfig;
|
||||
using nocal::sync::OAuthSecretStore;
|
||||
using nocal::sync::OAuthTokens;
|
||||
|
||||
void require(bool condition, std::string_view message) {
|
||||
if (!condition) {
|
||||
throw std::runtime_error(std::string(message));
|
||||
}
|
||||
}
|
||||
|
||||
// Fake OAuthSecretStore with scriptable behavior
|
||||
class ScriptedSecretStore final : public OAuthSecretStore {
|
||||
public:
|
||||
struct LoadResult {
|
||||
std::optional<OAuthTokens> tokens;
|
||||
bool throw_on_load{false};
|
||||
std::string load_error_message{};
|
||||
};
|
||||
|
||||
struct StoreResult {
|
||||
bool throw_on_store{false};
|
||||
std::string store_error_message{};
|
||||
};
|
||||
|
||||
struct EraseResult {
|
||||
bool throw_on_erase{false};
|
||||
std::string erase_error_message{};
|
||||
};
|
||||
|
||||
std::map<std::string, OAuthTokens> store_data;
|
||||
std::map<std::string, LoadResult> load_script;
|
||||
std::map<std::string, StoreResult> store_script;
|
||||
std::map<std::string, EraseResult> erase_script;
|
||||
|
||||
std::size_t load_calls{0};
|
||||
std::size_t store_calls{0};
|
||||
std::size_t erase_calls{0};
|
||||
|
||||
void store(std::string account_id, const OAuthTokens& tokens,
|
||||
std::stop_token = {}) override {
|
||||
++store_calls;
|
||||
const auto found = store_script.find(account_id);
|
||||
if (found != store_script.end() && found->second.throw_on_store) {
|
||||
throw std::runtime_error(found->second.store_error_message);
|
||||
}
|
||||
store_data[std::move(account_id)] = tokens;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<OAuthTokens> load(
|
||||
std::string account_id, std::stop_token = {}) override {
|
||||
++load_calls;
|
||||
const auto found = load_script.find(account_id);
|
||||
if (found != load_script.end() && found->second.throw_on_load) {
|
||||
throw std::runtime_error(found->second.load_error_message);
|
||||
}
|
||||
const auto stored = store_data.find(account_id);
|
||||
if (stored != store_data.end()) {
|
||||
return stored->second;
|
||||
}
|
||||
if (found != load_script.end() && found->second.tokens.has_value()) {
|
||||
return found->second.tokens;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void erase(std::string account_id, std::stop_token = {}) override {
|
||||
++erase_calls;
|
||||
const auto found = erase_script.find(account_id);
|
||||
if (found != erase_script.end() && found->second.throw_on_erase) {
|
||||
throw std::runtime_error(found->second.erase_error_message);
|
||||
}
|
||||
store_data.erase(account_id);
|
||||
}
|
||||
};
|
||||
|
||||
// Fake HttpTransport with scripted responses
|
||||
class ScriptedTransport final : public HttpTransport {
|
||||
public:
|
||||
struct ResponseSpec {
|
||||
int status{200};
|
||||
std::string body{};
|
||||
bool throw_on_send{false};
|
||||
std::string send_error_message{};
|
||||
};
|
||||
|
||||
std::vector<HttpRequest> requests;
|
||||
std::vector<ResponseSpec> responses;
|
||||
std::size_t fail_at{static_cast<std::size_t>(-1)};
|
||||
|
||||
HttpResponse send(const HttpRequest& request) override {
|
||||
requests.push_back(request);
|
||||
const std::size_t index = requests.size() - 1;
|
||||
if (index == fail_at) {
|
||||
throw std::runtime_error("transport failure");
|
||||
}
|
||||
if (index >= responses.size()) {
|
||||
return {200, {}, "{}"};
|
||||
}
|
||||
const ResponseSpec& spec = responses[index];
|
||||
if (spec.throw_on_send) {
|
||||
throw std::runtime_error(spec.send_error_message);
|
||||
}
|
||||
return {spec.status, {}, spec.body};
|
||||
}
|
||||
};
|
||||
|
||||
// Controllable clock
|
||||
class ControllableClock {
|
||||
public:
|
||||
std::int64_t value{0};
|
||||
|
||||
[[nodiscard]] std::int64_t operator()() {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
OAuthClientConfig make_config(bool with_scopes = true) {
|
||||
OAuthClientConfig config;
|
||||
config.client_id = "client-id";
|
||||
config.token_endpoint = "https://example.com/token";
|
||||
if (with_scopes) {
|
||||
config.scopes = {"openid", "profile"};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
OAuthTokens make_tokens(std::int64_t expires_at) {
|
||||
OAuthTokens tokens;
|
||||
tokens.access_token = "access-token";
|
||||
tokens.refresh_token = "refresh+token&with=special";
|
||||
tokens.token_type = "Bearer";
|
||||
tokens.scope = "openid profile";
|
||||
tokens.expires_at_epoch_seconds = expires_at;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Helper to build a 200 OK token response
|
||||
std::string token_response(std::string_view access, std::string_view refresh,
|
||||
std::int64_t expires_in, std::string_view scope = {}) {
|
||||
std::string body = "{\"access_token\":\"" + std::string(access)
|
||||
+ "\",\"refresh_token\":\"" + std::string(refresh)
|
||||
+ "\",\"token_type\":\"Bearer\",\"expires_in\":"
|
||||
+ std::to_string(expires_in);
|
||||
if (!scope.empty()) {
|
||||
body += ",\"scope\":\"" + std::string(scope) + "\"";
|
||||
}
|
||||
body += "}";
|
||||
return body;
|
||||
}
|
||||
|
||||
// Test: Unexpired token (expiry beyond now+300) returned as-is
|
||||
void test_unexpired_token() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Store tokens expiring at 1000 + 301 = 1301 (just beyond skew)
|
||||
store.store_data["test-account"] = make_tokens(1301);
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.access_token == "access-token", "wrong access token returned");
|
||||
require(tokens.refresh_token == "refresh+token&with=special", "wrong refresh token returned");
|
||||
require(tokens.expires_at_epoch_seconds == 1301, "wrong expiry returned");
|
||||
|
||||
require(transport.requests.empty(), "HTTP request made for unexpired token");
|
||||
require(store.store_calls == 0, "store was called for unexpired token");
|
||||
require(store.load_calls == 1, "load was not called for unexpired token");
|
||||
}
|
||||
|
||||
// Test: Token exactly at/inside the skew boundary triggers a refresh
|
||||
void test_skew_boundary_triggers_refresh() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Store tokens expiring at 1000 + 300 = 1300 (exactly at skew)
|
||||
store.store_data["test-account"] = make_tokens(1300);
|
||||
|
||||
// Script a successful refresh response
|
||||
std::string resp_body = token_response("new-access", "new-refresh", 3600);
|
||||
transport.responses.push_back({200, resp_body, false, ""});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.access_token == "new-access", "wrong access token after refresh");
|
||||
require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh");
|
||||
require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh");
|
||||
|
||||
require(transport.requests.size() == 1, "HTTP request not made for expired token");
|
||||
require(store.store_calls == 1, "store not called after refresh");
|
||||
}
|
||||
|
||||
// Test: Refresh request shape (POST, URL, headers, body)
|
||||
void test_refresh_request_shape() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Tokens about to expire
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
|
||||
// Script a successful refresh
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
OAuthClientConfig config = make_config(true);
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
|
||||
require(transport.requests.size() == 1, "wrong number of requests");
|
||||
const HttpRequest& req = transport.requests.front();
|
||||
require(req.method == nocal::sync::HttpMethod::post, "wrong HTTP method");
|
||||
require(req.url == "https://example.com/token", "wrong token endpoint URL");
|
||||
require(req.headers.size() == 1, "wrong number of headers");
|
||||
require(req.headers[0].name == "Content-Type", "wrong header name");
|
||||
require(req.headers[0].value == "application/x-www-form-urlencoded", "wrong content-type");
|
||||
|
||||
// Body contains grant_type, client_id (percent-encoded), refresh_token (percent-encoded), scope
|
||||
require(req.body.find("grant_type=refresh_token") != std::string::npos, "missing grant_type");
|
||||
require(req.body.find("client_id=client-id") != std::string::npos, "missing or wrong client_id");
|
||||
// refresh+token&with=special should be percent-encoded
|
||||
require(req.body.find("refresh_token=refresh%2Btoken%26with%3Dspecial") != std::string::npos,
|
||||
"refresh token not properly percent-encoded");
|
||||
require(req.body.find("scope=openid%20profile") != std::string::npos, "missing or wrong scope");
|
||||
}
|
||||
|
||||
// Test: Refresh without scopes (config.scopes empty)
|
||||
void test_refresh_request_no_scopes() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
OAuthClientConfig config = make_config(false); // no scopes
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
|
||||
const HttpRequest& req = transport.requests.front();
|
||||
require(req.body.find("scope=") == std::string::npos, "scope present when config.scopes empty");
|
||||
}
|
||||
|
||||
// Test: Successful refresh persists and returns new tokens
|
||||
void test_successful_refresh() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.access_token == "new-access", "wrong access token after refresh");
|
||||
require(tokens.refresh_token == "new-refresh", "wrong refresh token after refresh");
|
||||
require(tokens.expires_at_epoch_seconds == 4600, "wrong expiry after refresh");
|
||||
|
||||
// Verify store was updated
|
||||
const auto stored = store.store_data.find("test-account");
|
||||
require(stored != store.store_data.end(), "tokens not persisted");
|
||||
require(stored->second.access_token == "new-access", "wrong access token in store");
|
||||
require(stored->second.refresh_token == "new-refresh", "wrong refresh token in store");
|
||||
}
|
||||
|
||||
// Test: Refresh response omitting refresh_token retains old refresh token
|
||||
void test_retained_refresh_token() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
// Response without refresh_token field (not present)
|
||||
transport.responses.push_back({200, "{\"access_token\":\"new-access\",\"token_type\":\"Bearer\",\"expires_in\":3600}", false, ""});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
const OAuthTokens tokens = broker.valid_tokens("test-account");
|
||||
require(tokens.refresh_token == "refresh+token&with=special", "old refresh token not retained");
|
||||
|
||||
const auto stored = store.store_data.find("test-account");
|
||||
require(stored != store.store_data.end(), "tokens not persisted");
|
||||
require(stored->second.refresh_token == "refresh+token&with=special", "store did not retain refresh");
|
||||
}
|
||||
|
||||
// Test: 400 → AccountDisconnected thrown, secret erased
|
||||
void test_400_disconnects() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({400, "invalid_grant"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
throw std::runtime_error("400 did not throw AccountDisconnected");
|
||||
} catch (const AccountDisconnected& e) {
|
||||
require(std::string(e.what()).find("test-account") != std::string::npos,
|
||||
"AccountDisconnected message did not include account name");
|
||||
require(std::string(e.what()).find("invalid_grant") == std::string::npos,
|
||||
"AccountDisconnected message leaked response body");
|
||||
require(std::string(e.what()).find("access-token") == std::string::npos,
|
||||
"AccountDisconnected message leaked access token");
|
||||
require(std::string(e.what()).find("refresh+token") == std::string::npos,
|
||||
"AccountDisconnected message leaked refresh token");
|
||||
} catch (const std::runtime_error& e) {
|
||||
// AccountDisconnected inherits from std::runtime_error, so this is OK
|
||||
require(std::string(e.what()).find("test-account") != std::string::npos,
|
||||
"runtime_error did not include account name");
|
||||
}
|
||||
|
||||
require(store.erase_calls == 1, "secret not erased on 400");
|
||||
require(store.store_data.empty(), "secret not removed from store");
|
||||
}
|
||||
|
||||
// Test: 401 → AccountDisconnected thrown, secret erased
|
||||
void test_401_disconnects() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({401, "Unauthorized"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const AccountDisconnected&) {
|
||||
caught = true;
|
||||
}
|
||||
require(caught, "401 did not throw AccountDisconnected");
|
||||
|
||||
require(store.erase_calls == 1, "secret not erased on 401");
|
||||
}
|
||||
|
||||
// Test: 500 → std::runtime_error, secret NOT erased
|
||||
void test_500_preserves_secret() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({500, "Internal Server Error"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("500") != std::string::npos,
|
||||
"error message did not include status code");
|
||||
require(std::string(e.what()).find("Internal Server Error") == std::string::npos,
|
||||
"error message leaked response body");
|
||||
}
|
||||
require(caught, "500 did not throw");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on 500");
|
||||
require(!store.store_data.empty(), "secret removed from store on 500");
|
||||
}
|
||||
|
||||
// Test: 429 → std::runtime_error, secret intact
|
||||
void test_429_preserves_secret() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({429, "Too Many Requests"});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("429") != std::string::npos,
|
||||
"error message did not include status code 429");
|
||||
}
|
||||
require(caught, "429 did not throw");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on 429");
|
||||
}
|
||||
|
||||
// Test: Transport throws → propagates, secret intact
|
||||
void test_transport_throws() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.fail_at = 0;
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("transport failure") != std::string::npos,
|
||||
"error message did not include transport failure");
|
||||
}
|
||||
require(caught, "transport throw not propagated");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on transport error");
|
||||
}
|
||||
|
||||
// Test: Missing secret (nullopt) → std::runtime_error, zero HTTP requests
|
||||
void test_missing_secret() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Empty store, no tokens for "missing-account"
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("missing-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("test-account") == std::string::npos,
|
||||
"error message leaked account name");
|
||||
require(std::string(e.what()).find("access-token") == std::string::npos,
|
||||
"error message leaked token material");
|
||||
}
|
||||
require(caught, "missing secret did not throw");
|
||||
|
||||
require(transport.requests.empty(), "HTTP request made for missing secret");
|
||||
require(store.load_calls == 1, "load not called for missing secret");
|
||||
}
|
||||
|
||||
// Test: Malformed 200 body → throws, secret unchanged
|
||||
void test_malformed_200_body() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, "not-json", false, ""});
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("parse error") != std::string::npos,
|
||||
"error message did not indicate parse error");
|
||||
}
|
||||
require(caught, "malformed body not rejected");
|
||||
|
||||
require(store.erase_calls == 0, "secret erased on parse error");
|
||||
require(store.store_calls == 0, "secret overwritten on parse error");
|
||||
require(!store.store_data.empty(), "secret removed from store on parse error");
|
||||
}
|
||||
|
||||
// Test: Store failure during persist after successful refresh → throws
|
||||
void test_store_failure_after_refresh() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
store.store_data["test-account"] = make_tokens(1200);
|
||||
transport.responses.push_back({200, token_response("new-access", "new-refresh", 3600)});
|
||||
|
||||
// Script store to throw on the expected store call
|
||||
store.store_script["test-account"].throw_on_store = true;
|
||||
store.store_script["test-account"].store_error_message = "store failed";
|
||||
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
caught = true;
|
||||
require(std::string(e.what()).find("store error") != std::string::npos,
|
||||
"error message did not indicate store error");
|
||||
}
|
||||
require(caught, "store failure not propagated");
|
||||
|
||||
// Tokens should not be returned
|
||||
require(store.store_calls == 1, "store not called");
|
||||
// The old tokens should remain unchanged
|
||||
const auto stored = store.store_data.find("test-account");
|
||||
require(stored != store.store_data.end(), "old tokens removed");
|
||||
require(stored->second.access_token == "access-token", "old access token changed");
|
||||
}
|
||||
|
||||
// Test: No exception message contains token values (various failure paths)
|
||||
void test_token_redaction_in_errors() {
|
||||
ControllableClock clock;
|
||||
clock.value = 1000;
|
||||
ScriptedSecretStore store;
|
||||
ScriptedTransport transport;
|
||||
|
||||
// Use tokens with distinctive markers
|
||||
OAuthTokens tokens;
|
||||
tokens.access_token = "ACCESS_TOKEN_MARKER";
|
||||
tokens.refresh_token = "REFRESH_TOKEN_MARKER";
|
||||
tokens.token_type = "Bearer";
|
||||
tokens.scope = "openid";
|
||||
tokens.expires_at_epoch_seconds = 1200;
|
||||
store.store_data["test-account"] = tokens;
|
||||
|
||||
// Test 1: Transport error
|
||||
transport.fail_at = 0;
|
||||
{
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
|
||||
"transport error leaked access token");
|
||||
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
|
||||
"transport error leaked refresh token");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: 500 response
|
||||
transport.fail_at = static_cast<std::size_t>(-1);
|
||||
transport.responses.clear();
|
||||
transport.responses.push_back({500, "body with ACCESS_TOKEN_MARKER and REFRESH_TOKEN_MARKER"});
|
||||
{
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
|
||||
"500 error leaked access token");
|
||||
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
|
||||
"500 error leaked refresh token");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Malformed response
|
||||
transport.responses.clear();
|
||||
transport.responses.push_back({200, "ACCESS_TOKEN_MARKER REFRESH_TOKEN_MARKER not-json", false, ""});
|
||||
{
|
||||
OAuthClientConfig config = make_config();
|
||||
nocal::sync::OAuthTokenBroker broker(config, transport, store, [&clock]() { return clock(); });
|
||||
try {
|
||||
static_cast<void>(broker.valid_tokens("test-account"));
|
||||
} catch (const std::runtime_error& e) {
|
||||
require(std::string(e.what()).find("ACCESS_TOKEN_MARKER") == std::string::npos,
|
||||
"parse error leaked access token");
|
||||
require(std::string(e.what()).find("REFRESH_TOKEN_MARKER") == std::string::npos,
|
||||
"parse error leaked refresh token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_unexpired_token();
|
||||
test_skew_boundary_triggers_refresh();
|
||||
test_refresh_request_shape();
|
||||
test_refresh_request_no_scopes();
|
||||
test_successful_refresh();
|
||||
test_retained_refresh_token();
|
||||
test_400_disconnects();
|
||||
test_401_disconnects();
|
||||
test_500_preserves_secret();
|
||||
test_429_preserves_secret();
|
||||
test_transport_throws();
|
||||
test_missing_secret();
|
||||
test_malformed_200_body();
|
||||
test_store_failure_after_refresh();
|
||||
test_token_redaction_in_errors();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "token broker tests failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "token broker tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user