Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ The following functions previously returned non-nil empty stubs on decryption fa

- `HTTPS_PROXY`/`HTTP_PROXY` environment variables are now honored when `ProxyUrl` is not explicitly set, matching standard `net/http` behavior. Previously these variables were silently ignored. (KSM-912)
- HTTP status code is now included in the caller-returned error on the JSON-error path. Previously it appeared only in the log line and the non-JSON fallback; callers on the common 4xx/5xx JSON path received no status code in `err.Error()`. (KSM-919)
- The offline-fallback cache is now consulted on network-level errors (DNS failure, connection refused, TLS failure, timeout) in addition to non-200 HTTP responses. Previously the cache was bypassed entirely when the request failed before receiving a response, providing no resilience against the most common outage mode. A warning is logged when cached records are served. (KSM-921)
- Replaced `strings.Cut()` (Go 1.18+) with `strings.SplitN()` throughout. Users on Go 1.16 or 1.17 would have seen build failures with the previous code.

---
Expand Down
9 changes: 9 additions & 0 deletions core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,15 @@ func (c *SecretsManager) PostQuery(path string, payload interface{}) (body []byt

ksmRs, err = c.PostFunction(url, transmissionKey, encryptedPayloadAndSignature, c.VerifySslCerts)
if err != nil {
if c.cache != nil && path == "get_secret" {
if cachedData, cerr := c.cache.GetCachedValue(); cerr == nil && len(cachedData) >= Aes256KeySize {
klog.Warning(fmt.Sprintf("network error contacting Keeper API (%v); serving cached records", err))
transmissionKey.Key = cachedData[:Aes256KeySize]
data := cachedData[Aes256KeySize:]
ksmRs = NewKsmHttpResponse(200, data, nil)
break
}
}
return nil, errors.New("error during POST request: " + err.Error())
}

Expand Down
101 changes: 101 additions & 0 deletions test/cache_fallback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package test

import (
"errors"
"net/http"
"strings"
"testing"

ksm "github.com/keeper-security/secrets-manager-go/core"
)

// networkErrorTransport is an http.RoundTripper that always returns a connection-refused error.
type networkErrorTransport struct{}

func (networkErrorTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp: connect: connection refused")
}

// memCache is a minimal ICache that stores one value in memory.
type memCache struct {
data []byte
}

func (c *memCache) SaveCachedValue(data []byte) error {
buf := make([]byte, len(data))
copy(buf, data)
c.data = buf
return nil
}

func (c *memCache) GetCachedValue() ([]byte, error) {
if len(c.data) == 0 {
return nil, errors.New("cache empty")
}
return c.data, nil
}

func (c *memCache) Purge() error {
c.data = nil
return nil
}

func TestCacheFallbackOnNetworkError(t *testing.T) {
// Call 1: normal 200 → populates the cache.
// Call 2: network-level error → cache has a value → serve cached records, no error.
// Call 3: cache purged + network-level error → error surfaces.
defer ResetMockResponseQueue()

configJson := MockConfig{}.MakeJson(MockConfig{}.MakeConfig(nil, "", "", ""))
cfg := ksm.NewMemoryKeyValueStorage(configJson)
sm := ksm.NewSecretsManager(&ksm.ClientOptions{Config: cfg}, Ctx)

cache := &memCache{}
sm.SetCache(cache)

res := NewMockResponse([]byte{}, 200, nil)
mockRecord := res.AddRecord("Cached Record", "login", "", nil, nil)
mockRecord.Field("login", "", "", "", "cached-user")
MockResponseQueue.AddMockResponse(res)

// Call 1: normal API response.
records, err := sm.GetSecrets(nil)
if err != nil {
t.Fatalf("call 1: unexpected error: %v", err)
}
if len(records) != 1 {
t.Fatalf("call 1: expected 1 record, got %d", len(records))
}
if len(cache.data) == 0 {
t.Fatal("call 1: cache should be populated after successful API call")
}

// Switch to network-error transport — all subsequent PostFunction calls fail immediately.
savedTransport := (*context).Transport
(*context).Transport = networkErrorTransport{}
defer func() { (*context).Transport = savedTransport }()

// Call 2: network error + populated cache → should return cached records.
records, err = sm.GetSecrets(nil)
if err != nil {
t.Fatalf("call 2: expected cached records, got error: %v", err)
}
if len(records) != 1 {
t.Fatalf("call 2: expected 1 cached record, got %d", len(records))
}
if records[0].Uid != mockRecord.Uid {
t.Errorf("call 2: UID mismatch: got %q, want %q", records[0].Uid, mockRecord.Uid)
}

// Call 3: purge cache + network error → error should surface.
if err := cache.Purge(); err != nil {
t.Fatalf("purge failed: %v", err)
}
_, err = sm.GetSecrets(nil)
if err == nil {
t.Fatal("call 3: expected network error after cache purge, got nil")
}
if !strings.Contains(err.Error(), "error during POST request") {
t.Errorf("call 3: unexpected error format: %v", err)
}
}