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 =***. 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) } }