package contratest import ( "encoding/hex" "encoding/json" "net/http" "testing" "github.com/carlosframework/contra/internal/contra/crypto" ) // Compare-and-swap on item writes. // // The failure this prevents is silent and total: two writers both open // version 3, both edit, both write version 4, and the second erases the first // with nothing anywhere recording that an edit ever existed. On one device it // cannot happen. With a second device — or a second member of a shared vault // — it is the ordinary case, which is why this landed before web/8 rather // than with it. func TestTwoWritersDoNotSilentlyOverwriteEachOther(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") itemID := newItemID(t) if rec := as(t, h, p.cookie, http.MethodPost, "/api/items", sealItem(t, p, itemID, 1, `{"title":"Original"}`)); rec.Code != http.StatusCreated { t.Fatalf("create: status %d", rec.Code) } // Two devices have both read version 1 and both composed an edit against // it. The first one home wins. first := sealItem(t, p, itemID, 2, `{"title":"Edited by the first device"}`) second := sealItem(t, p, itemID, 2, `{"title":"Edited by the second device"}`) if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", first); rec.Code != http.StatusOK { t.Fatalf("first write: status %d, body %s", rec.Code, rec.Body.String()) } rec := as(t, h, p.cookie, http.MethodPut, "/api/items", second) if rec.Code != http.StatusConflict { t.Fatalf("second write: status %d, want 409 — it overwrote the first", rec.Code) } // And the 409 says what to merge against, so the loser can refetch rather // than guess whether to resend the same bytes. var conflict struct { CurrentVersion uint64 `json:"current_version"` ItemIDHex string `json:"item_id_hex"` } if err := json.Unmarshal(rec.Body.Bytes(), &conflict); err != nil { t.Fatalf("409 body: %v", err) } if conflict.CurrentVersion != 2 { t.Errorf("409 reported current version %d, want 2", conflict.CurrentVersion) } if conflict.ItemIDHex != hex.EncodeToString(itemID) { t.Errorf("409 named the wrong item") } // The stored item is the FIRST device's edit, intact. body := openOnlyItem(t, h, p) if body != `{"title":"Edited by the first device"}` { t.Errorf("the losing write was not discarded\n got %s", body) } } // Having lost, the client refetches and writes against what it found. That // has to succeed, or the conflict is a dead end rather than a retry. func TestALostWriteSucceedsAfterRefetching(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") itemID := newItemID(t) as(t, h, p.cookie, http.MethodPost, "/api/items", sealItem(t, p, itemID, 1, `{"title":"Original"}`)) as(t, h, p.cookie, http.MethodPut, "/api/items", sealItem(t, p, itemID, 2, `{"title":"Theirs"}`)) // Rejected against the stale version, accepted against the current one. if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", sealItem(t, p, itemID, 2, `{"title":"Mine"}`)); rec.Code != http.StatusConflict { t.Fatalf("stale write: status %d, want 409", rec.Code) } if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", sealItem(t, p, itemID, 3, `{"title":"Mine, merged"}`)); rec.Code != http.StatusOK { t.Fatalf("merged write: status %d, want 200", rec.Code) } if body := openOnlyItem(t, h, p); body != `{"title":"Mine, merged"}` { t.Errorf("the merged write did not land\n got %s", body) } } // The client picks the bytes; it does not get to pick the numbering. A gap or // a repeat means a confused writer, and accepting one would let a client // reuse a version — which undercuts what binding the version into the AAD is // for. func TestItemVersionsMustBeExactlyOneApart(t *testing.T) { h := newApp(t) p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") itemID := newItemID(t) as(t, h, p.cookie, http.MethodPost, "/api/items", sealItem(t, p, itemID, 1, `{"title":"Original"}`)) for _, c := range []struct { what string expected uint64 version uint64 }{ {"a gap", 1, 5}, {"no bump at all", 1, 1}, {"going backwards", 1, 0}, } { body := sealItem(t, p, itemID, c.version, `{"title":"x"}`) body["expected_version"] = c.expected if rec := as(t, h, p.cookie, http.MethodPut, "/api/items", body); rec.Code == http.StatusOK { t.Errorf("%s was accepted", c.what) } } } // A vault that is not yours answers 404 on the CAS path too. Worth its own // case because the precondition added a second way to miss, and "somebody // else wrote first" must never be reported for a row the caller cannot see. func TestCASDoesNotLeakAnotherAccountsItem(t *testing.T) { h := newApp(t) mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c") theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b") itemID := newItemID(t) as(t, h, mine.cookie, http.MethodPost, "/api/items", sealItem(t, mine, itemID, 1, `{"title":"Mine"}`)) body := sealItem(t, mine, itemID, 2, `{"title":"Theirs now"}`) if rec := as(t, h, theirs.cookie, http.MethodPut, "/api/items", body); rec.Code != http.StatusNotFound { t.Errorf("another account's update: status %d, want 404", rec.Code) } } // openOnlyItem reads the vault's single item back and returns its plaintext, // so a test asserts on what a client would actually see rather than on a // status code. func openOnlyItem(t *testing.T, h http.Handler, p provisioned) string { t.Helper() var listed struct { Items []struct { ItemIDHex string `json:"item_id_hex"` Version uint64 `json:"version"` NonceHex string `json:"nonce_hex"` CiphertextHex string `json:"ciphertext_hex"` } `json:"items"` } rec := as(t, h, p.cookie, http.MethodGet, "/api/items?vault="+hex.EncodeToString(p.vaultID), nil) decode(t, rec, &listed) if len(listed.Items) != 1 { t.Fatalf("expected exactly one item, got %d", len(listed.Items)) } row := listed.Items[0] id, _ := hex.DecodeString(row.ItemIDHex) nonce, _ := hex.DecodeString(row.NonceHex) ct, _ := hex.DecodeString(row.CiphertextHex) plain, err := crypto.Open(p.kVault, nonce, crypto.AAD(p.vaultID, crypto.ObjectItem, id, row.Version), ct) if err != nil { t.Fatalf("the stored item does not open: %v", err) } unpadded, err := crypto.Unpad(plain) if err != nil { t.Fatalf("unpad: %v", err) } return string(unpadded) }