diff --git a/CMakeLists.txt b/CMakeLists.txt index a4a34e9..40e1e55 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/app_state.h b/src/app_state.h index 82862cd..40f9835 100644 --- a/src/app_state.h +++ b/src/app_state.h @@ -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; diff --git a/src/db/db_keys.h b/src/db/db_keys.h index 7c2d91c..6bcb5f7 100644 --- a/src/db/db_keys.h +++ b/src/db/db_keys.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index d097fcd..be37cc0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); if (state) { state->audioDownloader.shutdown(); + state->systemNameSync.shutdown(); state->licenseTracker.shutdown(); state->audio.shutdown(); state->network.shutdown(); diff --git a/src/network/api_client.cpp b/src/network/api_client.cpp index de8bd06..60b57f3 100644 --- a/src/network/api_client.cpp +++ b/src/network/api_client.cpp @@ -2,6 +2,7 @@ #include "network/json.h" #include +#include #include 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(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(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(response.status); + result.transport_ok = response.transportOk; + return result; +} + } // namespace radiantwave::api diff --git a/src/network/api_client.h b/src/network/api_client.h index cc9005b..3f8c0f6 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -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& 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 ────────────────────────────────── diff --git a/src/network/license_service.cpp b/src/network/license_service.cpp index a812893..31b25a7 100644 --- a/src/network/license_service.cpp +++ b/src/network/license_service.cpp @@ -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 #include @@ -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; } diff --git a/src/network/license_service.h b/src/network/license_service.h index 2888118..a0c9cf9 100644 --- a/src/network/license_service.h +++ b/src/network/license_service.h @@ -1,6 +1,8 @@ #pragma once #include "db/database_manager.h" +#include "network/api_client.h" +#include #include 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; + +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. diff --git a/src/network/system_name.cpp b/src/network/system_name.cpp new file mode 100644 index 0000000..f5ac0c3 --- /dev/null +++ b/src/network/system_name.cpp @@ -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 diff --git a/src/network/system_name.h b/src/network/system_name.h new file mode 100644 index 0000000..f286b5d --- /dev/null +++ b/src/network/system_name.h @@ -0,0 +1,62 @@ +#pragma once + +#include "db/database_manager.h" +#include "network/api_client.h" + +#include +#include + +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; + +// 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 diff --git a/src/network/system_name_sync.cpp b/src/network/system_name_sync.cpp new file mode 100644 index 0000000..5dd0483 --- /dev/null +++ b/src/network/system_name_sync.cpp @@ -0,0 +1,70 @@ +#include "network/system_name_sync.h" +#include "network/api_client.h" + +#include + +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 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 lock(mutex_); + stop_.store(true); + } + cv_.notify_one(); + if (worker_.joinable()) { + worker_.join(); + } +} + +void SystemNameSync::requestSync() { + { + std::lock_guard lock(mutex_); + if (!worker_.joinable()) return; + requested_.store(true); + } + cv_.notify_one(); +} + +void SystemNameSync::run() { + while (true) { + db::DatabaseManager* db = nullptr; + { + std::unique_lock 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 diff --git a/src/network/system_name_sync.h b/src/network/system_name_sync.h new file mode 100644 index 0000000..e687ea9 --- /dev/null +++ b/src/network/system_name_sync.h @@ -0,0 +1,47 @@ +#pragma once + +#include "db/database_manager.h" +#include "network/system_name.h" + +#include +#include +#include +#include +#include + +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 stop_{false}; + std::atomic requested_{false}; +}; + +} // namespace radiantwave::systemname diff --git a/src/validation.cpp b/src/validation.cpp index 5687645..6f28ee9 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -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(input[start]))) { + ++start; + } + while (end > start && std::isspace(static_cast(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 diff --git a/src/validation.h b/src/validation.h index 76273e6..66cfcf0 100644 --- a/src/validation.h +++ b/src/validation.h @@ -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 diff --git a/tests/test_license_service.cpp b/tests/test_license_service.cpp index d6188b9..a06f1d7 100644 --- a/tests/test_license_service.cpp +++ b/tests/test_license_service.cpp @@ -1,5 +1,6 @@ #include "network/license_service.h" #include "db/db_keys.h" +#include "network/system_name.h" #include #include @@ -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 = ""; + 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) { diff --git a/tests/test_system_name.cpp b/tests/test_system_name.cpp new file mode 100644 index 0000000..47f7b79 --- /dev/null +++ b/tests/test_system_name.cpp @@ -0,0 +1,244 @@ +#include "network/system_name.h" +#include "db/db_keys.h" +#include "validation.h" + +#include + +#include +#include + +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), ""); +}