Skip to content

Commit

Permalink
Implement differential updates (#115)
Browse files Browse the repository at this point in the history
  • Loading branch information
mschfh authored Nov 28, 2023
1 parent ac55de2 commit 8f32006
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 18 deletions.
27 changes: 23 additions & 4 deletions controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import (
"log"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
"github.com/brave/go-update/extension"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
Expand Down Expand Up @@ -44,8 +46,11 @@ func IsJSONRequest(contentType string) bool {
}

func initExtensionUpdatesFromDynamoDB() {
sess, err := session.NewSession(&aws.Config{})

awsConfig := &aws.Config{}
if endpoint := os.Getenv("DYNAMODB_ENDPOINT"); endpoint != "" {
awsConfig.Endpoint = aws.String(endpoint)
}
sess, err := session.NewSession(awsConfig)
if err != nil {
log.Printf("failed to connect to new session %v\n", err)
sentry.CaptureException(err)
Expand All @@ -71,13 +76,27 @@ func initExtensionUpdatesFromDynamoDB() {
// Update the extensions map
for _, item := range result.Items {
id := *item["ID"].S
AllExtensionsMap.Store(id, extension.Extension{

ext := extension.Extension{
ID: id,
Blacklisted: *item["Disabled"].BOOL,
SHA256: *item["SHA256"].S,
Title: *item["Title"].S,
Version: *item["Version"].S,
})
}

if plist := item["PatchList"]; plist != nil {
var pinfo map[string]*extension.PatchInfo
if err := dynamodbattribute.UnmarshalMap(plist.M, &pinfo); err != nil {
log.Printf("failed to parse PatchList %v\n", err)
sentry.CaptureException(err)
} else {
ext.PatchList = pinfo
}
}

AllExtensionsMap.Store(id, ext)

}
}

Expand Down
11 changes: 11 additions & 0 deletions extension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,24 @@ import (
"sync"
)

type PatchInfo struct {
Hashdiff string `json:"hashdiff"`
Namediff string `json:"namediff"`
Sizediff int `json:"sizediff"`
}

// Extension represents an extension which is both used in update checks
// and responses.
type Extension struct {
ID string
FP string
Version string
SHA256 string
Title string
URL string
Blacklisted bool
Status string
PatchList map[string]*PatchInfo
}

// Extensions is type for a slice of Extension.
Expand Down Expand Up @@ -83,6 +91,9 @@ func (updateRequest *UpdateRequest) FilterForUpdates(allExtensionsMap *Extension
if status == 0 {
foundExtension.Status = "noupdate"
}

foundExtension.FP = extensionBeingChecked.FP

filteredExtensions = append(filteredExtensions, foundExtension)
}
}
Expand Down
47 changes: 41 additions & 6 deletions extension/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@ import (
// MarshalJSON encodes the extension list into response JSON
func (updateResponse *UpdateResponse) MarshalJSON() ([]byte, error) {
type URL struct {
Codebase string `json:"codebase"`
Codebase string `json:"codebase,omitempty"`
CodebaseDiff string `json:"codebasediff,omitempty"`
}
type URLs struct {
URLs []URL `json:"url"`
}
type Package struct {
Name string `json:"name"`
SHA256 string `json:"hash_sha256"`
Required bool `json:"required"`
Name string `json:"name"`
NameDiff string `json:"namediff,omitempty"`
SizeDiff int `json:"sizediff,omitempty"`
FP string `json:"fp"`
SHA256 string `json:"hash_sha256"`
DiffSHA256 string `json:"hashdiff_sha256,omitempty"`
Required bool `json:"required"`
}
type Packages struct {
Package []Package `json:"package"`
Expand Down Expand Up @@ -50,9 +55,11 @@ func (updateResponse *UpdateResponse) MarshalJSON() ([]byte, error) {
response.Server = "prod"
for _, extension := range *updateResponse {
app := App{AppID: extension.ID, Status: "ok"}
patchInfo, pInfoFound := extension.PatchList[extension.FP]
app.UpdateCheck = UpdateCheck{Status: GetUpdateStatus(extension)}
extensionName := "extension_" + strings.Replace(extension.Version, ".", "_", -1) + ".crx"
url := "https://" + GetS3ExtensionBucketHost(extension.ID) + "/release/" + extension.ID + "/" + extensionName
diffUrl := "https://" + GetS3ExtensionBucketHost(extension.ID) + "/release/" + extension.ID + "/patches/" + extension.SHA256 + "/"
if app.UpdateCheck.Status == "ok" {
if app.UpdateCheck.URLs == nil {
app.UpdateCheck.URLs = &URLs{
Expand All @@ -62,15 +69,27 @@ func (updateResponse *UpdateResponse) MarshalJSON() ([]byte, error) {
app.UpdateCheck.URLs.URLs = append(app.UpdateCheck.URLs.URLs, URL{
Codebase: url,
})

app.UpdateCheck.Manifest = &Manifest{
Version: extension.Version,
}

pkg := Package{
Name: extensionName,
SHA256: extension.SHA256,
FP: extension.SHA256,
Required: true,
}

if pInfoFound {
app.UpdateCheck.URLs.URLs = append(app.UpdateCheck.URLs.URLs, URL{
CodebaseDiff: diffUrl,
})
pkg.NameDiff = patchInfo.Namediff
pkg.DiffSHA256 = patchInfo.Hashdiff
pkg.SizeDiff = patchInfo.Sizediff
}

app.UpdateCheck.Manifest.Packages.Package = append(app.UpdateCheck.Manifest.Packages.Package, pkg)
}

Expand Down Expand Up @@ -129,9 +148,17 @@ func (updateResponse *WebStoreUpdateResponse) MarshalJSON() ([]byte, error) {

// UnmarshalJSON decodes the update server request JSON data for a list of extensions
func (updateRequest *UpdateRequest) UnmarshalJSON(b []byte) error {
type Package struct {
FP string `json:"fp"`
}
type Packages struct {
Package []Package `json:"package"`
}
type App struct {
AppID string `json:"appid"`
Version string `json:"version"`
AppID string `json:"appid"`
FP string `json:"fp"`
Version string `json:"version"`
Packages Packages `json:"packages"`
}
type Request struct {
OS string `json:"@os"`
Expand All @@ -151,8 +178,16 @@ func (updateRequest *UpdateRequest) UnmarshalJSON(b []byte) error {

*updateRequest = UpdateRequest{}
for _, app := range request.Request.App {
fp := app.FP
// spec discrepancy: FP might be set within a "package" object (v3) instead of the "app" object (v3.1)
// https://github.com/google/omaha/blob/main/doc/ServerProtocolV3.md#package-request
// https://chromium.googlesource.com/chromium/src.git/+/master/docs/updater/protocol_3_1.md#update-checks-body-update-check-request-objects-update-check-request-3
if fp == "" && len(app.Packages.Package) > 0 {
fp = app.Packages.Package[0].FP
}
*updateRequest = append(*updateRequest, Extension{
ID: app.AppID,
FP: fp,
Version: app.Version,
})
}
Expand Down
4 changes: 2 additions & 2 deletions extension/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestUpdateResponseMarshalJSON(t *testing.T) {
updateResponse = []Extension{darkThemeExtension}
jsonData, err = json.Marshal(&updateResponse)
assert.Nil(t, err)
expectedOutput = `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + GetS3ExtensionBucketHost(darkThemeExtension.ID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
expectedOutput = `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + GetS3ExtensionBucketHost(darkThemeExtension.ID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
assert.Equal(t, expectedOutput, string(jsonData))

// Multiple extensions returns a multiple extension JSON update
Expand All @@ -36,7 +36,7 @@ func TestUpdateResponseMarshalJSON(t *testing.T) {
updateResponse = []Extension{lightThemeExtension, darkThemeExtension}
jsonData, err = json.Marshal(&updateResponse)
assert.Nil(t, err)
expectedOutput = `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + GetS3ExtensionBucketHost(lightThemeExtension.ID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}},{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + GetS3ExtensionBucketHost(darkThemeExtension.ID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
expectedOutput = `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + GetS3ExtensionBucketHost(lightThemeExtension.ID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}},{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + GetS3ExtensionBucketHost(darkThemeExtension.ID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
assert.Equal(t, expectedOutput, string(jsonData))
}

Expand Down
12 changes: 6 additions & 6 deletions server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ func TestUpdateExtensionsJSON(t *testing.T) {

// Single extension out of date
requestBody = lightThemeExtension("0.0.0")
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(lightThemeExtensionID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(lightThemeExtensionID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
testCall(t, server, http.MethodPost, contentTypeJSON, "", requestBody, http.StatusOK, expectedResponse, "")

// Single extension same version
Expand All @@ -452,17 +452,17 @@ func TestUpdateExtensionsJSON(t *testing.T) {

// Only one components out of date
requestBody = lightAndDarkThemeRequest("0.0.0", "70.0.0")
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(lightThemeExtensionID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(lightThemeExtensionID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
testCall(t, server, http.MethodPost, contentTypeJSON, "", requestBody, http.StatusOK, expectedResponse, "")

// Other component of 2 out of date
requestBody = lightAndDarkThemeRequest("70.0.0", "0.0.0")
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(darkThemeExtensionID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(darkThemeExtensionID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
testCall(t, server, http.MethodPost, contentTypeJSON, "", requestBody, http.StatusOK, expectedResponse, "")

// Both components need updates
requestBody = lightAndDarkThemeRequest("0.0.0", "0.0.0")
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(lightThemeExtensionID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}},{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(darkThemeExtensionID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"ldimlcelhnjgpjjemdjokpgeeikdinbm","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(lightThemeExtensionID) + `/release/ldimlcelhnjgpjjemdjokpgeeikdinbm/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","hash_sha256":"1c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}},{"appid":"bfdgpgibhagkpdlnjonhkabjoijopoge","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(darkThemeExtensionID) + `/release/bfdgpgibhagkpdlnjonhkabjoijopoge/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","hash_sha256":"ae517d6273a4fc126961cb026e02946db4f9dbb58e3d9bc29f5e1270e3ce9834","required":true}]}}}}]}}`
testCall(t, server, http.MethodPost, contentTypeJSON, "", requestBody, http.StatusOK, expectedResponse, "")

// Unknown extension ID goes to Google server via componentupdater proxy
Expand All @@ -486,12 +486,12 @@ func TestUpdateExtensionsJSON(t *testing.T) {

// Single new extension out of date that was added in by the refresh timer
requestBody = extensiontest.ExtensionRequestFnForJSON("newext1eplbcioakkpcpgfkobkghlhen")("0.0.0")
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"newext1eplbcioakkpcpgfkobkghlhen","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(newExtensionID1) + `/release/newext1eplbcioakkpcpgfkobkghlhen/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"4c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"newext1eplbcioakkpcpgfkobkghlhen","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(newExtensionID1) + `/release/newext1eplbcioakkpcpgfkobkghlhen/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"4c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","hash_sha256":"4c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
testCall(t, server, http.MethodPost, contentTypeJSON, "", requestBody, http.StatusOK, expectedResponse, "")

// Single second new extension out of date that was added in by the refresh timer
requestBody = extensiontest.ExtensionRequestFnForJSON("newext2eplbcioakkpcpgfkobkghlhen")("0.0.0")
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"newext2eplbcioakkpcpgfkobkghlhen","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(newExtensionID2) + `/release/newext2eplbcioakkpcpgfkobkghlhen/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","hash_sha256":"3c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
expectedResponse = jsonPrefix + `{"response":{"protocol":"3.1","server":"prod","app":[{"appid":"newext2eplbcioakkpcpgfkobkghlhen","status":"ok","updatecheck":{"status":"ok","urls":{"url":[{"codebase":"https://` + extension.GetS3ExtensionBucketHost(newExtensionID2) + `/release/newext2eplbcioakkpcpgfkobkghlhen/extension_1_0_0.crx"}]},"manifest":{"version":"1.0.0","packages":{"package":[{"name":"extension_1_0_0.crx","fp":"3c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","hash_sha256":"3c714fadd4208c63f74b707e4c12b81b3ad0153c37de1348fa810dd47cfc5618","required":true}]}}}}]}}`
testCall(t, server, http.MethodPost, contentTypeJSON, "", requestBody, http.StatusOK, expectedResponse, "")
}

Expand Down

0 comments on commit 8f32006

Please sign in to comment.