Add web.FieldErrorsFromDB — a constraint-name-to-field translator that converts PostgreSQL constraint violations into 422 FieldErrors (per the §6 error contract) instead of leaking raw driver text to users. - New web/dberrors.go: FieldErrorsFromDB + ConstraintMessages map - New web/dberrors_test.go: constraint hits, per-class fallbacks (23505/23503/23514/22001), nil map, non-Postgres, 23P01 exclusion requires named entry - Fix 9 leak sites: workspace creation, enrollment (IssueGrant/ExtendGrant/ CreateNonPlanGrant/RevokeGrantAndTransition), org type default-plan update, entitlement set CRUD, product create/update - New lint rule raw-error-render: flags err.Error() on any line touching a UI sink (render*, fireErrorToast, fireSuccessToast, http.Error), exempts slog lines - Docs: add §4 db-write-failure contract and §9 anti-pattern row Closes: openspec/changes/db-error-translation
87 lines
2.7 KiB
Go
87 lines
2.7 KiB
Go
package lint
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// writeFixture drops src at name under dir, creating parents.
|
|
func writeFixture(t *testing.T, dir, name, src string) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestRuleRawErrorRender(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
leaky := writeFixture(t, dir, "handlers.go", `package server
|
|
|
|
func (h *H) bad(err error, attErr error) {
|
|
h.renderFooPage(w, r, "", "Failed to save: "+err.Error()) // line 4: flag
|
|
fireErrorToast(w, attErr.Error()) // line 5: flag (suffixed identifier)
|
|
http.Error(w, err.Error(), 500) // line 6: flag
|
|
h.Logger.Error("failed", slog.Any("error", err)) // line 7: ok — slog only
|
|
slog.Error("failed to save: " + err.Error()) // line 8: ok — slog carve-out
|
|
return fmt.Errorf("wrap: %w", err) // line 9: ok — no UI sink
|
|
msg := err.Error() // line 10: ok — no UI sink on this line
|
|
h.renderFooPage(w, r, "", "Failed to save. See the logs.") // line 11: ok — generic text only
|
|
fireSuccessToast(w, "saved "+err.Error()) // line 12: flag (success toast is a sink too)
|
|
}
|
|
`)
|
|
|
|
writeFixture(t, dir, "handlers_test.go", `package server
|
|
|
|
func TestX(t *testing.T) {
|
|
h.renderFooPage(w, r, "", "boom: "+err.Error()) // tests are exempt
|
|
}
|
|
`)
|
|
|
|
got := ruleRawErrorRender(dir)
|
|
|
|
wantLines := map[int]bool{4: true, 5: true, 6: true, 12: true}
|
|
if len(got) != len(wantLines) {
|
|
t.Fatalf("got %d violations, want %d: %+v", len(got), len(wantLines), got)
|
|
}
|
|
for _, viol := range got {
|
|
if viol.File != leaky {
|
|
t.Errorf("violation in %s, want only %s", viol.File, leaky)
|
|
}
|
|
if viol.Rule != "raw-error-render" {
|
|
t.Errorf("rule = %q, want raw-error-render", viol.Rule)
|
|
}
|
|
if !wantLines[viol.Line] {
|
|
t.Errorf("unexpected violation at line %d", viol.Line)
|
|
}
|
|
delete(wantLines, viol.Line)
|
|
}
|
|
for line := range wantLines {
|
|
t.Errorf("missing violation at line %d", line)
|
|
}
|
|
}
|
|
|
|
func TestRuleRawErrorRender_CleanDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFixture(t, dir, "clean.go", `package server
|
|
|
|
func (h *H) good(err error) {
|
|
if fe, ok := web.FieldErrorsFromDB(err, nil); ok {
|
|
h.renderFooFormErrors(w, r, fe)
|
|
return
|
|
}
|
|
h.Logger.Error("failed to save", slog.Any("error", err))
|
|
h.renderFooPage(w, r, "", "Failed to save. Details are in the server logs.")
|
|
}
|
|
`)
|
|
if got := ruleRawErrorRender(dir); len(got) != 0 {
|
|
t.Fatalf("got %d violations on clean fixture, want 0: %+v", len(got), got)
|
|
}
|
|
}
|