From aa405b17774f03b9675d7c5c7c0ac8990610dcb5 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Thu, 7 Sep 2023 21:54:56 -0300 Subject: [PATCH 01/13] Bootstraps the store mock server Import the building blocks from package restserver --- .../storemockserver/storemockserver.go | 153 ++++++++++++++++++ mocks/storeserver/storeserver/main.go | 30 ++++ 2 files changed, 183 insertions(+) create mode 100644 mocks/storeserver/storemockserver/storemockserver.go create mode 100644 mocks/storeserver/storeserver/main.go diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go new file mode 100644 index 000000000..21fc391b1 --- /dev/null +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -0,0 +1,153 @@ +// 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 ( + "fmt" + "net/http" + "time" + + "github.com/canonical/ubuntu-pro-for-windows/mocks/restserver" +) + +// Settings contains the parameters for the Server. +type Settings struct { + AllAuthenticatedUsers restserver.Endpoint + GenerateUserJWT restserver.Endpoint + GetProducts restserver.Endpoint + Purchase restserver.Endpoint + + AllProducts []Product + + address string +} + +// Server is a configurable mock of the MS Store runtime component that talks REST. +type Server struct { + restserver.ServerBase + settings Settings +} + +// SetAddress updates a Settings object with the new address. +func (s *Settings) SetAddress(address string) { + s.address = address +} + +// GetAddress returns the previously set address. +func (s *Settings) GetAddress() string { + return s.address +} + +// 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{ + address: "localhost:0", + 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: `"user@email.pizza"`, 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.EndpointOk(), + Purchase: restserver.EndpointOk(), + } +} + +// NewServer creates a new store mock server with the provided Settings. +func NewServer(s Settings) *Server { + sv := &Server{ + ServerBase: restserver.ServerBase{GetAddress: s.GetAddress}, + settings: s, + } + sv.Mux = sv.NewMux() + + return sv +} + +// NewMux sets up a ServeMux to handle the server endpoints enabled according to the server settings. +func (s *Server) NewMux() *http.ServeMux { + mux := http.NewServeMux() + + if !s.settings.AllAuthenticatedUsers.Disabled { + mux.HandleFunc("/allauthenticatedusers", s.generateHandler(s.settings.AllAuthenticatedUsers, s.handleAllAuthenticatedUsers)) + } + + if !s.settings.GenerateUserJWT.Disabled { + mux.HandleFunc("/generateuserjwt", s.generateHandler(s.settings.GenerateUserJWT, s.handleGenerateUserJWT)) + } + + if !s.settings.GetProducts.Disabled { + mux.HandleFunc("/products", s.generateHandler(s.settings.GetProducts, s.handleGetProducts)) + } + + if !s.settings.Purchase.Disabled { + mux.HandleFunc("/purchase", s.generateHandler(s.settings.Purchase, s.handlePurchase)) + } + + return mux +} + +// 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, `{"users":[%s]}`, resp.Value) +} + +func (s *Server) handleGenerateUserJWT(w http.ResponseWriter, r *http.Request) { + resp := s.settings.GenerateUserJWT.OnSuccess + if resp.Status != http.StatusOK { + w.WriteHeader(resp.Status) + fmt.Fprintf(w, "mock error: %d", resp.Status) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprintf(w, `{"jwt":%q}`, resp.Value) +} + +func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) { + resp := s.settings.GetProducts.OnSuccess + if resp.Status != http.StatusOK { + w.WriteHeader(resp.Status) + fmt.Fprintf(w, "mock error: %d", resp.Status) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprint(w, resp.Value) + +} + +func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { + resp := s.settings.Purchase.OnSuccess + if resp.Status != http.StatusOK { + w.WriteHeader(resp.Status) + fmt.Fprintf(w, "mock error: %d", resp.Status) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprintf(w, `{"result":%q}`, resp.Value) + +} diff --git a/mocks/storeserver/storeserver/main.go b/mocks/storeserver/storeserver/main.go new file mode 100644 index 000000000..1acfe0970 --- /dev/null +++ b/mocks/storeserver/storeserver/main.go @@ -0,0 +1,30 @@ +// 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 { + innerSettings, ok := settings.(*storemockserver.Settings) + if !ok { + panic("Cannot receive my own settings") + } + return storemockserver.NewServer(*innerSettings) +} + +func main() { + defaultSettings := storemockserver.DefaultSettings() + + app := restserver.Application{ + Name: "Store Server", + Description: "MS Store API", + DefaultSettings: &defaultSettings, + ServerFactory: serverFactory, + } + + os.Exit(app.Execute()) +} From 72a5ffc7b8bde48c1f030fcf63097aef19fcf1ab Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Thu, 7 Sep 2023 22:58:45 -0300 Subject: [PATCH 02/13] Handling purchase Failing fast: If the product is named "nonexistent" we fail fast = 400 If the product is named "servererror" we return a server error If the product is named "cannotpurchase" we return a not purchased error The product properties are updated if the purchase succeeds. If the requested product ID exists in the database the purchase suceeds, unless it is in the user collection already ("AlreadyPurchased") // https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storepurchasestatus?view=winrt-22621#fields --- .../storemockserver/storemockserver.go | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index 21fc391b1..b35376692 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -8,6 +8,7 @@ import ( "time" "github.com/canonical/ubuntu-pro-for-windows/mocks/restserver" + "golang.org/x/exp/slog" ) // Settings contains the parameters for the Server. @@ -139,15 +140,62 @@ func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) { } +// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storepurchasestatus?view=winrt-22621#fields +const ( + // "NetworkError" is technically not needed, since this is a client-originated error. + alreadyPurchased = "AlreadyPurchased" + notPurchased = "NotPurchased" + serverError = "ServerError" + succeeded = "Succeeded" +) + func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { - resp := s.settings.Purchase.OnSuccess - if resp.Status != http.StatusOK { - w.WriteHeader(resp.Status) - fmt.Fprintf(w, "mock error: %d", resp.Status) + id := r.URL.Query().Get("id") + + if len(id) == 0 { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "product ID is required.") + return + } + + if id == "nonexistent" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "product %s does not exist", id) + return + } + + if id == "servererror" { + slog.Info("server error triggered", id) + fmt.Fprintf(w, `{"status":%q}`, serverError) + return + } + + if id == "cannotpurchase" { + slog.Info("purchase error triggered", id) + fmt.Fprintf(w, `{"status":%q}`, notPurchased) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") - fmt.Fprintf(w, `{"result":%q}`, resp.Value) + for i, p := range s.settings.AllProducts { + if p.StoreID != id { + continue + } + + if p.IsInUserCollection { + slog.Info("product already in user collection", id) + fmt.Fprintf(w, `{"status":%q}`, alreadyPurchased) + 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. + s.settings.AllProducts[i].IsInUserCollection = true + fmt.Fprintf(w, `{"status":%q}`, succeeded) + return + } + + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "product %s does not exist", id) } From bf232c5b7f827545c91318bbb3b50398558df9db Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Thu, 7 Sep 2023 23:05:38 -0300 Subject: [PATCH 03/13] Handles GetProducts Products are filtered by IDs and Kinds as specified in MS Docs Return null is not an error. the output is solely governed by the store state and the query. No magic words for this method. See https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storecontext.getstoreproductsasync --- .../storemockserver/storemockserver.go | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index b35376692..bd52dd8c6 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -3,11 +3,13 @@ package storemockserver import ( + "encoding/json" "fmt" "net/http" "time" "github.com/canonical/ubuntu-pro-for-windows/mocks/restserver" + "golang.org/x/exp/slices" "golang.org/x/exp/slog" ) @@ -128,16 +130,23 @@ func (s *Server) handleGenerateUserJWT(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) { - resp := s.settings.GetProducts.OnSuccess - if resp.Status != http.StatusOK { - w.WriteHeader(resp.Status) - fmt.Fprintf(w, "mock error: %d", resp.Status) - return + q := r.URL.Query() + kinds := q["kinds"] + ids := q["ids"] + var productsFound []Product + for _, p := range s.settings.AllProducts { + if slices.Contains(kinds, p.ProductKind) && slices.Contains(ids, p.StoreID) { + productsFound = append(productsFound, p) + } } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - fmt.Fprint(w, resp.Value) + bs, err := json.Marshal(productsFound) + if err != nil { + fmt.Fprintf(w, "failed to marshall the matching products: %v", err) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprint(w, string(bs)) } // https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storepurchasestatus?view=winrt-22621#fields From c902fdbbdb496b0c79aa773f4bae8fbee5835da4 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Thu, 7 Sep 2023 23:34:04 -0300 Subject: [PATCH 04/13] Handles GenerateUserJWT serviceTicket (a.k.a. Azure token) is required. publisherUserID is not, but if supplied, goes back in the response "JWT" expiredtoken and servererror are special serviceTiket values --- .../storemockserver/storemockserver.go | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index bd52dd8c6..7a2ab9b3b 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -118,15 +118,37 @@ func (s *Server) handleAllAuthenticatedUsers(w http.ResponseWriter, r *http.Requ } func (s *Server) handleGenerateUserJWT(w http.ResponseWriter, r *http.Request) { - resp := s.settings.GenerateUserJWT.OnSuccess - if resp.Status != http.StatusOK { - w.WriteHeader(resp.Status) - fmt.Fprintf(w, "mock error: %d", resp.Status) + q := r.URL.Query() + // https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storecontext.getcustomerpurchaseidasync + serviceTicket := q.Get("serviceticket") + publisherUserID := q.Get("publisheruserid") + if len(serviceTicket) == 0 { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "service ticket (Azure access token) is required.") + return + } + + // Predefined errors + if serviceTicket == "expiredtoken" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "service ticket is expired.") return } + if serviceTicket == "servererror" { + 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, `{"jwt":%q}`, resp.Value) + fmt.Fprintf(w, `{"jwt":%q}`, responseValue) } func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) { From d5a29e8efcf7964004f3f037d15543b9ee0bbd25 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Mon, 11 Sep 2023 15:08:17 -0300 Subject: [PATCH 05/13] Updates after rebase NewMux was inlined as with the contracs server mock Address no longer part of Settings s/EndpointOk/NewEndpoint/g --- .../storemockserver/storemockserver.go | 46 ++++++------------- mocks/storeserver/storeserver/main.go | 11 ++--- 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index 7a2ab9b3b..cd26a01bf 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -21,8 +21,6 @@ type Settings struct { Purchase restserver.Endpoint AllProducts []Product - - address string } // Server is a configurable mock of the MS Store runtime component that talks REST. @@ -31,16 +29,6 @@ type Server struct { settings Settings } -// SetAddress updates a Settings object with the new address. -func (s *Settings) SetAddress(address string) { - s.address = address -} - -// GetAddress returns the previously set address. -func (s *Settings) GetAddress() string { - return s.address -} - // Product models the interesting properties from the MS StoreProduct type. type Product struct { StoreID string @@ -54,48 +42,42 @@ type Product struct { // DefaultSettings returns the default set of Settings for the server. func DefaultSettings() Settings { return Settings{ - address: "localhost:0", 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: `"user@email.pizza"`, 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.EndpointOk(), - Purchase: restserver.EndpointOk(), + GetProducts: restserver.NewEndpoint(), + Purchase: restserver.NewEndpoint(), } } // NewServer creates a new store mock server with the provided Settings. func NewServer(s Settings) *Server { sv := &Server{ - ServerBase: restserver.ServerBase{GetAddress: s.GetAddress}, - settings: s, + settings: s, } - sv.Mux = sv.NewMux() - return sv -} - -// NewMux sets up a ServeMux to handle the server endpoints enabled according to the server settings. -func (s *Server) NewMux() *http.ServeMux { mux := http.NewServeMux() - if !s.settings.AllAuthenticatedUsers.Disabled { - mux.HandleFunc("/allauthenticatedusers", s.generateHandler(s.settings.AllAuthenticatedUsers, s.handleAllAuthenticatedUsers)) + if !s.AllAuthenticatedUsers.Disabled { + mux.HandleFunc("/allauthenticatedusers", sv.generateHandler(s.AllAuthenticatedUsers, sv.handleAllAuthenticatedUsers)) } - if !s.settings.GenerateUserJWT.Disabled { - mux.HandleFunc("/generateuserjwt", s.generateHandler(s.settings.GenerateUserJWT, s.handleGenerateUserJWT)) + if !s.GenerateUserJWT.Disabled { + mux.HandleFunc("/generateuserjwt", sv.generateHandler(s.GenerateUserJWT, sv.handleGenerateUserJWT)) } - if !s.settings.GetProducts.Disabled { - mux.HandleFunc("/products", s.generateHandler(s.settings.GetProducts, s.handleGetProducts)) + if !s.GetProducts.Disabled { + mux.HandleFunc("/products", sv.generateHandler(s.GetProducts, sv.handleGetProducts)) } - if !s.settings.Purchase.Disabled { - mux.HandleFunc("/purchase", s.generateHandler(s.settings.Purchase, s.handlePurchase)) + if !s.Purchase.Disabled { + mux.HandleFunc("/purchase", sv.generateHandler(s.Purchase, sv.handlePurchase)) } - return mux + sv.Mux = mux + + return sv } // Generates a request handler function by chaining calls to the server request validation routine and the actual handler. diff --git a/mocks/storeserver/storeserver/main.go b/mocks/storeserver/storeserver/main.go index 1acfe0970..e577000d8 100644 --- a/mocks/storeserver/storeserver/main.go +++ b/mocks/storeserver/storeserver/main.go @@ -9,22 +9,19 @@ import ( ) func serverFactory(settings restserver.Settings) restserver.Server { - innerSettings, ok := settings.(*storemockserver.Settings) - if !ok { - panic("Cannot receive my own settings") - } - return storemockserver.NewServer(*innerSettings) + //nolint:forcetypeassert // Let the type coersion panic on failure. + return storemockserver.NewServer(settings.(storemockserver.Settings)) } func main() { defaultSettings := storemockserver.DefaultSettings() - app := restserver.Application{ + app := restserver.App{ Name: "Store Server", Description: "MS Store API", DefaultSettings: &defaultSettings, ServerFactory: serverFactory, } - os.Exit(app.Execute()) + os.Exit(app.Run()) } From 03f8c80368d5e26987c6930e2429d0fbf7706bbd Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Tue, 12 Sep 2023 11:37:54 -0300 Subject: [PATCH 06/13] Export constants --- .../storemockserver/storemockserver.go | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index cd26a01bf..9dbaf0edb 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -13,6 +13,80 @@ import ( "golang.org/x/exp/slog" ) +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 From 6a30ca08675bfad719d6bb650a2a1b0f441bc44e Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Tue, 12 Sep 2023 11:39:15 -0300 Subject: [PATCH 07/13] Reuses the exported constants --- .../storemockserver/storemockserver.go | 49 ++++++++----------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index 9dbaf0edb..4693643a5 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -134,19 +134,19 @@ func NewServer(s Settings) *Server { mux := http.NewServeMux() if !s.AllAuthenticatedUsers.Disabled { - mux.HandleFunc("/allauthenticatedusers", sv.generateHandler(s.AllAuthenticatedUsers, sv.handleAllAuthenticatedUsers)) + mux.HandleFunc(AllAuthenticatedUsersPath, sv.generateHandler(s.AllAuthenticatedUsers, sv.handleAllAuthenticatedUsers)) } if !s.GenerateUserJWT.Disabled { - mux.HandleFunc("/generateuserjwt", sv.generateHandler(s.GenerateUserJWT, sv.handleGenerateUserJWT)) + mux.HandleFunc(GenerateUserJWTPath, sv.generateHandler(s.GenerateUserJWT, sv.handleGenerateUserJWT)) } if !s.GetProducts.Disabled { - mux.HandleFunc("/products", sv.generateHandler(s.GetProducts, sv.handleGetProducts)) + mux.HandleFunc(ProductPath, sv.generateHandler(s.GetProducts, sv.handleGetProducts)) } if !s.Purchase.Disabled { - mux.HandleFunc("/purchase", sv.generateHandler(s.Purchase, sv.handlePurchase)) + mux.HandleFunc(PurchasePath, sv.generateHandler(s.Purchase, sv.handlePurchase)) } sv.Mux = mux @@ -170,14 +170,14 @@ func (s *Server) generateHandler(endpoint restserver.Endpoint, handler func(http func (s *Server) handleAllAuthenticatedUsers(w http.ResponseWriter, r *http.Request) { resp := s.settings.AllAuthenticatedUsers.OnSuccess - fmt.Fprintf(w, `{"users":[%s]}`, resp.Value) + 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("serviceticket") - publisherUserID := q.Get("publisheruserid") + 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.") @@ -185,13 +185,13 @@ func (s *Server) handleGenerateUserJWT(w http.ResponseWriter, r *http.Request) { } // Predefined errors - if serviceTicket == "expiredtoken" { + if serviceTicket == ExpiredTokenValue { w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, "service ticket is expired.") return } - if serviceTicket == "servererror" { + if serviceTicket == ServerErrorValue { w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "internal server error.") return @@ -204,13 +204,13 @@ func (s *Server) handleGenerateUserJWT(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json; charset=utf-8") - fmt.Fprintf(w, `{"jwt":%q}`, responseValue) + fmt.Fprintf(w, `{%q:%q}`, JWTResponseKey, responseValue) } func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - kinds := q["kinds"] - ids := q["ids"] + 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) { @@ -227,39 +227,30 @@ func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, string(bs)) } -// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storepurchasestatus?view=winrt-22621#fields -const ( - // "NetworkError" is technically not needed, since this is a client-originated error. - alreadyPurchased = "AlreadyPurchased" - notPurchased = "NotPurchased" - serverError = "ServerError" - succeeded = "Succeeded" -) - func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("id") + id := r.URL.Query().Get(ProductIDParam) if len(id) == 0 { w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, "product ID is required.") + fmt.Fprintf(w, "%s is required.", ProductIDParam) return } - if id == "nonexistent" { + if id == NonExistentValue { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "product %s does not exist", id) return } - if id == "servererror" { + if id == ServerErrorValue { slog.Info("server error triggered", id) - fmt.Fprintf(w, `{"status":%q}`, serverError) + fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, ServerErrorResult) return } if id == "cannotpurchase" { slog.Info("purchase error triggered", id) - fmt.Fprintf(w, `{"status":%q}`, notPurchased) + fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, NotPurchasedResult) return } @@ -272,14 +263,14 @@ func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { if p.IsInUserCollection { slog.Info("product already in user collection", id) - fmt.Fprintf(w, `{"status":%q}`, alreadyPurchased) + 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. s.settings.AllProducts[i].IsInUserCollection = true - fmt.Fprintf(w, `{"status":%q}`, succeeded) + fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, SucceededResult) return } From e643a5b097e9f901af1cd93110b410c7c661b5d7 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Tue, 12 Sep 2023 11:36:47 -0300 Subject: [PATCH 08/13] Fix slog import --- mocks/storeserver/storemockserver/storemockserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index 4693643a5..63eaf7753 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -5,12 +5,12 @@ 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" - "golang.org/x/exp/slog" ) const ( From 0ae40a08e55dc8d1bbad44e4ce8a4dcff22d6acb Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Tue, 12 Sep 2023 11:39:54 -0300 Subject: [PATCH 09/13] Fix slog calls --- mocks/storeserver/storemockserver/storemockserver.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index 63eaf7753..42342ee14 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -243,13 +243,13 @@ func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { } if id == ServerErrorValue { - slog.Info("server error triggered", id) + slog.Info("server error triggered", "endpoint", PurchasePath, ProductIDParam, id) fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, ServerErrorResult) return } - if id == "cannotpurchase" { - slog.Info("purchase error triggered", id) + if id == CannotPurchaseValue { + slog.Info("purchase error triggered", "endpoint", PurchasePath, ProductIDParam, id) fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, NotPurchasedResult) return } @@ -262,7 +262,7 @@ func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { } if p.IsInUserCollection { - slog.Info("product already in user collection", id) + slog.Info("product already in user collection", "endpoint", PurchasePath, ProductIDParam, id) fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, AlreadyPurchasedResult) return } From 96f164cda531a39193a6134ccbc7b5bff351e8b2 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Tue, 12 Sep 2023 11:39:38 -0300 Subject: [PATCH 10/13] Sets internal server error on JSON marshall error --- mocks/storeserver/storemockserver/storemockserver.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index 42342ee14..e446153c2 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -220,7 +220,9 @@ func (s *Server) handleGetProducts(w http.ResponseWriter, r *http.Request) { 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") From 0c60a9e5a705bf6c7b6ca60c68a4a9dc049cf088 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Tue, 12 Sep 2023 11:52:19 -0300 Subject: [PATCH 11/13] Restores double * to pass settings as interface --- mocks/storeserver/storeserver/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocks/storeserver/storeserver/main.go b/mocks/storeserver/storeserver/main.go index e577000d8..71fbb54f5 100644 --- a/mocks/storeserver/storeserver/main.go +++ b/mocks/storeserver/storeserver/main.go @@ -10,7 +10,7 @@ import ( func serverFactory(settings restserver.Settings) restserver.Server { //nolint:forcetypeassert // Let the type coersion panic on failure. - return storemockserver.NewServer(settings.(storemockserver.Settings)) + return storemockserver.NewServer(*settings.(*storemockserver.Settings)) } func main() { From 754209c8c55c1c8fb5d61db8a740dadea1569402 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Wed, 13 Sep 2023 09:41:48 -0300 Subject: [PATCH 12/13] Simplify passing Settings in main @EduardGomezEscandell showed that passing the pointer to defaultSettings was the reason for the double star in serverFactory. --- mocks/storeserver/storeserver/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mocks/storeserver/storeserver/main.go b/mocks/storeserver/storeserver/main.go index 71fbb54f5..348e13534 100644 --- a/mocks/storeserver/storeserver/main.go +++ b/mocks/storeserver/storeserver/main.go @@ -10,16 +10,14 @@ import ( func serverFactory(settings restserver.Settings) restserver.Server { //nolint:forcetypeassert // Let the type coersion panic on failure. - return storemockserver.NewServer(*settings.(*storemockserver.Settings)) + return storemockserver.NewServer(settings.(storemockserver.Settings)) } func main() { - defaultSettings := storemockserver.DefaultSettings() - app := restserver.App{ Name: "Store Server", Description: "MS Store API", - DefaultSettings: &defaultSettings, + DefaultSettings: storemockserver.DefaultSettings(), ServerFactory: serverFactory, } From 0b36e86fb24bd58b9d085fbd0fa92e81039f45c7 Mon Sep 17 00:00:00 2001 From: Carlos Nihelton Date: Fri, 15 Sep 2023 09:42:36 -0300 Subject: [PATCH 13/13] Avoid slog abuse :) Log messages must stay readable and complete. Structured logging fields are for data analysis. A human reading a log should not need to look into them to completely understand what's being reported. --- mocks/storeserver/storemockserver/storemockserver.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mocks/storeserver/storemockserver/storemockserver.go b/mocks/storeserver/storemockserver/storemockserver.go index e446153c2..0c9a4a35c 100644 --- a/mocks/storeserver/storemockserver/storemockserver.go +++ b/mocks/storeserver/storemockserver/storemockserver.go @@ -245,13 +245,13 @@ func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { } if id == ServerErrorValue { - slog.Info("server error triggered", "endpoint", PurchasePath, ProductIDParam, id) + 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("purchase error triggered", "endpoint", PurchasePath, ProductIDParam, id) + slog.Info(fmt.Sprintf("%s: purchase error triggered. Product ID was: %s", PurchasePath, id)) fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, NotPurchasedResult) return } @@ -264,7 +264,7 @@ func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) { } if p.IsInUserCollection { - slog.Info("product already in user collection", "endpoint", PurchasePath, ProductIDParam, id) + slog.Info(fmt.Sprintf("%s: product %q already in user collection", PurchasePath, id)) fmt.Fprintf(w, `{%q:%q}`, PurchaseStatusKey, AlreadyPurchasedResult) return }