Skip to content

Commit

Permalink
Implements a Context that talks to the store mock server (#293)
Browse files Browse the repository at this point in the history
This PR adds a replacement for the thin MS Store API wrapper by a
not-so-thin client of the store mock server we presented in #268.
For ease of implementation I opted for writing a Windows specific
version of the mock client. You'll see that it's indeed not that hard to
talk to REST API's using WinRT. Since we adopted a very consistent
calling convention in our REST API, a single `call()` function models
very well talking to any of the exposed endpoints, so that the
`WinMockContext` methods just wrap around it passing the appropriate
arguments. The actual HTTP call could be more elaborated, using streamed
data to prevent against large responses, but I don't believe this is
necessary, because we control the server and we know the responses are
rather small.

Since this client code is not trivial, I added a few test cases for it,
but I didn't plug it in CI right away because running it is a bit
cumbersome:
1. We need to acquire a port to run the store mock server
2. Export the environment variable `UP4W_MS_STORE_MOCK_ENDPOINT` with
the address the mock server will run (`localhost:port`)
3. Run the store mock server passing that port and the testcase.yaml
configuration file.
4. Export the environment variable `UP4W_TEST_WITH_MS_STORE_MOCK` so the
replacement context takes the stage instead of the production one.
5. Configure and build with CMake
6. Run CTest.

Not that complicated, but requires Go to build the store mock server and
a few lines of powershell scripting to do the port thingy. So I found
that this is not the appropriate moment for pluging this on in the CI.
Also, the mock client is for supporting testing higher level components,
and this is testing it, not the targets.

Here's a gist of how I run on my computer:

```powershell

cd ./storeapi/test
$listener = new-object System.Net.Sockets.TcpListener("127.0.0.1",0)
$listener.Start()
$env:UP4W_TEST_WITH_MS_STORE_MOCK=1
cmake -S . -B .\out\build\
cmake --build .\out\build
$env:UP4W_MS_STORE_MOCK_ENDPOINT="$($listener.LocalEndpoint.Address):$($listener.LocalEndopint.Port)";
$listener.Stop()

go run ..\..\mocks\storeserver\storeserver\main.go -a $env:UP4W_MS_STORE_MOCK_ENDPOINT .\storeapi\test\testcase.yaml  # On another terminal

ctest --test-dir .\out\dir --output-on-failure
```

Fixes Udeng-1258
  • Loading branch information
CarlosNihelton authored Sep 22, 2023
2 parents 75dc4fc + 1615761 commit eda4994
Show file tree
Hide file tree
Showing 8 changed files with 499 additions and 13 deletions.
1 change: 1 addition & 0 deletions storeapi/base/DefaultContext.hpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#pragma once

#include "impl/StoreContext.hpp"
#include "impl/WinMockContext.hpp"
14 changes: 1 addition & 13 deletions storeapi/base/impl/StoreContext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
#include <winrt/Windows.System.h>
#include <winrt/base.h>

#include <algorithm>
#include <format>
#include <functional>
#include <iterator>
#include <type_traits>

#include "../Exception.hpp"
#include "WinRTHelpers.hpp"

namespace StoreApi::impl {

Expand All @@ -29,10 +29,6 @@ using winrt::Windows::Services::Store::StorePurchaseStatus;
using winrt::Windows::Services::Store::StoreSku;

namespace {
// Converts a span of strings into a vector of hstrings, needed when passing
// a collection of string as a parameter to an async operation.
std::vector<winrt::hstring> to_hstrings(std::span<const std::string> input);

// Translates a [StorePurchaseStatus] into the [PurchaseStatus] enum.
PurchaseStatus translate(StorePurchaseStatus purchaseStatus) noexcept;

Expand Down Expand Up @@ -140,14 +136,6 @@ std::vector<std::string> StoreContext::AllLocallyAuthenticatedUserHashes() {
}

namespace {
std::vector<winrt::hstring> to_hstrings(std::span<const std::string> input) {
std::vector<winrt::hstring> hStrs;
hStrs.reserve(input.size());
std::ranges::transform(input, std::back_inserter(hStrs),
&winrt::to_hstring<std::string>);
return hStrs;
}

PurchaseStatus translate(StorePurchaseStatus purchaseStatus) noexcept {
using winrt::Windows::Services::Store::StorePurchaseStatus;
switch (purchaseStatus) {
Expand Down
217 changes: 217 additions & 0 deletions storeapi/base/impl/WinMockContext.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#if defined UP4W_TEST_WITH_MS_STORE_MOCK && defined _MSC_VER

#include "WinMockContext.hpp"

#include <Windows.h>
#include <processenv.h>
#include <winrt/Windows.Data.Json.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Web.Http.h>
#include <winrt/base.h>

#include <algorithm>
#include <cassert>
#include <functional>
#include <iterator>
#include <sstream>
#include <unordered_map>
#include <utility>

#include "WinRTHelpers.hpp"

namespace StoreApi::impl {

using winrt::Windows::Data::Json::IJsonValue;
using winrt::Windows::Data::Json::JsonArray;
using winrt::Windows::Data::Json::JsonObject;
using UrlParams = std::unordered_multimap<winrt::hstring, winrt::hstring>;
using winrt::Windows::Foundation::Uri;

using winrt::Windows::Foundation::IAsyncOperation;

namespace {
// Handles the HTTP calls, returning a JsonObject containing the mock server
// response. Notice that the supplied path is relative.
IAsyncOperation<JsonObject> call(winrt::hstring relativePath,
UrlParams const& params = {});

/// Translates a textual representation of a purchase transaction result into an
/// instance of the PurchaseStatus enum.
StoreApi::PurchaseStatus translate(winrt::hstring const& purchaseStatus);
} // namespace

std::vector<WinMockContext::Product> WinMockContext::GetProducts(
std::span<const std::string> kinds,
std::span<const std::string> ids) const {
assert(!kinds.empty() && "kinds vector cannot be empty");
assert(!ids.empty() && "ids vector cannot be empty");

auto hKinds = to_hstrings(kinds);
auto hIds = to_hstrings(ids);

UrlParams parameters;

std::ranges::transform(
hKinds, std::inserter(parameters, parameters.end()),
[](winrt::hstring k) { return std::make_pair(L"kinds", k); });
std::ranges::transform(
hIds, std::inserter(parameters, parameters.end()),
[](winrt::hstring id) { return std::make_pair(L"ids", id); });

auto productsJson = call(L"/products", parameters).get();

JsonArray products = productsJson.GetNamedArray(L"products");

std::vector<WinMockContext::Product> result;
result.reserve(products.Size());
for (const IJsonValue& product : products) {
JsonObject p = product.GetObject();
result.emplace_back(WinMockContext::Product{p});
}

return result;
}

std::vector<std::string> WinMockContext::AllLocallyAuthenticatedUserHashes() {
JsonObject usersList = call(L"/allauthenticatedusers").get();
JsonArray users = usersList.GetNamedArray(L"users");

std::vector<std::string> result;
result.reserve(users.Size());
for (const IJsonValue& user : users) {
result.emplace_back(winrt::to_string(user.GetString()));
}

return result;
}

std::string WinMockContext::GenerateUserJwt(std::string token,
std::string userId) const {
assert(!token.empty() && "Azure AD token is required");
JsonObject res{nullptr};

UrlParams parameters{
{L"serviceticket", winrt::to_hstring(token)},
};
if (!userId.empty()) {
parameters.insert({L"publisheruserid", winrt::to_hstring(userId)});
}

res = call(L"generateuserjwt", parameters).get();

return winrt::to_string(res.GetNamedString(L"jwt"));
}

// NOOP, for now at least.
void WinMockContext::InitDialogs(Window parentWindow) {}

void WinMockContext::Product::PromptUserForPurchase(
PurchaseCallback callback) const {
using winrt::Windows::Foundation::AsyncStatus;

UrlParams params{{L"id", winrt::to_hstring(storeID)}};
call(L"purchase", params)
.Completed(
[cb = std::move(callback)](IAsyncOperation<JsonObject> const& async,
AsyncStatus const& asyncStatus) {
PurchaseStatus translated;
std::int32_t error = async.ErrorCode().value;
if (error != 0 || asyncStatus == AsyncStatus::Error) {
translated = PurchaseStatus::NetworkError;
} else {
auto json = async.GetResults();
auto status = json.GetNamedString(L"status");
translated = translate(status);
}

cb(translated, error);
});
}

WinMockContext::Product::Product(JsonObject const& json)
: storeID{winrt::to_string(json.GetNamedString(L"StoreID"))},
title{winrt::to_string(json.GetNamedString(L"Title"))},
description{winrt::to_string(json.GetNamedString(L"Description"))},
productKind{winrt::to_string(json.GetNamedString(L"ProductKind"))},
expirationDate{},
isInUserCollection{json.GetNamedBoolean(L"IsInUserCollection")}

{
std::chrono::system_clock::time_point tp{};
std::stringstream ss{
winrt::to_string(json.GetNamedString(L"ExpirationDate"))};
ss >> std::chrono::parse("%FT%T%Tz", tp);
expirationDate = tp;
}

namespace {
// Returns the mock server endpoint address and port by reading the environment
// variable UP4W_MS_STORE_MOCK_ENDPOINT or localhost:9 if the variable is unset.
winrt::hstring readStoreMockEndpoint() {
constexpr std::size_t endpointSize = 20;
wchar_t endpoint[endpointSize];
if (0 == GetEnvironmentVariableW(L"UP4W_MS_STORE_MOCK_ENDPOINT", endpoint,
endpointSize)) {
return L"127.0.0.1:9"; // Discard protocol
}
return endpoint;
}

// Builds a complete URI with a URL encoded query if params are passed.
IAsyncOperation<Uri> buildUri(winrt::hstring& relativePath,
UrlParams const& params) {
// Being tied t an environment variable means that it cannot change after
// program's creation. Thus, there is no reason for recreating this value
// every call.
static winrt::hstring endpoint = L"http://" + readStoreMockEndpoint();

if (!params.empty()) {
winrt::Windows::Web::Http::HttpFormUrlEncodedContent p{
{params.begin(), params.end()},
};
auto rawParams = co_await p.ReadAsStringAsync();
// http://127.0.0.1:56567/relativePath?param=value...
co_return Uri{endpoint, relativePath + L'?' + rawParams};
}
// http://127.0.0.1:56567/relativePath
co_return Uri{endpoint, relativePath};
}

IAsyncOperation<JsonObject> call(winrt::hstring relativePath,
UrlParams const& params) {
// Initialize only once.
static winrt::Windows::Web::Http::HttpClient httpClient{};

Uri uri = co_await buildUri(relativePath, params);

// We can rely on the fact that our mock will return small pieces of data
// certainly under 1 KB.
winrt::hstring contents = co_await httpClient.GetStringAsync(uri);
co_return JsonObject::Parse(contents);
}

StoreApi::PurchaseStatus translate(winrt::hstring const& purchaseStatus) {
if (purchaseStatus == L"Succeeded") {
return PurchaseStatus::Succeeded;
}

if (purchaseStatus == L"AlreadyPurchased") {
return StoreApi::PurchaseStatus::AlreadyPurchased;
}

if (purchaseStatus == L"NotPurchased") {
return StoreApi::PurchaseStatus::UserGaveUp;
}

if (purchaseStatus == L"ServerError") {
return StoreApi::PurchaseStatus::ServerError;
}

assert(false && "Missing enum elements to translate StorePurchaseStatus.");
return StoreApi::PurchaseStatus::Unknown; // To be future proof.
}

} // namespace
} // namespace StoreApi::impl
#endif // UP4W_TEST_WITH_MS_STORE_MOCK
90 changes: 90 additions & 0 deletions storeapi/base/impl/WinMockContext.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#pragma once

/// Implements a replacement for [StoreContext] which talks to the MS Store Mock
/// Server (still on Windows) instead of the real MS APIs.
/// DO NOT USE IN PRODUCTION.
#if defined UP4W_TEST_WITH_MS_STORE_MOCK && defined _MSC_VER

#include <chrono>
#include <span>
#include <string>
#include <type_traits>
#include <vector>

#include "../Purchase.hpp"

namespace winrt::Windows::Data::Json {
class JsonObject;
}

namespace StoreApi::impl {

class WinMockContext {
public:
using Window = std::int32_t;
class Product {
std::string storeID;
std::string title;
std::string description;
std::string productKind;
std::chrono::system_clock::time_point expirationDate;
bool isInUserCollection;

public:
// Whether the current user owns this product.
bool IsInUserCollection() const { return isInUserCollection; }

// Assuming this is a Subscription add-on product the current user __owns__,
// returns the expiration date of the current billing period.
std::chrono::system_clock::time_point CurrentExpirationDate() const {
return expirationDate;
}

protected:
// Assuming this is a Subscription add-on product the current user __does
// not own__, requests the runtime to display a purchase flow so users can
// subscribe to this product. THis function returns early, the result will
// eventually arrive through the supplied callback. This must be called from
// a UI thread with the underlying store context initialized with the parent
// GUI window because we need to render native dialogs. Thus, access to this
// method must be protected so we can ensure it can only happen with GUI
// clients, making API misuse harder to happen.
// See
// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storeproduct.requestpurchaseasync
void PromptUserForPurchase(PurchaseCallback callback) const;

public:
/// Creates a product from a JsonObject obtained from a call to the mock
/// server containing the relevant information.
explicit Product(winrt::Windows::Data::Json::JsonObject const& json);
Product() = default;
};

// Returns a collection of products matching the supplied [kinds] and [ids].
// Ids must match the Product IDs in Partner Center. Kinds can be:
// Application; Game; Consumable; UnmanagedConsumable; Durable. See
// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storeproduct.productkind#remarks
std::vector<Product> GetProducts(std::span<const std::string> kinds,
std::span<const std::string> ids) const;

// Generates the user ID key (a.k.a the JWT) provided the server AAD [token]
// and the [userId] the caller wants to have encoded in the JWT.
std::string GenerateUserJwt(std::string token, std::string userId) const;

// Initializes the GUI "subsystem" with the [parentWindow] handle so we can
// render native dialogs, such as when purchase or other kinds of
// authorization are required.
void InitDialogs(Window parentWindow);

// Returns a collection of hashes of all locally authenticated users running
// in this session. Most likely the collection will contain a single element.
static std::vector<std::string> AllLocallyAuthenticatedUserHashes();
};

} // namespace StoreApi::impl

namespace StoreApi {
using DefaultContext = impl::WinMockContext;
}

#endif // UP4W_TEST_WITH_MS_STORE_MOCK && _MSC_VER
19 changes: 19 additions & 0 deletions storeapi/base/impl/WinRTHelpers.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#include <winrt/base.h>

#include <algorithm>
#include <span>
#include <string>
#include <vector>

namespace StoreApi::impl {
// Converts a span of strings into a vector of hstrings, needed when passing
// a collection of string as a parameter to an async operation.
inline std::vector<winrt::hstring> to_hstrings(
std::span<const std::string> input) {
std::vector<winrt::hstring> hStrs;
hStrs.reserve(input.size());
std::ranges::transform(input, std::back_inserter(hStrs),
&winrt::to_hstring<std::string>);
return hStrs;
}
} // namespace StoreApi::impl
9 changes: 9 additions & 0 deletions storeapi/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ set(StoreApiServicesTests_SRCS
"ClientStoreServiceTest.cpp"
"ServerStoreServiceTest.cpp"
"StoreServiceTest.cpp"
"MockTest.cpp"
"../base/impl/WinMockContext.cpp"
)
set(StoreApiServicesTests_DEFINES "")
if(DEFINED ENV{UP4W_TEST_WITH_MS_STORE_MOCK})
list(APPEND StoreApiServicesTests_DEFINES "UP4W_TEST_WITH_MS_STORE_MOCK")
else()
message(STATUS "Building with the production version of MS Store client API. Set the environment variable 'UP4W_TEST_WITH_MS_STORE_MOCK' if you want to build with the mock store API.")
endif()

include(FetchContent)

Expand All @@ -40,6 +48,7 @@ enable_testing()
add_executable(StoreApiServicesTests ${StoreApi_SRCS} ${StoreApiServicesTests_SRCS} )
target_include_directories(StoreApiServicesTests PUBLIC ${CMAKE_CURRENT_LIST_DIR}/.. )
target_compile_features(StoreApiServicesTests PRIVATE cxx_std_20)
target_compile_definitions(StoreApiServicesTests PRIVATE ${StoreApiServicesTests_DEFINES})
set_target_properties(StoreApiServicesTests PROPERTIES VS_WINDOWS_TARGET_PLATFORM_MIN_VERSION 10.0.14393.0 COMPILE_WARNING_AS_ERROR ON)
target_link_libraries(StoreApiServicesTests PRIVATE WindowsApp GTest::gtest_main)
if(MSVC)
Expand Down
Loading

0 comments on commit eda4994

Please sign in to comment.