carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1package contratest
2
3import (
4 "encoding/hex"
5 "encoding/json"
6 "net/http"
7 "testing"
8
9 "github.com/carlosframework/contra/internal/contra/crypto"
10)
11
12// Compare-and-swap on item writes.
13//
14// The failure this prevents is silent and total: two writers both open
15// version 3, both edit, both write version 4, and the second erases the first
16// with nothing anywhere recording that an edit ever existed. On one device it
17// cannot happen. With a second device — or a second member of a shared vault
18// — it is the ordinary case, which is why this landed before web/8 rather
19// than with it.
20
21func TestTwoWritersDoNotSilentlyOverwriteEachOther(t *testing.T) {
22 h := newApp(t)
23 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
24
25 itemID := newItemID(t)
26 if rec := as(t, h, p.cookie, http.MethodPost, "/api/items",
27 sealItem(t, p, itemID, 1, `{"title":"Original"}`)); rec.Code != http.StatusCreated {
28 t.Fatalf("create: status %d", rec.Code)
29 }
30
31 // Two devices have both read version 1 and both composed an edit against
32 // it. The first one home wins.
33 first := sealItem(t, p, itemID, 2, `{"title":"Edited by the first device"}`)
34 second := sealItem(t, p, itemID, 2, `{"title":"Edited by the second device"}`)
35
36 if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", first); rec.Code != http.StatusOK {
37 t.Fatalf("first write: status %d, body %s", rec.Code, rec.Body.String())
38 }
39
40 rec := as(t, h, p.cookie, http.MethodPut, "/api/items", second)
41 if rec.Code != http.StatusConflict {
42 t.Fatalf("second write: status %d, want 409 — it overwrote the first", rec.Code)
43 }
44
45 // And the 409 says what to merge against, so the loser can refetch rather
46 // than guess whether to resend the same bytes.
47 var conflict struct {
48 CurrentVersion uint64 `json:"current_version"`
49 ItemIDHex string `json:"item_id_hex"`
50 }
51 if err := json.Unmarshal(rec.Body.Bytes(), &conflict); err != nil {
52 t.Fatalf("409 body: %v", err)
53 }
54 if conflict.CurrentVersion != 2 {
55 t.Errorf("409 reported current version %d, want 2", conflict.CurrentVersion)
56 }
57 if conflict.ItemIDHex != hex.EncodeToString(itemID) {
58 t.Errorf("409 named the wrong item")
59 }
60
61 // The stored item is the FIRST device's edit, intact.
62 body := openOnlyItem(t, h, p)
63 if body != `{"title":"Edited by the first device"}` {
64 t.Errorf("the losing write was not discarded\n got %s", body)
65 }
66}
67
68// Having lost, the client refetches and writes against what it found. That
69// has to succeed, or the conflict is a dead end rather than a retry.
70func TestALostWriteSucceedsAfterRefetching(t *testing.T) {
71 h := newApp(t)
72 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
73
74 itemID := newItemID(t)
75 as(t, h, p.cookie, http.MethodPost, "/api/items",
76 sealItem(t, p, itemID, 1, `{"title":"Original"}`))
77 as(t, h, p.cookie, http.MethodPut, "/api/items",
78 sealItem(t, p, itemID, 2, `{"title":"Theirs"}`))
79
80 // Rejected against the stale version, accepted against the current one.
81 if rec := as(t, h, p.cookie, http.MethodPut, "/api/items",
82 sealItem(t, p, itemID, 2, `{"title":"Mine"}`)); rec.Code != http.StatusConflict {
83 t.Fatalf("stale write: status %d, want 409", rec.Code)
84 }
85 if rec := as(t, h, p.cookie, http.MethodPut, "/api/items",
86 sealItem(t, p, itemID, 3, `{"title":"Mine, merged"}`)); rec.Code != http.StatusOK {
87 t.Fatalf("merged write: status %d, want 200", rec.Code)
88 }
89 if body := openOnlyItem(t, h, p); body != `{"title":"Mine, merged"}` {
90 t.Errorf("the merged write did not land\n got %s", body)
91 }
92}
93
94// The client picks the bytes; it does not get to pick the numbering. A gap or
95// a repeat means a confused writer, and accepting one would let a client
96// reuse a version — which undercuts what binding the version into the AAD is
97// for.
98func TestItemVersionsMustBeExactlyOneApart(t *testing.T) {
99 h := newApp(t)
100 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
101
102 itemID := newItemID(t)
103 as(t, h, p.cookie, http.MethodPost, "/api/items",
104 sealItem(t, p, itemID, 1, `{"title":"Original"}`))
105
106 for _, c := range []struct {
107 what string
108 expected uint64
109 version uint64
110 }{
111 {"a gap", 1, 5},
112 {"no bump at all", 1, 1},
113 {"going backwards", 1, 0},
114 } {
115 body := sealItem(t, p, itemID, c.version, `{"title":"x"}`)
116 body["expected_version"] = c.expected
117 if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", body); rec.Code == http.StatusOK {
118 t.Errorf("%s was accepted", c.what)
119 }
120 }
121}
122
123// A vault that is not yours answers 404 on the CAS path too. Worth its own
124// case because the precondition added a second way to miss, and "somebody
125// else wrote first" must never be reported for a row the caller cannot see.
126func TestCASDoesNotLeakAnotherAccountsItem(t *testing.T) {
127 h := newApp(t)
128 mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
129 theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b")
130
131 itemID := newItemID(t)
132 as(t, h, mine.cookie, http.MethodPost, "/api/items",
133 sealItem(t, mine, itemID, 1, `{"title":"Mine"}`))
134
135 body := sealItem(t, mine, itemID, 2, `{"title":"Theirs now"}`)
136 if rec := as(t, h, theirs.cookie, http.MethodPut, "/api/items", body); rec.Code != http.StatusNotFound {
137 t.Errorf("another account's update: status %d, want 404", rec.Code)
138 }
139}
140
141// openOnlyItem reads the vault's single item back and returns its plaintext,
142// so a test asserts on what a client would actually see rather than on a
143// status code.
144func openOnlyItem(t *testing.T, h http.Handler, p provisioned) string {
145 t.Helper()
146 var listed struct {
147 Items []struct {
148 ItemIDHex string `json:"item_id_hex"`
149 Version uint64 `json:"version"`
150 NonceHex string `json:"nonce_hex"`
151 CiphertextHex string `json:"ciphertext_hex"`
152 } `json:"items"`
153 }
154 rec := as(t, h, p.cookie, http.MethodGet,
155 "/api/items?vault="+hex.EncodeToString(p.vaultID), nil)
156 decode(t, rec, &listed)
157 if len(listed.Items) != 1 {
158 t.Fatalf("expected exactly one item, got %d", len(listed.Items))
159 }
160 row := listed.Items[0]
161 id, _ := hex.DecodeString(row.ItemIDHex)
162 nonce, _ := hex.DecodeString(row.NonceHex)
163 ct, _ := hex.DecodeString(row.CiphertextHex)
164 plain, err := crypto.Open(p.kVault, nonce,
165 crypto.AAD(p.vaultID, crypto.ObjectItem, id, row.Version), ct)
166 if err != nil {
167 t.Fatalf("the stored item does not open: %v", err)
168 }
169 unpadded, err := crypto.Unpad(plain)
170 if err != nil {
171 t.Fatalf("unpad: %v", err)
172 }
173 return string(unpadded)
174}
175