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>
5 files changed,
+367
−0
README.md+13 −0db_test.go+127 −0handlers_test.go+152 −0package.json+8 −0test/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), "<script>") { |
| + 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); |