diff --git a/v2/api/template_models.go b/v2/api/template_models.go index 526b8c4..70eefd7 100644 --- a/v2/api/template_models.go +++ b/v2/api/template_models.go @@ -81,7 +81,13 @@ type UpdateTemplateArg struct { AllowedRequesters *[]string `json:"AllowedRequesters,omitempty"` RFCEnforcement *bool `json:"RFCEnforcement,omitempty"` RequiresApproval *bool `json:"RequiresApproval,omitempty"` - KeyUsage *bool `json:"KeyUsage,omitempty"` + // KeyUsage is an int32 bitmask on Command's wire format (e.g. 160 = + // digitalSignature|keyEncipherment), matching GetTemplateResponse.KeyUsage and + // Command's TemplateUpdateRequest/TemplateRetrievalResponse swagger schema + // (both typed "integer"/"int32"). A *bool here previously produced a live + // HTTP 400 ("Unexpected character encountered while parsing value: t. Path + // 'KeyUsage'") since Command rejects a JSON boolean for an integer field. + KeyUsage *int `json:"KeyUsage,omitempty"` } type UpdateTemplateResponse struct{ GetTemplateResponse } diff --git a/v3/api/client.go b/v3/api/client.go index add6a21..b8c63d0 100644 --- a/v3/api/client.go +++ b/v3/api/client.go @@ -28,6 +28,7 @@ import ( "net/url" "path" "strings" + "sync" "time" "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" @@ -69,6 +70,47 @@ var ( type Client struct { AuthClient AuthConfig LoggerType string + + // httpClient caches the *http.Client returned by AuthClient.GetHttpClient() + // so that sendRequest reuses a single underlying transport/connection pool + // across requests instead of asking AuthClient to build a brand new one on + // every call. Both CommandConfigOauth.GetHttpClient() and + // CommandAuthConfigBasic.GetHttpClient() (in keyfactor-auth-client-go) + // construct a fresh http.Transport per invocation, and that transport's + // IdleConnTimeout is derived from the configured HttpClientTimeout - so + // without this cache, every request opens its own connection pool whose + // sockets linger for up to HttpClientTimeout before being reclaimed. This + // was already true at the old fixed 60s default; plumbing a caller-supplied + // ClientTimeout (see NewKeyfactorClient) just widens the window, so caching + // here keeps that fix from amplifying a pre-existing resource leak. + httpClient *http.Client + httpClientMu sync.Mutex +} + +// getHttpClient returns the cached *http.Client if one has already been +// resolved for this Client, populating the cache on first use otherwise. +// This guarantees AuthClient.GetHttpClient() is invoked at most once per +// Client instance, so the transport (and its connection pool) is reused +// across requests. It is safe for concurrent use. +// +// Note this does not affect OAuth token refresh: the cached *http.Client's +// transport wraps an oauth2 TokenSource that is consulted (and refreshed as +// needed) on every RoundTrip, independent of how many times the *http.Client +// itself is reused. +func (c *Client) getHttpClient() (*http.Client, error) { + c.httpClientMu.Lock() + defer c.httpClientMu.Unlock() + + if c.httpClient != nil { + return c.httpClient, nil + } + + httpClient, err := c.AuthClient.GetHttpClient() + if err != nil { + return nil, err + } + c.httpClient = httpClient + return httpClient, nil } // TerraformLogger wraps the tflog logging to handle Go's log messages with log level mapping. @@ -142,11 +184,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie clientAuthType := cfg.GetAuthType() baseConfig := auth_providers.CommandAuthConfig{ - CommandHostName: cfg.Host, - CommandPort: cfg.Port, - CommandAPIPath: cfg.APIPath, - CommandCACert: cfg.CACertPath, - SkipVerify: cfg.SkipTLSVerify, + CommandHostName: cfg.Host, + CommandPort: cfg.Port, + CommandAPIPath: cfg.APIPath, + CommandCACert: cfg.CACertPath, + SkipVerify: cfg.SkipTLSVerify, + HttpClientTimeout: cfg.ClientTimeout, } if clientAuthType == "basic" { @@ -160,11 +203,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie if aErr != nil { return nil, aErr } - _, cErr := basicCfg.GetHttpClient() + httpClient, cErr := basicCfg.GetHttpClient() if cErr != nil { return nil, cErr } client.AuthClient = &basicCfg + client.httpClient = httpClient return &client, nil } else if clientAuthType == "oauth" { oauthCfg := auth_providers.CommandConfigOauth{ @@ -180,11 +224,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie if aErr != nil { return nil, aErr } - _, cErr := oauthCfg.GetHttpClient() + httpClient, cErr := oauthCfg.GetHttpClient() if cErr != nil { return nil, cErr } client.AuthClient = &oauthCfg + client.httpClient = httpClient return &client, nil } else { return nil, fmt.Errorf("unsupported auth type or authentication cfg: '%s'", clientAuthType) @@ -342,7 +387,7 @@ func (c *Client) sendRequest(request *request) (*http.Response, error) { // Log the request logRequest(req) - httpClient, cErr := c.AuthClient.GetHttpClient() + httpClient, cErr := c.getHttpClient() if cErr != nil { return nil, cErr } diff --git a/v3/api/client_test.go b/v3/api/client_test.go new file mode 100644 index 0000000..f256d40 --- /dev/null +++ b/v3/api/client_test.go @@ -0,0 +1,451 @@ +// Copyright 2024 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" +) + +// newFakeCommandServer stands in for a Keyfactor Command instance for +// CommandAuthConfigBasic.Authenticate(), which performs a real GET against +// {host}/{apiPath}/Status/Endpoints as part of authentication. It always +// returns 200 with a valid JSON string array, regardless of credentials. +func newFakeCommandServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`["endpoint1"]`)) + })) + t.Cleanup(server.Close) + return server +} + +// isolateKeyfactorEnv unsets ambient KEYFACTOR_* environment variables that +// CommandAuthConfig.ValidateAuthConfig() falls back to whenever the +// corresponding struct field is left at its zero value, restoring their +// original values (present-and-unset, or present-with-value) once the test +// completes. This makes tests that build a Server/CommandAuthConfig with an +// intentionally-zero field (e.g. ClientTimeout: 0 to exercise the "use the +// default" path, or SkipTLSVerify relying on a literal true) hermetic: +// without this, a developer or CI job with KEYFACTOR_CLIENT_TIMEOUT or +// KEYFACTOR_SKIP_VERIFY exported in their shell would get spurious failures +// or, worse, a silently-clobbered SkipVerify that rejects the test's +// self-signed httptest TLS cert. +// +// Note: t.Setenv(key, "") is NOT equivalent to unsetting - os.LookupEnv still +// reports the variable as present with an empty value, which is enough to +// take the "environment variable is set" branch in ValidateAuthConfig (e.g. +// strconv.Atoi("") fails silently and leaves HttpClientTimeout at 0 rather +// than falling through to DefaultClientTimeout). The variable must be +// actually removed from the environment. +func isolateKeyfactorEnv(t *testing.T, keys ...string) { + t.Helper() + for _, key := range keys { + key := key + originalValue, wasSet := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("failed to unset %s: %v", key, err) + } + t.Cleanup(func() { + if wasSet { + _ = os.Setenv(key, originalValue) + } else { + _ = os.Unsetenv(key) + } + }) + } +} + +// TestNewKeyfactorClient_PlumbsClientTimeout is a regression test proving that +// a Server.ClientTimeout value survives NewKeyfactorClient's rebuild of the +// CommandAuthConfig. Before this fix, baseConfig never set HttpClientTimeout, +// so the rebuilt auth config (and everything derived from it, including +// BuildTransport's ResponseHeaderTimeout) silently fell back to +// DefaultClientTimeout (60s) regardless of what the caller configured, +// producing "net/http: timeout awaiting response headers" on long-running +// calls such as PFX enrollment. +func TestNewKeyfactorClient_PlumbsClientTimeout(t *testing.T) { + isolateKeyfactorEnv( + t, + auth_providers.EnvKeyfactorClientTimeout, + auth_providers.EnvKeyfactorSkipVerify, + auth_providers.EnvKeyfactorPort, + auth_providers.EnvKeyfactorCACert, + ) + server := newFakeCommandServer(t) + u, uErr := url.Parse(server.URL) + if uErr != nil { + t.Fatalf("failed to parse test server URL: %v", uErr) + } + + cfg := &auth_providers.Server{ + Host: u.Host, + Username: "user", + Password: "pass", + APIPath: "api", + SkipTLSVerify: true, + ClientTimeout: 300, + } + + ctx := context.Background() + client, err := NewKeyfactorClient(cfg, &ctx) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + basicCfg, ok := client.AuthClient.(*auth_providers.CommandAuthConfigBasic) + if !ok { + t.Fatalf("expected AuthClient to be *auth_providers.CommandAuthConfigBasic, got %T", client.AuthClient) + } + + if basicCfg.HttpClientTimeout != 300 { + t.Fatalf("expected HttpClientTimeout to be 300, got %d", basicCfg.HttpClientTimeout) + } + + transport, tErr := basicCfg.CommandAuthConfig.BuildTransport() + if tErr != nil { + t.Fatalf("expected no error building transport, got %v", tErr) + } + + expected := 300 * time.Second + if transport.ResponseHeaderTimeout != expected { + t.Fatalf("expected ResponseHeaderTimeout to be %v, got %v", expected, transport.ResponseHeaderTimeout) + } +} + +// TestNewKeyfactorClient_DefaultClientTimeout confirms the zero-value +// (unset) case still falls back to the library default rather than 0s, +// preserving pre-fix behavior for callers who don't set ClientTimeout. +func TestNewKeyfactorClient_DefaultClientTimeout(t *testing.T) { + isolateKeyfactorEnv( + t, + auth_providers.EnvKeyfactorClientTimeout, + auth_providers.EnvKeyfactorSkipVerify, + auth_providers.EnvKeyfactorPort, + auth_providers.EnvKeyfactorCACert, + ) + server := newFakeCommandServer(t) + u, uErr := url.Parse(server.URL) + if uErr != nil { + t.Fatalf("failed to parse test server URL: %v", uErr) + } + + cfg := &auth_providers.Server{ + Host: u.Host, + Username: "user", + Password: "pass", + APIPath: "api", + SkipTLSVerify: true, + } + + ctx := context.Background() + client, err := NewKeyfactorClient(cfg, &ctx) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + basicCfg, ok := client.AuthClient.(*auth_providers.CommandAuthConfigBasic) + if !ok { + t.Fatalf("expected AuthClient to be *auth_providers.CommandAuthConfigBasic, got %T", client.AuthClient) + } + + if basicCfg.HttpClientTimeout != auth_providers.DefaultClientTimeout { + t.Fatalf("expected HttpClientTimeout to fall back to default %d, got %d", auth_providers.DefaultClientTimeout, basicCfg.HttpClientTimeout) + } +} + +// perCallTransportAuthConfig is a minimal AuthConfig test double that mimics +// the real behavior of keyfactor-auth-client-go's CommandConfigOauth and +// CommandAuthConfigBasic GetHttpClient() implementations: every call builds a +// brand new *http.Transport (and therefore a brand new, empty connection +// pool) rather than reusing one. It exists to prove that Client caches the +// *http.Client it gets back rather than calling GetHttpClient() (and paying +// for a fresh transport/connection pool) on every request. +type perCallTransportAuthConfig struct { + server *httptest.Server + getClientCalls int32 +} + +func (a *perCallTransportAuthConfig) GetServerConfig() *auth_providers.Server { + return &auth_providers.Server{ + Host: a.server.URL, + APIPath: "KeyfactorAPI", + SkipTLSVerify: true, + } +} + +func (a *perCallTransportAuthConfig) GetHttpClient() (*http.Client, error) { + atomic.AddInt32(&a.getClientCalls, 1) + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, nil +} + +func (a *perCallTransportAuthConfig) Authenticate() error { return nil } + +func (a *perCallTransportAuthConfig) GetCommandVersion() string { return "25.1.0.0" } + +// TestClient_ReusesHttpClientAcrossRequests is a regression test for a +// resource leak: sendRequest used to call c.AuthClient.GetHttpClient() on +// every single request. Since the real AuthConfig implementations build a +// brand new http.Transport (and connection pool) per call, and that +// transport's IdleConnTimeout is derived from the configured +// HttpClientTimeout, every API call opened its own never-reused connection +// whose socket lingered until IdleConnTimeout fired - amplified by the fix +// that plumbs a caller-configured ClientTimeout (which can be arbitrarily +// large, e.g. 1800s) all the way through instead of the fixed 60s default. +// +// This test drives Client.sendRequest directly across multiple requests and +// asserts both that AuthConfig.GetHttpClient() is invoked at most once +// (proving the *http.Client is cached) and that the underlying TCP +// connection is reused rather than growing linearly with the request count. +func TestClient_ReusesHttpClientAcrossRequests(t *testing.T) { + var newConns int32 + srv := httptest.NewUnstartedServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + }, + ), + ) + srv.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateNew { + atomic.AddInt32(&newConns, 1) + } + } + srv.StartTLS() + t.Cleanup(srv.Close) + + auth := &perCallTransportAuthConfig{server: srv} + client := NewKeyfactorClientWithAuth(auth, nil) + + const requestCount = 10 + for i := 0; i < requestCount; i++ { + resp, err := client.sendRequest( + &request{ + Method: http.MethodGet, + Endpoint: "CertificateStoreContainers", + Headers: &apiHeaders{}, + }, + ) + if err != nil { + t.Fatalf("request %d failed: %v", i, err) + } + // Fully drain and close the body so the underlying transport is free + // to return the connection to its idle pool for reuse. + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + + if calls := atomic.LoadInt32(&auth.getClientCalls); calls != 1 { + t.Fatalf( + "expected AuthClient.GetHttpClient to be called exactly once across %d requests (client should be cached), got %d calls", + requestCount, + calls, + ) + } + + if conns := atomic.LoadInt32(&newConns); conns > 2 { + t.Fatalf( + "expected the TCP connection to be reused across %d sequential requests (at most ~1-2 new connections), observed %d new connections", + requestCount, + conns, + ) + } +} + +// TestClient_ConcurrentRequestsNotCappedByMaxConnsPerHost is an end-to-end +// regression test closing the loop on a finding this package's own +// http.Client-caching fix (see TestClient_ReusesHttpClientAcrossRequests) +// caused but could not fix locally: caching a single *http.Client means the +// transport keyfactor-auth-client-go builds is now reused for the lifetime +// of the Client instance, so any nonzero MaxConnsPerHost on that transport +// stops being a harmless per-request default and becomes a permanent, +// unqueued-timeout ceiling on concurrent in-flight requests for the whole +// process - e.g. `terraform apply -parallelism=25` would silently serialize +// into batches of N with no bound on how long excess requests wait, since +// neither the cached client's Timeout (0, unset) nor its requests' contexts +// impose one. +// +// keyfactor-auth-client-go previously hardcoded MaxConnsPerHost: 10 on this +// transport. Its own fix (auth_core.go's newHTTPTransport, now pinned at 0 / +// unbounded to match net/http.DefaultTransport) was verified from that +// repo's side by inspecting the constructed *http.Transport's field value. +// This test verifies the fix end-to-end from this repo's perspective instead +// of trusting that inspection alone: it builds a real Client via +// NewKeyfactorClient (exactly as production code does), retrieves its cached +// *http.Client via getHttpClient(), and drives 25 concurrent requests through +// it against a real httptest server, asserting the server actually observes +// well more than 10 requests in flight at once rather than serializing into +// batches of 10. +func TestClient_ConcurrentRequestsNotCappedByMaxConnsPerHost(t *testing.T) { + const ( + concurrentRequests = 25 + holdDuration = 200 * time.Millisecond + ) + + var ( + mu sync.Mutex + inFlight int + maxInFlight int + ) + + srv := httptest.NewTLSServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // The initial CommandAuthConfigBasic.Authenticate() call made + // by NewKeyfactorClient below hits this same handler; letting + // it fall through the same slow path is harmless since it + // happens once, sequentially, before the concurrent phase + // starts timing anything. + mu.Lock() + inFlight++ + if inFlight > maxInFlight { + maxInFlight = inFlight + } + mu.Unlock() + + time.Sleep(holdDuration) + + mu.Lock() + inFlight-- + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-keyfactor-product-version", "99.9.9") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`["endpoint1"]`)) + }, + ), + ) + t.Cleanup(srv.Close) + + u, uErr := url.Parse(srv.URL) + if uErr != nil { + t.Fatalf("failed to parse test server URL: %v", uErr) + } + + cfg := &auth_providers.Server{ + Host: u.Host, + Username: "user", + Password: "pass", + APIPath: "api", + SkipTLSVerify: true, + } + + ctx := context.Background() + client, err := NewKeyfactorClient(cfg, &ctx) + if err != nil { + t.Fatalf("NewKeyfactorClient failed: %v", err) + } + + httpClient, hErr := client.getHttpClient() + if hErr != nil { + t.Fatalf("getHttpClient failed: %v", hErr) + } + + // Reset the counters: the single sequential Authenticate() call above + // already touched inFlight/maxInFlight and this resets the baseline so + // the assertion below reflects only the concurrent phase. + mu.Lock() + inFlight = 0 + maxInFlight = 0 + mu.Unlock() + + start := time.Now() + var wg sync.WaitGroup + for i := 0; i < concurrentRequests; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req, rErr := http.NewRequest(http.MethodGet, srv.URL+"/KeyfactorAPI/concurrent-probe", nil) + if rErr != nil { + t.Errorf("failed to build request: %v", rErr) + return + } + resp, dErr := httpClient.Do(req) + if dErr != nil { + t.Errorf("request failed: %v", dErr) + return + } + _ = resp.Body.Close() + }() + } + wg.Wait() + elapsed := time.Since(start) + + mu.Lock() + observedMax := maxInFlight + mu.Unlock() + + t.Logf( + "observed max in-flight requests: %d/%d, wall time: %s (old MaxConnsPerHost=10 cap measured ~10 in-flight/909ms for a comparable batch; unbounded measured ~315ms)", + observedMax, + concurrentRequests, + elapsed, + ) + + // The old hardcoded MaxConnsPerHost: 10 would cap this at exactly 10 + // no matter how many requests are fired concurrently. Assert well above + // that ceiling (comfortably below concurrentRequests to tolerate + // scheduler jitter) to prove the requests are not being serialized into + // batches of 10. + const minAcceptableMaxInFlight = 15 + if observedMax <= 10 { + t.Fatalf( + "expected max concurrent in-flight requests to exceed the old MaxConnsPerHost=10 ceiling, got %d (elapsed %s) - concurrency ceiling regression", + observedMax, + elapsed, + ) + } + if observedMax < minAcceptableMaxInFlight { + t.Fatalf( + "expected max concurrent in-flight requests to be close to %d (unbounded), got only %d (elapsed %s)", + concurrentRequests, + observedMax, + elapsed, + ) + } + + // Wall time is a secondary signal: fully serialized into batches of 10 + // would take ceil(25/10)*holdDuration ~= 3*200ms = 600ms; unbounded + // concurrency should complete in roughly one holdDuration plus overhead. + maxAcceptableElapsed := holdDuration * 2 + if elapsed > maxAcceptableElapsed { + t.Fatalf( + "expected wall time close to a single %s hold duration for unbounded concurrency, got %s (elapsed too long, suggests serialization)", + holdDuration, + elapsed, + ) + } +} diff --git a/v3/api/template_models.go b/v3/api/template_models.go index 127683b..39d905b 100644 --- a/v3/api/template_models.go +++ b/v3/api/template_models.go @@ -119,7 +119,13 @@ type UpdateTemplateArg struct { AllowedRequesters *[]string `json:"AllowedRequesters,omitempty"` RFCEnforcement *bool `json:"RFCEnforcement,omitempty"` RequiresApproval *bool `json:"RequiresApproval,omitempty"` - KeyUsage *bool `json:"KeyUsage,omitempty"` + // KeyUsage is an int32 bitmask on Command's wire format (e.g. 160 = + // digitalSignature|keyEncipherment), matching GetTemplateResponse.KeyUsage and + // Command's TemplateUpdateRequest/TemplateRetrievalResponse swagger schema + // (both typed "integer"/"int32"). A *bool here previously produced a live + // HTTP 400 ("Unexpected character encountered while parsing value: t. Path + // 'KeyUsage'") since Command rejects a JSON boolean for an integer field. + KeyUsage *int `json:"KeyUsage,omitempty"` // TemplatePolicy must be round-tripped from the corresponding GetTemplateResponse // on every update; see the field comment on GetTemplateResponse.TemplatePolicy. TemplatePolicy *TemplatePolicy `json:"TemplatePolicy,omitempty"` diff --git a/v3/api/template_test.go b/v3/api/template_test.go index c18c629..ee27197 100644 --- a/v3/api/template_test.go +++ b/v3/api/template_test.go @@ -244,3 +244,62 @@ func TestUpdateTemplateArg_TemplatePolicy_Roundtrip(t *testing.T) { t.Fatalf("TemplatePolicy.PrimaryKeyAlgorithms on the wire = %v, want 2 entries", policy["PrimaryKeyAlgorithms"]) } } + +// TestUpdateTemplateArg_KeyUsage_SerializesAsInt verifies that UpdateTemplateArg.KeyUsage +// serializes onto the wire as a JSON number, matching Command's TemplateUpdateRequest +// swagger schema ({"type":"integer","format":"int32"}) confirmed against a live v25.5 +// instance. Before the fix, KeyUsage was typed *bool, which serialized as a JSON boolean +// and produced a live HTTP 400 from Command ("Unexpected character encountered while +// parsing value: t. Path 'KeyUsage'"). This also verifies the value returned by +// GetTemplateResponse.KeyUsage (an int) can be assigned directly to +// UpdateTemplateArg.KeyUsage without a type conversion, since both now agree on int. +func TestUpdateTemplateArg_KeyUsage_SerializesAsInt(t *testing.T) { + var receivedBody []byte + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + receivedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.Write(receivedBody) + })) + defer srv.Close() + + c := newTestClient(srv) + + // Simulate a real read-modify-write: KeyUsage comes straight off a + // GetTemplateResponse (int) with no bool<->int conversion required. + fetched := GetTemplateResponse{Id: 4, KeyUsage: 160} // digitalSignature|keyEncipherment + keyUsage := fetched.KeyUsage + + arg := &UpdateTemplateArg{ + Id: 4, + KeyUsage: &keyUsage, + } + + if _, err := c.UpdateTemplate(arg); err != nil { + t.Fatalf("UpdateTemplate() error: %v", err) + } + + var onWire map[string]interface{} + if err := json.Unmarshal(receivedBody, &onWire); err != nil { + t.Fatalf("failed to decode request body sent to server: %v", err) + } + + rawKeyUsage, ok := onWire["KeyUsage"] + if !ok { + t.Fatalf("request body sent to server has no KeyUsage field; got keys: %v", onWire) + } + switch v := rawKeyUsage.(type) { + case float64: + if v != 160 { + t.Errorf("KeyUsage on the wire = %v, want 160", v) + } + case bool: + t.Fatalf("KeyUsage on the wire is a JSON boolean (%v); Command's API expects an int32 bitmask and returns HTTP 400 for a boolean payload", v) + default: + t.Fatalf("KeyUsage on the wire has unexpected type %T (value %v), want a JSON number", v, v) + } +} diff --git a/v3/go.mod b/v3/go.mod index f4eb047..705858a 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -19,7 +19,7 @@ go 1.24.0 toolchain go1.24.5 require ( - github.com/Keyfactor/keyfactor-auth-client-go v1.5.0 + github.com/Keyfactor/keyfactor-auth-client-go v1.6.0-rc.5 github.com/hashicorp/terraform-plugin-log v0.10.0 github.com/spbsoluble/go-pkcs12 v0.4.0 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 diff --git a/v3/go.sum b/v3/go.sum index 451aad8..df302cb 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -14,8 +14,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/Keyfactor/keyfactor-auth-client-go v1.5.0 h1:sq7SGkJeTtDspFSuX2oJxTmFiiFfaQ68B4JP7jryl94= -github.com/Keyfactor/keyfactor-auth-client-go v1.5.0/go.mod h1:rFBZPMSHWwWuUwE1kXhLsDaOxjGiHMbXTTEni8Dmufo= +github.com/Keyfactor/keyfactor-auth-client-go v1.6.0-rc.5 h1:nsp5hrG7EtGFOAaAIyRHt7FSLSWJSIDz66GrLaJU4yA= +github.com/Keyfactor/keyfactor-auth-client-go v1.6.0-rc.5/go.mod h1:rFBZPMSHWwWuUwE1kXhLsDaOxjGiHMbXTTEni8Dmufo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -75,8 +75,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/spbsoluble/go-pkcs12 v0.3.3 h1:3nh7IKn16RDpmrSMtOu1JvbB0XHYq1j+IsICdU1c7J4= -github.com/spbsoluble/go-pkcs12 v0.3.3/go.mod h1:MAxKIUEIl/QVcua/I1L4Otyxl9UvLCCIktce2Tjz6Nw= github.com/spbsoluble/go-pkcs12 v0.4.0 h1:3HOVPZ8pvYqhAyz/NJzT9YODQJ3HbZvB9/CMVmvGaUM= github.com/spbsoluble/go-pkcs12 v0.4.0/go.mod h1:MAxKIUEIl/QVcua/I1L4Otyxl9UvLCCIktce2Tjz6Nw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=