okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petapi: zstd responses, streams and request bodies; images served as raw bytes; drop redundant re-encodes7fd8cf5

main
9.0 KiB262 linesraw
1//go:build unix
2
3// This proxy waits for real daemon admission before corrupting a response.
4// It preserves effect evidence while exposing acknowledgement-loss races.
5package e2e
6
7import (
8	"albedo/cli/internal/daemon"
9	"bytes"
10	"context"
11	"errors"
12	"fmt"
13	"io"
14	"maps"
15	"net"
16	"net/http"
17	"net/http/httptest"
18	"net/url"
19	"slices"
20	"strconv"
21	"strings"
22	"sync"
23	"sync/atomic"
24	"testing"
25)
26
27type acknowledgementFault string
28
29const (
30	expiredAcknowledgement     acknowledgementFault = "expired"
31	dropAcknowledgement        acknowledgementFault = "drop"
32	hideReceiptAcknowledgement acknowledgementFault = "drop-and-hide-receipt"
33	unresolvedAcknowledgement  acknowledgementFault = "unresolved"
34	truncateAcknowledgement    acknowledgementFault = "truncate"
35	rejectAcknowledgement      acknowledgementFault = "server-error"
36	cancelAcknowledgement      acknowledgementFault = "cancel"
37	emptyAcknowledgement       acknowledgementFault = "empty"
38	malformedAcknowledgement   acknowledgementFault = "malformed"
39	missingAcknowledgement     acknowledgementFault = "missing-fields"
40	negativeAcknowledgement    acknowledgementFault = "negative"
41	wrongStatusAcknowledgement acknowledgementFault = "wrong-status"
42)
43
44type acknowledgementProxy struct {
45	expireReceipt atomic.Bool
46	allowReceipt  atomic.Bool
47	failure       error
48	connection    *daemon.Connection
49	admitted      chan struct{}
50	responses     [][]byte
51	requests      []string
52	mu            sync.Mutex
53}
54
55func cutAcknowledgement(t *testing.T, path string, fault acknowledgementFault) *acknowledgementProxy {
56	t.Helper()
57	return mutateAcknowledgement(t, path, fault, nil, nil)
58}
59
60func mutateAcknowledgement(t *testing.T, path string, fault acknowledgementFault, matches func([]byte) bool, transform func([]byte) ([]byte, error)) *acknowledgementProxy {
61	t.Helper()
62	upstream := conn(t).Snapshot()
63	destination, err := url.Parse(conn(t).BaseURL())
64	if err != nil {
65		t.Fatal(err)
66	}
67	proxy := &acknowledgementProxy{admitted: make(chan struct{}, 1)}
68	transport := &http.Transport{}
69	t.Cleanup(transport.CloseIdleConnections)
70	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
71		requestBody, bodyErr := io.ReadAll(request.Body)
72		if bodyErr != nil {
73			http.Error(writer, bodyErr.Error(), http.StatusBadRequest)
74			return
75		}
76		proxy.mu.Lock()
77		streamRequest := request.Header.Get("Accept") == "text/event-stream"
78		receiptQuery := request.Method == http.MethodGet && !streamRequest && slices.Contains(proxy.requests, "PUT "+request.URL.Path)
79		hideReceipt := fault == hideReceiptAcknowledgement && receiptQuery && !slices.Contains(proxy.requests, "GET "+request.URL.Path)
80		if !streamRequest {
81			proxy.requests = append(proxy.requests, request.Method+" "+request.URL.Path)
82		}
83		proxy.mu.Unlock()
84		if receiptQuery {
85			switch {
86			case fault == unresolvedAcknowledgement && !proxy.allowReceipt.Load():
87				writer.WriteHeader(http.StatusServiceUnavailable)
88				_, _ = writer.Write([]byte(`{"type":"about:blank","title":"Unavailable","status":503,"code":"receipt_unavailable","detail":"receipt unavailable"}`))
89				return
90			case fault == expiredAcknowledgement || proxy.expireReceipt.Load():
91				code := "creation_expired"
92				if strings.Contains(request.URL.Path, "/inputs/") {
93					code = "input_expired"
94				}
95				writer.WriteHeader(http.StatusGone)
96				_, _ = fmt.Fprintf(writer, `{"type":"about:blank","title":"Expired","status":410,"code":%q,"detail":"expired"}`, code)
97				return
98			case hideReceipt:
99				writer.WriteHeader(http.StatusNotFound)
100				_, _ = writer.Write([]byte(`{"type":"about:blank","title":"Unknown","status":404,"code":"resource_not_found","detail":"unknown"}`))
101				return
102			}
103		}
104		forwarded := request.Clone(request.Context())
105		forwarded.Body = http.NoBody
106		if len(requestBody) > 0 {
107			forwarded.Body = io.NopCloser(bytes.NewReader(requestBody))
108		}
109		forwarded.ContentLength = int64(len(requestBody))
110		forwarded.TransferEncoding = nil
111		forwarded.URL.Scheme, forwarded.URL.Host = destination.Scheme, destination.Host
112		forwarded.RequestURI = ""
113		forwarded.Host = destination.Host
114		// Faults rewrite the JSON acknowledgement, so it must cross uncoded.
115		forwarded.Header.Del("Accept-Encoding")
116		response, forwardErr := transport.RoundTrip(forwarded)
117		if forwardErr != nil {
118			proxy.mu.Lock()
119			proxy.failure = forwardErr
120			proxy.mu.Unlock()
121			http.Error(writer, forwardErr.Error(), http.StatusBadGateway)
122			return
123		}
124		defer response.Body.Close()
125		if strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
126			maps.Copy(writer.Header(), response.Header)
127			writer.WriteHeader(response.StatusCode)
128			_ = http.NewResponseController(writer).Flush()
129			_, _ = io.Copy(flushingResponseWriter{writer}, response.Body)
130			return
131		}
132		body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
133		if readErr != nil {
134			proxy.mu.Lock()
135			proxy.failure = readErr
136			proxy.mu.Unlock()
137			http.Error(writer, readErr.Error(), http.StatusBadGateway)
138			return
139		}
140		pathMatches := request.URL.Path == path
141		if path == "/sessions" {
142			pathMatches = request.Method == http.MethodPut && strings.HasPrefix(request.URL.Path, "/sessions/") && strings.Count(request.URL.Path, "/") == 2
143		}
144		if strings.HasSuffix(path, "/") {
145			pathMatches = strings.HasPrefix(request.URL.Path, path)
146		}
147		isMutation := request.Method != http.MethodGet && pathMatches && (matches == nil || matches(requestBody))
148		first := false
149		if isMutation {
150			proxy.mu.Lock()
151			first = len(proxy.responses) == 0
152			proxy.responses = append(proxy.responses, body)
153			if (response.StatusCode < 200 || response.StatusCode >= 300) && !(request.Method == http.MethodPut && response.StatusCode == http.StatusPreconditionFailed) {
154				proxy.failure = fmt.Errorf("daemon rejected mutation: %s: %s", response.Status, body)
155			}
156			proxy.mu.Unlock()
157		}
158		maps.Copy(writer.Header(), response.Header)
159		if isMutation && (first || fault == unresolvedAcknowledgement || fault == expiredAcknowledgement) {
160			if first {
161				proxy.admitted <- struct{}{}
162			}
163			if transform != nil {
164				transformed, transformErr := transform(body)
165				if transformErr != nil {
166					proxy.mu.Lock()
167					proxy.failure = transformErr
168					proxy.mu.Unlock()
169					http.Error(writer, transformErr.Error(), http.StatusBadGateway)
170					return
171				}
172				writer.WriteHeader(response.StatusCode)
173				_, _ = writer.Write(transformed)
174				return
175			}
176			switch fault {
177			case dropAcknowledgement, hideReceiptAcknowledgement, unresolvedAcknowledgement, expiredAcknowledgement:
178				socket, _, hijackErr := writer.(http.Hijacker).Hijack()
179				if hijackErr == nil {
180					_ = socket.Close()
181				}
182				return
183			case truncateAcknowledgement:
184				writer.Header().Set("Content-Length", strconv.Itoa(len(body)+1))
185				writer.WriteHeader(response.StatusCode)
186				_, _ = writer.Write(body[:len(body)/2])
187				return
188			case rejectAcknowledgement:
189				http.Error(writer, "acknowledgement unavailable", http.StatusServiceUnavailable)
190				return
191			case emptyAcknowledgement:
192				writer.WriteHeader(response.StatusCode)
193				return
194			case malformedAcknowledgement:
195				writer.WriteHeader(response.StatusCode)
196				_, _ = writer.Write([]byte("{"))
197				return
198			case missingAcknowledgement:
199				writer.WriteHeader(response.StatusCode)
200				_, _ = writer.Write([]byte("{}"))
201				return
202			case negativeAcknowledgement:
203				writer.WriteHeader(response.StatusCode)
204				_, _ = writer.Write([]byte(`{"admission":"accepted","http_status":false}`))
205				return
206			case wrongStatusAcknowledgement:
207				status := http.StatusCreated
208				if response.StatusCode == status {
209					status = http.StatusAccepted
210				}
211				writer.WriteHeader(status)
212				_, _ = writer.Write(body)
213				return
214			case cancelAcknowledgement:
215				<-request.Context().Done()
216				return
217			}
218		}
219		writer.WriteHeader(response.StatusCode)
220		_, _ = writer.Write(body)
221	}))
222	t.Cleanup(server.Close)
223	upstream.Port = server.Listener.Addr().(*net.TCPAddr).Port
224	var attachErr error
225	proxy.connection, attachErr = daemon.Attach(t.Context(), upstream, func(context.Context) (daemon.ConnectionSnapshot, error) { return upstream, nil })
226	if attachErr != nil {
227		t.Fatal(attachErr)
228	}
229	t.Cleanup(proxy.connection.HTTPClient().CloseIdleConnections)
230	return proxy
231}
232
233func (proxy *acknowledgementProxy) assertSingleEffect(t *testing.T, operationErr error) []byte {
234	t.Helper()
235	if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](operationErr); !uncertain {
236		t.Fatalf("lost acknowledgement should be uncertain, got %v", operationErr)
237	}
238	return proxy.acceptedResponse(t)
239}
240
241func (proxy *acknowledgementProxy) acceptedResponse(t *testing.T) []byte {
242	t.Helper()
243	proxy.mu.Lock()
244	defer proxy.mu.Unlock()
245	if proxy.failure != nil {
246		t.Fatal(proxy.failure)
247	}
248	if len(proxy.responses) != 1 {
249		t.Fatalf("daemon admitted %d mutations, want one", len(proxy.responses))
250	}
251	return slices.Clone(proxy.responses[0])
252}
253
254type flushingResponseWriter struct{ http.ResponseWriter }
255
256func (writer flushingResponseWriter) Write(data []byte) (int, error) {
257	count, err := writer.ResponseWriter.Write(data)
258	if err == nil {
259		err = http.NewResponseController(writer.ResponseWriter).Flush()
260	}
261	return count, err
262}