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