Files
mopac-keyproxy/internal/server/server_test.go
T
mrcharles 82861b6d23 Add localhost resolve HTTP hop with bearer auth and redaction
Implement `keyproxy serve`: POST /v1/resolve (constant-time bearer-token
auth, 4 KiB body cap, mpk- ref validation before any echo) and
GET /healthz. The token is resolved once at startup from the file
backend. Panics are recovered with the panic value discarded and detail
suppressed; auth failures log the remote address only; every log line
naming a ref masks it as <ref>=***. Failures respond with fixed reason
enums (404/400/405/413/500/501/502) that cannot embed material.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-28 22:30:50 -05:00

468 lines
13 KiB
Go

package server
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend"
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/config"
)
const (
authToken = "smoke-bearer-token-do-not-log"
fileSecret = "sk-file-supersecret-material-aaa111"
envSecret = "sk-env-supersecret-material-bbb222"
panicSecret = "sk-panic-supersecret-material-ccc333"
)
type fixture struct {
cfg *config.Config
log *bytes.Buffer
ts *httptest.Server
backend *backend.Registry
}
// newFixture builds a full stack: real config file, real 0600 env files,
// real registry, real HTTP listener on 127.0.0.1 (httptest), shared log
// buffer for redaction sweeps.
func newFixture(t *testing.T) *fixture {
t.Helper()
dir := t.TempDir()
write := func(name, content string, mode os.FileMode) string {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, mode); err != nil {
t.Fatal(err)
}
return path
}
tokenFile := write("keyproxy.env", "KEYPROXY_TOKEN="+authToken+"\n", 0o600)
goodFile := write("good.env", "API_KEY="+fileSecret+"\nOTHER=x\n", 0o600)
looseFile := write("loose.env", "API_KEY="+fileSecret+"\n", 0o644)
emptyFile := write("empty.env", "API_KEY=\n", 0o600)
t.Setenv("KEYPROXY_TEST_ENV_SECRET", envSecret)
cfgPath := write("keyproxy.toml", fmt.Sprintf(`
listen = "127.0.0.1:0"
[auth]
token_ref = "mpk-keyproxy-self"
[refs."mpk-keyproxy-self"]
backend = "file"
source = %q
key = "KEYPROXY_TOKEN"
[refs."mpk-file"]
backend = "file"
source = %q
key = "API_KEY"
[refs."mpk-env"]
backend = "env"
source = "KEYPROXY_TEST_ENV_SECRET"
[refs."mpk-loose"]
backend = "file"
source = %q
key = "API_KEY"
[refs."mpk-empty"]
backend = "file"
source = %q
key = "API_KEY"
[refs."mpk-bitwarden"]
backend = "bitwarden"
source = "sm://p/x"
key = "API_KEY"
[refs."mpk-vault"]
backend = "vault"
source = "secret/data/x"
key = "API_KEY"
`, tokenFile, goodFile, looseFile, emptyFile), 0o600)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("fixture config: %v", err)
}
reg := backend.NewRegistry(
backend.NewFile(),
backend.NewEnv(),
backend.NewBitwarden(),
backend.NewVault(),
)
log := &bytes.Buffer{}
srv, err := New(cfg, reg, log)
if err != nil {
t.Fatalf("fixture server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &fixture{cfg: cfg, log: log, ts: ts, backend: reg}
}
// post drives /v1/resolve with an optional bearer token.
func (f *fixture) post(t *testing.T, token, body string) (int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, f.ts.URL+"/v1/resolve", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(resp.Body)
return resp.StatusCode, buf.String()
}
func TestResolveTable(t *testing.T) {
f := newFixture(t)
tests := []struct {
name string
token string
body string
wantStatus int
wantBody string // substring expected in the body
wantBody2 string // second substring expected in the body
noBody string // substring that must NOT appear in the body
wantLog string // substring expected in the log
}{
{
name: "file ref resolves",
token: authToken,
body: `{"ref":"mpk-file"}`,
wantStatus: http.StatusOK,
wantBody: `"value":"` + fileSecret + `"`,
wantLog: "ref=mpk-file=*** backend=file status=200",
},
{
name: "env ref resolves",
token: authToken,
body: `{"ref":"mpk-env"}`,
wantStatus: http.StatusOK,
wantBody: `"value":"` + envSecret + `"`,
wantLog: "ref=mpk-env=*** backend=env status=200",
},
{
name: "no token 401",
body: `{"ref":"mpk-file"}`,
wantStatus: http.StatusUnauthorized,
wantBody: "unauthorized",
noBody: fileSecret,
wantLog: "auth failure",
},
{
name: "wrong token 401",
token: "wrong-token",
body: `{"ref":"mpk-file"}`,
wantStatus: http.StatusUnauthorized,
wantBody: "unauthorized",
noBody: "wrong-token", // presented token never logged/echoed
},
{
name: "unknown ref 404",
token: authToken,
body: `{"ref":"mpk-ghost"}`,
wantStatus: http.StatusNotFound,
wantBody: `"ref":"mpk-ghost"`,
wantLog: "ref=mpk-ghost=*** backend=- status=404 reason=unknown_ref",
},
{
name: "invalid ref format 400 without echo",
token: authToken,
body: `{"ref":"sk-live-pasted-secret-xyz"}`,
wantStatus: http.StatusBadRequest,
wantBody: "invalid ref",
noBody: "sk-live-pasted-secret-xyz",
},
{
name: "malformed body 400",
token: authToken,
body: `not json`,
wantStatus: http.StatusBadRequest,
wantBody: "malformed body",
},
{
name: "empty ref 400",
token: authToken,
body: `{"ref":""}`,
wantStatus: http.StatusBadRequest,
wantBody: "malformed body",
},
{
name: "bitwarden stub 501",
token: authToken,
body: `{"ref":"mpk-bitwarden"}`,
wantStatus: http.StatusNotImplemented,
wantBody: `"reason":"not_implemented"`,
wantBody2: `"backend":"bitwarden"`,
wantLog: "ref=mpk-bitwarden=*** backend=bitwarden status=501 reason=not_implemented",
},
{
name: "vault stub 501",
token: authToken,
body: `{"ref":"mpk-vault"}`,
wantStatus: http.StatusNotImplemented,
wantBody: `"reason":"not_implemented"`,
wantBody2: `"backend":"vault"`,
wantLog: "ref=mpk-vault=*** backend=vault status=501 reason=not_implemented",
},
{
name: "empty value 502",
token: authToken,
body: `{"ref":"mpk-empty"}`,
wantStatus: http.StatusBadGateway,
wantBody: `"reason":"empty_value"`,
wantLog: "ref=mpk-empty=*** backend=file status=502 reason=empty_value",
},
{
name: "loose file mode 502",
token: authToken,
body: `{"ref":"mpk-loose"}`,
wantStatus: http.StatusBadGateway,
wantBody: `"reason":"insecure_source_mode"`,
wantLog: "ref=mpk-loose=*** backend=file status=502 reason=insecure_source_mode",
},
{
name: "oversized body 413",
token: authToken,
body: `{"ref":"` + strings.Repeat("a", 8192) + `"}`,
wantStatus: http.StatusRequestEntityTooLarge,
wantBody: "payload too large",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, body := f.post(t, tt.token, tt.body)
if status != tt.wantStatus {
t.Fatalf("status = %d (%s), want %d", status, body, tt.wantStatus)
}
if tt.wantBody != "" && !strings.Contains(body, tt.wantBody) {
t.Fatalf("body %q missing %q", body, tt.wantBody)
}
if tt.wantBody2 != "" && !strings.Contains(body, tt.wantBody2) {
t.Fatalf("body %q missing %q", body, tt.wantBody2)
}
if tt.noBody != "" && strings.Contains(body, tt.noBody) {
t.Fatalf("BODY LEAKS %q: %q", tt.noBody, body)
}
if tt.wantLog != "" && !strings.Contains(f.log.String(), tt.wantLog) {
t.Fatalf("log %q missing %q", f.log.String(), tt.wantLog)
}
})
}
// Method checks.
resp, err := http.Get(f.ts.URL + "/v1/resolve")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("GET /v1/resolve = %d, want 405", resp.StatusCode)
}
resp, err = http.Get(f.ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /healthz = %d, want 200 (unauthenticated)", resp.StatusCode)
}
// Whole-run redaction sweep: no material, no presented token, no
// panic values anywhere in the log buffer; every ref named in the
// log appears masked as <ref>=***.
logAll := f.log.String()
for _, secret := range []string{authToken, "wrong-token", fileSecret, envSecret, "sk-live-pasted-secret"} {
if strings.Contains(logAll, secret) {
t.Fatalf("LOG LEAKS MATERIAL %q:\n%s", secret, logAll)
}
}
for _, ref := range []string{"mpk-file", "mpk-env", "mpk-ghost", "mpk-empty", "mpk-loose"} {
if strings.Contains(logAll, ref) && !strings.Contains(logAll, ref+"=***") {
// ref may appear only in masked form; check per line
for _, line := range strings.Split(logAll, "\n") {
if strings.Contains(line, ref) && !strings.Contains(line, ref+"=***") {
t.Fatalf("LOG LINE NAMES REF UNMASKED: %s", line)
}
}
}
}
}
// TestPanicRedaction drives a backend panic whose VALUE is the material
// and asserts the crash path stays redacted.
func TestPanicRedaction(t *testing.T) {
dir := t.TempDir()
tokenPath := filepath.Join(dir, "keyproxy.env")
if err := os.WriteFile(tokenPath, []byte("KEYPROXY_TOKEN="+authToken+"\n"), 0o600); err != nil {
t.Fatal(err)
}
cfgPath := filepath.Join(dir, "keyproxy.toml")
content := fmt.Sprintf(`
[auth]
token_ref = "mpk-keyproxy-self"
[refs."mpk-keyproxy-self"]
backend = "file"
source = %q
key = "KEYPROXY_TOKEN"
[refs."mpk-file"]
backend = "file"
source = %q
key = "API_KEY"
`, tokenPath, filepath.Join(dir, "absent.env"))
if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
// A file-backend implementation that serves the auth token normally
// but panics with the material as the panic value on data refs: the
// worst-case crash path.
reg := backend.NewRegistry(&panicBackend{token: authToken, material: panicSecret}, backend.NewEnv(), backend.NewBitwarden(), backend.NewVault())
log := &bytes.Buffer{}
srv, err := New(cfg, reg, log)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/resolve", strings.NewReader(`{"ref":"mpk-file"}`))
req.Header.Set("Authorization", "Bearer "+authToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(resp.Body)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
if strings.Contains(buf.String(), panicSecret) {
t.Fatalf("RESPONSE LEAKS MATERIAL: %q", buf.String())
}
got := log.String()
if !strings.Contains(got, "panic recovered") || !strings.Contains(got, "ref=mpk-file=***") {
t.Fatalf("panic log line missing or unmasked:\n%s", got)
}
if strings.Contains(got, panicSecret) {
t.Fatalf("LOG LEAKS MATERIAL ON CRASH PATH:\n%s", got)
}
}
// TestNewFailFastAuth covers startup refusing when the auth token cannot
// be resolved (fail fast, error names the ref, never the token).
func TestNewFailFastAuth(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "keyproxy.toml")
content := fmt.Sprintf(`
[auth]
token_ref = "mpk-keyproxy-self"
[refs."mpk-keyproxy-self"]
backend = "file"
source = %q
key = "KEYPROXY_TOKEN"
`, filepath.Join(dir, "absent.env"))
if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
reg := backend.NewRegistry(backend.NewFile(), backend.NewEnv(), backend.NewBitwarden(), backend.NewVault())
_, err = New(cfg, reg, &bytes.Buffer{})
if err == nil {
t.Fatal("New must fail when the auth token cannot be resolved")
}
if !strings.Contains(err.Error(), "mpk-keyproxy-self") || !strings.Contains(err.Error(), "unreadable_source") {
t.Fatalf("error %q must name the auth ref and reason", err)
}
}
// panicBackend implements Backend (registered under the file name):
// with all set it panics on every resolve; otherwise it serves the auth
// token normally and panics with material in the panic value on every
// other ref.
type panicBackend struct {
token string
material string
all bool
}
func (p *panicBackend) Name() string { return "file" }
func (p *panicBackend) Resolve(ctx context.Context, ref backend.Ref) (string, error) {
if !p.all && ref.Key == "KEYPROXY_TOKEN" {
return p.token, nil
}
panic(p.material)
}
// TestNewPanicSuppressed: a backend that panics even on the auth-token
// resolve makes startup fail fast with a generic, material-free error.
func TestNewPanicSuppressed(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "keyproxy.toml")
content := fmt.Sprintf(`
[auth]
token_ref = "mpk-keyproxy-self"
[refs."mpk-keyproxy-self"]
backend = "file"
source = %q
key = "KEYPROXY_TOKEN"
`, filepath.Join(dir, "absent.env"))
if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
reg := backend.NewRegistry(
&panicBackend{token: "never-served", material: panicSecret, all: true},
backend.NewEnv(), backend.NewBitwarden(), backend.NewVault(),
)
logBuf := &bytes.Buffer{}
_, err = New(cfg, reg, logBuf)
if err == nil {
t.Fatal("New must fail when the auth-token resolve panics")
}
if strings.Contains(err.Error(), panicSecret) {
t.Fatalf("STARTUP ERROR LEAKS MATERIAL: %q", err)
}
if !strings.Contains(err.Error(), "panicked (detail suppressed)") {
t.Fatalf("error %q must say the panic detail is suppressed", err)
}
}