web/8: the sharing UI, and the vault picker it turned out to need
The panel on the vault page: your own account id to give out, a form to share by somebody else's, the member list, and removal. **The browser walk found a real gap.** The vault page had only ever opened `vaults[0]`, which was indistinguishable from opening the right one while there was exactly one. With a shared vault in the list it stopped being true: a member's own empty vault sorts first, so the vault they had actually been given never appeared and the feature was invisible to the person it was for. There is now a picker, shown only when there is a choice to make, labelled from names that come out of the keysets — so the list is built from strings the server has never seen. The copy carries DESIGN.md's revocation answer in full, before the button rather than after it, including the part every product in this category quietly omits: **anything they already downloaded is still theirs.** The removal is gated on a checkbox acknowledging exactly that. A member sees that the vault is shared with them and no more — who else is in it is the owner's business — and is told so rather than shown controls that would fail. After a rotation the page reloads rather than patching itself. Every key the tab is holding is the old one at that point, and a stale K_vault in a live tab is what produces items nobody can open. The browser walk is two accounts in one profile, sharing only the session between them: the guest reads an item the owner sealed, and stops being able to after the owner removes them. One account's browser seals, another account's browser opens — which is the only arrangement that proves this works rather than that rows moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+393
−2
internal/contra/static/pages/vault.js+44 −1internal/contra/static/pages/vaultshare.js+139 −0internal/contra/templates/vault.html+52 −0internal/contratest/browser_sharing_test.go+156 −0internal/contratest/jsmodules_test.go+2 −1
CI passed — run details
CI log
=== vet === go vet ./... go: downloading github.com/carlosframework/rastrillo v0.23.0 go: downloading github.com/go-chi/chi/v5 v5.3.2 go: downloading gorm.io/gorm v1.31.2 go: downloading golang.org/x/crypto v0.55.0 go: downloading github.com/jinzhu/now v1.1.5 go: downloading github.com/jinzhu/inflection v1.0.0 go: downloading golang.org/x/text v0.41.0 go: downloading modernc.org/sqlite v1.55.0 go: downloading gorm.io/plugin/dbresolver v1.6.2 go: downloading golang.org/x/sys v0.47.0 go: downloading modernc.org/libc v1.74.1 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec === fmt === === test === go test ./... ? github.com/carlosframework/contra/cmd/contra [no test files] ? github.com/carlosframework/contra/internal/contra [no test files] ? github.com/carlosframework/contra/internal/contra/auth [no test files] ok github.com/carlosframework/contra/internal/contra/crypto 0.421s ? github.com/carlosframework/contra/internal/contra/icons [no test files] ? github.com/carlosframework/contra/internal/contra/mail [no test files] ? github.com/carlosframework/contra/internal/contra/model [no test files] ? github.com/carlosframework/contra/internal/contra/store [no test files] ? github.com/carlosframework/contra/internal/contra/vault [no test files] ok github.com/carlosframework/contra/internal/contratest 16.497s === js-test === node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \ test/sharing.test.mjs TAP version 13 # Subtest: HKDF: every contra/v1 label ok 1 - HKDF: every contra/v1 label --- duration_ms: 10.228711 type: 'test' ... # Subtest: KCV ok 2 - KCV --- duration_ms: 2.205396 type: 'test' ... # Subtest: P-256 from a seed, including the rejection branch ok 3 - P-256 from a seed, including the rejection branch --- duration_ms: 12.520409 type: 'test' ... # Subtest: signed challenge round-trips against the derived public key ok 4 - signed challenge round-trips against the derived public key --- duration_ms: 5.120359 type: 'test' ... # Subtest: Crockford: alphabet, encoding, normalisation, strict decoding ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding --- duration_ms: 1.01326 type: 'test' ... # Subtest: the printed code, and the O/I/L confusions it has to survive ok 6 - the printed code, and the O/I/L confusions it has to survive --- duration_ms: 2.216928 type: 'test' ... # Subtest: repair recovers one substituted character ok 7 - repair recovers one substituted character --- duration_ms: 49.18481 type: 'test' ... # Subtest: AEAD: the AAD binding and every wrap ok 8 - AEAD: the AAD binding and every wrap --- duration_ms: 2.610876 type: 'test' ... # Subtest: AEAD: the item, scoped to its vault ok 9 - AEAD: the item, scoped to its vault --- duration_ms: 1.112387 type: 'test' ... # Subtest: a transplanted ciphertext does not open ok 10 - a transplanted ciphertext does not open --- duration_ms: 2.72976 type: 'test' ... # Subtest: padding quantises into 256-byte buckets and round-trips ok 11 - padding quantises into 256-byte buckets and round-trips --- duration_ms: 0.897944 type: 'test' ... # Subtest: the KDF parameter floor refuses what a malicious server would offer ok 12 - the KDF parameter floor refuses what a malicious server would offer --- duration_ms: 0.580097 type: 'test' ... # Subtest: Argon2id matches the published PHC reference vector ok 13 - Argon2id matches the published PHC reference vector --- duration_ms: 1794.014271 type: 'test' ... # Subtest: Argon2id at the corners the 64 MiB case does not reach ok 14 - Argon2id at the corners the 64 MiB case does not reach --- duration_ms: 221.26787 type: 'test' ... # Subtest: Argon2id matches the golden vector at the parameters DESIGN.md pins ok 15 - Argon2id matches the golden vector at the parameters DESIGN.md pins --- duration_ms: 2582.961805 type: 'test' ... # Subtest: the account chain, end to end ok 16 - the account chain, end to end --- duration_ms: 2589.885341 type: 'test' ... # Subtest: handoff: both keypairs derive from their published seeds ok 17 - handoff: both keypairs derive from their published seeds --- duration_ms: 16.045436 type: 'test' ... # Subtest: handoff: ECDH agrees from both sides ok 18 - handoff: ECDH agrees from both sides --- duration_ms: 8.066536 type: 'test' ... # Subtest: handoff: the derived key, the AAD and the sealed bytes ok 19 - handoff: the derived key, the AAD and the sealed bytes --- duration_ms: 2.386306 type: 'test' ... # Subtest: handoff: an envelope opens for its device and for nobody else ok 20 - handoff: an envelope opens for its device and for nobody else --- duration_ms: 16.645454 type: 'test' ... # Subtest: handoff: the Go and JS halves seal for each other ok 21 - handoff: the Go and JS halves seal for each other --- duration_ms: 8.19983 type: 'test' ... # Subtest: handoff: a truncated envelope is refused before any key work ok 22 - handoff: a truncated envelope is refused before any key work --- duration_ms: 0.542717 type: 'test' ... # Subtest: an item round-trips through create, list and update ok 23 - an item round-trips through create, list and update --- duration_ms: 10.969125 type: 'test' ... # Subtest: nothing readable reaches the network ok 24 - nothing readable reaches the network --- duration_ms: 2.601324 type: 'test' ... # Subtest: item ciphertexts are quantised into 256-byte buckets ok 25 - item ciphertexts are quantised into 256-byte buckets --- duration_ms: 1.653904 type: 'test' ... # Subtest: an item does not open in another slot ok 26 - an item does not open in another slot --- duration_ms: 2.793387 type: 'test' ... # Subtest: every field is present whether or not it was filled in ok 27 - every field is present whether or not it was filled in --- duration_ms: 1.25216 type: 'test' ... # Subtest: the Account Key cache is storage handed in, not reached for ok 28 - the Account Key cache is storage handed in, not reached for --- duration_ms: 0.352856 type: 'test' ... # Subtest: an owner seals a keyset that a different account can open ok 29 - an owner seals a keyset that a different account can open --- duration_ms: 17.454225 type: 'test' ... # Subtest: nobody else can open a member's wrap ok 30 - nobody else can open a member's wrap --- duration_ms: 15.536726 type: 'test' ... # Subtest: a member wrap carries the vault key and not the drop box ok 31 - a member wrap carries the vault key and not the drop box --- duration_ms: 5.751453 type: 'test' ... # Subtest: a member cannot share or rotate ok 32 - a member cannot share or rotate --- duration_ms: 1.609465 type: 'test' ... # Subtest: rotation re-seals every item under the new key ok 33 - rotation re-seals every item under the new key --- duration_ms: 7.593226 type: 'test' ... 1..33 # tests 33 # suites 0 # pass 33 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 7347.293639 === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check go: downloading github.com/BurntSushi/toml v1.6.0 rastrillo migration check: models and migrations agree
diff --git a/internal/contra/static/pages/vault.js b/internal/contra/static/pages/vault.js| index 2d234c9..878d7ec 100644 |
| --- a/internal/contra/static/pages/vault.js |
| +++ b/internal/contra/static/pages/vault.js |
| @@ -12,6 +12,7 @@ import { net } from "../crypto/net.js"; |
| import { items as itemStore } from "../crypto/items.js"; |
| import { accountKeys, unlock } from "../crypto/vaultkeys.js"; |
| import { FIELDS } from "../crypto/items.js"; |
| +import { wireSharing } from "./vaultshare.js"; |
| function el(tag, attrs = {}, ...children) { |
| const node = document.createElement(tag); |
| @@ -134,6 +135,48 @@ function view(root, store) { |
| }; |
| } |
| +// vaultLabel names a vault on the picker. The name comes out of the keyset, |
| +// so the server has never seen it and could not label this list if it tried. |
| +function vaultLabel(v) { |
| + const name = v.name || "Vault"; |
| + return v.owned ? name : `${name} (shared with you)`; |
| +} |
| + |
| +// showVaults draws the picker and opens one vault. |
| +// |
| +// Before sharing there was only ever vaults[0], and picking it silently was |
| +// indistinguishable from picking correctly. With a shared vault in the list |
| +// that stopped being true: a member's own empty vault sorts first, so the |
| +// vault they were actually given never appeared. A browser walk caught it, |
| +// which is the argument for having one. |
| +async function showVaults(root, opened) { |
| + const picker = document.querySelector("[data-vault-picker]"); |
| + const sharingPanel = document.querySelector("[data-sharing]"); |
| + |
| + async function openOne(vault) { |
| + const store = itemStore(net, vault); |
| + await view(root, store).load(); |
| + // Sharing needs the vault key — it seals the keyset to somebody, and |
| + // removing somebody re-seals every item — so it only appears once a |
| + // vault is actually open, and it is rewired per vault. |
| + if (sharingPanel) { |
| + sharingPanel.hidden = false; |
| + wireSharing(document, opened, vault, store); |
| + } |
| + } |
| + |
| + if (picker) { |
| + picker.replaceChildren(...opened.vaults.map((v, i) => |
| + el("option", { value: String(i) }, vaultLabel(v)))); |
| + // Only worth showing when there is a choice to make. |
| + picker.parentElement.hidden = opened.vaults.length < 2; |
| + picker.addEventListener("change", async () => { |
| + await openOne(opened.vaults[Number(picker.value)]); |
| + }); |
| + } |
| + await openOne(opened.vaults[0]); |
| +} |
| + |
| async function main() { |
| const root = document.querySelector("[data-vault]"); |
| if (!root) return; |
| @@ -169,7 +212,7 @@ async function main() { |
| const opened = await unlock(bootstrap, passphrase, accountKey); |
| gate.hidden = true; |
| root.hidden = false; |
| - await view(root, itemStore(net, opened.vaults[0])).load(); |
| + await showVaults(root, opened); |
| } catch (err) { |
| box.replaceChildren(document.createTextNode(err.message)); |
| } |
diff --git a/internal/contra/static/pages/vaultshare.js b/internal/contra/static/pages/vaultshare.js| new file mode 100644 |
| index 0000000..bf80043 |
| --- /dev/null |
| +++ b/internal/contra/static/pages/vaultshare.js |
| @@ -0,0 +1,139 @@ |
| +// The sharing panel on the vault page. |
| +// |
| +// Split out of vault.js because that file has a line budget and this is a |
| +// second concern. Everything that decides anything lives in |
| +// crypto/sharing.js; this moves text between a form and it. |
| +// |
| +// # The copy rule this file exists to hold |
| +// |
| +// DESIGN.md's revocation answer has three parts and the screen must not |
| +// soften any of them: access dies immediately, the key rotates forward, and |
| +// **nothing already downloaded is un-downloaded**. The third is the one every |
| +// product in this category quietly omits. It is said here, before the button, |
| +// in those words. |
| + |
| +import { randomBytes, toHex } from "../crypto/bytes.js"; |
| +import { net } from "../crypto/net.js"; |
| +import * as sharing from "../crypto/sharing.js"; |
| + |
| +const $ = (sel, root = document) => root.querySelector(sel); |
| + |
| +function el(tag, attrs = {}, ...kids) { |
| + const node = document.createElement(tag); |
| + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); |
| + node.append(...kids.filter((k) => k !== null && k !== undefined)); |
| + return node; |
| +} |
| + |
| +function say(box, text, kind = "working") { |
| + box.replaceChildren(el("p", { "data-kind": kind }, text)); |
| +} |
| + |
| +// wireSharing takes the opened vault and a way to re-read the item list, |
| +// because rotation has to re-seal every item and cannot do that from a |
| +// summary. |
| +export function wireSharing(root, opened, vault, store) { |
| + const panel = $("[data-sharing]", root); |
| + if (!panel) return; |
| + |
| + // Your own account id, so you can give it to somebody who wants to share |
| + // with you. It is not a secret — it is a random 128-bit label — but it is |
| + // the only handle there is, because there is no directory to look you up |
| + // in and deliberately so. |
| + $("[data-my-account]", panel).textContent = toHex(opened.accountId); |
| + |
| + if (!vault.owned) { |
| + // A member sees that it is shared with them and nothing else. Who else is |
| + // in it is the owner's business. |
| + panel.setAttribute("data-role", "member"); |
| + say($("[data-share-status]", panel), |
| + "This vault is shared with you by somebody else. Only its owner can " + |
| + "change who can see it.", "ok"); |
| + $("[data-share-form]", panel).hidden = true; |
| + return; |
| + } |
| + |
| + const status = $("[data-share-status]", panel); |
| + const list = $("[data-members]", panel); |
| + let current = vault; |
| + |
| + async function draw() { |
| + const rows = await sharing.members(net, current); |
| + if (rows.length === 0) { |
| + list.replaceChildren(el("li", {}, "Not shared with anybody.")); |
| + return; |
| + } |
| + list.replaceChildren(...rows.map((m) => el("li", { "data-member": m.account_id_hex }, |
| + el("code", {}, m.account_id_hex), |
| + el("button", { type: "button", "data-remove": m.account_id_hex }, "Remove")))); |
| + } |
| + |
| + $("[data-share-form]", panel).addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + const idHex = $("[name=account_id]", panel).value.trim().toLowerCase(); |
| + try { |
| + say(status, "Looking them up…"); |
| + const who = await sharing.lookupAccount(net, idHex); |
| + |
| + say(status, "Sealing the vault key to them…"); |
| + await sharing.share(net, current, who.account_id_hex, who.public_key_hex); |
| + |
| + $("[name=account_id]", panel).value = ""; |
| + await draw(); |
| + say(status, "Shared. They will see it the next time they unlock.", "ok"); |
| + } catch (err) { |
| + console.error("CONTRA could not share the vault", err); |
| + say(status, err.status === 404 |
| + ? "No account with that id, or they have not finished signing up." |
| + : "Could not share this vault. The browser console has the detail.", |
| + "error"); |
| + } |
| + }); |
| + |
| + list.addEventListener("click", async (e) => { |
| + const idHex = e.target.getAttribute?.("data-remove"); |
| + if (!idHex) return; |
| + if (!$("[data-understood]", panel).checked) { |
| + say(status, "Tick the box first: removing somebody cannot take back " + |
| + "what they have already downloaded.", "error"); |
| + return; |
| + } |
| + try { |
| + // Revocation IS rotation. The whole vault is re-sealed under a new key |
| + // and every remaining member is re-wrapped, in one transaction — so |
| + // there is no moment where the removed member is still holding a key |
| + // that opens anything current. |
| + say(status, "Re-keying the vault…"); |
| + const rows = await sharing.members(net, current); |
| + const keep = []; |
| + for (const m of rows) { |
| + if (m.account_id_hex === idHex) continue; |
| + const who = await sharing.lookupAccount(net, m.account_id_hex); |
| + keep.push({ |
| + account_id_hex: who.account_id_hex, |
| + public_key_hex: who.public_key_hex, |
| + }); |
| + } |
| + |
| + say(status, "Re-sealing every item…"); |
| + const items = await store.list(); |
| + await sharing.rotate(net, opened, current, items, keep, randomBytes(32)); |
| + |
| + // Every key this page is holding is now the old one, so the page is |
| + // reloaded rather than patched. Rotation is rare and a stale K_vault in |
| + // a live tab is the kind of state that produces items nobody can open. |
| + say(status, "Removed, and the vault has a new key. What they already " + |
| + "downloaded is still theirs — nothing can take that back. " + |
| + "Reloading…", "ok"); |
| + setTimeout(() => window.location.reload(), 1500); |
| + } catch (err) { |
| + console.error("CONTRA could not remove the member", err); |
| + say(status, err.status === 409 |
| + ? "Somebody changed this vault while that was being prepared. " + |
| + "Reload and try again." |
| + : "Could not remove them. Nothing was changed.", "error"); |
| + } |
| + }); |
| + |
| + draw().catch((err) => console.error("CONTRA could not list members", err)); |
| +} |
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html| index ad73cf6..f186d6b 100644 |
| --- a/internal/contra/templates/vault.html |
| +++ b/internal/contra/templates/vault.html |
| @@ -20,8 +20,60 @@ |
| <div data-status aria-live="polite"></div> |
| </form> |
| +{{/* Shown only when there is more than one vault to choose between. The |
| + labels come out of the keysets, so this list is built from names the |
| + server has never seen. */}} |
| +<p data-vault-choice hidden> |
| + <label for="vault-picker">Vault</label> |
| + <select id="vault-picker" data-vault-picker></select> |
| +</p> |
| + |
| <div data-vault hidden></div> |
| +{{/* Sharing. Hidden until the vault is open, because everything in it needs |
| + the vault key: sharing seals the keyset to somebody, and removing |
| + somebody re-seals every item under a new one. */}} |
| +<section data-sharing hidden> |
| + <h2>Sharing</h2> |
| + |
| + <p>Your account id is <code data-my-account></code>. Give it to somebody who |
| + wants to share a vault with you — there is no directory to look you up in, |
| + and deliberately so: an address-to-account lookup would let anyone test |
| + whether an address has an account here.</p> |
| + |
| + <form data-share-form method="post" action="#"> |
| + <p> |
| + <label for="account_id">Share with this account id</label> |
| + <input id="account_id" name="account_id" type="text" autocomplete="off" |
| + spellcheck="false" required |
| + placeholder="32 hexadecimal characters"> |
| + </p> |
| + <p><button type="submit">Share this vault</button></p> |
| + </form> |
| + |
| + <h3>Members</h3> |
| + <ul data-members></ul> |
| + |
| + {{/* DESIGN.md's revocation answer, all three parts, before the button |
| + rather than after it. The third is the one every product in this |
| + category quietly omits. */}} |
| + <p data-kind="warning">Removing somebody does three things, and only two of |
| + them are what people expect. Their access stops immediately. The vault gets |
| + a new key, and every item is re-encrypted under it. But |
| + <strong>anything they already downloaded is still theirs</strong> — no |
| + design can take that back, and ours does not pretend to.</p> |
| + |
| + <p> |
| + <label> |
| + <input type="checkbox" data-understood> |
| + I understand that removing somebody cannot un-download what they already |
| + have. |
| + </label> |
| + </p> |
| + |
| + <p data-share-status aria-live="polite"></p> |
| +</section> |
| + |
| {{/* DESIGN.md: the reprint flow must be obvious rather than buried, and |
| the nag to enrol a second device lives beside it. Both are the |
| mitigation for the one loss mode the Account Key introduced. */}} |
diff --git a/internal/contratest/browser_sharing_test.go b/internal/contratest/browser_sharing_test.go| new file mode 100644 |
| index 0000000..8d58e98 |
| --- /dev/null |
| +++ b/internal/contratest/browser_sharing_test.go |
| @@ -0,0 +1,156 @@ |
| +//go:build browser |
| + |
| +package contratest |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo/harness" |
| + "github.com/chromedp/chromedp" |
| +) |
| + |
| +// Two accounts sharing a vault, in a real engine. |
| +// |
| +// The Go and node suites each prove a half. This is the only test where one |
| +// account's browser seals a vault keyset and ANOTHER account's browser opens |
| +// it, through the real API, with the real ECDH, and reads a real item out of |
| +// it. It is the walk a person actually does. |
| +// |
| +// One browser profile, two accounts. Only the SESSION changes between them — |
| +// localStorage is left alone, because the Account Key cache is keyed by |
| +// account id (`contra/v1/account-key/<id>`) and the two coexist. That is not |
| +// a shortcut around the test; it is what a shared family machine looks like. |
| + |
| +func signOut(t *testing.T, rig *harness.Rig) { |
| + t.Helper() |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/vault"), |
| + chromedp.Click(`form[action="/auth/signout"] button`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`body`, chromedp.ByQuery), |
| + ) |
| +} |
| + |
| +func signIn(t *testing.T, rig *harness.Rig, email, passphrase string) { |
| + t.Helper() |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/signin"), |
| + chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery), |
| + chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`form[action="/auth/signout"]`, chromedp.ByQuery), |
| + ) |
| +} |
| + |
| +func unlockActions(rig *harness.Rig, passphrase string) []chromedp.Action { |
| + return []chromedp.Action{ |
| + chromedp.Navigate(rig.Origin + "/vault"), |
| + chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), |
| + } |
| +} |
| + |
| +func TestBrowserTwoAccountsShareAVault(t *testing.T) { |
| + rig, box := newRig(t) |
| + |
| + const ownerEmail = "share-owner@example.invalid" |
| + const ownerPass = "a generated test passphrase 8f3a1c" |
| + const guestEmail = "share-guest@example.invalid" |
| + const guestPass = "a different generated one 4d2e9b" |
| + |
| + // The guest signs up first, so their account id exists to be shared with. |
| + signUpInBrowser(t, rig, box, guestEmail, guestPass) |
| + rig.Run(unlockActions(rig, guestPass)...) |
| + |
| + var guestID string |
| + rig.Run(chromedp.Text(`[data-my-account]`, &guestID, chromedp.ByQuery)) |
| + guestID = strings.TrimSpace(guestID) |
| + if len(guestID) != 32 { |
| + t.Fatalf("the guest's account id is not 32 hex characters: %q", guestID) |
| + } |
| + rig.Screen("[data-sharing]", "the sharing panel, before anything is shared") |
| + signOut(t, rig) |
| + |
| + // The owner signs up, adds an item, and shares with the id the guest read |
| + // off their own screen — which is the only handle there is, because there |
| + // is no directory to look anybody up in. |
| + signUpInBrowser(t, rig, box, ownerEmail, ownerPass) |
| + rig.Run(unlockActions(rig, ownerPass)...) |
| + rig.Run( |
| + chromedp.SendKeys(`[data-item-form] input[name=title]`, "Jamaica Bakery", chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-item-form] input[name=password]`, "generated-not-real-8f3a", chromedp.ByQuery), |
| + chromedp.Click(`[data-item-form] [data-save]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-items] li`, chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-share-form] input[name=account_id]`, guestID, chromedp.ByQuery), |
| + chromedp.Click(`[data-share-form] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.Poll( |
| + `document.querySelector('[data-members]').textContent.includes('`+guestID+`')`, |
| + nil), |
| + ) |
| + rig.Screen("[data-sharing]", "shared with one member") |
| + signOut(t, rig) |
| + |
| + // The guest opens the owner's item. This is the assertion the whole |
| + // feature exists for: sealed by one account's browser under one set of |
| + // keys, opened by another under theirs. |
| + signIn(t, rig, guestEmail, guestPass) |
| + rig.Run(unlockActions(rig, guestPass)...) |
| + |
| + // The guest has two vaults now — their own, and the one shared with them |
| + // — so the picker has to be used. Their own sorts first. |
| + rig.Run( |
| + chromedp.WaitVisible(`[data-vault-picker]`, chromedp.ByQuery), |
| + chromedp.SetValue(`[data-vault-picker]`, "1", chromedp.ByQuery), |
| + chromedp.Evaluate(`document.querySelector('[data-vault-picker]')`+ |
| + `.dispatchEvent(new Event('change'))`, nil), |
| + chromedp.Poll( |
| + `document.querySelector('[data-items]').textContent.includes('Jamaica')`, |
| + nil), |
| + ) |
| + |
| + var listed string |
| + rig.Run(chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery)) |
| + if !strings.Contains(listed, "Jamaica Bakery") { |
| + t.Fatalf("the member cannot see the shared item: %q", listed) |
| + } |
| + rig.Screen("[data-vault]", "a vault opened by the account it was shared with") |
| + |
| + // And a member is told they cannot change who else can see it, rather |
| + // than being shown a control that fails. |
| + var panel string |
| + rig.Run(chromedp.Text(`[data-sharing]`, &panel, chromedp.ByQuery)) |
| + if !strings.Contains(panel, "shared with you by somebody else") { |
| + t.Errorf("a member is not told whose vault this is: %q", panel) |
| + } |
| + |
| + // The owner removes them, which rotates the vault, and the member loses |
| + // it. Nothing already downloaded is un-downloaded — which is exactly why |
| + // the screen says so before the button. |
| + signOut(t, rig) |
| + signIn(t, rig, ownerEmail, ownerPass) |
| + rig.Run(unlockActions(rig, ownerPass)...) |
| + rig.Run( |
| + chromedp.Click(`[data-understood]`, chromedp.ByQuery), |
| + chromedp.Click(`[data-members] [data-remove]`, chromedp.ByQuery), |
| + chromedp.Poll( |
| + `document.querySelector('[data-share-status]').textContent.includes('new key')`, |
| + nil), |
| + ) |
| + rig.Screen("[data-sharing]", "after removing a member") |
| + |
| + signOut(t, rig) |
| + signIn(t, rig, guestEmail, guestPass) |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/vault"), |
| + chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, guestPass, chromedp.ByQuery), |
| + chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), |
| + ) |
| + rig.Run(chromedp.Text(`[data-items]`, &listed, chromedp.ByQuery)) |
| + if strings.Contains(listed, "Jamaica Bakery") { |
| + t.Error("a removed member can still read the vault") |
| + } |
| +} |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index b4397c8..2fe8b54 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -56,7 +56,8 @@ var jsHighWater = map[string]int{ |
| "pages/devicescan.js": 96, |
| "pages/kit.js": 156, |
| "pages/signup.js": 258, |
| - "pages/vault.js": 179, |
| + "pages/vault.js": 222, |
| + "pages/vaultshare.js": 139, |
| } |
| func TestJSModulesStaySmall(t *testing.T) { |