package cli import ( "bytes" "context" "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "runtime" "testing" "time" "github.com/stretchr/testify/require" "go.uber.org/goleak" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/sloghuman" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/telemetry" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/pretty" "github.com/coder/serpent" ) func TestMain(m *testing.M) { // Report multi-second pauses of the whole process, see coder/internal#1365. testutil.StartStallDetector(5 * time.Second) // Fail with a heap profile before a runaway allocation pushes the runner into swap. testutil.StartMemoryGuardFromEnv() if runtime.GOOS == "windows" { // Don't run goleak on windows tests, they're super flaky right now. // See: https://github.com/coder/coder/issues/8954 os.Exit(m.Run()) } goleak.VerifyTestMain(m, testutil.GoleakOptions...) } func Test_formatExamples(t *testing.T) { t.Parallel() tests := []struct { name string examples []Example wantMatches []string }{ { name: "No examples", examples: nil, wantMatches: nil, }, { name: "Output examples", examples: []Example{ { Description: "Hello world.", Command: "echo hello", }, { Description: "Bye bye.", Command: "echo bye", }, }, wantMatches: []string{ "Hello world", "echo hello", "Bye bye", "echo bye", }, }, { name: "No description outputs commands", examples: []Example{ { Command: "echo hello", }, }, wantMatches: []string{ "echo hello", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := FormatExamples(tt.examples...) if len(tt.wantMatches) == 0 { require.Empty(t, got) } else { for _, want := range tt.wantMatches { require.Contains(t, got, want) } } }) } } func Test_wrapTransportWithVersionCheck(t *testing.T) { t.Parallel() t.Run("NoOutput", func(t *testing.T) { t.Parallel() r := &RootCmd{} cmd, err := r.Command(nil) require.NoError(t, err) var buf bytes.Buffer inv := cmd.Invoke() inv.Stderr = &buf rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ // Provider a version that will not match! codersdk.BuildVersionHeader: []string{"v2.0.0"}, }, Body: io.NopCloser(nil), }, nil }), inv, "v2.0.0", nil) req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) res, err := rt.RoundTrip(req) require.NoError(t, err) defer res.Body.Close() require.Equal(t, "", buf.String()) }) t.Run("CustomUpgradeMessage", func(t *testing.T) { t.Parallel() r := &RootCmd{} cmd, err := r.Command(nil) require.NoError(t, err) var buf bytes.Buffer inv := cmd.Invoke() inv.Stderr = &buf expectedUpgradeMessage := "My custom upgrade message" rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ // Provider a version that will not match! codersdk.BuildVersionHeader: []string{"v1.0.0"}, }, Body: io.NopCloser(nil), }, nil }), inv, "v2.0.0", func(ctx context.Context) (codersdk.BuildInfoResponse, error) { return codersdk.BuildInfoResponse{ UpgradeMessage: expectedUpgradeMessage, }, nil }) req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) res, err := rt.RoundTrip(req) require.NoError(t, err) defer res.Body.Close() // Run this twice to ensure the upgrade message is only printed once. res, err = rt.RoundTrip(req) require.NoError(t, err) defer res.Body.Close() fmtOutput := fmt.Sprintf("version mismatch: client v2.0.0, server v1.0.0\n%s", expectedUpgradeMessage) expectedOutput := fmt.Sprintln(pretty.Sprint(cliui.DefaultStyles.Warn, fmtOutput)) require.Equal(t, expectedOutput, buf.String()) }) t.Run("ServerStableVersion", func(t *testing.T) { t.Parallel() r := &RootCmd{} cmd, err := r.Command(nil) require.NoError(t, err) var buf bytes.Buffer inv := cmd.Invoke() inv.Stderr = &buf rt := wrapTransportWithVersionCheck(roundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ codersdk.BuildVersionHeader: []string{"v2.31.0"}, }, Body: io.NopCloser(nil), }, nil }), inv, "v2.31.0", nil) req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) res, err := rt.RoundTrip(req) require.NoError(t, err) defer res.Body.Close() require.Empty(t, buf.String()) }) } func Test_serverVersionMessage(t *testing.T) { t.Parallel() cases := []struct { name string version string expected string }{ {"Stable", "v2.31.0", ""}, {"Dev", "v0.0.0-devel+abc123", "the server is running a development version of Coder (v0.0.0-devel+abc123)"}, {"RC", "v2.31.0-rc.1", "the server is running a release candidate of Coder (v2.31.0-rc.1)"}, {"RCDevel", "v2.33.0-rc.1-devel+727ec00f7", "the server is running a release candidate of Coder (v2.33.0-rc.1-devel+727ec00f7)"}, {"Empty", "", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() require.Equal(t, c.expected, serverVersionMessage(c.version)) }) } } func Test_wrapTransportWithTelemetryHeader(t *testing.T) { t.Parallel() rt := wrapTransportWithTelemetryHeader(roundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{ Body: io.NopCloser(nil), }, nil }), &serpent.Invocation{ Command: &serpent.Command{ Use: "test", Options: serpent.OptionSet{{ Name: "bananas", Description: "hey", }}, }, }) req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) res, err := rt.RoundTrip(req) require.NoError(t, err) defer res.Body.Close() resp := req.Header.Get(codersdk.CLITelemetryHeader) require.NotEmpty(t, resp) data, err := base64.StdEncoding.DecodeString(resp) require.NoError(t, err) var ti telemetry.Invocation err = json.Unmarshal(data, &ti) require.NoError(t, err) require.Equal(t, ti.Command, "test") } //nolint:tparallel,paralleltest // This test modifies environment variables. func TestPrintDeprecatedOptions(t *testing.T) { newValue := serpent.StringOf(new(string)) // Both the "new" option and the deprecated option point at the // same Value, mirroring how codersdk/deployment.go wires the // CODER_EMAIL_* / CODER_NOTIFICATIONS_EMAIL_* pairs. newOpt := serpent.Option{ Name: "new-option", Flag: "new-option", Env: "CODER_TEST_NEW_OPTION", Value: newValue, } deprecatedOpt := serpent.Option{ Name: "old-option", Flag: "old-option", Env: "CODER_TEST_OLD_OPTION", Value: newValue, // same pointer UseInstead: serpent.OptionSet{newOpt}, } makeCmd := func(opts serpent.OptionSet) *serpent.Command { return &serpent.Command{ Use: "test", Options: opts, Middleware: PrintDeprecatedOptions(), Handler: func(_ *serpent.Invocation) error { return nil }, } } t.Run("EnvOnlyNew_NoWarning", func(t *testing.T) { t.Setenv("CODER_TEST_NEW_OPTION", "val") cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt}) var stderr bytes.Buffer inv := cmd.Invoke() inv.Environ = serpent.ParseEnviron(os.Environ(), "") inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.Empty(t, stderr.String(), "setting only the new env var should not produce a deprecation warning") }) t.Run("EnvOnlyOld_Warning", func(t *testing.T) { t.Setenv("CODER_TEST_OLD_OPTION", "val") cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt}) var stderr bytes.Buffer inv := cmd.Invoke() inv.Environ = serpent.ParseEnviron(os.Environ(), "") inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.Contains(t, stderr.String(), "is deprecated", "setting the deprecated env var should produce a warning") }) t.Run("EnvBothSet_Warning", func(t *testing.T) { t.Setenv("CODER_TEST_NEW_OPTION", "new") t.Setenv("CODER_TEST_OLD_OPTION", "old") cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt}) var stderr bytes.Buffer inv := cmd.Invoke() inv.Environ = serpent.ParseEnviron(os.Environ(), "") inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.Contains(t, stderr.String(), "is deprecated", "setting both env vars should still warn about the deprecated one") }) t.Run("DeprecatedEnvAndNewFlag_Warning", func(t *testing.T) { t.Setenv("CODER_TEST_OLD_OPTION", "val") cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt}) var stderr bytes.Buffer inv := cmd.Invoke("--new-option", "val") inv.Environ = serpent.ParseEnviron(os.Environ(), "") inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.Contains(t, stderr.String(), "`CODER_TEST_OLD_OPTION` is deprecated", "setting the deprecated env var should still warn even if the replacement flag overrides the value") require.NotContains(t, stderr.String(), "`--old-option` is deprecated", "the deprecated environment variable should not be misreported as a deprecated flag") }) t.Run("FlagOnlyNew_NoWarning", func(t *testing.T) { cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt}) var stderr bytes.Buffer inv := cmd.Invoke("--new-option", "val") inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.Empty(t, stderr.String(), "passing only the new flag should not produce a deprecation warning") }) t.Run("FlagOnlyOld_Warning", func(t *testing.T) { cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt}) var stderr bytes.Buffer inv := cmd.Invoke("--old-option", "val") inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.Contains(t, stderr.String(), "is deprecated", "passing the deprecated flag should produce a warning") }) t.Run("CODER_EMAIL_FROM_NoWarning", func(t *testing.T) { t.Setenv("CODER_EMAIL_FROM", "noreply@example.com") deploymentValues := new(codersdk.DeploymentValues) cmd := makeCmd(deploymentValues.Options()) var stderr bytes.Buffer inv := cmd.Invoke() inv.Environ = serpent.ParseEnviron([]string{"CODER_EMAIL_FROM=noreply@example.com"}, "") inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.NotContains(t, stderr.String(), "is deprecated", "setting only CODER_EMAIL_FROM should not produce any deprecation warning") }) t.Run("NothingSet_NoWarning", func(t *testing.T) { t.Parallel() cmd := makeCmd(serpent.OptionSet{newOpt, deprecatedOpt}) var stderr bytes.Buffer inv := cmd.Invoke() inv.Stderr = &stderr err := inv.Run() require.NoError(t, err) require.Empty(t, stderr.String(), "setting nothing should not produce a deprecation warning") }) } func Test_wrapTransportWithEntitlementsCheck(t *testing.T) { t.Parallel() lines := []string{"First Warning", "Second Warning"} var buf bytes.Buffer rt := wrapTransportWithEntitlementsCheck(roundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{ codersdk.EntitlementsWarningHeader: lines, }, Body: io.NopCloser(nil), }, nil }), &buf) res, err := rt.RoundTrip(httptest.NewRequest(http.MethodGet, "http://example.com", nil)) require.NoError(t, err) defer res.Body.Close() expectedOutput := fmt.Sprintf("%s\n%s\n", pretty.Sprint(cliui.DefaultStyles.Warn, lines[0]), pretty.Sprint(cliui.DefaultStyles.Warn, lines[1])) require.Equal(t, expectedOutput, buf.String()) } func Test_ensureTLSConfig(t *testing.T) { t.Parallel() t.Run("NoFilesSpecified", func(t *testing.T) { t.Parallel() r := &RootCmd{} err := r.ensureTLSConfig() require.NoError(t, err) require.Nil(t, r.tlsConfig) }) t.Run("OnlyCertFileErrors", func(t *testing.T) { t.Parallel() r := &RootCmd{ tlsClientCertFile: "/some/cert.pem", } err := r.ensureTLSConfig() require.Error(t, err) require.Contains(t, err.Error(), "must be specified together") }) t.Run("OnlyKeyFileErrors", func(t *testing.T) { t.Parallel() r := &RootCmd{ tlsClientKeyFile: "/some/key.pem", } err := r.ensureTLSConfig() require.Error(t, err) require.Contains(t, err.Error(), "must be specified together") }) t.Run("InvalidCAFileErrors", func(t *testing.T) { t.Parallel() r := &RootCmd{ tlsCAFile: "/nonexistent/ca.pem", } err := r.ensureTLSConfig() require.Error(t, err) require.Contains(t, err.Error(), "read TLS CA file") }) t.Run("AlreadySetSkipsLoading", func(t *testing.T) { t.Parallel() existingConfig := &tls.Config{MinVersion: tls.VersionTLS13} r := &RootCmd{ tlsConfig: existingConfig, tlsClientCertFile: "/some/cert.pem", } err := r.ensureTLSConfig() require.NoError(t, err) require.Same(t, existingConfig, r.tlsConfig) }) t.Run("InvalidPEMContentErrors", func(t *testing.T) { t.Parallel() tmpFile, err := os.CreateTemp("", "invalid-ca-*.pem") require.NoError(t, err) defer os.Remove(tmpFile.Name()) _, err = tmpFile.WriteString("this is not valid PEM data") require.NoError(t, err) require.NoError(t, tmpFile.Close()) r := &RootCmd{ tlsCAFile: tmpFile.Name(), } err = r.ensureTLSConfig() require.Error(t, err) require.Contains(t, err.Error(), "failed to parse CA certificate") }) } func TestNewHTTPTransportClonesDefaultTransport(t *testing.T) { t.Parallel() transport, err := newHTTPTransport(nil) require.NoError(t, err) require.NotSame(t, http.DefaultTransport, transport) require.IsType(t, &http.Transport{}, transport) } func TestNewHTTPTransportAppliesTLSConfigToClone(t *testing.T) { t.Parallel() tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13} transport, err := newHTTPTransport(tlsConfig) require.NoError(t, err) require.NotSame(t, http.DefaultTransport, transport) httpTransport, ok := transport.(*http.Transport) require.True(t, ok) require.Same(t, tlsConfig, httpTransport.TLSClientConfig) } func TestResolveClientSessionID(t *testing.T) { t.Parallel() t.Run("GeneratesWhenUnset", func(t *testing.T) { t.Parallel() inv := &serpent.Invocation{Stderr: io.Discard} id, err := resolveClientSessionID(inv) require.NoError(t, err) require.True(t, tracing.ValidSessionID(id), "generated session ID must be valid") }) t.Run("UsesValidEnv", func(t *testing.T) { t.Parallel() const want = "0123456789abcdef0123456789abcdef" inv := &serpent.Invocation{Stderr: io.Discard} inv.Environ.Set(clientSessionIDEnv, want) id, err := resolveClientSessionID(inv) require.NoError(t, err) require.Equal(t, want, id) }) t.Run("UsesMalformedEnvVerbatim", func(t *testing.T) { t.Parallel() const want = "not-a-valid-session-id" inv := &serpent.Invocation{Stderr: io.Discard} inv.Environ.Set(clientSessionIDEnv, want) id, err := resolveClientSessionID(inv) require.NoError(t, err) require.Equal(t, want, id, "a set CODER_TRACE_SESSION_ID is used verbatim") }) t.Run("GeneratesWhenEnvEmpty", func(t *testing.T) { t.Parallel() inv := &serpent.Invocation{Stderr: io.Discard} inv.Environ.Set(clientSessionIDEnv, "") id, err := resolveClientSessionID(inv) require.NoError(t, err) require.True(t, tracing.ValidSessionID(id), "empty env must fall back to a generated ID") }) } func TestClientSessionIDMiddleware(t *testing.T) { t.Parallel() // runMiddleware runs clientSessionIDMiddleware around a handler that // captures the resulting invocation, returning it for assertions. The // invocation opts in with the annotationClientSessionID annotation unless // shouldAttachSessionID is false. runMiddleware := func(t *testing.T, inv *serpent.Invocation, shouldAttachSessionID bool) *serpent.Invocation { t.Helper() annotations := serpent.Annotations{} if shouldAttachSessionID { annotations = annotations.Mark(annotationClientSessionID, "") } inv.Command = &serpent.Command{Annotations: annotations} var got *serpent.Invocation handler := clientSessionIDMiddleware()(func(i *serpent.Invocation) error { got = i return nil }) require.NoError(t, handler(inv)) require.NotNil(t, got) return got } t.Run("GeneratesAndStoresOnContext", func(t *testing.T) { t.Parallel() inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context()) got := runMiddleware(t, inv, true) id := clientSessionIDFromContext(got.Context()) require.True(t, tracing.ValidSessionID(id), "middleware must store a valid generated ID") }) t.Run("UsesEnv", func(t *testing.T) { t.Parallel() const want = "0123456789abcdef0123456789abcdef" inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context()) inv.Environ.Set(clientSessionIDEnv, want) got := runMiddleware(t, inv, true) require.Equal(t, want, clientSessionIDFromContext(got.Context())) }) t.Run("AttachesSlogField", func(t *testing.T) { t.Parallel() inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context()) got := runMiddleware(t, inv, true) id := clientSessionIDFromContext(got.Context()) require.NotEmpty(t, id) // A fresh logger that logs with the invocation context must include the // client_session_id field, proving the field rides on the context rather // than a specific logger instance. var buf bytes.Buffer logger := slog.Make(sloghuman.Sink(&buf)) logger.Info(got.Context(), "session id log line") require.Contains(t, buf.String(), "client_session_id="+id) }) t.Run("SkipsWithoutOptIn", func(t *testing.T) { t.Parallel() inv := (&serpent.Invocation{Stderr: io.Discard}).WithContext(t.Context()) inv.Environ.Set(clientSessionIDEnv, "0123456789abcdef0123456789abcdef") got := runMiddleware(t, inv, false) require.Empty(t, clientSessionIDFromContext(got.Context()), "commands without the opt-in annotation must not resolve a session ID") }) t.Run("SkipsCompletionMode", func(t *testing.T) { t.Parallel() inv := (&serpent.Invocation{Stderr: io.Discard}). WithContext(t.Context()) inv.Environ.Set(serpent.CompletionModeEnv, "1") got := runMiddleware(t, inv, true) require.Empty(t, clientSessionIDFromContext(got.Context()), "completion mode must not resolve a session ID") }) } func TestWrapTransportWithSessionIDHeader(t *testing.T) { t.Parallel() const sessionID = "0123456789abcdef0123456789abcdef" var gotHeader http.Header srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { gotHeader = r.Header.Clone() rw.WriteHeader(http.StatusOK) })) defer srv.Close() client := &http.Client{ Transport: wrapTransportWithSessionIDHeader(http.DefaultTransport, sessionID), } req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil) require.NoError(t, err) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() // The baggage header the server receives must carry the session ID under // the client_session_id key so the tracing middleware can extract it. require.Equal(t, tracing.SessionIDBaggageKey+"="+sessionID, gotHeader.Get("baggage")) } func Test_createHTTPClientRedirects(t *testing.T) { t.Parallel() // newRedirectingServers returns a stale server that 301s every request // to a target serving the "list" shape on GET, so that following the // redirect would silently turn a create into a list. newRedirectingServers := func(t *testing.T) (stale, target *httptest.Server) { target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte("[]")) })) t.Cleanup(target.Close) stale = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL+r.URL.Path, http.StatusMovedPermanently) })) t.Cleanup(stale.Close) return stale, target } newClient := func(t *testing.T, r *RootCmd, rawURL string) *codersdk.Client { serverURL, err := url.Parse(rawURL) require.NoError(t, err) inv := &serpent.Invocation{Command: &serpent.Command{Use: "test"}} httpClient, err := r.createHTTPClient(context.Background(), serverURL, inv) require.NoError(t, err) t.Cleanup(httpClient.CloseIdleConnections) return codersdk.New(serverURL, codersdk.WithHTTPClient(httpClient)) } t.Run("RejectedByDefault", func(t *testing.T) { t.Parallel() stale, target := newRedirectingServers(t) r := &RootCmd{noVersionCheck: true, noFeatureWarning: true} client := newClient(t, r, stale.URL) _, err := client.CreateToken(context.Background(), codersdk.Me, codersdk.CreateTokenRequest{}) require.Error(t, err) var redirectErr *redirectError require.ErrorAs(t, err, &redirectErr) require.Equal(t, stale.URL+"/api/v2/users/me/keys/tokens", redirectErr.from.String()) require.Equal(t, target.URL+"/api/v2/users/me/keys/tokens", redirectErr.to.String()) msg, special := cliHumanFormatError("", err, nil) require.True(t, special) require.Contains(t, msg, "server redirected request from "+stale.URL) require.Contains(t, msg, fmt.Sprintf("Run %q to log in against the new URL", "coder login "+target.URL)) require.Contains(t, msg, "--allow-redirects") }) t.Run("AllowRedirects", func(t *testing.T) { t.Parallel() stale, _ := newRedirectingServers(t) r := &RootCmd{noVersionCheck: true, noFeatureWarning: true, allowRedirects: true} client := newClient(t, r, stale.URL) tokens, err := client.Tokens(context.Background(), codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.Empty(t, tokens) // Legacy behavior: the POST is followed as a GET and fails on the // mismatched body rather than on the redirect itself. _, err = client.CreateToken(context.Background(), codersdk.Me, codersdk.CreateTokenRequest{}) require.Error(t, err) var redirectErr *redirectError require.False(t, errors.As(err, &redirectErr)) }) } func Test_redirectErrorHelper(t *testing.T) { t.Parallel() mustParse := func(s string) *url.URL { u, err := url.Parse(s) require.NoError(t, err) return u } t.Run("DifferentHost", func(t *testing.T) { t.Parallel() err := &redirectError{ from: mustParse("https://old.example.com/api/v2/users/me"), to: mustParse("https://new.example.com/api/v2/users/me"), } require.Contains(t, err.Helper(), `"coder login https://new.example.com"`) }) t.Run("SchemeUpgrade", func(t *testing.T) { t.Parallel() err := &redirectError{ from: mustParse("http://coder.example.com/api/v2/users/me"), to: mustParse("https://coder.example.com/api/v2/users/me"), } require.Contains(t, err.Helper(), `"coder login https://coder.example.com"`) }) t.Run("SameDeployment", func(t *testing.T) { t.Parallel() err := &redirectError{ from: mustParse("https://coder.example.com/api/v2/users/me"), to: mustParse("https://coder.example.com/api/v2/users/me/"), } require.Contains(t, err.Helper(), "redirected within the same deployment") require.NotContains(t, err.Helper(), "coder login") }) }