~bigbes/lethe

ref: 50654f96ce59bec2ca3200cd54806704472ff21f lethe/internal/pkg/apierror/apierror_test.go -rw-r--r-- 5.2 KiB
50654f96 — Eugene Blikh web: wire display settings UI a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package apierror

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"go.bigb.es/auxilia/culpa"
)

// TestRender_CodeToStatusMapping verifies each documented culpa code maps to
// the expected HTTP status, and the response body has the RFC 7807 shape with
// the code echoed under the "code" extension field.
func TestRender_CodeToStatusMapping(t *testing.T) {
	cases := []struct {
		name    string
		code    string
		wantSt  int
		wantTit string
	}{
		{"not-found", "NOT_FOUND", http.StatusNotFound, http.StatusText(http.StatusNotFound)},
		{"invalid", "INVALID", http.StatusBadRequest, http.StatusText(http.StatusBadRequest)},
		{"validation", "VALIDATION", http.StatusBadRequest, http.StatusText(http.StatusBadRequest)},
		{"unauthorized", "UNAUTHORIZED", http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)},
		{"forbidden", "FORBIDDEN", http.StatusForbidden, http.StatusText(http.StatusForbidden)},
		{"conflict", "CONFLICT", http.StatusConflict, http.StatusText(http.StatusConflict)},
		{"too-large", "TOO_LARGE", http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)},
		{"unknown-defaults-500", "WHO_KNOWS", http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := culpa.WithCode(culpa.WithPublic(culpa.New("boom"), "user-facing"), tc.code)
			rec := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodGet, "/api/v1/things/42", nil)

			Render(rec, req, err)

			if got := rec.Code; got != tc.wantSt {
				t.Fatalf("status = %d; want %d", got, tc.wantSt)
			}
			if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
				t.Errorf("Content-Type = %q; want application/problem+json", ct)
			}
			var p Problem
			if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
				t.Fatalf("unmarshal body: %v", err)
			}
			if p.Status != tc.wantSt {
				t.Errorf("body.status = %d; want %d", p.Status, tc.wantSt)
			}
			if p.Title != tc.wantTit {
				t.Errorf("body.title = %q; want %q", p.Title, tc.wantTit)
			}
			if p.Instance != "/api/v1/things/42" {
				t.Errorf("body.instance = %q; want /api/v1/things/42", p.Instance)
			}
			// 5xx codes have their detail sanitized to the constant message.
			if tc.wantSt >= 500 {
				if p.Detail != "internal server error" {
					t.Errorf("body.detail = %q; want %q", p.Detail, "internal server error")
				}
				// Unknown code is not echoed as the public error code.
				if p.Code == "WHO_KNOWS" {
					t.Errorf("body.code = WHO_KNOWS leaked for 5xx response")
				}
			} else {
				if p.Code != tc.code {
					t.Errorf("body.code = %q; want %q", p.Code, tc.code)
				}
				if p.Detail != "user-facing" {
					t.Errorf("body.detail = %q; want %q", p.Detail, "user-facing")
				}
			}
		})
	}
}

// TestRender_5xxSanitizesDetail proves an internal error never leaks the
// underlying message (which may contain credentials or stack-like text)
// through the response body.
func TestRender_5xxSanitizesDetail(t *testing.T) {
	sensitive := "DB password=hunter2 connection refused"
	err := culpa.Wrap(culpa.New(sensitive), "open db")

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/whatever", nil)
	Render(rec, req, err)

	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d; want 500", rec.Code)
	}
	body := rec.Body.String()
	if strings.Contains(body, "hunter2") || strings.Contains(body, "password") {
		t.Fatalf("response body leaked sensitive substring; body=%q", body)
	}
	var p Problem
	if err := json.Unmarshal([]byte(body), &p); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if p.Detail != "internal server error" {
		t.Errorf("detail = %q; want sanitized", p.Detail)
	}
}

// TestRender_NoCodeDefaultsTo500 ensures errors without a CodeDetail still
// produce a valid problem document at 500.
func TestRender_NoCodeDefaultsTo500(t *testing.T) {
	err := culpa.New("plain")

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/x", nil)
	Render(rec, req, err)

	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d; want 500", rec.Code)
	}
	var p Problem
	if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if p.Status != 500 {
		t.Errorf("body.status = %d; want 500", p.Status)
	}
	if p.Detail != "internal server error" {
		t.Errorf("detail = %q; want sanitized", p.Detail)
	}
}

// TestRender_PublicDetailUsedForClientErrors confirms PublicDetail is what
// shows up in the client-facing detail for non-5xx responses.
func TestRender_PublicDetailUsedForClientErrors(t *testing.T) {
	err := culpa.WithCode(culpa.WithPublic(culpa.New("internal"), "session not found"), "NOT_FOUND")
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/abc", nil)
	Render(rec, req, err)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d; want 404", rec.Code)
	}
	var p Problem
	if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if p.Detail != "session not found" {
		t.Errorf("detail = %q; want public message", p.Detail)
	}
	if p.Code != "NOT_FOUND" {
		t.Errorf("code = %q; want NOT_FOUND", p.Code)
	}
}