Kiosk: System Name storage, validation, sync, and sent on license validation

Adds local System Name persistence (trimmed, 1-40) in system_kv, an
injectable sync module with conflict/invalid revert and a pending-sync
flag, a background SystemNameSync worker (retries on boot and on a timer),
and api_client support for PUT /licenses/{key}/system-name plus the
additive systemName field/systemNameStatus on license validation. The
license service sends the stored name and applies the status. Covers
validation, pending retry, and conflict revert with a faked HTTP layer.
This commit is contained in:
NeilClack
2026-09-29 09:57:52 -05:00
parent 003a90d810
commit e7df02344b
16 changed files with 812 additions and 13 deletions
+16
View File
@@ -59,6 +59,8 @@ add_executable(radiantwave
src/network/api_client.cpp
src/network/license_service.cpp
src/network/license_status.cpp
src/network/system_name.cpp
src/network/system_name_sync.cpp
src/config/yaml_parser.cpp
src/config/config_manager.cpp
src/db/database_manager.cpp
@@ -193,9 +195,11 @@ target_link_libraries(test_api_client PRIVATE GTest::gtest_main CURL::libcurl SQ
add_executable(test_license_service
tests/test_license_service.cpp
src/network/license_service.cpp
src/network/system_name.cpp
src/network/api_client.cpp
src/network/json.cpp
src/db/database_manager.cpp
src/validation.cpp
)
target_include_directories(test_license_service PRIVATE src)
target_link_libraries(test_license_service PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3)
@@ -205,9 +209,11 @@ add_executable(test_license_status
tests/test_license_status.cpp
src/network/license_status.cpp
src/network/license_service.cpp
src/network/system_name.cpp
src/network/api_client.cpp
src/network/json.cpp
src/db/database_manager.cpp
src/validation.cpp
)
target_include_directories(test_license_status PRIVATE src)
target_link_libraries(test_license_status PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3)
@@ -236,6 +242,16 @@ add_executable(test_settings_menu
target_include_directories(test_settings_menu PRIVATE src)
target_link_libraries(test_settings_menu PRIVATE GTest::gtest_main)
# System Name local state + sync logic (HTTP layer is injected/faked)
add_executable(test_system_name
tests/test_system_name.cpp
src/network/system_name.cpp
src/validation.cpp
src/db/database_manager.cpp
)
target_include_directories(test_system_name PRIVATE src)
target_link_libraries(test_system_name PRIVATE GTest::gtest_main SQLite3::SQLite3)
# TimeZone unit tests
add_executable(test_timezone
tests/test_timezone.cpp
+2
View File
@@ -7,6 +7,7 @@
#include "db/database_manager.h"
#include "network/network_manager.h"
#include "network/license_status.h"
#include "network/system_name_sync.h"
#include "audio/audio_manager.h"
#include "audio/audio_downloader.h"
@@ -19,6 +20,7 @@ struct AppState {
radiantwave::db::DatabaseManager db;
radiantwave::network::NetworkManager network;
radiantwave::license::LicenseStatusTracker licenseTracker;
radiantwave::systemname::SystemNameSync systemNameSync;
radiantwave::audio::AudioManager audio;
radiantwave::audio::AudioDownloader audioDownloader;
bool quitRequested = false;
+16
View File
@@ -12,4 +12,20 @@ constexpr const char* KV_LICENSE_KEY = "license_key";
// outages do not revoke a known-good license.
constexpr const char* KV_LICENSE_LAST_VERIFIED = "license_last_verified";
// The System Name the user typed on the Kiosk (trimmed). A human-readable label
// set only here; never a reference or key.
constexpr const char* KV_SYSTEM_NAME = "system_name";
// The last System Name the server accepted. Used to revert a local change when
// the server reports a conflict or invalid value.
constexpr const char* KV_SYSTEM_NAME_SYNCED = "system_name_synced";
// "1" while the local System Name still needs to be sent to the portal (e.g.
// the license is not validated yet, or a previous sync failed).
constexpr const char* KV_SYSTEM_NAME_PENDING = "system_name_pending_sync";
// The most recent System Name status message to show the user (e.g. a conflict
// message after a background sync). Cleared once consumed/acknowledged.
constexpr const char* KV_SYSTEM_NAME_MESSAGE = "system_name_message";
} // namespace radiantwave::db
+5
View File
@@ -162,6 +162,10 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
// displayed; the result gates access to Play and the Proclamation options.
state->licenseTracker.start(state->db);
// Background System Name sync. Retries a pending name on boot and on a
// timer; license validation also applies the name (see license_service).
state->systemNameSync.start(state->db);
state->config.onChanged(
[state](const radiantwave::config::AppConfig &new_config) {
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d",
@@ -271,6 +275,7 @@ void SDL_AppQuit(void *appstate, SDL_AppResult result) {
auto *state = static_cast<AppState *>(appstate);
if (state) {
state->audioDownloader.shutdown();
state->systemNameSync.shutdown();
state->licenseTracker.shutdown();
state->audio.shutdown();
state->network.shutdown();
+65 -9
View File
@@ -2,6 +2,7 @@
#include "network/json.h"
#include <curl/curl.h>
#include <cstdio>
#include <string>
namespace radiantwave::api {
@@ -26,9 +27,10 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
return size * nmemb;
}
// Shared curl setup + perform. Returns a populated HttpResponse.
// Shared curl setup + perform. Returns a populated HttpResponse. `method` is
// "GET", "POST", or "PUT"; GET sends no body.
HttpResponse performRequest(const std::string& url,
bool post, const std::string& jsonBody) {
const char* method, const std::string& jsonBody) {
HttpResponse response;
CURL* curl = curl_easy_init();
@@ -42,12 +44,17 @@ HttpResponse performRequest(const std::string& url,
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
const bool sendsBody = method != nullptr && std::string(method) != "GET";
struct curl_slist* headers = nullptr;
if (post) {
if (sendsBody) {
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, jsonBody.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(jsonBody.size()));
if (std::string(method) == "POST") {
curl_easy_setopt(curl, CURLOPT_POST, 1L);
} else {
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method);
}
}
if (headers) {
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
@@ -65,16 +72,44 @@ HttpResponse performRequest(const std::string& url,
return response;
}
// Escapes a string for inclusion in a JSON string literal.
std::string jsonEscape(const std::string& input) {
std::string out;
out.reserve(input.size() + 2);
for (unsigned char c : input) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
} else {
out += static_cast<char>(c);
}
}
}
return out;
}
} // anonymous namespace
// ─── HTTP helpers ──────────────────────────────────────────────────────
HttpResponse httpGet(const std::string& path) {
return performRequest(urlFor(path), false, "");
return performRequest(urlFor(path), "GET", "");
}
HttpResponse httpPost(const std::string& path, const std::string& jsonBody) {
return performRequest(urlFor(path), true, jsonBody);
return performRequest(urlFor(path), "POST", jsonBody);
}
HttpResponse httpPut(const std::string& path, const std::string& jsonBody) {
return performRequest(urlFor(path), "PUT", jsonBody);
}
// ─── GET /frequencies ──────────────────────────────────────────────────
@@ -177,6 +212,7 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) {
out.key = root.stringField("key");
out.email_address = root.stringField("email_address");
out.system_name_status = root.stringField("systemNameStatus");
// Any other fields (e.g. the legacy `subscription_level`) are ignored. A
// successful validation itself is the "subscribed" flag; the kiosk treats
@@ -185,11 +221,17 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) {
return !out.key.empty();
}
LicenseResult validateLicense(const std::string& key, const std::string& email) {
LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& systemName) {
LicenseResult result;
const std::string jsonBody =
"{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}";
std::string jsonBody = "{\"license_key\":\"" + jsonEscape(key) +
"\",\"email_address\":\"" + jsonEscape(email) + "\"";
if (!systemName.empty()) {
jsonBody += ",\"systemName\":\"" + jsonEscape(systemName) + "\"";
}
jsonBody += "}";
HttpResponse response = httpPost(PATH_LICENSE, jsonBody);
result.status = response.status;
@@ -327,4 +369,18 @@ AccountResult createAccount(const std::string& email, const std::string& name) {
return result;
}
// ─── PUT /licenses/{key}/system-name ───────────────────────────────────
PutNameResult putSystemName(const std::string& key, const std::string& name) {
PutNameResult result;
const std::string path = "/licenses/" + urlEncode(key) + "/system-name";
const std::string jsonBody = "{\"systemName\":\"" + jsonEscape(name) + "\"}";
HttpResponse response = httpPut(path, jsonBody);
result.status = static_cast<int>(response.status);
result.transport_ok = response.transportOk;
return result;
}
} // namespace radiantwave::api
+25 -2
View File
@@ -26,9 +26,10 @@ struct HttpResponse {
bool transportOk = false; // true when the request completed without a transport error
};
// Performs a GET/POST against `path` (relative to API_BASE_URL).
// Performs a GET/POST/PUT against `path` (relative to API_BASE_URL).
HttpResponse httpGet(const std::string& path);
HttpResponse httpPost(const std::string& path, const std::string& jsonBody);
HttpResponse httpPut(const std::string& path, const std::string& jsonBody);
// ─── GET /frequencies ──────────────────────────────────────────────────
@@ -79,6 +80,10 @@ bool fetchColorPalettes(std::vector<ColorPalette>& out);
struct LicenseInfo {
std::string key;
std::string email_address;
// Advisory System Name status returned by the portal: one of "ok",
// "unchanged", "conflict", "invalid", or empty when the portal did not
// report one (e.g. older portal).
std::string system_name_status;
};
// Parse the 200 JSON payload returned by the license endpoint.
@@ -95,7 +100,25 @@ struct LicenseResult {
// Validate a license key / email pair against the portal. Never throws; failures
// (network, HTTP error status) are reported through the returned LicenseResult.
// A 2xx response means the license is valid and the subscription is active.
LicenseResult validateLicense(const std::string& key, const std::string& email);
// When `systemName` is non-empty it is sent as the additive `systemName` field
// (the Kiosk is the only writer); the response's `systemNameStatus` is advisory.
LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& systemName = "");
// ─── PUT /licenses/{key}/system-name ───────────────────────────────────
// Result of a System Name PUT. `status` is the HTTP status (0 on transport
// failure); `transport_ok` is false when the request never completed.
struct PutNameResult {
int status = 0;
bool transport_ok = false;
bool ok() const { return status >= 200 && status < 300; }
};
// Set the System Name for a License Key. 200 on success, 400 invalid_name,
// 404 unknown_license, 409 name_taken. Never throws.
PutNameResult putSystemName(const std::string& key, const std::string& name);
// ─── POST /automations/create/account ──────────────────────────────────
+23 -2
View File
@@ -1,6 +1,7 @@
#include "network/license_service.h"
#include "db/db_keys.h"
#include "network/api_client.h"
#include "network/system_name.h"
#include <chrono>
#include <cstdlib>
@@ -18,6 +19,18 @@ long long nowEpochSeconds() {
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
std::string* outErrorMessage) {
return validateStoredLicenseWith(
db,
[](const std::string& key, const std::string& email,
const std::string& systemName) {
return radiantwave::api::validateLicense(key, email, systemName);
},
outErrorMessage);
}
Result validateStoredLicenseWith(radiantwave::db::DatabaseManager& db,
const ValidateFn& validate,
std::string* outErrorMessage) {
if (outErrorMessage) outErrorMessage->clear();
const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
@@ -26,8 +39,10 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
return Result::NotConfigured;
}
const radiantwave::api::LicenseResult result =
radiantwave::api::validateLicense(key, email);
// The Kiosk is the only writer of the System Name; send the locally stored
// name (possibly empty) so the portal can confirm or reject it.
const std::string systemName = radiantwave::systemname::storedName(db);
const radiantwave::api::LicenseResult result = validate(key, email, systemName);
if (result.status == 0) {
if (outErrorMessage) *outErrorMessage = "network error";
@@ -42,6 +57,12 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
// and the subscription is active. Only the "last verified" stamp is stored
// (it drives the offline grace window).
markLicenseVerified(db);
// Apply the advisory System Name status. A conflict/invalid reverts the
// local name and records a message the UI can surface.
if (!result.info.system_name_status.empty()) {
radiantwave::systemname::applyServerStatus(db, result.info.system_name_status);
}
return Result::Ok;
}
+12
View File
@@ -1,6 +1,8 @@
#pragma once
#include "db/database_manager.h"
#include "network/api_client.h"
#include <functional>
#include <string>
namespace radiantwave::license {
@@ -28,6 +30,16 @@ constexpr long long GRACE_PERIOD_SECONDS = 7LL * 24 * 60 * 60;
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
std::string* outErrorMessage = nullptr);
// Injectable core of validateStoredLicense: performs the given validation call
// instead of the live portal. On success it stamps "last verified" and applies
// any advisory `systemNameStatus` to the local System Name state.
using ValidateFn = std::function<api::LicenseResult(
const std::string& key, const std::string& email, const std::string& systemName)>;
Result validateStoredLicenseWith(radiantwave::db::DatabaseManager& db,
const ValidateFn& validate,
std::string* outErrorMessage = nullptr);
// Records the current time as the "last verified" stamp of the license. Called
// after every successful server validation so the offline grace window starts
// (or is extended) from the most recent confirmation.
+123
View File
@@ -0,0 +1,123 @@
#include "network/system_name.h"
#include "db/db_keys.h"
#include "validation.h"
namespace radiantwave::systemname {
namespace {
void revertToSynced(db::DatabaseManager& db) {
const std::string synced = db.getKV(db::KV_SYSTEM_NAME_SYNCED);
if (synced.empty()) {
db.deleteKV(db::KV_SYSTEM_NAME);
} else {
db.setKV(db::KV_SYSTEM_NAME, synced);
}
}
} // namespace
Status parseStatus(const std::string& status) {
if (status == "ok") return Status::Ok;
if (status == "conflict") return Status::Conflict;
if (status == "invalid") return Status::Invalid;
return Status::Unchanged;
}
std::string storedName(const db::DatabaseManager& db) {
return db.getKV(db::KV_SYSTEM_NAME);
}
bool pendingSync(const db::DatabaseManager& db) {
return db.getKV(db::KV_SYSTEM_NAME_PENDING) == "1";
}
bool storeLocalName(db::DatabaseManager& db, const std::string& raw,
std::string* error) {
const std::string trimmed = validation::trim(raw);
if (!validation::isValidSystemName(trimmed)) {
if (error) *error = INVALID_MESSAGE;
return false;
}
db.setKV(db::KV_SYSTEM_NAME, trimmed);
db.setKV(db::KV_SYSTEM_NAME_PENDING, "1");
db.setKV(db::KV_SYSTEM_NAME_MESSAGE, "");
return true;
}
Status applyServerStatus(db::DatabaseManager& db, const std::string& serverStatus) {
const Status status = parseStatus(serverStatus);
switch (status) {
case Status::Ok:
db.setKV(db::KV_SYSTEM_NAME_SYNCED, db.getKV(db::KV_SYSTEM_NAME));
db.setKV(db::KV_SYSTEM_NAME_PENDING, "0");
db.setKV(db::KV_SYSTEM_NAME_MESSAGE, "");
return Status::Ok;
case Status::Unchanged:
if (!db.getKV(db::KV_SYSTEM_NAME).empty()) {
db.setKV(db::KV_SYSTEM_NAME_SYNCED, db.getKV(db::KV_SYSTEM_NAME));
}
db.setKV(db::KV_SYSTEM_NAME_PENDING, "0");
db.setKV(db::KV_SYSTEM_NAME_MESSAGE, "");
return Status::Unchanged;
case Status::Conflict:
revertToSynced(db);
db.setKV(db::KV_SYSTEM_NAME_PENDING, "0");
db.setKV(db::KV_SYSTEM_NAME_MESSAGE, CONFLICT_MESSAGE);
return Status::Conflict;
case Status::Invalid:
revertToSynced(db);
db.setKV(db::KV_SYSTEM_NAME_PENDING, "0");
db.setKV(db::KV_SYSTEM_NAME_MESSAGE, INVALID_MESSAGE);
return Status::Invalid;
default:
return Status::Unchanged;
}
}
Status syncWithServer(db::DatabaseManager& db, const PutFn& put) {
const std::string key = db.getKV(db::KV_LICENSE_KEY);
if (key.empty()) {
return Status::NotConfigured;
}
const std::string name = db.getKV(db::KV_SYSTEM_NAME);
if (name.empty()) {
// Nothing to sync; a cleared/empty name is never sent.
db.setKV(db::KV_SYSTEM_NAME_PENDING, "0");
return Status::Unchanged;
}
if (!pendingSync(db)) {
return Status::Unchanged;
}
const api::PutNameResult result = put(key, name);
if (!result.transport_ok) {
return Status::NetworkError; // keep pending for a later retry
}
switch (result.status) {
case 200:
return applyServerStatus(db, "ok");
case 400:
return applyServerStatus(db, "invalid");
case 409:
return applyServerStatus(db, "conflict");
case 404:
return Status::UnknownLicense; // keep pending; license not validated
default:
return Status::NetworkError; // 5xx etc: keep pending
}
}
std::string consumeMessage(db::DatabaseManager& db) {
const std::string message = db.getKV(db::KV_SYSTEM_NAME_MESSAGE);
if (!message.empty()) {
db.setKV(db::KV_SYSTEM_NAME_MESSAGE, "");
}
return message;
}
} // namespace radiantwave::systemname
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include "db/database_manager.h"
#include "network/api_client.h"
#include <functional>
#include <string>
namespace radiantwave::systemname {
// Outcome of a System Name operation.
enum class Status {
Ok, // server accepted the name (or it was saved locally)
Unchanged, // no change needed / already in sync
Conflict, // another Kiosk on the account already uses the name
Invalid, // 1-40 character validation failed
NetworkError, // the request never completed; safe to retry
UnknownLicense,// the portal does not know this License Key
NotConfigured, // no License Key stored yet; nothing to sync
};
// Sends a System Name to the portal. Injectable so tests can fake the HTTP
// layer. The production default is `api::putSystemName`.
using PutFn = std::function<api::PutNameResult(const std::string& key,
const std::string& name)>;
// Messages shown to the user. Kept here so the page and the service agree.
constexpr const char* CONFLICT_MESSAGE =
"That name is already used on your account. Choose another.";
constexpr const char* INVALID_MESSAGE =
"Enter a name between 1 and 40 characters.";
// Maps a portal `systemNameStatus` string ("ok"/"unchanged"/"conflict"/
// "invalid") to a Status. Unknown/empty maps to Unchanged.
Status parseStatus(const std::string& status);
// The locally stored System Name (may be empty).
std::string storedName(const db::DatabaseManager& db);
// True while the stored name still needs to be sent to the portal.
bool pendingSync(const db::DatabaseManager& db);
// Validates and persists `raw` locally (trimmed), marks it pending sync, and
// clears any stale message. Returns false and sets `error` when invalid. The
// caller is responsible for triggering a sync; the UI is never blocked.
bool storeLocalName(db::DatabaseManager& db, const std::string& raw,
std::string* error = nullptr);
// Applies a portal `systemNameStatus` to local state. On "ok"/"unchanged" the
// name is marked synced; on "conflict"/"invalid" the local name is reverted to
// the last server-accepted value and a user-facing message is stored.
Status applyServerStatus(db::DatabaseManager& db, const std::string& serverStatus);
// If a sync is pending and a License Key is stored, PUTs the name via `put` and
// applies the result. Network failures and 404s leave the pending flag set so
// the caller can retry later.
Status syncWithServer(db::DatabaseManager& db, const PutFn& put);
// Returns the pending user-facing message and clears it (consume-once).
std::string consumeMessage(db::DatabaseManager& db);
} // namespace radiantwave::systemname
+70
View File
@@ -0,0 +1,70 @@
#include "network/system_name_sync.h"
#include "network/api_client.h"
#include <utility>
namespace radiantwave::systemname {
SystemNameSync::SystemNameSync()
: put_([](const std::string& key, const std::string& name) {
return api::putSystemName(key, name);
}) {
}
SystemNameSync::SystemNameSync(PutFn put) : put_(std::move(put)) {
}
SystemNameSync::~SystemNameSync() {
shutdown();
}
void SystemNameSync::start(db::DatabaseManager& db) {
std::lock_guard<std::mutex> lock(mutex_);
if (worker_.joinable()) return;
db_ = &db;
stop_.store(false);
worker_ = std::thread(&SystemNameSync::run, this);
requested_.store(true);
cv_.notify_one();
}
void SystemNameSync::shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
stop_.store(true);
}
cv_.notify_one();
if (worker_.joinable()) {
worker_.join();
}
}
void SystemNameSync::requestSync() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!worker_.joinable()) return;
requested_.store(true);
}
cv_.notify_one();
}
void SystemNameSync::run() {
while (true) {
db::DatabaseManager* db = nullptr;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait_for(lock, RETRY_INTERVAL, [this] {
return stop_.load() || requested_.load();
});
if (stop_.load()) return;
requested_.store(false);
db = db_;
}
if (db && pendingSync(*db)) {
syncWithServer(*db, put_);
}
}
}
} // namespace radiantwave::systemname
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "db/database_manager.h"
#include "network/system_name.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <mutex>
#include <thread>
namespace radiantwave::systemname {
// Background worker that pushes a pending System Name to the portal without
// blocking the UI. It retries on a timer and can be nudged immediately
// (`requestSync`) after a local change. `start()` attempts one sync right away,
// covering the retry-on-boot case; a successful license validation also syncs
// the name (see license_service), covering the retry-on-validation case.
class SystemNameSync {
public:
SystemNameSync();
explicit SystemNameSync(PutFn put);
~SystemNameSync();
SystemNameSync(const SystemNameSync&) = delete;
SystemNameSync& operator=(const SystemNameSync&) = delete;
void start(db::DatabaseManager& db);
void shutdown();
void requestSync();
// Retry interval for the periodic attempt.
static constexpr std::chrono::seconds RETRY_INTERVAL{30};
private:
void run();
PutFn put_;
db::DatabaseManager* db_ = nullptr;
std::thread worker_;
std::mutex mutex_;
std::condition_variable cv_;
std::atomic<bool> stop_{false};
std::atomic<bool> requested_{false};
};
} // namespace radiantwave::systemname
+17
View File
@@ -87,4 +87,21 @@ std::string formatLicenseKey(const std::string& sanitized) {
return out;
}
std::string trim(const std::string& input) {
size_t start = 0;
size_t end = input.size();
while (start < end && std::isspace(static_cast<unsigned char>(input[start]))) {
++start;
}
while (end > start && std::isspace(static_cast<unsigned char>(input[end - 1]))) {
--end;
}
return input.substr(start, end - start);
}
bool isValidSystemName(const std::string& name) {
const std::string trimmed = trim(name);
return !trimmed.empty() && trimmed.size() <= SYSTEM_NAME_MAX_LENGTH;
}
} // namespace radiantwave::validation
+10
View File
@@ -17,4 +17,14 @@ std::string sanitizeLicenseKey(const std::string& input);
// characters (XXXX-XXXX-XXXX-XXXX). Partial input yields partial output.
std::string formatLicenseKey(const std::string& sanitized);
// The maximum length of a System Name (per the API contract).
constexpr size_t SYSTEM_NAME_MAX_LENGTH = 40;
// Removes leading and trailing ASCII whitespace from `input`.
std::string trim(const std::string& input);
// Returns true when `name` (already trimmed) is a valid System Name: 1-40
// characters. Empty or whitespace-only names are invalid (never a clear).
bool isValidSystemName(const std::string& name);
} // namespace radiantwave::validation
+75
View File
@@ -1,5 +1,6 @@
#include "network/license_service.h"
#include "db/db_keys.h"
#include "network/system_name.h"
#include <gtest/gtest.h>
#include <cstdlib>
@@ -9,6 +10,7 @@
using namespace radiantwave::license;
using namespace radiantwave::db;
using namespace radiantwave::systemname;
namespace {
@@ -103,6 +105,79 @@ TEST(LicenseServiceTest, GracePeriodIgnoresCorruptStamp) {
EXPECT_FALSE(isLicenseWithinGracePeriod(tmp.db));
}
// ─── Offline: System Name status handling (faked validation) ────────────
namespace {
radiantwave::api::LicenseResult licenseResult(const std::string& systemNameStatus) {
radiantwave::api::LicenseResult result;
result.status = 200;
result.info.key = "ABCD-EFGH-IJKL-MNOP";
result.info.email_address = "user@example.com";
result.info.system_name_status = systemNameStatus;
return result;
}
} // namespace
TEST(LicenseServiceTest, SendsStoredSystemNameAndMarksSyncedOnOk) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
std::string sentName = "<none>";
const Result result = validateStoredLicenseWith(
tmp.db,
[&](const std::string&, const std::string&, const std::string& systemName) {
sentName = systemName;
return licenseResult("ok");
});
EXPECT_EQ(result, Result::Ok);
EXPECT_EQ(sentName, "Den");
EXPECT_FALSE(pendingSync(tmp.db));
EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_SYNCED), "Den");
}
TEST(LicenseServiceTest, RevertsLocalNameOnConflictStatus) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
const Result result = validateStoredLicenseWith(
tmp.db,
[](const std::string&, const std::string&, const std::string&) {
return licenseResult("conflict");
});
EXPECT_EQ(result, Result::Ok);
EXPECT_EQ(storedName(tmp.db), "Living Room");
EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), CONFLICT_MESSAGE);
}
TEST(LicenseServiceTest, LeavesPendingWhenServerReportsNoNameStatus) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
const Result result = validateStoredLicenseWith(
tmp.db,
[](const std::string&, const std::string&, const std::string&) {
return licenseResult("");
});
EXPECT_EQ(result, Result::Ok);
EXPECT_TRUE(pendingSync(tmp.db));
EXPECT_EQ(storedName(tmp.db), "Den");
}
// ─── Live: full validation + persistence (requires env credentials) ─────
TEST(LicenseServiceLiveTest, ValidatesAndGrantsSubscribedState) {
+244
View File
@@ -0,0 +1,244 @@
#include "network/system_name.h"
#include "db/db_keys.h"
#include "validation.h"
#include <gtest/gtest.h>
#include <filesystem>
#include <string>
using namespace radiantwave::systemname;
using namespace radiantwave::db;
namespace api = radiantwave::api;
namespace {
struct TempDb {
TempDb() {
path = (std::filesystem::temp_directory_path() /
("test_rw_sysname_" + std::to_string(counter++) + ".db"))
.string();
std::filesystem::remove(path);
db.initialize(path);
}
~TempDb() {
db.close();
std::filesystem::remove(path);
}
std::string path;
DatabaseManager db;
static int counter;
};
int TempDb::counter = 0;
api::PutNameResult ok() { return api::PutNameResult{200, true}; }
api::PutNameResult conflict() { return api::PutNameResult{409, true}; }
api::PutNameResult invalid() { return api::PutNameResult{400, true}; }
api::PutNameResult unknown() { return api::PutNameResult{404, true}; }
api::PutNameResult offline() { return api::PutNameResult{0, false}; }
} // namespace
// ─── Validation ────────────────────────────────────────────────────────
TEST(SystemNameValidationTest, TrimAndBounds) {
EXPECT_EQ(radiantwave::validation::trim(" Den "), "Den");
EXPECT_TRUE(radiantwave::validation::isValidSystemName("Den"));
EXPECT_TRUE(radiantwave::validation::isValidSystemName(" Den "));
EXPECT_TRUE(radiantwave::validation::isValidSystemName(std::string(40, 'a')));
EXPECT_FALSE(radiantwave::validation::isValidSystemName(""));
EXPECT_FALSE(radiantwave::validation::isValidSystemName(" "));
EXPECT_FALSE(radiantwave::validation::isValidSystemName(std::string(41, 'a')));
}
// ─── Local storage ─────────────────────────────────────────────────────
TEST(SystemNameLocalTest, StoresTrimmedNameAndMarksPending) {
TempDb tmp;
std::string error;
ASSERT_TRUE(storeLocalName(tmp.db, " Living Room ", &error));
EXPECT_EQ(storedName(tmp.db), "Living Room");
EXPECT_TRUE(pendingSync(tmp.db));
}
TEST(SystemNameLocalTest, RejectsInvalidWithoutStoring) {
TempDb tmp;
std::string error;
EXPECT_FALSE(storeLocalName(tmp.db, " ", &error));
EXPECT_EQ(error, INVALID_MESSAGE);
EXPECT_FALSE(tmp.db.hasKV(KV_SYSTEM_NAME));
EXPECT_FALSE(pendingSync(tmp.db));
EXPECT_FALSE(storeLocalName(tmp.db, std::string(41, 'a')));
EXPECT_FALSE(tmp.db.hasKV(KV_SYSTEM_NAME));
}
// ─── Server status application (K2) ────────────────────────────────────
TEST(SystemNameStatusTest, OkMarksSyncedAndClearsPending) {
TempDb tmp;
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(applyServerStatus(tmp.db, "ok"), Status::Ok);
EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_SYNCED), "Den");
EXPECT_FALSE(pendingSync(tmp.db));
}
TEST(SystemNameStatusTest, ConflictRevertsToSyncedAndRecordsMessage) {
TempDb tmp;
tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(applyServerStatus(tmp.db, "conflict"), Status::Conflict);
EXPECT_EQ(storedName(tmp.db), "Living Room");
EXPECT_FALSE(pendingSync(tmp.db));
EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), CONFLICT_MESSAGE);
}
TEST(SystemNameStatusTest, ConflictWithNoPreviousNameClearsIt) {
TempDb tmp;
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(applyServerStatus(tmp.db, "conflict"), Status::Conflict);
EXPECT_FALSE(tmp.db.hasKV(KV_SYSTEM_NAME));
}
TEST(SystemNameStatusTest, InvalidRevertsAndRecordsMessage) {
TempDb tmp;
tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Den");
tmp.db.setKV(KV_SYSTEM_NAME, "Nope");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(applyServerStatus(tmp.db, "invalid"), Status::Invalid);
EXPECT_EQ(storedName(tmp.db), "Den");
EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), INVALID_MESSAGE);
}
TEST(SystemNameStatusTest, UnchangedClearsPending) {
TempDb tmp;
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(applyServerStatus(tmp.db, "unchanged"), Status::Unchanged);
EXPECT_FALSE(pendingSync(tmp.db));
}
// ─── Background sync (K3) ──────────────────────────────────────────────
TEST(SystemNameSyncTest, NotConfiguredWithoutLicenseKey) {
TempDb tmp;
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
bool called = false;
EXPECT_EQ(syncWithServer(tmp.db, [&](const std::string&, const std::string&) {
called = true;
return ok();
}),
Status::NotConfigured);
EXPECT_FALSE(called);
}
TEST(SystemNameSyncTest, DoesNothingWhenNotPending) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
bool called = false;
EXPECT_EQ(syncWithServer(tmp.db, [&](const std::string&, const std::string&) {
called = true;
return ok();
}),
Status::Unchanged);
EXPECT_FALSE(called);
}
TEST(SystemNameSyncTest, PushesNameAndMarksSynced) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
std::string sentKey;
std::string sentName;
EXPECT_EQ(syncWithServer(tmp.db, [&](const std::string& k, const std::string& n) {
sentKey = k;
sentName = n;
return ok();
}),
Status::Ok);
EXPECT_EQ(sentKey, "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(sentName, "Den");
EXPECT_FALSE(pendingSync(tmp.db));
EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_SYNCED), "Den");
}
TEST(SystemNameSyncTest, ConflictRevertsAndKeepsOldName) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) {
return conflict();
}),
Status::Conflict);
EXPECT_EQ(storedName(tmp.db), "Living Room");
EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), CONFLICT_MESSAGE);
}
TEST(SystemNameSyncTest, InvalidReverts) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room");
tmp.db.setKV(KV_SYSTEM_NAME, "x");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) {
return invalid();
}),
Status::Invalid);
EXPECT_EQ(storedName(tmp.db), "Living Room");
}
TEST(SystemNameSyncTest, NetworkFailureKeepsPendingForRetry) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) {
return offline();
}),
Status::NetworkError);
EXPECT_TRUE(pendingSync(tmp.db));
EXPECT_EQ(storedName(tmp.db), "Den");
}
TEST(SystemNameSyncTest, UnknownLicenseKeepsPending) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
tmp.db.setKV(KV_SYSTEM_NAME, "Den");
tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1");
EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) {
return unknown();
}),
Status::UnknownLicense);
EXPECT_TRUE(pendingSync(tmp.db));
}
TEST(SystemNameMessageTest, ConsumeReturnsAndClears) {
TempDb tmp;
tmp.db.setKV(KV_SYSTEM_NAME_MESSAGE, CONFLICT_MESSAGE);
EXPECT_EQ(consumeMessage(tmp.db), CONFLICT_MESSAGE);
EXPECT_EQ(consumeMessage(tmp.db), "");
}