eleven / messenger Public

Add test suite: Go (db + handlers) and crypto.js round-trip

- db_test.go: invites, admin token, messages, push subscriptions, group name, VAPID.
- handlers_test.go: admin auth gating, invite/messages flow, push subscribe gating,
  group-name endpoints + HTML-escaping (XSS) — via httptest against Server.handler().
- test/crypto.test.mjs: E2EE round-trip, wrong-key rejection, fresh-IV, b64url,
  cross-client interop (Node 22 Web Crypto). package.json marks the repo as ESM.
- README: how to run the tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 90833bb3f297a87111a9db023b75e19877692ce9 parent cff01a1
5 files changed, +367 −0
  • README.md +13 −0
  • db_test.go +127 −0
  • handlers_test.go +152 −0
  • package.json +8 −0
  • test/crypto.test.mjs +67 −0
diff --git a/README.md b/README.md
index fcf9d1c..4fc5f83 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,19 @@ https://group.luan.chat/admin#token=<ADMIN_TOKEN>
2. **A guest** opens their link, picks a screen name, and joins.
3. Everyone shares **one feed**, like an iMessage group chat.
+## Tests
+
+```sh
+go test ./... # backend: DB logic + HTTP handlers (httptest)
+node test/crypto.test.mjs # crypto.js end-to-end (Web Crypto, runs in Node)
+```
+
+The Go tests cover the invite lifecycle, admin auth, messages, push subscriptions,
+group name, and the HTTP handlers (auth gating, invite/messages flow, group-name
+injection + HTML-escaping). The JS test exercises the E2EE round-trip in `crypto.js`.
+Browser/DOM end-to-end (chat UI, onboarding) isn't covered — add Playwright if the
+UI grows.
+
## Layout
```
diff --git a/db_test.go b/db_test.go
new file mode 100644
index 0000000..abdc56a
--- /dev/null
+++ b/db_test.go
@@ -0,0 +1,127 @@
+package main
+
+import "testing"
+
+func newTestDB(t *testing.T) *DB {
+ t.Helper()
+ db, err := openDB(t.TempDir() + "/test.db")
+ if err != nil {
+ t.Fatalf("openDB: %v", err)
+ }
+ return db
+}
+
+func TestInviteLifecycle(t *testing.T) {
+ db := newTestDB(t)
+
+ inv, err := db.createInvite("Sophie")
+ if err != nil || inv.Token == "" {
+ t.Fatalf("createInvite: %v token=%q", err, inv.Token)
+ }
+
+ exists, revoked, err := db.inviteStatus(inv.Token)
+ if err != nil || !exists || revoked {
+ t.Fatalf("fresh invite: exists=%v revoked=%v err=%v", exists, revoked, err)
+ }
+
+ if label, _ := db.inviteLabel(inv.Token); label != "Sophie" {
+ t.Fatalf("inviteLabel = %q, want Sophie", label)
+ }
+
+ if err := db.claimInvite(inv.Token); err != nil {
+ t.Fatalf("claimInvite: %v", err)
+ }
+
+ if err := db.revokeInvite(inv.Token); err != nil {
+ t.Fatalf("revokeInvite: %v", err)
+ }
+ if _, revoked, _ := db.inviteStatus(inv.Token); !revoked {
+ t.Fatal("invite should be revoked")
+ }
+
+ if exists, _, _ := db.inviteStatus("does-not-exist"); exists {
+ t.Fatal("unknown token should not exist")
+ }
+}
+
+func TestAdminToken(t *testing.T) {
+ db := newTestDB(t)
+ if _, err := db.ensureAdminToken("s3cret"); err != nil {
+ t.Fatalf("ensureAdminToken: %v", err)
+ }
+ if !db.checkAdmin("s3cret") {
+ t.Fatal("correct token rejected")
+ }
+ if db.checkAdmin("wrong") || db.checkAdmin("") {
+ t.Fatal("bad token accepted")
+ }
+}
+
+func TestMessages(t *testing.T) {
+ db := newTestDB(t)
+ m1, err := db.insertMessage("blob1")
+ if err != nil || m1.ID == 0 {
+ t.Fatalf("insertMessage: %v id=%d", err, m1.ID)
+ }
+ db.insertMessage("blob2")
+
+ all, err := db.messagesAfter(0, 100)
+ if err != nil || len(all) != 2 {
+ t.Fatalf("messagesAfter(0): %v len=%d", err, len(all))
+ }
+ after, _ := db.messagesAfter(m1.ID, 100)
+ if len(after) != 1 || after[0].Blob != "blob2" {
+ t.Fatalf("messagesAfter(id): %+v", after)
+ }
+}
+
+func TestPushSubscriptions(t *testing.T) {
+ db := newTestDB(t)
+ inv, _ := db.createInvite("x")
+
+ db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p", Auth: "a"})
+ db.addSubscription(PushSub{Endpoint: "https://e/2", Token: inv.Token, P256dh: "p", Auth: "a"})
+ if subs, _ := db.listSubscriptions(); len(subs) != 2 {
+ t.Fatalf("expected 2 subs, got %d", len(subs))
+ }
+
+ // Same endpoint upserts, not duplicates.
+ db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p2", Auth: "a2"})
+ if subs, _ := db.listSubscriptions(); len(subs) != 2 {
+ t.Fatalf("upsert duplicated: %d", len(subs))
+ }
+
+ db.deleteSubscription("https://e/1")
+ if subs, _ := db.listSubscriptions(); len(subs) != 1 {
+ t.Fatalf("after delete: %d", len(subs))
+ }
+ db.deleteSubscriptionsForToken(inv.Token)
+ if subs, _ := db.listSubscriptions(); len(subs) != 0 {
+ t.Fatalf("after token delete: %d", len(subs))
+ }
+}
+
+func TestGroupName(t *testing.T) {
+ db := newTestDB(t)
+ if got := db.getGroupName(); got != defaultGroupName {
+ t.Fatalf("default group name = %q", got)
+ }
+ if err := db.setGroupName("Family Chat"); err != nil {
+ t.Fatalf("setGroupName: %v", err)
+ }
+ if got := db.getGroupName(); got != "Family Chat" {
+ t.Fatalf("group name = %q", got)
+ }
+}
+
+func TestVAPIDPersists(t *testing.T) {
+ db := newTestDB(t)
+ pub, priv, err := db.ensureVAPID()
+ if err != nil || pub == "" || priv == "" {
+ t.Fatalf("ensureVAPID: %v pub=%q", err, pub)
+ }
+ pub2, priv2, _ := db.ensureVAPID()
+ if pub != pub2 || priv != priv2 {
+ t.Fatal("VAPID keys should persist across calls")
+ }
+}
diff --git a/handlers_test.go b/handlers_test.go
new file mode 100644
index 0000000..81fadda
--- /dev/null
+++ b/handlers_test.go
@@ -0,0 +1,152 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func newTestServer(t *testing.T) *httptest.Server {
+ t.Helper()
+ db, err := openDB(t.TempDir() + "/test.db")
+ if err != nil {
+ t.Fatalf("openDB: %v", err)
+ }
+ db.ensureAdminToken("admintok")
+ pub, priv, _ := db.ensureVAPID()
+ pages, err := loadPages()
+ if err != nil {
+ t.Fatalf("loadPages: %v", err)
+ }
+ hub := newHub()
+ go hub.run()
+ s := &Server{db: db, hub: hub, pages: pages, vapidPub: pub, vapidPriv: priv}
+ ts := httptest.NewServer(s.handler())
+ t.Cleanup(ts.Close)
+ return ts
+}
+
+// adminPost issues a POST with the admin bearer token.
+func adminPost(t *testing.T, url, body string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest("POST", url, strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer admintok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ return resp
+}
+
+func TestAdminAuthGating(t *testing.T) {
+ ts := newTestServer(t)
+
+ resp, _ := http.Post(ts.URL+"/api/admin/invites", "application/json", strings.NewReader(`{"label":"x"}`))
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no auth: want 401, got %d", resp.StatusCode)
+ }
+
+ req, _ := http.NewRequest("POST", ts.URL+"/api/admin/invites", strings.NewReader(`{}`))
+ req.Header.Set("Authorization", "Bearer wrong")
+ resp, _ = http.DefaultClient.Do(req)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("wrong auth: want 401, got %d", resp.StatusCode)
+ }
+}
+
+func TestInviteAndMessagesFlow(t *testing.T) {
+ ts := newTestServer(t)
+
+ resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"Sophie"}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("create invite: %d", resp.StatusCode)
+ }
+ var inv struct {
+ Token string `json:"token"`
+ }
+ json.NewDecoder(resp.Body).Decode(&inv)
+ if inv.Token == "" {
+ t.Fatal("no token returned")
+ }
+
+ // Invite info exposes the label (for screen-name pre-fill).
+ resp, _ = http.Get(ts.URL + "/api/invite/" + inv.Token)
+ var info struct {
+ Ok bool `json:"ok"`
+ Label string `json:"label"`
+ }
+ json.NewDecoder(resp.Body).Decode(&info)
+ if !info.Ok || info.Label != "Sophie" {
+ t.Fatalf("invite info: %+v", info)
+ }
+
+ // Messages need a valid, non-revoked token.
+ resp, _ = http.Get(ts.URL + "/api/messages?token=bogus")
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("messages bad token: want 403, got %d", resp.StatusCode)
+ }
+ resp, _ = http.Get(ts.URL + "/api/messages?token=" + inv.Token)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("messages good token: want 200, got %d", resp.StatusCode)
+ }
+}
+
+func TestPushSubscribeGating(t *testing.T) {
+ ts := newTestServer(t)
+ body := `{"token":"bad","subscription":{"endpoint":"https://e","keys":{"p256dh":"p","auth":"a"}}}`
+ resp, _ := http.Post(ts.URL+"/api/push/subscribe", "application/json", strings.NewReader(body))
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("subscribe with bad invite: want 403, got %d", resp.StatusCode)
+ }
+}
+
+func TestGroupNameEndpoints(t *testing.T) {
+ ts := newTestServer(t)
+
+ resp, _ := http.Get(ts.URL + "/api/group")
+ var g struct{ Name string }
+ json.NewDecoder(resp.Body).Decode(&g)
+ if g.Name != defaultGroupName {
+ t.Fatalf("default name = %q", g.Name)
+ }
+
+ // Setting requires admin.
+ resp, _ = http.Post(ts.URL+"/api/admin/group", "application/json", strings.NewReader(`{"name":"Fam"}`))
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("set without auth: want 401, got %d", resp.StatusCode)
+ }
+ resp = adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam"}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("set name: %d", resp.StatusCode)
+ }
+
+ // Reflected in the page title and the manifest.
+ resp, _ = http.Get(ts.URL + "/")
+ body, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(body), "<title>Fam</title>") {
+ t.Fatal("group name not injected into page title")
+ }
+ resp, _ = http.Get(ts.URL + "/manifest.webmanifest")
+ var m struct{ Name string }
+ json.NewDecoder(resp.Body).Decode(&m)
+ if m.Name != "Fam" {
+ t.Fatalf("manifest name = %q", m.Name)
+ }
+}
+
+func TestGroupNameEscapedInHTML(t *testing.T) {
+ ts := newTestServer(t)
+ adminPost(t, ts.URL+"/api/admin/group", `{"name":"<script>x</script>"}`)
+ resp, _ := http.Get(ts.URL + "/")
+ body, _ := io.ReadAll(resp.Body)
+ if strings.Contains(string(body), "<script>x</script>") {
+ t.Fatal("group name not HTML-escaped (XSS risk)")
+ }
+ if !strings.Contains(string(body), "&lt;script&gt;") {
+ t.Fatal("expected escaped group name in page")
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4bafcfb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "lchat",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node test/crypto.test.mjs"
+ }
+}
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs
new file mode 100644
index 0000000..f57a51d
--- /dev/null
+++ b/test/crypto.test.mjs
@@ -0,0 +1,67 @@
+// Tests for the browser crypto module, run in Node (Node 22 has Web Crypto).
+// node test/crypto.test.mjs
+import assert from "node:assert/strict";
+import {
+ generateKey,
+ importKey,
+ encrypt,
+ decrypt,
+ b64urlToBytes,
+ bytesToB64url,
+} from "../web/static/crypto.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+await test("encrypt → decrypt round-trips (incl. unicode)", async () => {
+ const { key } = await generateKey();
+ const msg = { name: "Sophie", text: "héllo 🌍", ts: 1234567890 };
+ const out = await decrypt(key, await encrypt(key, msg));
+ assert.deepEqual(out, msg);
+});
+
+await test("a different key cannot decrypt", async () => {
+ const { key } = await generateKey();
+ const { key: other } = await generateKey();
+ const blob = await encrypt(key, { text: "secret" });
+ await assert.rejects(() => decrypt(other, blob));
+});
+
+await test("ciphertext is not plaintext", async () => {
+ const { key } = await generateKey();
+ const blob = await encrypt(key, { text: "hello world" });
+ assert.ok(!blob.includes("hello"), "blob must not contain plaintext");
+});
+
+await test("each encryption uses a fresh IV (different ciphertext)", async () => {
+ const { key } = await generateKey();
+ const a = await encrypt(key, { text: "same" });
+ const b = await encrypt(key, { text: "same" });
+ assert.notEqual(a, b);
+});
+
+await test("b64url is URL-safe and round-trips", async () => {
+ const bytes = new Uint8Array([0, 1, 2, 250, 251, 255, 62, 63]);
+ const s = bytesToB64url(bytes);
+ assert.ok(!/[+/=]/.test(s), "must not contain + / =");
+ assert.deepEqual(Array.from(b64urlToBytes(s)), Array.from(bytes));
+});
+
+await test("two clients sharing the exported key interoperate", async () => {
+ const { b64 } = await generateKey();
+ const sender = await importKey(b64);
+ const receiver = await importKey(b64);
+ const out = await decrypt(receiver, await encrypt(sender, { text: "shared" }));
+ assert.equal(out.text, "shared");
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);