Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/core-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ trying again can help:
| `api_error` | No definitive answer. The API was unreachable (`status_code()` is 0), rate limited (429), failing (5xx, including the gateway's own 502 and 504), or something other than the API answered, such as a captive portal | Retry later; `retry_after()` says when, if the server did |
| `license_invalid_error` | The API refused the license for good: a bad signature or product, or a revoked license or activation | Lock, and offer to activate again |
| `license_expired_error` | The license has expired: a trial ended, or a subscription lapsed | Lock, and offer to buy |
| `store_closed_error` | The merchant closed their Moonbase account, so the store answers 410 to everything. A `license_invalid_error`, as in the .NET SDK. Offline licenses keep working | Lock; nothing can be checked online again |
| `store_closed_error` | The merchant closed their Moonbase account. No verdict on the license | Treat it like being offline |
| `activation_request_error` | A browser activation request can never complete | Start a new request |
| `configuration_error` | Bad options, or a device id resolver that returned an empty id | Fix the setup |

Expand Down
4 changes: 2 additions & 2 deletions docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ you add hidden with `addChildComponent` shows when it is needed.
- **It says why.** The welcome screen swaps its title and body for the reason, from
`controller.lockReason()`: `Deactivated` (here or in another instance), `Expired` (a
subscription or offline license reached its end date), `Invalid` (Moonbase no longer
accepts it: revoked, store closed, or bound to another device) or `Unverified` (not
accepts it: revoked, or bound to another device) or `Unverified` (not
checked online within `onlineGracePeriod`). A trial that ends gets the **Trial
expired** screen instead. The copy is in `config.strings` (`deactivatedTitle`,
`licenseExpiredBody`, and so on), and `lockReason()` goes back to `None` once a
Expand Down Expand Up @@ -507,7 +507,7 @@ non-fatal: the current license is kept and the reason goes to `onDiagnostic`. So
limiting, a server error, and a response that didn't come from Moonbase (a captive
portal's sign-in page), as long as the license is within `onlineGracePeriod` of its last
successful check. A definitive rejection is not: when the server says the license
was revoked or has lapsed, or that the store has closed, the controller drops it
was revoked or has lapsed, the controller drops it
and shows the welcome screen, and `onActivationChanged` fires. The same happens when the
grace period has run out and Moonbase still can't be reached. The license file stays, as it
does when `start()` meets the same answer, so the next launch checks it again. Offline
Expand Down
16 changes: 6 additions & 10 deletions include/moonbase/errors.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,14 @@ class license_device_mismatch_error : public license_invalid_error {
}
};

// The merchant has closed their Moonbase account, so their store answers every
// request with 410 StoreClosed: nothing about a license can be checked, activated
// or renewed online again. Not transient, so it must not ride the offline grace
// period. An offline license keeps working, since it never asks the server.
//
// Derives from license_invalid_error, as the .NET SDK's StoreClosedException
// does, so existing catch sites and the grace-period logic treat it as the
// definitive answer it is. Code switching on type() must add the new case.
class store_closed_error : public license_invalid_error {
// The merchant has closed their Moonbase account (410 StoreClosed). Not a
// license_invalid_error: it says nothing against the license, so treat it like
// being offline. An activated device's validation gets a signed offline license
// from the closed store instead.
class store_closed_error : public moonbase_error {
public:
explicit store_closed_error(const std::string& message)
: license_invalid_error(error_type::store_closed, message)
: moonbase_error(error_type::store_closed, message)
{
}
};
Expand Down
1 change: 1 addition & 0 deletions include/moonbase/licensing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ class licensing {
} catch (const license_expired_error&) {
throw;
} catch (const std::exception&) {
// store_closed_error included: it is not signed, so it relaxes nothing.
if (age <= options_.online_validation_grace_period) {
return freshest;
}
Expand Down
2 changes: 1 addition & 1 deletion modules/moonbase_licensing/juce/ActivationConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ enum class LockReason
None, // licensed, or nothing to explain (never activated here)
Deactivated, // deactivated or forgotten on this computer, here or in another instance
Expired, // the license reached its end date (a trial also gets the Expired screen)
Invalid, // Moonbase no longer accepts it: revoked, store closed, or bound to another device
Invalid, // Moonbase no longer accepts it: revoked, or bound to another device
Unverified, // it couldn't be checked online within onlineGracePeriod
};

Expand Down
14 changes: 8 additions & 6 deletions modules/moonbase_licensing/juce/ActivationController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ void ActivationController::start()
}
catch (const moonbase::license_invalid_error& ex)
{
// Refused for good: revoked, store closed -> locked.
// Refused for good, such as revoked -> locked.
diag = juce::String("Re-validating stored license failed: ") + describeError(ex);
reason = LockReason::Invalid;
}
Expand Down Expand Up @@ -568,8 +568,7 @@ void ActivationController::refreshLicense(bool force, std::function<void(bool)>
catch (const moonbase::license_invalid_error& ex)
{
// The server refused the license for good: it was revoked or
// deleted, or the store has closed. Also not a network blip, so this
// locks too.
// deleted. Also not a network blip, so this locks too.
rejected = true;
diag = juce::String::fromUTF8(ex.what());
}
Expand Down Expand Up @@ -875,8 +874,9 @@ void ActivationController::deactivate()
juce::String diag;
juce::String userMessage;
// A license_invalid_error here means the server refused the token for good
// (it can't be revoked, or the store has closed), so forgetting it locally
// is all that is left to do.
// (it can't be revoked), so forgetting it locally is all that is left to
// do. On a closed store the license stays: this device could never be
// activated again.
try
{
licensing->revoke_activation(token);
Expand All @@ -889,7 +889,9 @@ void ActivationController::deactivate()
outcome = Outcome::Unreachable;
diag = describeError(ex);
const auto failure = classifyFailure(ex);
if (failure == Failure::Busy)
if (failure == Failure::StoreClosed)
userMessage = "This store has closed, so this computer can't be deactivated.";
else if (failure == Failure::Busy)
userMessage = "Moonbase couldn't deactivate right now. Try again in a minute.";
else if (failure == Failure::Unexpected)
userMessage = unexpectedFailure("Couldn't deactivate", ex);
Expand Down
16 changes: 6 additions & 10 deletions modules/moonbase_licensing/moonbase/errors.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,14 @@ class license_device_mismatch_error : public license_invalid_error {
}
};

// The merchant has closed their Moonbase account, so their store answers every
// request with 410 StoreClosed: nothing about a license can be checked, activated
// or renewed online again. Not transient, so it must not ride the offline grace
// period. An offline license keeps working, since it never asks the server.
//
// Derives from license_invalid_error, as the .NET SDK's StoreClosedException
// does, so existing catch sites and the grace-period logic treat it as the
// definitive answer it is. Code switching on type() must add the new case.
class store_closed_error : public license_invalid_error {
// The merchant has closed their Moonbase account (410 StoreClosed). Not a
// license_invalid_error: it says nothing against the license, so treat it like
// being offline. An activated device's validation gets a signed offline license
// from the closed store instead.
class store_closed_error : public moonbase_error {
public:
explicit store_closed_error(const std::string& message)
: license_invalid_error(error_type::store_closed, message)
: moonbase_error(error_type::store_closed, message)
{
}
};
Expand Down
1 change: 1 addition & 0 deletions modules/moonbase_licensing/moonbase/licensing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ class licensing {
} catch (const license_expired_error&) {
throw;
} catch (const std::exception&) {
// store_closed_error included: it is not signed, so it relaxes nothing.
if (age <= options_.online_validation_grace_period) {
return freshest;
}
Expand Down
14 changes: 10 additions & 4 deletions tests/client_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <optional>
#include <string>
#include <thread>
#include <type_traits>

#include <nlohmann/json.hpp>

Expand Down Expand Up @@ -933,7 +934,12 @@ const http_response store_closed{

} // namespace

TEST_CASE("a closed store is a definitive answer on every endpoint")
// Says nothing against the license, so code that deletes the stored license on
// a license_invalid_error must not see it.
static_assert(!std::is_base_of_v<license_invalid_error, store_closed_error>);
static_assert(!std::is_base_of_v<api_error, store_closed_error>);

TEST_CASE("every endpoint reports a closed store as store_closed_error")
{
const auto expect_store_closed = [](const std::function<void()>& call) {
try {
Expand All @@ -942,8 +948,6 @@ TEST_CASE("a closed store is a definitive answer on every endpoint")
} catch (const store_closed_error& ex) {
CHECK(std::string(ex.what()) == "This store has closed.");
CHECK(ex.type() == error_type::store_closed);
// A license_invalid_error, so grace and existing catch sites treat it as final.
CHECK(dynamic_cast<const license_invalid_error*>(&ex) != nullptr);
}
};

Expand Down Expand Up @@ -991,8 +995,10 @@ TEST_CASE("a 410 that is not the API's StoreClosed stays retryable")
try {
(void)fixture.client.validate_token_online("token");
FAIL("expected validation to throw");
} catch (const license_invalid_error&) {
} catch (const store_closed_error&) {
FAIL("only the API's own StoreClosed answer is final");
} catch (const license_invalid_error&) {
FAIL("a bare 410 is no verdict on the license");
} catch (const api_error& ex) {
CHECK(ex.status_code() == 410);
}
Expand Down
106 changes: 104 additions & 2 deletions tests/juce/controller_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,28 @@ TEST_CASE("deactivate() while Moonbase is rate limiting says to try again shortl
CHECK(fx.licenseFile.existsAsFile());
}

TEST_CASE("deactivate() keeps the license when the store has closed")
{
controller_fixture fx;
fx.seedStored(fx.token(default_claims()));
ActivationController controller(fx.config, fx.makeLicensing());
controller.start();
REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; }));

// The seat can't be freed, and nothing could be activated again, so
// forgetting the license would only take it away from the buyer.
fx.transport->responses.push_back(moonbase::http_response{
410, {}, R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"});
controller.deactivate();

REQUIRE(pumpUntil([&] { return ! controller.isBusy(); }));
CHECK(controller.screen() == Screen::Details);
REQUIRE(controller.license().has_value());
CHECK(controller.licensedFlag().load());
CHECK(controller.statusMessage().contains("This store has closed"));
CHECK(fx.licenseFile.existsAsFile());
}

//==============================================================================
// Offline activation flow (machine file out, license file in)
//==============================================================================
Expand Down Expand Up @@ -1187,8 +1209,6 @@ TEST_CASE("refreshLicense locks when the server rejects the license for good")
moonbase::http_response{400, {}, R"({"title":"Invalid state","detail":"License has expired","status":400,"errorType":"LicenseExpired"})"}},
{"license no longer active",
moonbase::http_response{400, {}, R"({"title":"Invalid state","detail":"License is no longer active","status":400})"}},
{"store closed",
moonbase::http_response{410, {}, R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"}},
{"activation revoked",
moonbase::http_response{400, {}, R"({"title":"Not allowed","detail":"License has been revoked","status":400,"errorType":"LicenseActivationRevoked"})"}},
};
Expand Down Expand Up @@ -1221,6 +1241,88 @@ TEST_CASE("refreshLicense locks when the server rejects the license for good")
}
}

namespace {
const moonbase::http_response storeClosed{
410, {}, R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"};
} // namespace

TEST_CASE("start() takes the offline license a closed store answers with, and never asks again")
{
controller_fixture fx;
auto online = default_claims();
online.erase("exp"); // bought outright
online["validated"] = now_seconds() - 60 * 60 * 24 * 365; // far past the grace period
fx.seedStored(fx.token(online));

// The same license as an offline one for this device, signed with the store's key.
auto offline = online;
offline["method"] = "Offline";
offline["validated"] = now_seconds();
offline.erase("p:rel");
const auto offlineToken = fx.token(offline);
fx.transport->responses.push_back(moonbase::http_response{200, {}, offlineToken});

ActivationController controller(fx.config, fx.makeLicensing());
controller.start();

REQUIRE(pumpUntil([&] { return settled(controller); }));
CHECK(controller.screen() == Screen::Details);
REQUIRE(controller.license().has_value());
CHECK(controller.license()->method == moonbase::activation_method::offline);
CHECK(controller.lockReason() == LockReason::None);
CHECK(fx.store->load_local_license()->token == offlineToken);

bool done = false;
controller.refreshLicense(true, [&](bool) { done = true; });
REQUIRE(pumpUntil([&] { return done; }));
CHECK(controller.license().has_value());
CHECK(fx.transport->requests.size() == 1); // offline licenses are not re-validated
}

TEST_CASE("a closed store's StoreClosed reads like being offline")
{
SUBCASE("within the grace period, refreshLicense keeps the license")
{
controller_fixture fx;
auto claims = default_claims();
claims["trial"] = true; // a trial's validation is one of the calls a closed store refuses
fx.seedStored(fx.token(claims));
ActivationController controller(fx.config, fx.makeLicensing());
controller.start();
REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Trial; }));

fx.transport->responses.push_back(storeClosed);
bool done = false, ok = true;
controller.refreshLicense(true, [&](bool refreshed) { done = true; ok = refreshed; });

REQUIRE(pumpUntil([&] { return done; }));
CHECK_FALSE(ok);
CHECK(controller.license().has_value());
CHECK(controller.screen() == Screen::Trial);
CHECK(fx.licenseFile.existsAsFile());
}

SUBCASE("past it, start() locks as unverified and keeps the file")
{
controller_fixture fx;
fx.config.onlineGracePeriod = std::chrono::seconds(20);
auto claims = default_claims();
claims["trial"] = true;
claims["validated"] = now_seconds() - 60;
fx.seedStored(fx.token(claims));
fx.transport->responses.push_back(storeClosed);

ActivationController controller(fx.config, fx.makeLicensing());
controller.start();

REQUIRE(pumpUntil([&] { return settled(controller); }));
CHECK(controller.screen() == Screen::Welcome);
CHECK_FALSE(controller.license().has_value());
CHECK(controller.lockReason() == LockReason::Unverified);
CHECK(fx.licenseFile.existsAsFile());
}
}

TEST_CASE("refreshLicense keeps the license through what only looks like a verdict")
{
const std::vector<std::pair<const char*, moonbase::http_response>> blips{
Expand Down
Loading
Loading