mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
9dfa7c3ae6
Replace MakeTempFile usage with MakeTempFilePath and MakeTempFileWithContent helpers that automatically handle file lifecycle. This prevents resource leaks by ensuring temporary files are properly closed. Shoudld also make the tests easier to read. Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
676 lines
17 KiB
Go
676 lines
17 KiB
Go
//go:build search
|
|
|
|
package server_test
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
. "github.com/smartystreets/goconvey/convey"
|
|
|
|
cli "zotregistry.dev/zot/v2/pkg/cli/server"
|
|
test "zotregistry.dev/zot/v2/pkg/test/common"
|
|
)
|
|
|
|
func TestConfigReloader(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("reload access control config", t, func(c C) {
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
|
|
logPath := test.MakeTempFilePath(t, "zot-log.txt")
|
|
|
|
username := "alice"
|
|
password := "alice"
|
|
|
|
htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password))
|
|
|
|
content := fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s",
|
|
"realm": "zot",
|
|
"auth": {
|
|
"htpasswd": {
|
|
"path": "%s"
|
|
},
|
|
"failDelay": 1
|
|
},
|
|
"accessControl": {
|
|
"repositories": {
|
|
"**": {
|
|
"policies": [
|
|
{
|
|
"users": ["charlie"],
|
|
"actions": ["read"]
|
|
}
|
|
],
|
|
"defaultPolicy": ["read", "create"]
|
|
}
|
|
},
|
|
"adminPolicy": {
|
|
"users": ["admin"],
|
|
"actions": ["read", "create", "update", "delete"]
|
|
}
|
|
}
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
}
|
|
}`, t.TempDir(), port, htpasswdPath, logPath)
|
|
|
|
cfgfile := test.MakeTempFile(t, "zot-test.json")
|
|
defer cfgfile.Close()
|
|
_, err := cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "serve", cfgfile.Name()}
|
|
|
|
go func() {
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
}()
|
|
|
|
test.WaitTillServerReady(baseURL)
|
|
|
|
// verify initial startup authentication logs
|
|
initialData, err := os.ReadFile(logPath)
|
|
So(err, ShouldBeNil)
|
|
So(string(initialData), ShouldContainSubstring, "configuration settings")
|
|
// verify authentication methods status messages are present in initial startup
|
|
verifyAuthenticationLogs(initialData, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": true,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
|
|
content = fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s",
|
|
"realm": "zot",
|
|
"auth": {
|
|
"htpasswd": {
|
|
"path": "%s"
|
|
},
|
|
"failDelay": 1
|
|
},
|
|
"accessControl": {
|
|
"repositories": {
|
|
"**": {
|
|
"policies": [
|
|
{
|
|
"users": ["alice"],
|
|
"actions": ["read", "create", "update", "delete"]
|
|
}
|
|
],
|
|
"defaultPolicy": ["read"]
|
|
}
|
|
},
|
|
"adminPolicy": {
|
|
"users": ["admin"],
|
|
"actions": ["read", "create", "update", "delete"]
|
|
}
|
|
}
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
}
|
|
}`, t.TempDir(), port, htpasswdPath, logPath)
|
|
|
|
err = cfgfile.Truncate(0)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.Seek(0, io.SeekStart)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
err = cfgfile.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
// wait for config reload
|
|
time.Sleep(2 * time.Second)
|
|
|
|
data, err := os.ReadFile(logPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
t.Logf("log file: %s", data)
|
|
So(string(data), ShouldContainSubstring, "reloaded params")
|
|
So(string(data), ShouldContainSubstring, "loaded new configuration settings")
|
|
So(string(data), ShouldContainSubstring, "\"Users\":[\"alice\"]")
|
|
So(string(data), ShouldContainSubstring, "\"Actions\":[\"read\",\"create\",\"update\",\"delete\"]")
|
|
// verify authentication methods status messages are present
|
|
verifyAuthenticationLogs(data, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": true,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
})
|
|
|
|
Convey("reload gc config", t, func(c C) {
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
|
|
logFile := test.MakeTempFile(t, "zot-log.txt")
|
|
defer logFile.Close()
|
|
|
|
content := fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": false,
|
|
"dedupe": false,
|
|
"subPaths": {
|
|
"/a": {
|
|
"rootDirectory": "%s",
|
|
"gc": false,
|
|
"dedupe": false
|
|
}
|
|
}
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
}
|
|
}`, t.TempDir(), t.TempDir(), port, logFile.Name())
|
|
|
|
cfgfile := test.MakeTempFile(t, "zot-test.json")
|
|
defer cfgfile.Close()
|
|
|
|
_, err := cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "serve", cfgfile.Name()}
|
|
|
|
go func() {
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
}()
|
|
|
|
test.WaitTillServerReady(baseURL)
|
|
|
|
// verify initial startup authentication logs (no auth configured)
|
|
initialData, err := os.ReadFile(logFile.Name())
|
|
So(err, ShouldBeNil)
|
|
So(string(initialData), ShouldContainSubstring, "configuration settings")
|
|
// verify authentication methods status messages are present in initial startup
|
|
verifyAuthenticationLogs(initialData, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": false,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
|
|
content = fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"dedupe": true,
|
|
"subPaths": {
|
|
"/a": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"dedupe": true
|
|
}
|
|
}
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
}
|
|
}`, t.TempDir(), t.TempDir(), port, logFile.Name())
|
|
|
|
err = cfgfile.Truncate(0)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.Seek(0, io.SeekStart)
|
|
So(err, ShouldBeNil)
|
|
|
|
// truncate log before changing config, for the ShouldNotContainString
|
|
So(logFile.Truncate(0), ShouldBeNil)
|
|
err = logFile.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
err = cfgfile.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
// wait for config reload
|
|
time.Sleep(2 * time.Second)
|
|
|
|
data, err := os.ReadFile(logFile.Name())
|
|
So(err, ShouldBeNil)
|
|
t.Logf("log file: %s", data)
|
|
|
|
So(string(data), ShouldContainSubstring, "reloaded params")
|
|
So(string(data), ShouldContainSubstring, "loaded new configuration settings")
|
|
So(string(data), ShouldContainSubstring, "\"GC\":true")
|
|
So(string(data), ShouldContainSubstring, "\"Dedupe\":true")
|
|
So(string(data), ShouldNotContainSubstring, "\"GC\":false")
|
|
So(string(data), ShouldNotContainSubstring, "\"Dedupe\":false")
|
|
// verify authentication methods status messages are present
|
|
verifyAuthenticationLogs(data, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": false,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
})
|
|
|
|
Convey("reload sync config", t, func(c C) {
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
|
|
logPath := test.MakeTempFilePath(t, "zot-log.txt")
|
|
|
|
content := fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
},
|
|
"extensions": {
|
|
"sync": {
|
|
"registries": [{
|
|
"urls": ["http://localhost:8080"],
|
|
"tlsVerify": false,
|
|
"onDemand": true,
|
|
"maxRetries": 3,
|
|
"retryDelay": "15m",
|
|
"certDir": "",
|
|
"content":[
|
|
{
|
|
"prefix": "zot-test",
|
|
"tags": {
|
|
"regex": ".*",
|
|
"semver": true
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
}
|
|
}
|
|
}`, t.TempDir(), port, logPath)
|
|
|
|
cfgfile := test.MakeTempFile(t, "zot-test.json")
|
|
defer cfgfile.Close()
|
|
|
|
_, err := cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "serve", cfgfile.Name()}
|
|
|
|
go func() {
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
}()
|
|
|
|
test.WaitTillServerReady(baseURL)
|
|
|
|
// verify initial startup authentication logs (no auth configured)
|
|
initialData, err := os.ReadFile(logPath)
|
|
So(err, ShouldBeNil)
|
|
So(string(initialData), ShouldContainSubstring, "configuration settings")
|
|
// verify authentication methods status messages are present in initial startup
|
|
verifyAuthenticationLogs(initialData, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": false,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
|
|
content = fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
},
|
|
"extensions": {
|
|
"sync": {
|
|
"registries": [{
|
|
"urls": ["http://localhost:9999"],
|
|
"tlsVerify": true,
|
|
"onDemand": false,
|
|
"maxRetries": 10,
|
|
"retryDelay": "5m",
|
|
"certDir": "certs",
|
|
"content":[
|
|
{
|
|
"prefix": "zot-cve-test",
|
|
"tags": {
|
|
"regex": "tag",
|
|
"semver": false
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
}
|
|
}
|
|
}`, t.TempDir(), port, logPath)
|
|
|
|
err = cfgfile.Truncate(0)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.Seek(0, io.SeekStart)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
err = cfgfile.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
// wait for config reload
|
|
time.Sleep(2 * time.Second)
|
|
|
|
data, err := os.ReadFile(logPath)
|
|
So(err, ShouldBeNil)
|
|
t.Logf("log file: %s", data)
|
|
|
|
So(string(data), ShouldContainSubstring, "reloaded params")
|
|
So(string(data), ShouldContainSubstring, "loaded new configuration settings")
|
|
So(string(data), ShouldContainSubstring, "\"URLs\":[\"http://localhost:9999\"]")
|
|
So(string(data), ShouldContainSubstring, "\"TLSVerify\":true")
|
|
So(string(data), ShouldContainSubstring, "\"OnDemand\":false")
|
|
So(string(data), ShouldContainSubstring, "\"MaxRetries\":10")
|
|
So(string(data), ShouldContainSubstring, "\"RetryDelay\":300000000000")
|
|
So(string(data), ShouldContainSubstring, "\"CertDir\":\"certs\"")
|
|
So(string(data), ShouldContainSubstring, "\"Prefix\":\"zot-cve-test\"")
|
|
So(string(data), ShouldContainSubstring, "\"Regex\":\"tag\"")
|
|
So(string(data), ShouldContainSubstring, "\"Semver\":false")
|
|
// verify authentication methods status messages are present
|
|
verifyAuthenticationLogs(data, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": false,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
})
|
|
|
|
Convey("reload scrub and CVE config", t, func(c C) {
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
|
|
logPath := test.MakeTempFilePath(t, "zot-log.txt")
|
|
|
|
content := fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
},
|
|
"extensions": {
|
|
"search": {
|
|
"cve": {
|
|
"updateInterval": "24h",
|
|
"trivy": {
|
|
"DBRepository": "unreachable/trivy/url1"
|
|
}
|
|
}
|
|
},
|
|
"scrub": {
|
|
"enable": true,
|
|
"interval": "24h"
|
|
}
|
|
}
|
|
}`, t.TempDir(), port, logPath)
|
|
|
|
cfgfile := test.MakeTempFile(t, "zot-test.json")
|
|
defer cfgfile.Close()
|
|
|
|
_, err := cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "serve", cfgfile.Name()}
|
|
|
|
go func() {
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
}()
|
|
|
|
test.WaitTillServerReady(baseURL)
|
|
|
|
// verify initial startup authentication logs (no auth configured)
|
|
initialData, err := os.ReadFile(logPath)
|
|
So(err, ShouldBeNil)
|
|
So(string(initialData), ShouldContainSubstring, "configuration settings")
|
|
// verify authentication methods status messages are present in initial startup
|
|
verifyAuthenticationLogs(initialData, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": false,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
|
|
content = fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
},
|
|
"extensions": {
|
|
"search": {
|
|
"cve": {
|
|
"updateInterval": "5h",
|
|
"trivy": {
|
|
"DBRepository": "another/unreachable/trivy/url2"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`, t.TempDir(), port, logPath)
|
|
|
|
err = cfgfile.Truncate(0)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.Seek(0, io.SeekStart)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
err = cfgfile.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
// wait for config reload
|
|
time.Sleep(5 * time.Second)
|
|
|
|
// Wait for the async trivy download to fail and log the error
|
|
found, err := test.ReadLogFileAndSearchString(logPath,
|
|
"failed to download trivy-db to destination dir", 30*time.Second)
|
|
So(err, ShouldBeNil)
|
|
So(found, ShouldBeTrue)
|
|
|
|
// Now read the file once and check all the expected log content
|
|
data, err := os.ReadFile(logPath)
|
|
So(err, ShouldBeNil)
|
|
t.Logf("log file: %s", data)
|
|
|
|
So(string(data), ShouldContainSubstring, "reloaded params")
|
|
So(string(data), ShouldContainSubstring, "loaded new configuration settings")
|
|
So(string(data), ShouldContainSubstring, "\"UpdateInterval\":18000000000000")
|
|
So(string(data), ShouldContainSubstring, "\"Scrub\":null")
|
|
So(string(data), ShouldContainSubstring, "\"DBRepository\":\"another/unreachable/trivy/url2\"")
|
|
// verify authentication methods status messages are present
|
|
verifyAuthenticationLogs(data, map[string]bool{
|
|
"bearer authentication": false,
|
|
"basic authentication (htpasswd)": false,
|
|
"basic authentication (LDAP)": false,
|
|
"basic authentication (API key)": false,
|
|
"OpenID authentication": false,
|
|
"mutual TLS authentication": false,
|
|
})
|
|
|
|
// Just verify the new URL appears in the logs to confirm config reload worked and ignore
|
|
// the order of json message formatting that can change independent of this functional
|
|
// test.
|
|
found, err = test.ReadLogFileAndSearchString(logPath,
|
|
"index.docker.io/another/unreachable/trivy/url2", 1*time.Minute)
|
|
So(err, ShouldBeNil)
|
|
So(found, ShouldBeTrue)
|
|
})
|
|
|
|
Convey("reload bad config", t, func(c C) {
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
|
|
logPath := test.MakeTempFilePath(t, "zot-log.txt")
|
|
|
|
content := fmt.Sprintf(`{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug",
|
|
"output": "%s"
|
|
},
|
|
"extensions": {
|
|
"sync": {
|
|
"registries": [{
|
|
"urls": ["http://localhost:8080"],
|
|
"tlsVerify": false,
|
|
"onDemand": true,
|
|
"maxRetries": 3,
|
|
"retryDelay": "15m",
|
|
"certDir": "",
|
|
"content":[
|
|
{
|
|
"prefix": "zot-test",
|
|
"tags": {
|
|
"regex": ".*",
|
|
"semver": true
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
}
|
|
}
|
|
}`, t.TempDir(), port, logPath)
|
|
|
|
cfgfile := test.MakeTempFile(t, "zot-test.json")
|
|
defer cfgfile.Close()
|
|
|
|
_, err := cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "serve", cfgfile.Name()}
|
|
|
|
go func() {
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
}()
|
|
|
|
test.WaitTillServerReady(baseURL)
|
|
|
|
content = "[]"
|
|
|
|
err = cfgfile.Truncate(0)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.Seek(0, io.SeekStart)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, err = cfgfile.WriteString(content)
|
|
So(err, ShouldBeNil)
|
|
|
|
err = cfgfile.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
// wait for config reload
|
|
time.Sleep(2 * time.Second)
|
|
|
|
data, err := os.ReadFile(logPath)
|
|
So(err, ShouldBeNil)
|
|
t.Logf("log file: %s", data)
|
|
|
|
So(string(data), ShouldNotContainSubstring, "reloaded params")
|
|
So(string(data), ShouldNotContainSubstring, "new configuration settings")
|
|
So(string(data), ShouldContainSubstring, "\"URLs\":[\"http://localhost:8080\"]")
|
|
So(string(data), ShouldContainSubstring, "\"TLSVerify\":false")
|
|
So(string(data), ShouldContainSubstring, "\"OnDemand\":true")
|
|
So(string(data), ShouldContainSubstring, "\"MaxRetries\":3")
|
|
So(string(data), ShouldContainSubstring, "\"CertDir\":\"\"")
|
|
So(string(data), ShouldContainSubstring, "\"Prefix\":\"zot-test\"")
|
|
So(string(data), ShouldContainSubstring, "\"Regex\":\".*\"")
|
|
So(string(data), ShouldContainSubstring, "\"Semver\":true")
|
|
})
|
|
}
|