| 1 | package callstest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "regexp" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | var linkInPage = regexp.MustCompile(`http://app\.test/j/([A-Za-z0-9_-]{43})`) |
| 15 | |
| 16 | func createMeeting(t *testing.T, c *client, title, starts string) (sqid, token string) { |
| 17 | t.Helper() |
| 18 | rec := c.post("/meetings", url.Values{"title": {title}, "starts_at": {starts}}) |
| 19 | if rec.Code != http.StatusOK { |
| 20 | t.Fatalf("POST /meetings: %d\n%s", rec.Code, rec.Body.String()) |
| 21 | } |
| 22 | m := linkInPage.FindStringSubmatch(rec.Body.String()) |
| 23 | if m == nil { |
| 24 | t.Fatalf("no link on the page:\n%s", rec.Body.String()) |
| 25 | } |
| 26 | s := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(rec.Body.String()) |
| 27 | return s[1], m[1] |
| 28 | } |
| 29 | |
| 30 | func TestCreateShowsTheLinkOnceAndTheListNever(t *testing.T) { |
| 31 | x := newHarness(t, "paul@example.com") |
| 32 | c := x.browser(t) |
| 33 | c.signIn("paul@example.com") |
| 34 | _, token := createMeeting(t, c, "Board meeting", "") |
| 35 | rec := c.get("/") |
| 36 | if strings.Contains(rec.Body.String(), token) { |
| 37 | t.Fatal("the list shows a link token") |
| 38 | } |
| 39 | if !strings.Contains(rec.Body.String(), "Board meeting") { |
| 40 | t.Fatal("the list does not show the title") |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // TestStartCallShowsTheLinkOnceAndListsItUnderUpcoming is the one-click |
| 45 | // button above "New meeting": a member POSTs with no form fields at |
| 46 | // all, and gets the same link-once page a scheduled meeting's creation |
| 47 | // does, with a title Calls minted itself. |
| 48 | func TestStartCallShowsTheLinkOnceAndListsItUnderUpcoming(t *testing.T) { |
| 49 | x := newHarness(t, "paul@example.com") |
| 50 | c := x.browser(t) |
| 51 | c.signIn("paul@example.com") |
| 52 | rec := c.post("/meetings/start", nil) |
| 53 | if rec.Code != http.StatusOK { |
| 54 | t.Fatalf("POST /meetings/start: %d\n%s", rec.Code, rec.Body.String()) |
| 55 | } |
| 56 | body := rec.Body.String() |
| 57 | if !linkInPage.MatchString(body) { |
| 58 | t.Fatalf("no link on the page:\n%s", body) |
| 59 | } |
| 60 | if !strings.Contains(body, "Call, ") { |
| 61 | t.Fatalf("page title does not start \"Call, \":\n%s", body) |
| 62 | } |
| 63 | rec = c.get("/") |
| 64 | if !strings.Contains(rec.Body.String(), "Call, ") { |
| 65 | t.Fatal("the list does not show the started call under Upcoming") |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestCreateRefusesAnEmptyTitleInPlace(t *testing.T) { |
| 70 | x := newHarness(t, "paul@example.com") |
| 71 | c := x.browser(t) |
| 72 | c.signIn("paul@example.com") |
| 73 | rec := c.post("/meetings", url.Values{"title": {" "}}) |
| 74 | if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Give the meeting a title") { |
| 75 | t.Fatalf("empty title: %d", rec.Code) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestReplaceLinkNeedsItsConfirmPageAndKillsTheOldLink(t *testing.T) { |
| 80 | x := newHarness(t, "paul@example.com") |
| 81 | c := x.browser(t) |
| 82 | c.signIn("paul@example.com") |
| 83 | sqid, old := createMeeting(t, c, "x", "") |
| 84 | if rec := c.get("/meetings/" + sqid + "/links/replace"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Replace the link") { |
| 85 | t.Fatalf("confirm page: %d", rec.Code) |
| 86 | } |
| 87 | rec := c.post("/meetings/"+sqid+"/links", nil) |
| 88 | if rec.Code != http.StatusOK || !linkInPage.MatchString(rec.Body.String()) { |
| 89 | t.Fatalf("replace: %d", rec.Code) |
| 90 | } |
| 91 | if rec := x.browser(t).get("/j/" + old); rec.Code != http.StatusNotFound { |
| 92 | t.Fatalf("old link after replace: %d, want 404", rec.Code) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestEndMeetingNeedsItsConfirmPageAndRefusesJoinsAfter(t *testing.T) { |
| 97 | x := newHarness(t, "paul@example.com") |
| 98 | c := x.browser(t) |
| 99 | c.signIn("paul@example.com") |
| 100 | sqid, tok := createMeeting(t, c, "x", "") |
| 101 | if rec := c.get("/meetings/" + sqid + "/end"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "End meeting for everyone") { |
| 102 | t.Fatalf("confirm page: %d", rec.Code) |
| 103 | } |
| 104 | if rec := c.post("/meetings/"+sqid+"/end", nil); rec.Code != http.StatusSeeOther { |
| 105 | t.Fatalf("end: %d", rec.Code) |
| 106 | } |
| 107 | if rec := x.browser(t).get("/j/" + tok); rec.Code != http.StatusNotFound { |
| 108 | t.Fatalf("link after end: %d, want 404", rec.Code) |
| 109 | } |
| 110 | if rec := c.get("/"); !strings.Contains(rec.Body.String(), "Ended meetings (1)") { |
| 111 | t.Fatal("ended meeting not in the collapsed section") |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestCrossOriginPostIsRefused(t *testing.T) { |
| 116 | x := newHarness(t, "paul@example.com") |
| 117 | c := x.browser(t) |
| 118 | c.signIn("paul@example.com") |
| 119 | sqid, _ := createMeeting(t, c, "x", "") |
| 120 | req := httptestPost("/meetings/"+sqid+"/end", nil) |
| 121 | req.Header.Set("Origin", "https://evil.example") |
| 122 | rec := c.do(req) |
| 123 | if rec.Code != http.StatusForbidden { |
| 124 | t.Fatalf("cross-origin end: %d, want 403", rec.Code) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // The one rule (spec §0.1): the database file and its WAL never hold a |
| 129 | // plaintext title. |
| 130 | func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) { |
| 131 | x := newHarness(t, "paul@example.com") |
| 132 | c := x.browser(t) |
| 133 | c.signIn("paul@example.com") |
| 134 | const secret = "Quarterly board meeting about the acquisition" |
| 135 | createMeeting(t, c, secret, "") |
| 136 | grep := func(when string) { |
| 137 | for _, suffix := range []string{"", "-wal", "-shm"} { |
| 138 | b, err := os.ReadFile(x.dbPath + suffix) |
| 139 | // A missing -wal or -shm is ordinary — SQLite only keeps |
| 140 | // them while there is unchecked WAL content. The primary |
| 141 | // file missing is not ordinary: a wrong path would read |
| 142 | // nothing and this test would pass having grepped nothing |
| 143 | // at all, which is the one way a privacy gate can lie |
| 144 | // (review C1-8, codex round 2 C2-4). |
| 145 | if suffix != "" && os.IsNotExist(err) { |
| 146 | continue |
| 147 | } |
| 148 | if err != nil { |
| 149 | t.Fatalf("%s: reading %s: %v", when, filepath.Base(x.dbPath+suffix), err) |
| 150 | } |
| 151 | if bytes.Contains(b, []byte(secret)) { |
| 152 | t.Errorf("%s: title in plaintext in %s", when, filepath.Base(x.dbPath+suffix)) |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | grep("before checkpoint") |
| 157 | if _, err := x.sqlDB().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | grep("after checkpoint") |
| 161 | } |
| 162 | |