diff --git a/docs/core-sdk.md b/docs/core-sdk.md index 8a8f84b..0651f8c 100644 --- a/docs/core-sdk.md +++ b/docs/core-sdk.md @@ -118,13 +118,33 @@ std::cout << "Open: " << request.browser_url << "\n"; std::optional license; while (!license) { - std::this_thread::sleep_for(std::chrono::seconds(1)); + std::this_thread::sleep_for(std::chrono::seconds(2)); license = licensing.get_requested_activation(request); } licensing.store().store_local_license(*license); ``` +Poll every 2 to 3 seconds. The user is switching back from the browser, so a +faster cadence gains nothing. + +To protect the Moonbase API, the SDK sends at most one poll per second for each +request. A call to `get_requested_activation` less than a second after the +previous poll of the same request returns `std::nullopt` without contacting the +API, so a caller polling from a fast UI timer does not flood the server. Each +request is limited separately, so polling several requests in turn never holds +one of them back. The limit is built in and cannot be turned off. + +A 400 from the poll means the server will never complete the request: it +expired, was cancelled, or was refused. `get_requested_activation` then throws +`activation_request_error` (`error_type::activation_request_ended`) carrying the +server's reason. Stop polling and start a new activation with +`request_activation`. Earlier releases threw `license_invalid_error` here, so +if your polling loop catches that type to stop, catch `activation_request_error` +too. +Network failures and other server errors throw `api_error`; those are worth +retrying on the next poll. + `request_activation` takes an optional `moonbase::activation_method`. Pass `activation_method::offline` to have the same browser flow mint an *offline* license instead: diff --git a/docs/juce-module.md b/docs/juce-module.md index fea2dfa..a670881 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -125,8 +125,11 @@ time (plugin scanning, pluginval, rapid open/close) without deferring or guardin The screens: - **Welcome** — Activate online (browser flow) or Activate offline. -- **Activating** — opens the browser and polls `get_requested_activation()`; the - device chip shows the local fingerprint + platform; Cancel aborts. +- **Activating** — opens the browser and polls `get_requested_activation()` every + 2 seconds; the device chip shows the local fingerprint + platform; Cancel aborts. + If the request expires or is cancelled (the server answers 400), polling stops + and the welcome screen returns with "This activation expired or was cancelled. + Activate again to continue."; the server's reason goes to `onDiagnostic`. - **Success** — animated confirmation with a mini license card. - **Offline** — two-step machine-file flow: save the request (`generate_device_token`), then load the response file (`read_offline_license`, validated locally). diff --git a/docs/juce.md b/docs/juce.md index cacd64a..8d0f977 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -159,16 +159,46 @@ url.launchInDefaultBrowser(); // Then, on a juce::Timer running on the message thread: void timerCallback() override { - if (unlockStatus.pollPendingActivation()) + try { - stopTimer(); - // unlockStatus.isMoonbaseUnlocked() is now true. + if (unlockStatus.pollPendingActivation()) + { + stopTimer(); + // unlockStatus.isMoonbaseUnlocked() is now true. + } + } + catch (const std::exception& ex) + { + // A rejection drops the pending request; a network blip keeps it. + if (! unlockStatus.pendingActivationMethod()) + { + stopTimer(); + // Show ex.what() and offer to start again. + } } } ``` -`pollPendingActivation()` is non-blocking. The poll cadence is up to you; -once a second is plenty for a UI-driven flow. +`pollPendingActivation()` is non-blocking. Poll every 2 to 3 seconds; the user +is switching back from the browser, so a faster cadence gains nothing. To protect +the Moonbase API, the SDK sends at most one poll per second, and calls in between +return `false` without contacting the server. Calling it from an editor timer +that runs much faster is therefore safe, though only one call per second does +anything. + +Always catch around `pollPendingActivation()`: it throws when a poll fails, and +JUCE does not catch exceptions thrown from a timer, so one that escapes ends the +process (in a plugin, the host). + +- `moonbase::activation_request_error` means the server answered 400: the request + expired or was cancelled and can never complete. Any other + `license_invalid_error` or `license_expired_error` means the server refused the + activation. In both cases the bridge drops the pending request before + throwing, so `pendingActivationMethod()` returns `std::nullopt` and later polls + return `false` without contacting the server. Stop the timer and let the user + start again. +- `moonbase::api_error` is a network or server problem. The request stays + pending, so keep polling. `beginActivation()` takes an optional `moonbase::activation_method`. Pass `activation_method::offline` to have the same browser flow mint an offline diff --git a/examples/activation.cpp b/examples/activation.cpp index 92eb42a..14f8ac7 100644 --- a/examples/activation.cpp +++ b/examples/activation.cpp @@ -58,7 +58,7 @@ int main() std::optional activated; while (!activated) { - std::this_thread::sleep_for(std::chrono::seconds(1)); + std::this_thread::sleep_for(std::chrono::seconds(2)); activated = licensing.get_requested_activation(request); std::cout << "Waiting for activation...\n"; } diff --git a/examples/juce/MoonbaseJuceBridge.h b/examples/juce/MoonbaseJuceBridge.h index aeee186..aa94857 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -675,6 +675,17 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus // Non-blocking poll. Returns true the first call after the user finishes // activation in the browser; returns false otherwise. Run from a juce::Timer // on the message thread. + // + // Throws when the poll fails, so catch in your timer callback: JUCE does not + // catch exceptions thrown from a timer, and one that escapes ends the process. + // - moonbase::activation_request_error: the request expired or was + // cancelled. Any other license_invalid_error or license_expired_error: + // the server refused the activation. Either way the bridge drops the + // request first, so pendingActivationMethod() returns nullopt and later + // polls return false without contacting the server. Stop the timer and + // let the user start again. + // - moonbase::api_error: a network or server problem. The request stays + // pending, so keep polling. bool pollPendingActivation() { std::optional request; @@ -685,7 +696,26 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus if (!request) return false; - auto fulfilled = licensing_->get_requested_activation(*request); + std::optional fulfilled; + try + { + fulfilled = licensing_->get_requested_activation(*request); + } + catch (const moonbase::activation_request_error&) + { + dropPendingRequest(request->id); + throw; + } + catch (const moonbase::license_invalid_error&) + { + dropPendingRequest(request->id); + throw; + } + catch (const moonbase::license_expired_error&) + { + dropPendingRequest(request->id); + throw; + } if (!fulfilled) return false; @@ -887,6 +917,15 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus } private: + // Forgets a browser activation the server refused, unless a newer + // beginActivation() has already replaced it. + void dropPendingRequest(const std::string& requestId) + { + const juce::ScopedLock lock(stateLock_); + if (pendingRequest_ && pendingRequest_->id == requestId) + pendingRequest_.reset(); + } + void setUnlocked(std::optional license) { const juce::ScopedLock lock(stateLock_); diff --git a/examples/juce/PluginActivationComponent.h b/examples/juce/PluginActivationComponent.h index 7b06b1d..35dd53b 100644 --- a/examples/juce/PluginActivationComponent.h +++ b/examples/juce/PluginActivationComponent.h @@ -123,7 +123,7 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB { const auto url = unlockStatus_.beginActivation(); url.launchInDefaultBrowser(); - startTimer(1000); + startTimer(2000); refreshLabel("Waiting for activation in browser..."); } catch (const std::exception& ex) @@ -134,10 +134,28 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB void timerCallback() override { - if (unlockStatus_.pollPendingActivation()) + try + { + if (unlockStatus_.pollPendingActivation()) + { + stopTimer(); + refreshLabel(); + } + } + catch (const moonbase::activation_request_error&) { stopTimer(); - refreshLabel(); + refreshLabel("Activation expired or was cancelled. Click Activate to start again."); + } + catch (const std::exception& ex) + { + // Any other rejection also drops the request. A network or server + // problem leaves it pending, so keep polling. + if (!unlockStatus_.pendingActivationMethod()) + { + stopTimer(); + refreshLabel(juce::String("Activation failed: ") + ex.what()); + } } } diff --git a/include/moonbase/client.hpp b/include/moonbase/client.hpp index 1ba4158..05a4264 100644 --- a/include/moonbase/client.hpp +++ b/include/moonbase/client.hpp @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include #include #include @@ -125,31 +127,41 @@ inline std::map default_headers(const licensing_option return headers; } -inline void throw_for_problem(long status_code, const std::string& body) -{ +struct problem_details { std::string title; std::string detail; std::string error_type; +}; - if (!body.empty()) { - try { - const auto problem = nlohmann::json::parse(body); - if (problem.contains("title") && problem.at("title").is_string()) { - title = problem.at("title").get(); - } - if (problem.contains("detail") && problem.at("detail").is_string()) { - detail = problem.at("detail").get(); - } - if (problem.contains("errorType")) { - if (problem.at("errorType").is_string()) { - error_type = problem.at("errorType").get(); - } else if (problem.at("errorType").is_number_integer()) { - error_type = std::to_string(problem.at("errorType").get()); - } +inline problem_details parse_problem(const std::string& body) +{ + problem_details result; + if (body.empty()) { + return result; + } + try { + const auto problem = nlohmann::json::parse(body); + if (problem.contains("title") && problem.at("title").is_string()) { + result.title = problem.at("title").get(); + } + if (problem.contains("detail") && problem.at("detail").is_string()) { + result.detail = problem.at("detail").get(); + } + if (problem.contains("errorType")) { + if (problem.at("errorType").is_string()) { + result.error_type = problem.at("errorType").get(); + } else if (problem.at("errorType").is_number_integer()) { + result.error_type = std::to_string(problem.at("errorType").get()); } - } catch (const std::exception&) { } + } catch (const std::exception&) { } + return result; +} + +inline void throw_for_problem(long status_code, const std::string& body) +{ + const auto [title, detail, error_type] = parse_problem(body); const auto message = !detail.empty() ? detail @@ -168,6 +180,58 @@ inline void throw_for_problem(long status_code, const std::string& body) throw api_error(static_cast(status_code), message, title, detail); } +// A 400 from the activation poll means the request will never complete: it +// expired, was cancelled, or was refused. Kept away from throw_for_problem's +// text match, which would report "Activation request has expired" as an expired +// license; only an explicit LicenseExpired code still means that. +inline void throw_for_activation_poll_problem(long status_code, const std::string& body) +{ + if (status_code != 400) { + throw_for_problem(status_code, body); + } + + const auto problem = parse_problem(body); + const auto& reason = !problem.detail.empty() ? problem.detail : problem.title; + if (problem.error_type == "LicenseExpired") { + throw license_expired_error(reason.empty() ? "The license has expired" : reason); + } + throw activation_request_error( + reason.empty() ? "Activation request can no longer be completed (HTTP 400)" + : "Activation request can no longer be completed: " + reason); +} + +// Hard floor between activation polls, protecting the API from callers that poll +// from a fast UI timer or a tight loop. Deliberately not configurable. +inline constexpr std::chrono::milliseconds activation_poll_min_interval{1000}; + +// Spaces polls of the same activation request at least +// activation_poll_min_interval apart. Each request has its own slot, so a caller +// polling several requests in turn never starves the later ones. The timestamp +// is taken before the request goes out, so a poll still in flight on another +// thread counts too. +class activation_poll_gate { +public: + bool try_claim(const std::string& request_url) + { + std::lock_guard guard(mutex_); + const auto now = std::chrono::steady_clock::now(); + // Polls older than the interval no longer hold anything back. Dropping + // them keeps the map to the requests polled within the last interval. + for (auto it = last_poll_.begin(); it != last_poll_.end();) { + if (now - it->second >= activation_poll_min_interval) { + it = last_poll_.erase(it); + } else { + ++it; + } + } + return last_poll_.emplace(request_url, now).second; + } + +private: + std::mutex mutex_; + std::map last_poll_; +}; + } // namespace detail class license_client { @@ -274,9 +338,21 @@ class license_client { } } + // Returns nullopt while the activation is pending, and also, without contacting + // the API, when called within detail::activation_poll_min_interval of this + // client's previous poll of the same request. + // + // Throws activation_request_error when the server answers 400: the request + // expired, was cancelled, or was refused, so stop polling and start a new + // one. Transport failures and other server errors throw api_error and are + // worth retrying. [[nodiscard]] std::optional get_requested_activation( const activation_request& activation) const { + if (!poll_gate_->try_claim(activation.request_url)) { + return std::nullopt; + } + http_request request; request.method = "GET"; request.url = activation.request_url; @@ -289,7 +365,7 @@ class license_client { return std::nullopt; } if (response.status_code < 200 || response.status_code >= 300) { - detail::throw_for_problem(response.status_code, response.body); + detail::throw_for_activation_poll_problem(response.status_code, response.body); } return validator_->validate_token(response.body); } @@ -299,6 +375,9 @@ class license_client { std::shared_ptr device_ids_; std::shared_ptr validator_; std::shared_ptr transport_; + // Shared, so a copy of this client cannot double the poll rate. + std::shared_ptr poll_gate_ = + std::make_shared(); }; } // namespace moonbase diff --git a/include/moonbase/errors.hpp b/include/moonbase/errors.hpp index 00c58ab..f12e746 100644 --- a/include/moonbase/errors.hpp +++ b/include/moonbase/errors.hpp @@ -19,6 +19,9 @@ enum class error_type { license_device_mismatch, /// No stable hardware identifier could be read, so no device id exists. device_identity_unavailable, + /// The server will never complete a browser activation request: it expired, + /// was cancelled, or was refused. + activation_request_ended, }; class moonbase_error : public std::runtime_error { @@ -94,6 +97,21 @@ class license_device_mismatch_error : public license_invalid_error { } }; +// The server will never complete this browser activation: the request expired, +// was cancelled, or was otherwise refused. Polling it again cannot succeed; stop +// and start over with request_activation. +// +// Not a license_invalid_error: no license exists yet, so nothing about one is +// invalid. A refused poll used to throw license_invalid_error, so code that +// caught that type to stop polling must catch this one as well. +class activation_request_error : public moonbase_error { +public: + explicit activation_request_error(const std::string& message) + : moonbase_error(error_type::activation_request_ended, message) + { + } +}; + // The device fingerprint had nothing machine-specific to hash: either no // parameter could be read, or the only ones that could are model-level (vendor, // product and board names, shared by every unit of a product line). diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index 0e9394a..fd3ac9c 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -62,7 +62,10 @@ class licensing { } // Starts a browser activation. Poll the returned request with - // get_requested_activation until it yields a license. + // get_requested_activation every 2 to 3 seconds until it yields a license. + // The SDK sends at most one poll per second for each request; calls in + // between return nullopt. A request that expires or is cancelled makes the + // poll throw activation_request_error: stop, and start a new one. // // method (optional): pass activation_method::offline to ask the backend for // an offline license. The browser flow itself is identical, but the token it diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 4d261f0..cca81d7 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -7,7 +7,7 @@ namespace moonbase::juce_integration { namespace { -constexpr int kPollIntervalMs = 1500; +constexpr int kPollIntervalMs = 2000; // Diagnostic-only error text. For transport failures (moonbase::api_error) the // SDK stashes actionable guidance (e.g. the macOS network entitlement hint) in @@ -484,20 +484,39 @@ void ActivationController::timerCallback() std::optional fulfilled; bool fatal = false; juce::String error; + juce::String userMessage; juce::String transient; try { fulfilled = licensing->get_requested_activation(request); } - catch (const moonbase::license_invalid_error& ex) { fatal = true; error = ex.what(); } - catch (const moonbase::license_expired_error& ex) { fatal = true; error = ex.what(); } + catch (const moonbase::activation_request_error& ex) + { + // The server answered 400: the request expired or was cancelled, so + // it can never complete. The server's reason goes to diagnostics. + fatal = true; + error = ex.what(); + userMessage = "This activation expired or was cancelled. Activate again to continue."; + } + catch (const moonbase::license_invalid_error& ex) + { + fatal = true; + error = ex.what(); + userMessage = "Activation was rejected. " + error; + } + catch (const moonbase::license_expired_error& ex) + { + fatal = true; + error = ex.what(); + userMessage = "Activation was rejected. " + error; + } catch (const std::exception& ex) { // Transient transport/5xx error — keep polling. transient = describeError(ex); } - juce::MessageManager::callAsync([safe, generation, fulfilled, fatal, error, transient]() mutable + juce::MessageManager::callAsync([safe, generation, fulfilled, fatal, error, userMessage, transient]() mutable { auto* self = safe.get(); if (self == nullptr) @@ -511,7 +530,7 @@ void ActivationController::timerCallback() self->emitDiagnostic("Activation rejected during polling: " + error); self->stopTimer(); self->pendingRequest_.reset(); - self->setScreen(Screen::Error, "Activation was rejected. " + error); + self->setScreen(Screen::Error, userMessage); } else if (fulfilled) { diff --git a/modules/moonbase_licensing/moonbase/client.hpp b/modules/moonbase_licensing/moonbase/client.hpp index 1ba4158..05a4264 100644 --- a/modules/moonbase_licensing/moonbase/client.hpp +++ b/modules/moonbase_licensing/moonbase/client.hpp @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include #include #include @@ -125,31 +127,41 @@ inline std::map default_headers(const licensing_option return headers; } -inline void throw_for_problem(long status_code, const std::string& body) -{ +struct problem_details { std::string title; std::string detail; std::string error_type; +}; - if (!body.empty()) { - try { - const auto problem = nlohmann::json::parse(body); - if (problem.contains("title") && problem.at("title").is_string()) { - title = problem.at("title").get(); - } - if (problem.contains("detail") && problem.at("detail").is_string()) { - detail = problem.at("detail").get(); - } - if (problem.contains("errorType")) { - if (problem.at("errorType").is_string()) { - error_type = problem.at("errorType").get(); - } else if (problem.at("errorType").is_number_integer()) { - error_type = std::to_string(problem.at("errorType").get()); - } +inline problem_details parse_problem(const std::string& body) +{ + problem_details result; + if (body.empty()) { + return result; + } + try { + const auto problem = nlohmann::json::parse(body); + if (problem.contains("title") && problem.at("title").is_string()) { + result.title = problem.at("title").get(); + } + if (problem.contains("detail") && problem.at("detail").is_string()) { + result.detail = problem.at("detail").get(); + } + if (problem.contains("errorType")) { + if (problem.at("errorType").is_string()) { + result.error_type = problem.at("errorType").get(); + } else if (problem.at("errorType").is_number_integer()) { + result.error_type = std::to_string(problem.at("errorType").get()); } - } catch (const std::exception&) { } + } catch (const std::exception&) { } + return result; +} + +inline void throw_for_problem(long status_code, const std::string& body) +{ + const auto [title, detail, error_type] = parse_problem(body); const auto message = !detail.empty() ? detail @@ -168,6 +180,58 @@ inline void throw_for_problem(long status_code, const std::string& body) throw api_error(static_cast(status_code), message, title, detail); } +// A 400 from the activation poll means the request will never complete: it +// expired, was cancelled, or was refused. Kept away from throw_for_problem's +// text match, which would report "Activation request has expired" as an expired +// license; only an explicit LicenseExpired code still means that. +inline void throw_for_activation_poll_problem(long status_code, const std::string& body) +{ + if (status_code != 400) { + throw_for_problem(status_code, body); + } + + const auto problem = parse_problem(body); + const auto& reason = !problem.detail.empty() ? problem.detail : problem.title; + if (problem.error_type == "LicenseExpired") { + throw license_expired_error(reason.empty() ? "The license has expired" : reason); + } + throw activation_request_error( + reason.empty() ? "Activation request can no longer be completed (HTTP 400)" + : "Activation request can no longer be completed: " + reason); +} + +// Hard floor between activation polls, protecting the API from callers that poll +// from a fast UI timer or a tight loop. Deliberately not configurable. +inline constexpr std::chrono::milliseconds activation_poll_min_interval{1000}; + +// Spaces polls of the same activation request at least +// activation_poll_min_interval apart. Each request has its own slot, so a caller +// polling several requests in turn never starves the later ones. The timestamp +// is taken before the request goes out, so a poll still in flight on another +// thread counts too. +class activation_poll_gate { +public: + bool try_claim(const std::string& request_url) + { + std::lock_guard guard(mutex_); + const auto now = std::chrono::steady_clock::now(); + // Polls older than the interval no longer hold anything back. Dropping + // them keeps the map to the requests polled within the last interval. + for (auto it = last_poll_.begin(); it != last_poll_.end();) { + if (now - it->second >= activation_poll_min_interval) { + it = last_poll_.erase(it); + } else { + ++it; + } + } + return last_poll_.emplace(request_url, now).second; + } + +private: + std::mutex mutex_; + std::map last_poll_; +}; + } // namespace detail class license_client { @@ -274,9 +338,21 @@ class license_client { } } + // Returns nullopt while the activation is pending, and also, without contacting + // the API, when called within detail::activation_poll_min_interval of this + // client's previous poll of the same request. + // + // Throws activation_request_error when the server answers 400: the request + // expired, was cancelled, or was refused, so stop polling and start a new + // one. Transport failures and other server errors throw api_error and are + // worth retrying. [[nodiscard]] std::optional get_requested_activation( const activation_request& activation) const { + if (!poll_gate_->try_claim(activation.request_url)) { + return std::nullopt; + } + http_request request; request.method = "GET"; request.url = activation.request_url; @@ -289,7 +365,7 @@ class license_client { return std::nullopt; } if (response.status_code < 200 || response.status_code >= 300) { - detail::throw_for_problem(response.status_code, response.body); + detail::throw_for_activation_poll_problem(response.status_code, response.body); } return validator_->validate_token(response.body); } @@ -299,6 +375,9 @@ class license_client { std::shared_ptr device_ids_; std::shared_ptr validator_; std::shared_ptr transport_; + // Shared, so a copy of this client cannot double the poll rate. + std::shared_ptr poll_gate_ = + std::make_shared(); }; } // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/errors.hpp b/modules/moonbase_licensing/moonbase/errors.hpp index 00c58ab..f12e746 100644 --- a/modules/moonbase_licensing/moonbase/errors.hpp +++ b/modules/moonbase_licensing/moonbase/errors.hpp @@ -19,6 +19,9 @@ enum class error_type { license_device_mismatch, /// No stable hardware identifier could be read, so no device id exists. device_identity_unavailable, + /// The server will never complete a browser activation request: it expired, + /// was cancelled, or was refused. + activation_request_ended, }; class moonbase_error : public std::runtime_error { @@ -94,6 +97,21 @@ class license_device_mismatch_error : public license_invalid_error { } }; +// The server will never complete this browser activation: the request expired, +// was cancelled, or was otherwise refused. Polling it again cannot succeed; stop +// and start over with request_activation. +// +// Not a license_invalid_error: no license exists yet, so nothing about one is +// invalid. A refused poll used to throw license_invalid_error, so code that +// caught that type to stop polling must catch this one as well. +class activation_request_error : public moonbase_error { +public: + explicit activation_request_error(const std::string& message) + : moonbase_error(error_type::activation_request_ended, message) + { + } +}; + // The device fingerprint had nothing machine-specific to hash: either no // parameter could be read, or the only ones that could are model-level (vendor, // product and board names, shared by every unit of a product line). diff --git a/modules/moonbase_licensing/moonbase/licensing.hpp b/modules/moonbase_licensing/moonbase/licensing.hpp index 0e9394a..fd3ac9c 100644 --- a/modules/moonbase_licensing/moonbase/licensing.hpp +++ b/modules/moonbase_licensing/moonbase/licensing.hpp @@ -62,7 +62,10 @@ class licensing { } // Starts a browser activation. Poll the returned request with - // get_requested_activation until it yields a license. + // get_requested_activation every 2 to 3 seconds until it yields a license. + // The SDK sends at most one poll per second for each request; calls in + // between return nullopt. A request that expires or is cancelled makes the + // poll throw activation_request_error: stop, and start a new one. // // method (optional): pass activation_method::offline to ask the backend for // an offline license. The browser flow itself is identical, but the token it diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index cd1180f..5aaecee 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -204,19 +204,32 @@ TEST_CASE("request_activation throws for API errors") TEST_CASE("get_requested_activation returns nullopt while pending or missing") { - client_fixture fixture({ - http_response{204, {}, ""}, - http_response{404, {}, ""}, - }); + activation_request request{"request-123", "https://demo.moonbase.sh/api/client/activations/request-123?format=JWT", ""}; + + // A fresh client per status: a second poll on the same client would be held + // back by the activation poll floor before it reached the transport. + for (const long status : {204L, 404L}) { + CAPTURE(status); + client_fixture fixture({http_response{status, {}, ""}}); + + CHECK_FALSE(fixture.client.get_requested_activation(request).has_value()); + REQUIRE(fixture.transport->requests.size() == 1); + CHECK(fixture.transport->requests[0].method == "GET"); + CHECK(fixture.transport->requests[0].connect_timeout == std::chrono::milliseconds{1234}); + CHECK(fixture.transport->requests[0].request_timeout == std::chrono::milliseconds{5678}); + } +} + +TEST_CASE("copies of a client share the activation poll floor") +{ + client_fixture fixture({http_response{204, {}, ""}}); + const auto copy = fixture.client; activation_request request{"request-123", "https://demo.moonbase.sh/api/client/activations/request-123?format=JWT", ""}; CHECK_FALSE(fixture.client.get_requested_activation(request).has_value()); - CHECK_FALSE(fixture.client.get_requested_activation(request).has_value()); - REQUIRE(fixture.transport->requests.size() == 2); - CHECK(fixture.transport->requests[0].method == "GET"); - CHECK(fixture.transport->requests[0].connect_timeout == std::chrono::milliseconds{1234}); - CHECK(fixture.transport->requests[0].request_timeout == std::chrono::milliseconds{5678}); + CHECK_FALSE(copy.get_requested_activation(request).has_value()); + CHECK(fixture.transport->requests.size() == 1); } TEST_CASE("get_requested_activation validates fulfilled JWT response") @@ -244,6 +257,75 @@ TEST_CASE("get_requested_activation maps license problem details") CHECK_THROWS_AS((void)fixture.client.get_requested_activation(request), license_expired_error); } +TEST_CASE("a 400 from the activation poll ends the request with the server's reason") +{ + client_fixture fixture({ + http_response{400, {}, R"({"title":"Invalid state","detail":"Activation request was cancelled"})"}, + }); + + activation_request request{"request-123", "https://demo.moonbase.sh/api/client/activations/request-123?format=JWT", ""}; + CHECK_THROWS_WITH_AS( + (void)fixture.client.get_requested_activation(request), + "Activation request can no longer be completed: Activation request was cancelled", + activation_request_error); +} + +TEST_CASE("an expired activation request is not reported as an expired license") +{ + client_fixture fixture({ + http_response{400, {}, R"({"title":"Invalid state","detail":"Activation request has expired"})"}, + }); + + activation_request request{"request-123", "https://demo.moonbase.sh/api/client/activations/request-123?format=JWT", ""}; + CHECK_THROWS_WITH_AS( + (void)fixture.client.get_requested_activation(request), + "Activation request can no longer be completed: Activation request has expired", + activation_request_error); +} + +TEST_CASE("a 400 without a problem body still ends the activation request") +{ + client_fixture fixture({http_response{400, {}, ""}}); + + activation_request request{"request-123", "https://demo.moonbase.sh/api/client/activations/request-123?format=JWT", ""}; + CHECK_THROWS_WITH_AS( + (void)fixture.client.get_requested_activation(request), + "Activation request can no longer be completed (HTTP 400)", + activation_request_error); +} + +TEST_CASE("an ended activation request has its own error type, not license_invalid") +{ + client_fixture fixture({ + http_response{400, {}, R"({"detail":"Activation request was cancelled"})"}, + }); + + activation_request request{"request-123", "https://demo.moonbase.sh/api/client/activations/request-123?format=JWT", ""}; + try { + (void)fixture.client.get_requested_activation(request); + FAIL("expected the poll to throw"); + } catch (const moonbase_error& ex) { + CHECK(dynamic_cast(&ex) != nullptr); + CHECK(dynamic_cast(&ex) == nullptr); + CHECK(ex.type() == error_type::activation_request_ended); + } +} + +TEST_CASE("server errors from the activation poll stay retryable") +{ + client_fixture fixture({ + http_response{503, {}, R"({"title":"Service Unavailable"})"}, + }); + + activation_request request{"request-123", "https://demo.moonbase.sh/api/client/activations/request-123?format=JWT", ""}; + try { + (void)fixture.client.get_requested_activation(request); + FAIL("expected the poll to throw"); + } catch (const api_error& ex) { + CHECK(ex.status_code() == 503); + } +} + TEST_CASE("validate_token_online posts the JWT and parses the refreshed response") { client_fixture fixture({}); diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index da2e0f9..20b6c40 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -216,6 +216,127 @@ TEST_CASE("a browser activation requested as offline yields an offline license") CHECK(fixture.transport->requests.size() == 2); // neither call hit the API } +namespace { + +activation_request pending_activation() +{ + activation_request request; + request.id = "request-123"; + request.request_url = "https://demo.moonbase.sh/api/client/activations/request-123"; + request.browser_url = "https://demo.moonbase.sh/activate?token=request-123"; + return request; +} + +} // namespace + +TEST_CASE("get_requested_activation skips polls inside the minimum interval") +{ + facade_fixture fixture; + const auto request = pending_activation(); + fixture.transport->responses.push_back(http_response{204, {}, {}}); + + CHECK_FALSE(fixture.instance.get_requested_activation(request).has_value()); + CHECK_FALSE(fixture.instance.get_requested_activation(request).has_value()); + CHECK_FALSE(fixture.instance.get_requested_activation(request).has_value()); + + // Only the first poll reached the transport; the rest answered "not yet" + // locally. Had they gone out, the empty response queue would have thrown. + CHECK(fixture.transport->requests.size() == 1); +} + +TEST_CASE("get_requested_activation polls again once the minimum interval has passed") +{ + facade_fixture fixture; + const auto request = pending_activation(); + fixture.transport->responses.push_back(http_response{204, {}, {}}); + fixture.transport->responses.push_back( + http_response{200, {}, fixture.make_token(moonbase::tests::default_claims())}); + + CHECK_FALSE(fixture.instance.get_requested_activation(request).has_value()); + std::this_thread::sleep_for( + detail::activation_poll_min_interval + std::chrono::milliseconds(50)); + const auto activated = fixture.instance.get_requested_activation(request); + + REQUIRE(activated.has_value()); + CHECK(activated->id == "license-123"); + CHECK(fixture.transport->requests.size() == 2); +} + +TEST_CASE("polling two requests in turn lets both reach the server") +{ + facade_fixture fixture; + const auto first = pending_activation(); + auto second = pending_activation(); + second.id = "request-456"; + second.request_url = "https://demo.moonbase.sh/api/client/activations/request-456"; + + // Each tick polls both requests back to back, in the same order. A single + // floor for the whole client would let the first claim every tick and hold + // the second back for good. + fixture.transport->responses.push_back(http_response{204, {}, {}}); + fixture.transport->responses.push_back(http_response{204, {}, {}}); + CHECK_FALSE(fixture.instance.get_requested_activation(first).has_value()); + CHECK_FALSE(fixture.instance.get_requested_activation(second).has_value()); + CHECK(fixture.transport->requests.size() == 2); + + // Each request is still held to its own floor. + CHECK_FALSE(fixture.instance.get_requested_activation(first).has_value()); + CHECK_FALSE(fixture.instance.get_requested_activation(second).has_value()); + CHECK(fixture.transport->requests.size() == 2); + + std::this_thread::sleep_for( + detail::activation_poll_min_interval + std::chrono::milliseconds(50)); + fixture.transport->responses.push_back(http_response{204, {}, {}}); + fixture.transport->responses.push_back( + http_response{200, {}, fixture.make_token(moonbase::tests::default_claims())}); + CHECK_FALSE(fixture.instance.get_requested_activation(first).has_value()); + const auto activated = fixture.instance.get_requested_activation(second); + + REQUIRE(activated.has_value()); + CHECK(activated->id == "license-123"); + REQUIRE(fixture.transport->requests.size() == 4); + CHECK(fixture.transport->requests[3].url == second.request_url); +} + +TEST_CASE("the activation poll floor also covers the client accessor") +{ + facade_fixture fixture; + const auto request = pending_activation(); + fixture.transport->responses.push_back(http_response{204, {}, {}}); + + CHECK_FALSE(fixture.instance.get_requested_activation(request).has_value()); + CHECK_FALSE(fixture.instance.client().get_requested_activation(request).has_value()); + CHECK(fixture.transport->requests.size() == 1); +} + +TEST_CASE("a failed activation poll still counts against the interval") +{ + facade_fixture fixture; + const auto request = pending_activation(); + // No queued response: the transport throws, as a dropped connection would. + + CHECK_THROWS((void)fixture.instance.get_requested_activation(request)); + CHECK_FALSE(fixture.instance.get_requested_activation(request).has_value()); + CHECK(fixture.transport->requests.size() == 1); +} + +TEST_CASE("concurrent activation polls inside the interval send one request") +{ + facade_fixture fixture; + const auto request = pending_activation(); + fixture.transport->responses.push_back(http_response{204, {}, {}}); + + std::vector threads; + for (int i = 0; i < 8; ++i) { + threads.emplace_back([&] { (void)fixture.instance.get_requested_activation(request); }); + } + for (auto& thread : threads) { + thread.join(); + } + + CHECK(fixture.transport->requests.size() == 1); +} + TEST_CASE("generate_device_token emits a base64 JSON descriptor of the device and product") { facade_fixture fixture;