~bigbes/sr-ht-spec

ref: 636dc7a8d00c0f988b5afa2d715ab1140fd6d912 sr-ht-spec/api/api_test.go -rw-r--r-- 7.3 KiB
636dc7a8 — Eugene Blikh pages: read a form's body, bounded, and never its URL 9 days 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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
package api_test

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

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/sr-ht-spec/api"
	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// fakeWriter captures the request the handler builds and returns a canned
// result or error, so routing, header parsing and status mapping are checked
// without a service, a repository or a database.
type fakeWriter struct {
	got service.ProposeRequest
	res service.ProposeResult
	err error
}

func (f *fakeWriter) Propose(_ context.Context, req service.ProposeRequest) (service.ProposeResult, error) {
	f.got = req
	return f.res, f.err
}

// router builds the write routes with a principal injected into every request,
// bypassing token resolution — the handler reads the principal off the context,
// and what put it there is not this package's concern.
func router(t *testing.T, w api.Writer, p authn.Principal) http.Handler {
	t.Helper()
	srv, err := api.New(api.Options{Writer: w, Resolver: testResolver(t)})
	if err != nil {
		t.Fatalf("api.New: %v", err)
	}
	r := chi.NewRouter()
	r.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
			next.ServeHTTP(rw, req.WithContext(authn.WithPrincipal(req.Context(), p)))
		})
	})
	srv.Register(r)
	return r
}

// testResolver is a resolver New requires but Register does not use. These
// tests inject their principal directly, so it needs no agent plane.
func testResolver(t *testing.T) *authn.Resolver {
	t.Helper()
	res, err := authn.NewResolver("bigbes")
	if err != nil {
		t.Fatalf("NewResolver: %v", err)
	}
	return res
}

func agent() authn.Principal {
	return authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code", Session: "s1"}
}

const specPath = "/v1/spaces/~bigbes/rfcs/docs/specs/0007-storage.md"

// TestPutOpensProposal proves the whole open path: a PUT with a body and an
// If-Match opens a proposal, returns 201 with the proposal and its url, and
// forwards every field to the service.
func TestPutOpensProposal(t *testing.T) {
	w := &fakeWriter{res: service.ProposeResult{
		Proposal: service.Proposal{ID: 42, Branch: "proposals/42", BaseRev: "deadbeef", State: core.StateOpen},
		URL:      "https://spec.srht.bigb.es/~bigbes/rfcs/p/42",
	}}
	h := router(t, w, agent())

	req := httptest.NewRequest(http.MethodPut, specPath+"?title=Storage&message=add+it", strings.NewReader("the document"))
	req.Header.Set("If-Match", "1f0c1d1a")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body)
	}
	var body struct {
		Proposal int    `json:"proposal"`
		URL      string `json:"url"`
		State    string `json:"state"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode body: %v", err)
	}
	if body.Proposal != 42 || !strings.HasSuffix(body.URL, "/p/42") || body.State != "open" {
		t.Fatalf("body = %+v, want proposal 42 open with its url", body)
	}

	// The request the service saw.
	g := w.got
	if g.Space != (core.SpaceRef{Owner: "bigbes", Name: "rfcs"}) {
		t.Errorf("space = %v", g.Space)
	}
	if g.IfMatch != "1f0c1d1a" || g.Title != "Storage" || g.Message != "add it" {
		t.Errorf("forwarded fields wrong: %+v", g)
	}
	if g.ProposalID != 0 {
		t.Errorf("ProposalID = %d, want 0 for an open", g.ProposalID)
	}
	if len(g.Writes) != 1 || g.Writes[0].Path != "specs/0007-storage.md" || string(g.Writes[0].Content) != "the document" {
		t.Errorf("writes = %+v", g.Writes)
	}
	// Principal is no longer comparable with == — it carries a grant set — so
	// its rendering stands in for it here.
	if g.Principal.String() != agent().String() {
		t.Errorf("principal = %+v, want the agent on the context", g.Principal)
	}
}

// TestPutAddsToExistingProposal proves X-Proposal routes to an add and returns
// 200 rather than 201.
func TestPutAddsToExistingProposal(t *testing.T) {
	w := &fakeWriter{res: service.ProposeResult{
		Proposal: service.Proposal{ID: 7, Branch: "proposals/7", State: core.StateOpen},
		URL:      "https://spec.srht.bigb.es/~bigbes/rfcs/p/7",
	}}
	h := router(t, w, agent())

	req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
	req.Header.Set("If-Match", "1f0c1d1a")
	req.Header.Set("X-Proposal", "7")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body %s", rec.Code, rec.Body)
	}
	if w.got.ProposalID != 7 {
		t.Errorf("ProposalID = %d, want 7", w.got.ProposalID)
	}
}

// TestPutRequiresIfMatch refuses a write with no base.
func TestPutRequiresIfMatch(t *testing.T) {
	w := &fakeWriter{}
	h := router(t, w, agent())
	req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", rec.Code)
	}
	if w.got.IfMatch != "" || w.got.Space != (core.SpaceRef{}) {
		t.Fatal("service was called despite a missing If-Match")
	}
}

// TestPutRejectsBadProposalHeader refuses a non-numeric X-Proposal.
func TestPutRejectsBadProposalHeader(t *testing.T) {
	h := router(t, &fakeWriter{}, agent())
	req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
	req.Header.Set("If-Match", "1f0c1d1a")
	req.Header.Set("X-Proposal", "not-a-number")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", rec.Code)
	}
}

// TestPutMapsServiceErrors proves each service sentinel reaches the right status.
func TestPutMapsServiceErrors(t *testing.T) {
	tests := []struct {
		name string
		err  error
		want int
	}{
		{"forbidden", service.ErrForbidden, http.StatusForbidden},
		{"invalid", service.ErrInvalid, http.StatusUnprocessableEntity},
		{"stale", service.ErrStale, http.StatusConflict},
		{"already merged", service.ErrAlreadyMerged, http.StatusConflict},
		{"not open", service.ErrProposalNotOpen, http.StatusConflict},
		{"not found", service.ErrNotFound, http.StatusNotFound},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			h := router(t, &fakeWriter{err: tc.err}, agent())
			req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
			req.Header.Set("If-Match", "1f0c1d1a")
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, req)
			if rec.Code != tc.want {
				t.Fatalf("status = %d, want %d", rec.Code, tc.want)
			}
		})
	}
}

// TestPutDecodesEncodedPath proves a percent-encoded document path reaches the
// service decoded, so a name with a space or a Cyrillic letter is addressable.
func TestPutDecodesEncodedPath(t *testing.T) {
	w := &fakeWriter{res: service.ProposeResult{Proposal: service.Proposal{ID: 1, State: core.StateOpen}}}
	h := router(t, w, agent())
	req := httptest.NewRequest(http.MethodPut, "/v1/spaces/~bigbes/rfcs/docs/notes/hello%20world.md", strings.NewReader("doc"))
	req.Header.Set("If-Match", "1f0c1d1a")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusCreated {
		t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body)
	}
	if len(w.got.Writes) != 1 || w.got.Writes[0].Path != "notes/hello world.md" {
		t.Fatalf("path = %q, want decoded", w.got.Writes[0].Path)
	}
}