okami.mom/albedo

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

Aly Raffaufrefactor(cli): recover typed admission receipts48f3878

main
4.4 KiB137 linesraw
1// Request pointers and omission can change replay intent after preparation.
2// Controlled acknowledgement loss exercises the public API's saved request.
3package daemon
4
5import (
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"net"
11	"net/http"
12	"net/http/httptest"
13	"strings"
14	"sync"
15	"sync/atomic"
16	"testing"
17)
18
19func TestSubmissionRecoversRejectedInputWithoutReplay(t *testing.T) {
20	var admissions atomic.Int64
21	var lookups atomic.Int64
22	conn := controlledConnection(t, func(w http.ResponseWriter, r *http.Request) {
23		id := strings.TrimPrefix(r.URL.Path, "/sessions/s/inputs/")
24		if id == r.URL.Path {
25			t.Errorf("unexpected input resource %s", r.URL)
26			w.WriteHeader(http.StatusNotFound)
27			return
28		}
29		switch r.Method {
30		case http.MethodPut:
31			admissions.Add(1)
32			connection, _, err := w.(http.Hijacker).Hijack()
33			if err != nil {
34				t.Error(err)
35				return
36			}
37			_ = connection.Close()
38		case http.MethodGet:
39			lookups.Add(1)
40			input := canonicalInput("s", id, "message", "pending")
41			input["admission"], input["http_status"] = "rejected", http.StatusUnprocessableEntity
42			input["accepted_at"], input["acceptance_order"], input["delivery"] = nil, nil, nil
43			input["problem"] = map[string]any{"type": "about:blank", "title": "Input refused", "status": http.StatusUnprocessableEntity, "code": "invalid_input", "detail": ""}
44			_ = json.NewEncoder(w).Encode(input)
45		default:
46			t.Errorf("unexpected input method %s", r.Method)
47			w.WriteHeader(http.StatusMethodNotAllowed)
48		}
49	})
50	text := "rejected once"
51	handle, err := NewSubmission("s", SubmissionRequest{Content: &text})
52	if err != nil {
53		t.Fatal(err)
54	}
55	_, err = SubmitOperation(t.Context(), conn, handle)
56	problem, ok := errors.AsType[*APIError](err)
57	if !ok || problem.StatusCode != http.StatusUnprocessableEntity || problem.Code != "invalid_input" || problem.Message != "Input refused" {
58		t.Fatalf("lost rejection was not recovered: %v", err)
59	}
60	if admissions.Load() != 1 || lookups.Load() != 1 {
61		t.Fatalf("authoritative rejection replayed: admissions=%d lookups=%d", admissions.Load(), lookups.Load())
62	}
63}
64
65func TestSubmissionFreezesContentAndOmissionAcrossLostAcknowledgement(t *testing.T) {
66	for _, continuation := range []bool{false, true} {
67		t.Run(fmt.Sprint(continuation), func(t *testing.T) {
68			var mu sync.Mutex
69			var bodies []string
70			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71				if r.Method == http.MethodGet {
72					w.WriteHeader(404)
73					_, _ = fmt.Fprint(w, `{"type":"about:blank","title":"Input not found","status":404,"code":"input_unknown","detail":"not found"}`)
74					return
75				}
76				body, _ := io.ReadAll(r.Body)
77				mu.Lock()
78				bodies = append(bodies, string(body))
79				first := len(bodies) == 1
80				mu.Unlock()
81				if first {
82					connection, _, err := w.(http.Hijacker).Hijack()
83					if err != nil {
84						t.Error(err)
85						return
86					}
87					_ = connection.Close()
88					return
89				}
90				var fields map[string]string
91				_ = json.Unmarshal(body, &fields)
92				id := strings.TrimPrefix(r.URL.Path, "/sessions/s/inputs/")
93				if r.Method != http.MethodPut || id == r.URL.Path {
94					t.Errorf("wrong admission resource %s %s", r.Method, r.URL)
95				}
96				w.WriteHeader(202)
97				_ = json.NewEncoder(w).Encode(canonicalInput("s", id, fields["kind"], "pending"))
98			}))
99			defer server.Close()
100			conn := NewConnection(ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Token: "token"}, nil)
101			content := ""
102			request := SubmissionRequest{Content: &content, Type: "user"}
103			if continuation {
104				request.Content = nil
105				request.Type = "continue"
106			}
107			handle, err := NewSubmission("s", request)
108			if err != nil {
109				t.Fatal(err)
110			}
111			content = "changed after preparation"
112			_, err = SubmitOperation(t.Context(), conn, handle)
113			if err != nil {
114				t.Fatal(err)
115			}
116			mu.Lock()
117			defer mu.Unlock()
118			if len(bodies) != 2 || bodies[0] != bodies[1] {
119				t.Fatalf("recovery changed saved request: %v", bodies)
120			}
121			var fields map[string]json.RawMessage
122			if err = json.Unmarshal([]byte(bodies[0]), &fields); err != nil {
123				t.Fatal(err)
124			}
125			if continuation {
126				if _, present := fields["text"]; present {
127					t.Fatalf("continuation gained content: %s", bodies[0])
128				}
129			} else if string(fields["text"]) != `""` {
130				t.Fatalf("prepared empty content changed: %s", bodies[0])
131			}
132			if _, present := fields["submissionId"]; present {
133				t.Fatalf("input identity duplicated in body: %s", bodies[0])
134			}
135		})
136	}
137}