563 lines
18 KiB
Go
563 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"cloud.google.com/go/auth"
|
|
)
|
|
|
|
const (
|
|
testEmail = "sa-test@dom-lab.iam.gserviceaccount.com"
|
|
mainEnv = "IMDS_EMULATOR_RUN_MAIN"
|
|
)
|
|
|
|
var testNow = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
|
|
|
|
func TestMain(m *testing.M) {
|
|
// The signal tests run the test binary as the emulator.
|
|
if os.Getenv(mainEnv) == "1" {
|
|
main()
|
|
return
|
|
}
|
|
log.SetOutput(io.Discard)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// tokenProviderFunc adapts a function to auth.TokenProvider.
|
|
type tokenProviderFunc func(ctx context.Context) (*auth.Token, error)
|
|
|
|
func (f tokenProviderFunc) Token(ctx context.Context) (*auth.Token, error) { return f(ctx) }
|
|
|
|
// newTestServer returns a server whose tokens are named after the scopes and valid for an hour.
|
|
func newTestServer(scopesSeen *[]string) *metadataServer {
|
|
return &metadataServer{
|
|
email: testEmail,
|
|
projectID: "dom-lab",
|
|
projectNumber: "267098479356",
|
|
hostname: "agent-host",
|
|
now: func() time.Time { return testNow },
|
|
token: func(ctx context.Context, scopes []string) (*auth.Token, error) {
|
|
if scopesSeen != nil {
|
|
*scopesSeen = scopes
|
|
}
|
|
return &auth.Token{Value: "token-for-" + strings.Join(scopes, ","), Type: "Bearer", Expiry: testNow.Add(time.Hour)}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// get sends a request with the metadata flavor header to the server.
|
|
func get(server *metadataServer, target string) *httptest.ResponseRecorder {
|
|
request := httptest.NewRequest(http.MethodGet, target, nil)
|
|
request.Header.Set("Metadata-Flavor", "Google")
|
|
recorder := httptest.NewRecorder()
|
|
server.ServeHTTP(recorder, request)
|
|
return recorder
|
|
}
|
|
|
|
func TestRequestChecks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
headers map[string]string
|
|
want int
|
|
}{
|
|
{"valid", http.MethodGet, map[string]string{"Metadata-Flavor": "Google"}, http.StatusOK},
|
|
{"head", http.MethodHead, map[string]string{"Metadata-Flavor": "Google"}, http.StatusOK},
|
|
{"missing flavor", http.MethodGet, nil, http.StatusForbidden},
|
|
{"wrong flavor", http.MethodGet, map[string]string{"Metadata-Flavor": "Other"}, http.StatusForbidden},
|
|
{"forwarded", http.MethodGet, map[string]string{"Metadata-Flavor": "Google", "X-Forwarded-For": "192.0.2.1"}, http.StatusForbidden},
|
|
{"post", http.MethodPost, map[string]string{"Metadata-Flavor": "Google"}, http.StatusMethodNotAllowed},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
request := httptest.NewRequest(test.method, root+"project/project-id", nil)
|
|
for name, value := range test.headers {
|
|
request.Header.Set(name, value)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
newTestServer(nil).ServeHTTP(recorder, request)
|
|
if recorder.Code != test.want {
|
|
t.Errorf("status = %d, want %d", recorder.Code, test.want)
|
|
}
|
|
if got := recorder.Header().Get("Metadata-Flavor"); got != "Google" {
|
|
t.Errorf("response Metadata-Flavor = %q, want Google", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEndpoints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
status int
|
|
body string
|
|
}{
|
|
{"ping", "/", 200, ""},
|
|
{"root", root, 200, ""},
|
|
{"project ID", root + "project/project-id", 200, "dom-lab"},
|
|
{"project number", root + "project/numeric-project-id", 200, "267098479356"},
|
|
{"hostname", root + "instance/hostname", 200, "agent-host"},
|
|
{"universe domain", root + "universe/universe-domain", 200, "googleapis.com"},
|
|
{"email", root + "instance/service-accounts/default/email", 200, testEmail},
|
|
{"email by address", root + "instance/service-accounts/" + testEmail + "/email", 200, testEmail},
|
|
{"scopes", root + "instance/service-accounts/default/scopes", 200, defaultScope + "\n"},
|
|
{"account list", root + "instance/service-accounts/", 200, "default/\n" + testEmail + "/\n"},
|
|
{"account contents", root + "instance/service-accounts/default/", 200, "aliases\nemail\nscopes\ntoken\n"},
|
|
{"other account", root + "instance/service-accounts/other@dom-lab.iam.gserviceaccount.com/email", 404, "Not Found"},
|
|
{"unknown path", root + "instance/zone", 404, "Not Found"},
|
|
{"outside root", "/other", 404, "Not Found"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
recorder := get(newTestServer(nil), test.target)
|
|
if recorder.Code != test.status || recorder.Body.String() != test.body {
|
|
t.Errorf("got %d %q, want %d %q", recorder.Code, recorder.Body.String(), test.status, test.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMissingProject(t *testing.T) {
|
|
server := newTestServer(nil)
|
|
server.projectID, server.projectNumber = "", ""
|
|
for _, name := range []string{"project/project-id", "project/numeric-project-id"} {
|
|
if recorder := get(server, root+name); recorder.Code != http.StatusNotFound {
|
|
t.Errorf("%s status = %d, want 404", name, recorder.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAccountInfo(t *testing.T) {
|
|
want := serviceAccount{Aliases: []string{"default"}, Email: testEmail, Scopes: []string{defaultScope}}
|
|
|
|
recorder := get(newTestServer(nil), root+"instance/service-accounts/default/?recursive=true")
|
|
var account serviceAccount
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &account); err != nil {
|
|
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
|
|
}
|
|
if recorder.Code != 200 || !slices.Equal(account.Aliases, want.Aliases) || account.Email != want.Email || !slices.Equal(account.Scopes, want.Scopes) {
|
|
t.Errorf("got %d %+v, want 200 %+v", recorder.Code, account, want)
|
|
}
|
|
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", got)
|
|
}
|
|
|
|
recorder = get(newTestServer(nil), root+"instance/service-accounts/?recursive=true")
|
|
var listed map[string]serviceAccount
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &listed); err != nil {
|
|
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
|
|
}
|
|
if len(listed) != 2 || listed["default"].Email != testEmail || listed[testEmail].Email != testEmail {
|
|
t.Errorf("got %+v, want default and %s", listed, testEmail)
|
|
}
|
|
}
|
|
|
|
func TestToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
wantScopes []string
|
|
wantToken string
|
|
}{
|
|
{"default scope", root + "instance/service-accounts/default/token", []string{defaultScope}, "token-for-" + defaultScope},
|
|
{"scopes", root + "instance/service-accounts/default/token?scopes=a,b", []string{"a", "b"}, "token-for-a,b"},
|
|
{"by address", root + "instance/service-accounts/" + testEmail + "/token", []string{defaultScope}, "token-for-" + defaultScope},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var scopes []string
|
|
recorder := get(newTestServer(&scopes), test.target)
|
|
var response struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
|
|
}
|
|
if recorder.Code != 200 || response.AccessToken != test.wantToken || response.ExpiresIn != 3600 || response.TokenType != "Bearer" {
|
|
t.Errorf("got %d %+v", recorder.Code, response)
|
|
}
|
|
if !slices.Equal(scopes, test.wantScopes) {
|
|
t.Errorf("scopes = %v, want %v", scopes, test.wantScopes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTokenExpiry(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token auth.Token
|
|
wantType string
|
|
wantIn int
|
|
}{
|
|
{"remaining lifetime", auth.Token{Value: "t", Expiry: testNow.Add(90 * time.Second)}, "Bearer", 90},
|
|
{"no expiry", auth.Token{Value: "t"}, "Bearer", 3600},
|
|
{"expired", auth.Token{Value: "t", Expiry: testNow.Add(-time.Minute)}, "Bearer", 0},
|
|
{"token type", auth.Token{Value: "t", Type: "MAC", Expiry: testNow.Add(time.Minute)}, "MAC", 60},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server := newTestServer(nil)
|
|
server.token = func(context.Context, []string) (*auth.Token, error) { return &test.token, nil }
|
|
var response struct {
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
recorder := get(server, root+"instance/service-accounts/default/token")
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
|
|
}
|
|
if response.ExpiresIn != test.wantIn || response.TokenType != test.wantType {
|
|
t.Errorf("got expires_in %d token_type %q, want %d %q", response.ExpiresIn, response.TokenType, test.wantIn, test.wantType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTokenError(t *testing.T) {
|
|
server := newTestServer(nil)
|
|
server.token = func(context.Context, []string) (*auth.Token, error) { return nil, errors.New("secret detail") }
|
|
recorder := get(server, root+"instance/service-accounts/default/token")
|
|
if recorder.Code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want 500", recorder.Code)
|
|
}
|
|
if strings.Contains(recorder.Body.String(), "secret detail") {
|
|
t.Errorf("the response %q leaks the error", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServiceAccountEmail(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
want string
|
|
}{
|
|
{"external account", `{"type":"external_account","service_account_impersonation_url":"https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/sa-test@dom-lab.iam.gserviceaccount.com:generateAccessToken"}`, testEmail},
|
|
{"impersonated account", `{"type":"impersonated_service_account","service_account_impersonation_url":"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa-test@dom-lab.iam.gserviceaccount.com:generateAccessToken"}`, testEmail},
|
|
{"service account key", `{"type":"service_account","client_email":"sa-test@dom-lab.iam.gserviceaccount.com"}`, testEmail},
|
|
{"impersonation wins", `{"client_email":"key@p.iam.gserviceaccount.com","service_account_impersonation_url":"https://x/serviceAccounts/sa-test@dom-lab.iam.gserviceaccount.com:generateAccessToken"}`, testEmail},
|
|
{"external account without impersonation", `{"type":"external_account"}`, ""},
|
|
{"unexpected impersonation URL", `{"service_account_impersonation_url":"https://x/other"}`, ""},
|
|
{"invalid JSON", `{`, ""},
|
|
{"empty", ``, ""},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := serviceAccountEmail([]byte(test.json)); got != test.want {
|
|
t.Errorf("got %q, want %q", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderCache(t *testing.T) {
|
|
var detected atomic.Int32
|
|
var failures atomic.Int32
|
|
cache := &providerCache{detect: func(scopes []string) (auth.TokenProvider, error) {
|
|
detected.Add(1)
|
|
if slices.Equal(scopes, []string{"fail"}) && failures.Add(1) == 1 {
|
|
return nil, errors.New("detection failed")
|
|
}
|
|
return tokenProviderFunc(func(context.Context) (*auth.Token, error) {
|
|
return &auth.Token{Value: strings.Join(scopes, ",")}, nil
|
|
}), nil
|
|
}}
|
|
ctx := context.Background()
|
|
|
|
for _, scopes := range [][]string{{"a", "b"}, {"b", "a"}, {"a", "b"}} {
|
|
token, err := cache.token(ctx, scopes)
|
|
if err != nil || token.Value != "a,b" {
|
|
t.Fatalf("token(%v) = %v, %v; want a,b", scopes, token, err)
|
|
}
|
|
}
|
|
if got := detected.Load(); got != 1 {
|
|
t.Errorf("detected %d times for the same scopes in any order, want 1", got)
|
|
}
|
|
|
|
if _, err := cache.token(ctx, []string{"c"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := detected.Load(); got != 2 {
|
|
t.Errorf("detected %d times after other scopes, want 2", got)
|
|
}
|
|
|
|
if _, err := cache.token(ctx, []string{"fail"}); err == nil {
|
|
t.Error("the detection error was not returned")
|
|
}
|
|
if _, err := cache.token(ctx, []string{"fail"}); err != nil {
|
|
t.Errorf("the detection error was cached: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderCacheConcurrent(t *testing.T) {
|
|
var detected atomic.Int32
|
|
cache := &providerCache{detect: func([]string) (auth.TokenProvider, error) {
|
|
detected.Add(1)
|
|
return tokenProviderFunc(func(context.Context) (*auth.Token, error) { return &auth.Token{Value: "t"}, nil }), nil
|
|
}}
|
|
|
|
var wg sync.WaitGroup
|
|
for range 32 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, err := cache.token(context.Background(), []string{"a"}); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if got := detected.Load(); got != 1 {
|
|
t.Errorf("detected %d times, want 1", got)
|
|
}
|
|
}
|
|
|
|
// startServe serves the handler on a local listener and returns its address,
|
|
// the function that stops it and the channel with the result of serve.
|
|
func startServe(t *testing.T, handler http.Handler, grace time.Duration) (string, context.CancelFunc, <-chan error) {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
result <- serve(ctx, &http.Server{Handler: handler}, listener, grace)
|
|
}()
|
|
return listener.Addr().String(), cancel, result
|
|
}
|
|
|
|
// await returns the result of serve, failing the test when it takes too long.
|
|
func await(t *testing.T, result <-chan error) error {
|
|
t.Helper()
|
|
select {
|
|
case err := <-result:
|
|
return err
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("serve did not return")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func TestServeStopsOnCancel(t *testing.T) {
|
|
address, cancel, result := startServe(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), time.Second)
|
|
|
|
response, err := http.Get("http://" + address)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response.Body.Close()
|
|
|
|
cancel()
|
|
if err := await(t, result); err != nil {
|
|
t.Errorf("serve = %v, want nil", err)
|
|
}
|
|
if connection, err := net.Dial("tcp", address); err == nil {
|
|
connection.Close()
|
|
t.Error("the listener still accepts connections")
|
|
}
|
|
}
|
|
|
|
func TestServeFinishesRequestsInProgress(t *testing.T) {
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
address, cancel, result := startServe(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
close(started)
|
|
<-release
|
|
io.WriteString(w, "done")
|
|
}), 5*time.Second)
|
|
|
|
body := make(chan string, 1)
|
|
go func() {
|
|
response, err := http.Get("http://" + address)
|
|
if err != nil {
|
|
body <- err.Error()
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
data, _ := io.ReadAll(response.Body)
|
|
body <- string(data)
|
|
}()
|
|
<-started
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-result:
|
|
t.Fatalf("serve returned %v while a request was in progress", err)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
|
|
close(release)
|
|
if got := <-body; got != "done" {
|
|
t.Errorf("response = %q, want done", got)
|
|
}
|
|
if err := await(t, result); err != nil {
|
|
t.Errorf("serve = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestServeStopsAfterGracePeriod(t *testing.T) {
|
|
started := make(chan struct{})
|
|
address, cancel, result := startServe(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
close(started)
|
|
<-r.Context().Done()
|
|
}), 100*time.Millisecond)
|
|
|
|
failed := make(chan error, 1)
|
|
go func() {
|
|
response, err := http.Get("http://" + address)
|
|
if err == nil {
|
|
response.Body.Close()
|
|
}
|
|
failed <- err
|
|
}()
|
|
<-started
|
|
|
|
cancel()
|
|
if err := await(t, result); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf("serve = %v, want %v", err, context.DeadlineExceeded)
|
|
}
|
|
select {
|
|
case err := <-failed:
|
|
if err == nil {
|
|
t.Error("the request in progress was not interrupted")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("the request in progress was not interrupted")
|
|
}
|
|
}
|
|
|
|
func TestServeReturnsListenerFailure(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listener.Close()
|
|
|
|
if err := serve(context.Background(), &http.Server{}, listener, time.Second); err == nil {
|
|
t.Error("serve = nil, want the listener error")
|
|
}
|
|
}
|
|
|
|
// writeServiceAccountKey writes a service account key with a generated private key.
|
|
func writeServiceAccountKey(t *testing.T) string {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
der, err := x509.MarshalPKCS8PrivateKey(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := json.Marshal(map[string]string{
|
|
"type": "service_account",
|
|
"project_id": "dom-lab",
|
|
"private_key_id": "key",
|
|
"private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})),
|
|
"client_email": testEmail,
|
|
"client_id": "1",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "sa.json")
|
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// TestSignals runs the emulator as a process and stops it with signals.
|
|
func TestSignals(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX signals")
|
|
}
|
|
for _, signal := range []syscall.Signal{syscall.SIGTERM, syscall.SIGINT} {
|
|
t.Run(signal.String(), func(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
address := listener.Addr().String()
|
|
_, port, _ := net.SplitHostPort(address)
|
|
listener.Close()
|
|
|
|
var output bytes.Buffer
|
|
command := exec.Command(os.Args[0])
|
|
command.Env = append(os.Environ(), mainEnv+"=1", "GOOGLE_APPLICATION_CREDENTIALS="+writeServiceAccountKey(t), "IMDS_PORT="+port)
|
|
command.Stderr = &output
|
|
if err := command.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exited := make(chan error, 1)
|
|
go func() { exited <- command.Wait() }()
|
|
t.Cleanup(func() { command.Process.Kill() })
|
|
|
|
// Wait until the emulator answers the ping.
|
|
ready := false
|
|
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !ready; {
|
|
select {
|
|
case err := <-exited:
|
|
t.Fatalf("the emulator exited before it was ready: %v\n%s", err, output.String())
|
|
default:
|
|
}
|
|
request, _ := http.NewRequest(http.MethodGet, "http://"+address+"/", nil)
|
|
request.Header.Set("Metadata-Flavor", "Google")
|
|
if response, err := http.DefaultClient.Do(request); err == nil {
|
|
response.Body.Close()
|
|
ready = response.StatusCode == http.StatusOK
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
if !ready {
|
|
t.Fatalf("the emulator did not answer\n%s", output.String())
|
|
}
|
|
|
|
if err := command.Process.Signal(signal); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-exited:
|
|
if err != nil {
|
|
t.Errorf("the emulator exited with %v, want status 0\n%s", err, output.String())
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("the emulator did not stop\n%s", output.String())
|
|
}
|
|
if !strings.Contains(output.String(), "shutting down") || !strings.Contains(output.String(), "Stopped") {
|
|
t.Errorf("unexpected log:\n%s", output.String())
|
|
}
|
|
})
|
|
}
|
|
}
|