oficina / suite Public

Clone
git clone https://amadan.net/oficina/suite

Plain git — no account needed to clone.

Download

Download this file

1package suite
2
3import "testing"
4
5// S1 rows are (item_id, revision, gone, touched_at) and nothing else
6// (doctrine §6). Draft 1 of memoria's feed carried a visible-to-team field and
7// that was a leak: `visible=false, gone=false` tells a consumer that a
8// guest-only item exists, at what revision and when it was touched — exactly
9// what S3's "404, never 403" exists to hide.
10func TestS1DecodeRows(t *testing.T) {
11 body := []byte(`{"cursor":"c-92","rows":[
12 {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z"},
13 {"item_id":"doc-2","revision":2,"gone":true,"touched_at":"2026-09-05T09:01:00Z"}
14 ]}`)
15
16 feed, err := DecodeS1(200, body)
17 if err != nil {
18 t.Fatalf("unexpected error: %v", err)
19 }
20
21 if feed.Cursor != "c-92" {
22 t.Errorf("cursor: got %q, want %q", feed.Cursor, "c-92")
23 }
24 if len(feed.Rows) != 2 {
25 t.Fatalf("got %d rows, want 2", len(feed.Rows))
26 }
27 if feed.Rows[0].ItemID != "doc-1" || feed.Rows[0].Revision != 7 || feed.Rows[0].Gone {
28 t.Errorf("row 0: got %+v", feed.Rows[0])
29 }
30 if !feed.Rows[1].Gone {
31 t.Errorf("row 1: got gone=false, want the tombstone")
32 }
33}
34
35// A feed row carrying anything beyond the four fields is refused, loudly. The
36// contract says a row has no title, no content and no visibility flag; a
37// sibling sending one is either leaking or running a different contract, and
38// silently ignoring the field would let either go unnoticed for as long as
39// nobody grepped the store.
40func TestS1RejectsRowCarryingContent(t *testing.T) {
41 body := []byte(`{"cursor":"c-92","rows":[
42 {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z","title":"Q3 plan"}
43 ]}`)
44
45 _, err := DecodeS1(200, body)
46
47 if err == nil {
48 t.Fatal("got nil error for a row carrying a title, want a refusal")
49 }
50}
51
52// Departure from team-visibility and deletion are the same row, and the
53// client must not offer a caller any way to tell them apart (§4.2).
54func TestS1GoneCarriesNoReason(t *testing.T) {
55 body := []byte(`{"cursor":"c-1","rows":[
56 {"item_id":"doc-1","revision":7,"gone":true,"touched_at":"2026-09-05T09:00:00Z","reason":"went_private"}
57 ]}`)
58
59 _, err := DecodeS1(200, body)
60
61 if err == nil {
62 t.Fatal("got nil error for a gone row carrying a reason, want a refusal")
63 }
64}
65
66// An empty feed is legitimate and must keep working: it means nothing changed.
67func TestS1EmptyFeedIsValid(t *testing.T) {
68 feed, err := DecodeS1(200, []byte(`{"cursor":"c-1","rows":[]}`))
69 if err != nil {
70 t.Fatalf("unexpected error: %v", err)
71 }
72 if feed.Cursor != "c-1" || len(feed.Rows) != 0 {
73 t.Errorf("got %+v, want an empty feed at cursor c-1", feed)
74 }
75}
76
77// Bodies that parse but say nothing. `null` and `{}` both decode without
78// error into a zero Feed, which a caller would read as an empty feed at an
79// empty cursor — and committing that cursor loses the consumer's place.
80// Reported by the GPT-6 module review, finding 8.
81func TestS1RejectsBodiesThatSayNothing(t *testing.T) {
82 for _, body := range []string{`null`, `{}`, `{"rows":[]}`} {
83 if _, err := DecodeS1(200, []byte(body)); err == nil {
84 t.Errorf("accepted %s, want a refusal", body)
85 }
86 }
87}
88
89// A row missing its identity, or carrying null where a value belongs, decodes
90// to a live zero-value row: item "" at revision 0, present rather than gone.
91func TestS1RejectsIncompleteRows(t *testing.T) {
92 bodies := []string{
93 `{"cursor":"c-1","rows":[{}]}`,
94 `{"cursor":"c-1","rows":[{"item_id":"","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z"}]}`,
95 `{"cursor":"c-1","rows":[{"item_id":"doc-1","revision":7,"gone":null,"touched_at":null}]}`,
96 }
97 for _, body := range bodies {
98 if _, err := DecodeS1(200, []byte(body)); err == nil {
99 t.Errorf("accepted %s, want a refusal", body)
100 }
101 }
102}
103
104// Trailing content after a complete page, and duplicate keys inside a row.
105// json.Decoder.Decode reads one value and stops, so a second document — one
106// carrying a title, say — rides along unread; and a duplicated `gone` key is
107// resolved silently, so `gone:true,gone:false` loses a tombstone.
108func TestS1RejectsAmbiguousBodies(t *testing.T) {
109 bodies := []string{
110 `{"cursor":"c-1","rows":[]} {"cursor":"c-2","rows":[{"title":"Q3 plan"}]}`,
111 `{"cursor":"c-1","rows":[{"item_id":"doc-1","revision":7,"gone":true,"gone":false,"touched_at":"2026-09-05T09:00:00Z"}]}`,
112 `{"cursor":"c-1","cursor":"c-2","rows":[]}`,
113 }
114 for _, body := range bodies {
115 if _, err := DecodeS1(200, []byte(body)); err == nil {
116 t.Errorf("accepted %s, want a refusal", body)
117 }
118 }
119}
120
121// A non-2xx feed answer is an error, not an empty feed. An empty feed means
122// "nothing changed"; returning that for a failed call would advance a
123// consumer's cursor past changes it never saw.
124func TestS1NonSuccessIsAnError(t *testing.T) {
125 _, err := DecodeS1(503, []byte(`{"cursor":"c-1","rows":[]}`))
126
127 if err == nil {
128 t.Fatal("got nil error for a 503, want a refusal")
129 }
130}
131