~bigbes/sr-ht-spec

ref: 36976713874550ff48b51f954a59dd597c36fa87 sr-ht-spec/api/api_test.go -rw-r--r-- 7.3 KiB
36976713 — Eugene Blikh feat(db): webhook + user tables (Phase 5a foundation) 25 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
215
216
217
218
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. One token
// store with no tokens is enough to construct it.
func testResolver(t *testing.T) *authn.Resolver {
	t.Helper()
	res, err := authn.NewResolver("bigbes", stubTokens{})
	if err != nil {
		t.Fatalf("NewResolver: %v", err)
	}
	return res
}

type stubTokens struct{}

func (stubTokens) LookupAgentToken(context.Context, []byte) (authn.AgentToken, error) {
	return authn.AgentToken{}, authn.ErrUnknownToken
}

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)
	}
	if g.Principal != agent() {
		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)
	}
}