Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MS Store API backend mock #268

Merged
merged 13 commits into from
Sep 15, 2023
281 changes: 281 additions & 0 deletions mocks/storeserver/storemockserver/storemockserver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
// Package storemockserver implements a mocked version of the Windows Runtime components involved in the MS Store API that talks via REST.
// DO NOT USE IN PRODUCTION
package storemockserver

import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"

"github.com/canonical/ubuntu-pro-for-windows/mocks/restserver"
"golang.org/x/exp/slices"
)

const (
// endpoint paths.

//AllAuthenticatedUsersPath is the path to GET the list of anonymous user ID's locally authenticated.
AllAuthenticatedUsersPath = "/allauthenticatedusers"

// GenerateUserJWTPath is the path to GET the user's store ID key (a.k.a. JWT).
GenerateUserJWTPath = "/generateuserjwt"

// ProductPath is the path to GET a collection of products related to the current application.
ProductPath = "/products"

// PurchasePath is the path to GET to purchase a subscription.
PurchasePath = "/purchase"

// endpoint URL parameter keys.

// ProductIDParam is the URL encoded key parameter to the product ID.
ProductIDParam = "id"

// ProductIDsParam is the plural version of the above for retrieving a collection of products associated with the current application.
ProductIDsParam = "ids"

// ProductKindsParam is the URL encoded key parameter to filter the collection of products associated with the current application.
ProductKindsParam = "kinds"

// ServiceTicketParam is the URL encoded key parameter to the service ticket input to generate the user JWT (a.k.a. the Azure AD token).
ServiceTicketParam = "serviceticket"

// PublisherUserIDParam is the URL encoded key parameter to the anonymous user ID to be encoded in the JWT (a.k.a. the user ID).
PublisherUserIDParam = "publisheruserid"

// predefined error triggering inputs.

// CannotPurchaseValue is the product ID that triggers a product purchase error.
CannotPurchaseValue = "cannotpurchase"

// ExpiredTokenValue is a token input that triggers the expired AAD token error.
ExpiredTokenValue = "expiredtoken"

// NonExistentValue is the product ID that triggers a product not found error.
NonExistentValue = "nonexistent"

// ServerErrorValue is the product ID and service ticket inputs that triggers an internal server error.
ServerErrorValue = "servererror"

// Purchase result values
// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storepurchasestatus?view=winrt-22621#fields
// "NetworkError" is technically not needed, since this is a client-originated error.

// AlreadyPurchasedResult is the response value from the purchase endpoint when the user has previously purchased the supplied product ID.
AlreadyPurchasedResult = "AlreadyPurchased"

// NotPurchasedResult is the response value from the purchase endpoint when not even the store known why it failed :) .
NotPurchasedResult = "NotPurchased"

// ServerErrorResult is the response value from the purchase endpoint when an internal server error happens.
ServerErrorResult = "ServerError"

// SucceededResult is the response value of a succesfull purchase.
SucceededResult = "Succeeded"

// JSON response schema.

// UsersResponseKey is the JSON key of the response containing the list of locally authenticated users.
UsersResponseKey = "users"

// JWTResponseKey is the JSON key of the user JWT response.
JWTResponseKey = "jwt"

// PurchaseStatusKey is the JSON key of the purchase status response.
PurchaseStatusKey = "status"
)

// Settings contains the parameters for the Server.
type Settings struct {
AllAuthenticatedUsers restserver.Endpoint
GenerateUserJWT restserver.Endpoint
GetProducts restserver.Endpoint
Purchase restserver.Endpoint

AllProducts []Product
}

// Server is a configurable mock of the MS Store runtime component that talks REST.
type Server struct {
restserver.ServerBase
settings Settings
}

// Product models the interesting properties from the MS StoreProduct type.
type Product struct {
StoreID string
Title string
Description string
IsInUserCollection bool
ProductKind string
ExpirationDate time.Time
}

// DefaultSettings returns the default set of Settings for the server.
func DefaultSettings() Settings {
return Settings{
AllProducts: []Product{{StoreID: "A_NICE_ID", Title: "A nice title", Description: "A nice description", IsInUserCollection: false, ProductKind: "Durable", ExpirationDate: time.Time{}}},
AllAuthenticatedUsers: restserver.Endpoint{OnSuccess: restserver.Response{Value: `"[email protected]"`, Status: http.StatusOK}},
GenerateUserJWT: restserver.Endpoint{OnSuccess: restserver.Response{Value: "AMAZING_JWT", Status: http.StatusOK}},
// Predefined success configuration for those endpoints doesn't really make sense.
GetProducts: restserver.NewEndpoint(),
Purchase: restserver.NewEndpoint(),
}
}

// NewServer creates a new store mock server with the provided Settings.
func NewServer(s Settings) *Server {
sv := &Server{
settings: s,
}

mux := http.NewServeMux()

if !s.AllAuthenticatedUsers.Disabled {
mux.HandleFunc(AllAuthenticatedUsersPath, sv.generateHandler(s.AllAuthenticatedUsers, sv.handleAllAuthenticatedUsers))
}

if !s.GenerateUserJWT.Disabled {
mux.HandleFunc(GenerateUserJWTPath, sv.generateHandler(s.GenerateUserJWT, sv.handleGenerateUserJWT))
}

if !s.GetProducts.Disabled {
mux.HandleFunc(ProductPath, sv.generateHandler(s.GetProducts, sv.handleGetProducts))
}

if !s.Purchase.Disabled {
mux.HandleFunc(PurchasePath, sv.generateHandler(s.Purchase, sv.handlePurchase))
}

sv.Mux = mux

return sv
}

// Generates a request handler function by chaining calls to the server request validation routine and the actual handler.
func (s *Server) generateHandler(endpoint restserver.Endpoint, handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if err := s.ValidateRequest(w, r, http.MethodGet, endpoint); err != nil {
fmt.Fprintf(w, "%v", err)
return
}

handler(w, r)
}
}

// Handlers

func (s *Server) handleAllAuthenticatedUsers(w http.ResponseWriter, r *http.Request) {
resp := s.settings.AllAuthenticatedUsers.OnSuccess
fmt.Fprintf(w, `{%q:[%s]}`, UsersResponseKey, resp.Value)
}

func (s *Server) handleGenerateUserJWT(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storecontext.getcustomerpurchaseidasync
serviceTicket := q.Get(ServiceTicketParam)
publisherUserID := q.Get(PublisherUserIDParam)
if len(serviceTicket) == 0 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, "service ticket (Azure access token) is required.")
return
}

// Predefined errors
if serviceTicket == ExpiredTokenValue {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, "service ticket is expired.")
return
}

if serviceTicket == ServerErrorValue {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "internal server error.")
return
}

responseValue := s.settings.GenerateUserJWT.OnSuccess.Value
// The user JWT may encode an anonymous ID that identifies the current user in the context of services that manage the current app.
if len(publisherUserID) > 0 {
responseValue += "_from_user_" + publisherUserID
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprintf(w, `{%q:%q}`, JWTResponseKey, responseValue)
}

func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
kinds := q[ProductKindsParam]
ids := q[ProductIDsParam]
var productsFound []Product
for _, p := range s.settings.AllProducts {
if slices.Contains(kinds, p.ProductKind) && slices.Contains(ids, p.StoreID) {
productsFound = append(productsFound, p)
}
}

bs, err := json.Marshal(productsFound)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "failed to marshall the matching products: %v", err)
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, string(bs))
}

func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get(ProductIDParam)

if len(id) == 0 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "%s is required.", ProductIDParam)
return
}

if id == NonExistentValue {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "product %s does not exist", id)
return
}

if id == ServerErrorValue {
slog.Info(fmt.Sprintf("%s: server error triggered. Product ID was: %s", PurchasePath, id))
fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, ServerErrorResult)
return
}

if id == CannotPurchaseValue {
slog.Info(fmt.Sprintf("%s: purchase error triggered. Product ID was: %s", PurchasePath, id))
fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, NotPurchasedResult)
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")

for i, p := range s.settings.AllProducts {
if p.StoreID != id {
continue
}

if p.IsInUserCollection {
slog.Info(fmt.Sprintf("%s: product %q already in user collection", PurchasePath, id))
fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, AlreadyPurchasedResult)
return
}

year, month, day := time.Now().Date()
s.settings.AllProducts[i].ExpirationDate = time.Date(year+1, month, day, 1, 1, 1, 1, time.Local) // one year from now.
EduardGomezEscandell marked this conversation as resolved.
Show resolved Hide resolved
s.settings.AllProducts[i].IsInUserCollection = true
fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, SucceededResult)
return
}

w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "product %s does not exist", id)
}
25 changes: 25 additions & 0 deletions mocks/storeserver/storeserver/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Package main runs the MS Store server mock as its own process.
package main

import (
"os"

"github.com/canonical/ubuntu-pro-for-windows/mocks/restserver"
"github.com/canonical/ubuntu-pro-for-windows/mocks/storeserver/storemockserver"
)

func serverFactory(settings restserver.Settings) restserver.Server {
//nolint:forcetypeassert // Let the type coersion panic on failure.
return storemockserver.NewServer(settings.(storemockserver.Settings))
}

func main() {
app := restserver.App{
Name: "Store Server",
Description: "MS Store API",
DefaultSettings: storemockserver.DefaultSettings(),
ServerFactory: serverFactory,
}

os.Exit(app.Run())
}
Loading