Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

graphql-email-privacy.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

graphql-email-privacy.test.tsBlame68 lines · 1 contributor
43755faccantynz-alt1/**
2 * GraphQL must not disclose other users' email addresses.
3 *
4 * `user(username: "...")` selected users.email and returned it verbatim. The
5 * resolver is reachable anonymously (POST /api/graphql, softAuth only), so an
6 * unauthenticated caller could read the address of any account on the
7 * platform — bulk harvesting with one cheap query per username.
8 *
9 * Verified live 2026-07-28 before the fix:
10 * $ curl -s -X POST https://gluecron.com/api/graphql \
11 * -d '{"query":"{ user(username:\"ccantynz\"){ username email } }"}'
12 * {"data":{"user":{"username":"ccantynz","email":"ccantynz@gmail.com"}}}
13 *
14 * The `me` resolver remains the supported way to read your own address; it is
15 * already scoped to ctx.user.id.
16 */
17
18import { describe, expect, it } from "bun:test";
19import { readFileSync } from "fs";
20
21const SRC = readFileSync("src/lib/graphql.ts", "utf8");
22const userResolver = SRC.slice(
23 SRC.indexOf("user: async (args, sel, _ctx)"),
24 SRC.indexOf("repository: async (args, sel, _ctx)")
25);
26
27describe("user(username:) email exposure", () => {
28 it("only returns email to the account holder", () => {
29 expect(userResolver).toContain("_ctx?.user?.id === row.id");
30 expect(userResolver).toContain("email: isSelf ? row.email : null");
31 });
32
33 it("does not pass the raw row through to selection resolution", () => {
34 // The bug shape: `resolveSelections(u, sel)` where u came straight from
35 // the DB with email attached.
36 const idx = userResolver.indexOf("const u = {");
37 expect(idx).toBeGreaterThan(-1);
38 // The redacted object must be built before anything consumes it.
39 const consume = userResolver.indexOf("resolveSelections(u");
40 if (consume > -1) expect(idx).toBeLessThan(consume);
41 });
42
43 it("nulls the field rather than dropping it", () => {
44 // Clients that request `email` should still get a valid response shape.
45 expect(userResolver).toContain(": null");
46 });
47});
48
49describe("the viewer resolver is unaffected", () => {
50 it("still scopes to the authenticated user", () => {
51 // `viewer`, not `me` — it is the self-lookup and is already gated on
52 // ctx.user, so reading your own address keeps working.
53 const viewer = SRC.slice(
54 SRC.indexOf("viewer: async"),
55 SRC.indexOf("user: async")
56 );
57 expect(viewer).toContain("ctx.user.id");
58 expect(viewer).toContain("email: users.email");
59 expect(viewer).toContain("if (!ctx.user) return null");
60 });
61});
62
63describe("no other resolver selects email", () => {
64 it("only viewer and user touch users.email", () => {
65 const hits = SRC.match(/email: users\.email/g) ?? [];
66 expect(hits.length).toBe(2); // viewer (scoped) + user (now redacted)
67 });
68});