Files
radiantwave/tests/test_license_status.cpp
NeilClack c3b3d6774a Warning screen, smarter license re-validation, version reporting
Warning screen
- New reusable WarningPage (word-wrapped message, ENTER acknowledges and
  continues to the screen you were about to enter).
- First use: pressing Play while the selected audio is still downloading.
  Dynamic Audio requires every file; a fixed track requires its own file.

License re-validation
- No longer re-verifies on every Home render. Checks now run only when the
  email/license key is entered or changed, on the manual option, at startup
  when already due, and hourly while valid.
- Invalid + unchanged credentials are never re-checked (persisted pair).
- Valid re-checks at most once per hour, anchored on a DB timestamp so an
  overdue check runs immediately after a power cycle.
- New system_kv keys track the checked pair, outcome, and last-check time.
- Added a System Settings row "Manually Validate License Key OK|Invalid"
  (OK green, Invalid red, label white) that runs a check immediately.

Portal / local integration
- License validation request now sends the app version as `appVersion`.
- The entered license key is mirrored to ~/.local/radiantwave/license.txt.

Misc
- "Please connect to WiFi" only shows when no WiFi is joined AND the internet
  is unreachable (fixes the NetworkManager-missing and Ethernet cases).
- Added unit tests for the audio-download warning, license scheduling policy,
  license request shape, and the new settings-menu entry.
2026-10-14 10:58:40 -05:00

238 lines
8.9 KiB
C++

#include "network/license_status.h"
#include "db/db_keys.h"
#include <gtest/gtest.h>
#include <cstdlib>
#include <chrono>
#include <filesystem>
#include <string>
#include <thread>
using namespace radiantwave::license;
using namespace radiantwave::db;
namespace {
// A temporary database created fresh per test and cleaned up on exit.
struct TempDb {
TempDb() {
path = (std::filesystem::temp_directory_path() /
("test_rw_license_status_" + 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;
std::string env(const char* name) {
const char* value = std::getenv(name);
return value ? std::string(value) : std::string();
}
// Waits until `status()` no longer reports Checking (or `timeout` elapses).
// Returns the final status.
Status waitForResult(LicenseStatusTracker& tracker, int timeoutMs = 2000) {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(timeoutMs);
while (tracker.status() == Status::Checking) {
if (std::chrono::steady_clock::now() >= deadline) break;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return tracker.status();
}
} // namespace
// ─── Pure mapping (offline) ─────────────────────────────────────────────
TEST(LicenseStatusTest, StatusForResultMapping) {
EXPECT_EQ(statusForResult(Result::Ok), Status::Valid);
EXPECT_EQ(statusForResult(Result::NotConfigured), Status::Invalid);
EXPECT_EQ(statusForResult(Result::Invalid), Status::Invalid);
EXPECT_EQ(statusForResult(Result::NetworkError), Status::Invalid);
}
long long epochSeconds(long long offsetSeconds = 0) {
const auto now = std::chrono::system_clock::now();
return std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch())
.count() +
offsetSeconds;
}
TEST(LicenseStatusTest, StatusForResultWithGraceNoPriorValidation) {
TempDb tmp;
// No "last verified" stamp: a network error still invalidates because the
// device has no record of ever confirming the license.
EXPECT_EQ(statusForResultWithGrace(Result::Ok, tmp.db), Status::Valid);
EXPECT_EQ(statusForResultWithGrace(Result::NotConfigured, tmp.db), Status::Invalid);
EXPECT_EQ(statusForResultWithGrace(Result::Invalid, tmp.db), Status::Invalid);
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Invalid);
}
TEST(LicenseStatusTest, StatusForResultWithGraceWithinWindow) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds()));
// A network error keeps a recently-confirmed license valid...
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Valid);
// ...but the grace period never rescues an explicit rejection.
EXPECT_EQ(statusForResultWithGrace(Result::Invalid, tmp.db), Status::Invalid);
EXPECT_EQ(statusForResultWithGrace(Result::NotConfigured, tmp.db), Status::Invalid);
}
TEST(LicenseStatusTest, StatusForResultWithGraceExpired) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds(-8 * 24 * 3600)));
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Invalid);
}
// ─── Scheduling policy (pure) ───────────────────────────────────────────
TEST(LicenseStatusPolicyTest, ForcedAlwaysRuns) {
EXPECT_TRUE(shouldValidate(true, false, false, Status::Invalid, 1000, 999));
EXPECT_TRUE(shouldValidate(true, false, false, Status::Unknown, 1000, 0));
}
TEST(LicenseStatusPolicyTest, CredentialChangeRuns) {
EXPECT_TRUE(shouldValidate(false, true, false, Status::Invalid, 1000, 999));
}
TEST(LicenseStatusPolicyTest, OverdueRecheckRuns) {
EXPECT_TRUE(shouldValidate(false, false, true, Status::Invalid, 1000, 999));
}
TEST(LicenseStatusPolicyTest, InvalidUnchangedDoesNotRun) {
// An invalid license with unchanged credentials is never re-checked, no
// matter how old the last attempt is.
EXPECT_FALSE(shouldValidate(false, false, false, Status::Invalid, 1000, 999));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Invalid,
1000, 1000 - 10 * RECHECK_INTERVAL_SECONDS));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Unknown, 1000, 0));
}
TEST(LicenseStatusPolicyTest, ValidRechecksAfterInterval) {
const long long now = 1000000;
EXPECT_FALSE(shouldValidate(false, false, false, Status::Valid, now, now - 10));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Valid, now,
now - (RECHECK_INTERVAL_SECONDS - 1)));
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, now,
now - RECHECK_INTERVAL_SECONDS));
// Off for several hours: still immediately due, not another full hour away.
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, now,
now - 5 * RECHECK_INTERVAL_SECONDS));
}
TEST(LicenseStatusPolicyTest, ValidWithMissingStampRechecks) {
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, 1000, 0));
}
// ─── Worker lifecycle (no network) ──────────────────────────────────────
TEST(LicenseStatusTest, UnknownBeforeStart) {
LicenseStatusTracker tracker;
EXPECT_EQ(tracker.status(), Status::Unknown);
EXPECT_FALSE(tracker.isValid());
// refresh() before start() must not spawn a worker.
tracker.refresh();
EXPECT_EQ(tracker.status(), Status::Unknown);
}
TEST(LicenseStatusTest, InvalidWhenNoCredentials) {
TempDb tmp;
LicenseStatusTracker tracker;
tracker.start(tmp.db);
tracker.refresh();
// NotConfigured resolves without touching the network.
EXPECT_EQ(waitForResult(tracker), Status::Invalid);
EXPECT_FALSE(tracker.isValid());
tracker.shutdown();
}
TEST(LicenseStatusTest, InvalidWhenDatabaseClosed) {
TempDb tmp;
LicenseStatusTracker tracker;
tracker.start(tmp.db);
tracker.refresh();
EXPECT_EQ(waitForResult(tracker), Status::Invalid);
tracker.shutdown();
}
TEST(LicenseStatusTest, LoadsPersistedValidWithinGraceWithoutChecking) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "k");
tmp.db.setKV(KV_EMAIL_ADDRESS, "e");
tmp.db.setKV(KV_LICENSE_CHECKED_KEY, "k");
tmp.db.setKV(KV_LICENSE_CHECKED_EMAIL, "e");
tmp.db.setKV(KV_LICENSE_CHECK_STATUS, "valid");
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds()));
tmp.db.setKV(KV_LICENSE_LAST_CHECK, std::to_string(epochSeconds()));
LicenseStatusTracker tracker;
tracker.start(tmp.db);
// Seeded valid + recently checked + unchanged credentials: the worker must
// stay idle (no network), and the status is valid immediately.
EXPECT_EQ(tracker.status(), Status::Valid);
tracker.shutdown();
}
TEST(LicenseStatusTest, RecordResultUpdatesStatusAndPersistsCheckedPair) {
TempDb tmp;
const std::string key = "AAAA-BBBB-CCCC-DDDD";
const std::string email = "user@example.com";
tmp.db.setKV(KV_LICENSE_KEY, key);
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
// Seed the checked pair as already-invalid so the worker does not start a
// network check; this exercises recordResult() in isolation.
tmp.db.setKV(KV_LICENSE_CHECKED_KEY, key);
tmp.db.setKV(KV_LICENSE_CHECKED_EMAIL, email);
tmp.db.setKV(KV_LICENSE_CHECK_STATUS, "invalid");
LicenseStatusTracker tracker;
tracker.start(tmp.db);
EXPECT_EQ(tracker.status(), Status::Invalid);
tracker.recordResult(Result::Ok);
EXPECT_EQ(tracker.status(), Status::Valid);
EXPECT_TRUE(tracker.isValid());
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECKED_KEY), key);
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECKED_EMAIL), email);
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECK_STATUS), "valid");
EXPECT_FALSE(tmp.db.getKV(KV_LICENSE_LAST_CHECK).empty());
tracker.shutdown();
}
// ─── Live: confirmed-valid license (requires env credentials) ───────────
TEST(LicenseStatusLiveTest, ValidWhenLicensePairAccepted) {
const std::string key = env("RW_LIVE_LICENSE_KEY");
const std::string email = env("RW_LIVE_LICENSE_EMAIL");
if (key.empty() || email.empty()) {
GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set";
}
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, key);
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
LicenseStatusTracker tracker;
tracker.start(tmp.db);
tracker.refresh();
EXPECT_EQ(waitForResult(tracker, 20000), Status::Valid);
EXPECT_TRUE(tracker.isValid());
tracker.shutdown();
}