create-ailk 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +175 -14
- package/dist/cli.js +0 -0
- package/package.json +14 -15
- package/templates/apps/api/CLAUDE.md +4 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +127 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +8 -0
- package/templates/apps/api/src/server.ts +14 -0
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/flow-engine.ts +18 -0
- package/templates/apps/api/src/services/listing-promotion.ts +342 -0
- package/templates/apps/api/src/services/project-listing-decision.ts +21 -1
- package/templates/apps/api/src/services/project-listings.ts +373 -25
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/jest.config.cjs +6 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/package.json +1 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +61 -0
- package/templates/database/inbox/schema.prisma +98 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file listing-promotion.test.ts
|
|
3
|
+
* @description Unit tests for the listing-promotion service (#5295; ADR 0009
|
|
4
|
+
* D3 service-layer pattern), mirroring `lead-promotion.test.ts`.
|
|
5
|
+
*
|
|
6
|
+
* External dependency boundary mocked:
|
|
7
|
+
* - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
|
|
8
|
+
* manual mock (`jest.mock("../../lib/prisma.js")`).
|
|
9
|
+
*
|
|
10
|
+
* `prisma.projectListing.findMany` is given a REALISTIC filtering
|
|
11
|
+
* implementation over an in-memory fixture "table" rather than a static
|
|
12
|
+
* `mockResolvedValue`, so the two security properties this service exists for
|
|
13
|
+
* actually get exercised against what the service passes to the DB. A bug that
|
|
14
|
+
* widened the `siteId` match set, or that loosened the email comparison, would
|
|
15
|
+
* surface the foreign row here rather than only in an argument-shape assertion.
|
|
16
|
+
*
|
|
17
|
+
* `tenant-context.ts`'s `listTenancy` runs REAL — it is a pure read over the
|
|
18
|
+
* mocked `prisma.membership.findMany` — so the "resolve via the sole
|
|
19
|
+
* tenant-context assembler" contract is covered rather than re-stubbed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
23
|
+
jest.mock("../../lib/prisma.js");
|
|
24
|
+
|
|
25
|
+
import { prisma } from "../../lib/prisma.js";
|
|
26
|
+
import { promoteListingsForVerifiedUser } from "../listing-promotion";
|
|
27
|
+
import type { PromoteListingsResult } from "../listing-promotion";
|
|
28
|
+
|
|
29
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
|
|
32
|
+
const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
|
|
33
|
+
const OWN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
|
|
34
|
+
// OTHER_WORKSPACE_ID belongs to a different organization than OWN_ORG_ID —
|
|
35
|
+
// the tenant-safety fixture (never a workspace of OWN_ORG_ID).
|
|
36
|
+
const OTHER_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
|
|
37
|
+
// A SECOND workspace of the user's OWN organization — the per-site grain of the
|
|
38
|
+
// ambiguity refusal needs two of the caller's own sites to be observable.
|
|
39
|
+
const SECOND_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c3";
|
|
40
|
+
const USER_EMAIL = "founder@example.com";
|
|
41
|
+
|
|
42
|
+
type FakeListingRow = {
|
|
43
|
+
id: string;
|
|
44
|
+
founderEmail: string;
|
|
45
|
+
siteId: string;
|
|
46
|
+
ownerUserId: string | null;
|
|
47
|
+
/** Defaults to `requested` — a submitted, claimable row. */
|
|
48
|
+
status?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Shapes prisma.membership.findMany's mocked return — what listTenancy reads. */
|
|
52
|
+
function ownMembership() {
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
role: "owner",
|
|
56
|
+
organization: {
|
|
57
|
+
id: OWN_ORG_ID,
|
|
58
|
+
name: "Acme",
|
|
59
|
+
slug: "acme",
|
|
60
|
+
workspaces: [{ id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" }],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function verifiedUser(overrides?: Partial<{ emailVerified: boolean; email: string }>) {
|
|
67
|
+
return {
|
|
68
|
+
id: USER_ID,
|
|
69
|
+
email: overrides?.email ?? USER_EMAIL,
|
|
70
|
+
emailVerified: overrides?.emailVerified ?? true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Wires projectListing.findMany / updateMany to a realistic in-memory "table"
|
|
76
|
+
* filtered by the `where` the service passes.
|
|
77
|
+
*
|
|
78
|
+
* `founderEmail` is compared with `===` here deliberately: the service passes a
|
|
79
|
+
* plain string (EXACT equality), not `{ equals, mode: "insensitive" }`. Prisma
|
|
80
|
+
* compiles an insensitive `equals` to `ILIKE`, which would make `_` and `%` in
|
|
81
|
+
* the caller's own address LIKE wildcards — so if the service ever switched to
|
|
82
|
+
* that form, this fixture would no longer model the DB and the
|
|
83
|
+
* wildcard-capture test below would stop being meaningful. The fixture asserts
|
|
84
|
+
* the form it models.
|
|
85
|
+
*/
|
|
86
|
+
function seedListingTable(rows: FakeListingRow[]) {
|
|
87
|
+
const table = rows.map((r) => ({ ...r }));
|
|
88
|
+
|
|
89
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
90
|
+
async (args: {
|
|
91
|
+
where: {
|
|
92
|
+
founderEmail: unknown;
|
|
93
|
+
siteId: { in: string[] };
|
|
94
|
+
ownerUserId: null;
|
|
95
|
+
status?: { in?: string[] };
|
|
96
|
+
};
|
|
97
|
+
select: { id: true; siteId: true };
|
|
98
|
+
}) => {
|
|
99
|
+
const { founderEmail, siteId, status } = args.where;
|
|
100
|
+
if (typeof founderEmail !== "string") {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"listing-promotion must compare founderEmail by exact string equality; " +
|
|
103
|
+
"a filter object (e.g. mode: 'insensitive') compiles to ILIKE and makes " +
|
|
104
|
+
"`_`/`%` in the caller's own address wildcards",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
// A candidate read with no status term would make an anonymous,
|
|
108
|
+
// quota-free draft claimable; model the filter so its absence fails.
|
|
109
|
+
if (!Array.isArray(status?.in)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"the candidate read must constrain status; a draft is created anonymously with no quota, so a claimable draft is a planted-row vector",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const claimable = status.in;
|
|
115
|
+
return table
|
|
116
|
+
.filter(
|
|
117
|
+
(row) =>
|
|
118
|
+
row.founderEmail === founderEmail &&
|
|
119
|
+
siteId.in.includes(row.siteId) &&
|
|
120
|
+
row.ownerUserId === null &&
|
|
121
|
+
claimable.includes(row.status ?? "requested"),
|
|
122
|
+
)
|
|
123
|
+
.map((row) => ({ id: row.id, siteId: row.siteId }));
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Models the WRITE's own `ownerUserId: null` predicate, not just its id list.
|
|
129
|
+
* Without that the mock would happily overwrite a row already linked to
|
|
130
|
+
* somebody else, hiding the compare-and-swap the service relies on to survive
|
|
131
|
+
* two concurrent reconciliation runs.
|
|
132
|
+
*/
|
|
133
|
+
(prisma.projectListing.updateMany as jest.Mock).mockImplementation(
|
|
134
|
+
async (args: {
|
|
135
|
+
where: { id: { in: string[] }; ownerUserId: null };
|
|
136
|
+
data: { ownerUserId: string; ownerLinkedAt: Date };
|
|
137
|
+
}) => {
|
|
138
|
+
if (args.where.ownerUserId !== null) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
"the owner-link write must repeat `ownerUserId: null`; without it a concurrent run transfers a row away from the user who already claimed it",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
let count = 0;
|
|
144
|
+
for (const row of table) {
|
|
145
|
+
if (args.where.id.in.includes(row.id) && row.ownerUserId === null) {
|
|
146
|
+
row.ownerUserId = args.data.ownerUserId;
|
|
147
|
+
count += 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { count };
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return table;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe("promoteListingsForVerifiedUser", () => {
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
jest.clearAllMocks();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── The happy path: all three conditions hold ────────────────────────────
|
|
163
|
+
it("links an un-linked listing whose founderEmail is the verified user's own, in the user's own workspace", async () => {
|
|
164
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
165
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
166
|
+
const table = seedListingTable([
|
|
167
|
+
{
|
|
168
|
+
id: "prjl_00000000-0000-7000-8000-0000000000d1",
|
|
169
|
+
founderEmail: USER_EMAIL,
|
|
170
|
+
siteId: OWN_WORKSPACE_ID,
|
|
171
|
+
ownerUserId: null,
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
176
|
+
|
|
177
|
+
expect(result).toEqual<PromoteListingsResult>({
|
|
178
|
+
ok: true,
|
|
179
|
+
count: 1,
|
|
180
|
+
promoted: [
|
|
181
|
+
{
|
|
182
|
+
listingId: "prjl_00000000-0000-7000-8000-0000000000d1",
|
|
183
|
+
siteId: OWN_WORKSPACE_ID,
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
expect(prisma.projectListing.findMany).toHaveBeenCalledWith({
|
|
188
|
+
where: {
|
|
189
|
+
founderEmail: USER_EMAIL,
|
|
190
|
+
siteId: { in: [OWN_WORKSPACE_ID] },
|
|
191
|
+
ownerUserId: null,
|
|
192
|
+
status: { in: ["requested", "rejected", "active"] },
|
|
193
|
+
},
|
|
194
|
+
select: { id: true, siteId: true },
|
|
195
|
+
});
|
|
196
|
+
expect(prisma.projectListing.updateMany).toHaveBeenCalledWith({
|
|
197
|
+
where: {
|
|
198
|
+
id: { in: ["prjl_00000000-0000-7000-8000-0000000000d1"] },
|
|
199
|
+
// Repeated on the write, so the update is a compare-and-swap.
|
|
200
|
+
ownerUserId: null,
|
|
201
|
+
},
|
|
202
|
+
data: { ownerUserId: USER_ID, ownerLinkedAt: expect.any(Date) },
|
|
203
|
+
});
|
|
204
|
+
expect(table[0]?.ownerUserId).toBe(USER_ID);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── SECURITY PROPERTY 1 ──────────────────────────────────────────────────
|
|
208
|
+
it("an UNVERIFIED email never produces a link, even when it MATCHES founderEmail exactly", async () => {
|
|
209
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(
|
|
210
|
+
verifiedUser({ emailVerified: false }),
|
|
211
|
+
);
|
|
212
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
213
|
+
// The listing's founderEmail is byte-identical to the caller's address, and
|
|
214
|
+
// it sits in a workspace the caller owns. Only verification is missing.
|
|
215
|
+
const table = seedListingTable([
|
|
216
|
+
{
|
|
217
|
+
id: "prjl_unverified_match",
|
|
218
|
+
founderEmail: USER_EMAIL,
|
|
219
|
+
siteId: OWN_WORKSPACE_ID,
|
|
220
|
+
ownerUserId: null,
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
225
|
+
|
|
226
|
+
expect(result).toEqual<PromoteListingsResult>({
|
|
227
|
+
ok: true,
|
|
228
|
+
count: 0,
|
|
229
|
+
promoted: [],
|
|
230
|
+
});
|
|
231
|
+
// The gate is evaluated BEFORE any tenancy or listing read happens at all.
|
|
232
|
+
expect(prisma.membership.findMany).not.toHaveBeenCalled();
|
|
233
|
+
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
234
|
+
expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
|
|
235
|
+
expect(table[0]?.ownerUserId).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── SECURITY PROPERTY 2 ──────────────────────────────────────────────────
|
|
239
|
+
it("a matching, VERIFIED email in a DIFFERENT tenant never produces a link", async () => {
|
|
240
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
241
|
+
// The user belongs ONLY to OWN_ORG_ID / OWN_WORKSPACE_ID.
|
|
242
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
243
|
+
const table = seedListingTable([
|
|
244
|
+
{
|
|
245
|
+
id: "prjl_cross_tenant",
|
|
246
|
+
founderEmail: USER_EMAIL, // the caller's own verified address
|
|
247
|
+
siteId: OTHER_WORKSPACE_ID, // a DIFFERENT organization's workspace
|
|
248
|
+
ownerUserId: null,
|
|
249
|
+
},
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
253
|
+
|
|
254
|
+
expect(result).toEqual<PromoteListingsResult>({
|
|
255
|
+
ok: true,
|
|
256
|
+
count: 0,
|
|
257
|
+
promoted: [],
|
|
258
|
+
});
|
|
259
|
+
expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
|
|
260
|
+
expect(table.find((r) => r.id === "prjl_cross_tenant")?.ownerUserId).toBeNull();
|
|
261
|
+
// The query itself never widens siteId.in to the foreign workspace.
|
|
262
|
+
expect(prisma.projectListing.findMany).toHaveBeenCalledWith({
|
|
263
|
+
where: {
|
|
264
|
+
founderEmail: USER_EMAIL,
|
|
265
|
+
siteId: { in: [OWN_WORKSPACE_ID] },
|
|
266
|
+
ownerUserId: null,
|
|
267
|
+
status: { in: ["requested", "rejected", "active"] },
|
|
268
|
+
},
|
|
269
|
+
select: { id: true, siteId: true },
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The email comparison is EXACT, so a LIKE metacharacter in the caller's own
|
|
275
|
+
* verified address cannot capture a different founder's row.
|
|
276
|
+
*
|
|
277
|
+
* Verified against a live Postgres while building this: with
|
|
278
|
+
* `{ equals, mode: "insensitive" }` Prisma emits `founder_email ILIKE $1`,
|
|
279
|
+
* and a caller whose real address is `a_c@example.com` matched BOTH its own
|
|
280
|
+
* row and a stranger's `abc@example.com` in the same workspace — two rows in,
|
|
281
|
+
* two rows out. This test is the regression lock for that.
|
|
282
|
+
*/
|
|
283
|
+
it("a LIKE metacharacter in the caller's own verified address captures no other founder's listing", async () => {
|
|
284
|
+
const WILDCARD_EMAIL = "a_c@example.com";
|
|
285
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(
|
|
286
|
+
verifiedUser({ email: WILDCARD_EMAIL }),
|
|
287
|
+
);
|
|
288
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
289
|
+
const table = seedListingTable([
|
|
290
|
+
{
|
|
291
|
+
id: "prjl_mine",
|
|
292
|
+
founderEmail: WILDCARD_EMAIL,
|
|
293
|
+
siteId: OWN_WORKSPACE_ID,
|
|
294
|
+
ownerUserId: null,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
// A DIFFERENT person, in the same workspace, whose address differs from
|
|
298
|
+
// the caller's only where the `_` sits.
|
|
299
|
+
id: "prjl_neighbour",
|
|
300
|
+
founderEmail: "abc@example.com",
|
|
301
|
+
siteId: OWN_WORKSPACE_ID,
|
|
302
|
+
ownerUserId: null,
|
|
303
|
+
},
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
307
|
+
userId: USER_ID,
|
|
308
|
+
})) as Extract<PromoteListingsResult, { ok: true }>;
|
|
309
|
+
|
|
310
|
+
expect(result.count).toBe(1);
|
|
311
|
+
expect(result.promoted).toEqual([
|
|
312
|
+
{ listingId: "prjl_mine", siteId: OWN_WORKSPACE_ID },
|
|
313
|
+
]);
|
|
314
|
+
expect(table.find((r) => r.id === "prjl_neighbour")?.ownerUserId).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── Two people sharing an address: the link follows the VERIFIED one ──────
|
|
318
|
+
it("two listings sharing a founderEmail are both linked ONLY to the user who verified that address", async () => {
|
|
319
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
320
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
321
|
+
const table = seedListingTable([
|
|
322
|
+
{
|
|
323
|
+
id: "prjl_shared_1",
|
|
324
|
+
founderEmail: USER_EMAIL,
|
|
325
|
+
siteId: OWN_WORKSPACE_ID,
|
|
326
|
+
ownerUserId: null,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
// Already linked to SOMEBODY ELSE — excluded by `ownerUserId: null`, so
|
|
330
|
+
// a later verifier of the same address can never steal it.
|
|
331
|
+
id: "prjl_shared_2",
|
|
332
|
+
founderEmail: USER_EMAIL,
|
|
333
|
+
siteId: OWN_WORKSPACE_ID,
|
|
334
|
+
ownerUserId: "user_00000000-0000-7000-8000-0000000000a2",
|
|
335
|
+
},
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
339
|
+
userId: USER_ID,
|
|
340
|
+
})) as Extract<PromoteListingsResult, { ok: true }>;
|
|
341
|
+
|
|
342
|
+
expect(result.count).toBe(1);
|
|
343
|
+
expect(result.promoted).toEqual([
|
|
344
|
+
{ listingId: "prjl_shared_1", siteId: OWN_WORKSPACE_ID },
|
|
345
|
+
]);
|
|
346
|
+
expect(table.find((r) => r.id === "prjl_shared_2")?.ownerUserId).toBe(
|
|
347
|
+
"user_00000000-0000-7000-8000-0000000000a2",
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* THE COUNTEREXAMPLE this service is built to refuse.
|
|
353
|
+
*
|
|
354
|
+
* Two strangers both typed the same address into the anonymous form, so there
|
|
355
|
+
* are TWO un-linked rows carrying it at one site and nothing that says which
|
|
356
|
+
* is whose. Linking both to whoever verifies the address would hand one
|
|
357
|
+
* person the other's whole application — the disclosure the owner link exists
|
|
358
|
+
* to remove, arriving through the promotion path instead of the read path.
|
|
359
|
+
*
|
|
360
|
+
* Found by an independent verifier who reproduced it end to end against a
|
|
361
|
+
* live Postgres before the refusal existed: `count: 2`, and the verifying
|
|
362
|
+
* founder read the stranger's row through `GET /v1/project-listings/me`.
|
|
363
|
+
*/
|
|
364
|
+
it("REFUSES to link when TWO un-linked listings share the address at one site", async () => {
|
|
365
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
366
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
367
|
+
const table = seedListingTable([
|
|
368
|
+
{
|
|
369
|
+
id: "prjl_typed_by_founder_a",
|
|
370
|
+
founderEmail: USER_EMAIL,
|
|
371
|
+
siteId: OWN_WORKSPACE_ID,
|
|
372
|
+
ownerUserId: null,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
// A DIFFERENT person who typed the same address. Indistinguishable.
|
|
376
|
+
id: "prjl_typed_by_founder_b",
|
|
377
|
+
founderEmail: USER_EMAIL,
|
|
378
|
+
siteId: OWN_WORKSPACE_ID,
|
|
379
|
+
ownerUserId: null,
|
|
380
|
+
},
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
384
|
+
|
|
385
|
+
expect(result).toEqual<PromoteListingsResult>({
|
|
386
|
+
ok: true,
|
|
387
|
+
count: 0,
|
|
388
|
+
promoted: [],
|
|
389
|
+
});
|
|
390
|
+
expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
|
|
391
|
+
// BOTH rows stay null. Neither founder can read the other's application.
|
|
392
|
+
expect(table.find((r) => r.id === "prjl_typed_by_founder_a")?.ownerUserId).toBeNull();
|
|
393
|
+
expect(table.find((r) => r.id === "prjl_typed_by_founder_b")?.ownerUserId).toBeNull();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("an ambiguous group at one site does not block an unambiguous row at another", async () => {
|
|
397
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
398
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue([
|
|
399
|
+
{
|
|
400
|
+
role: "owner",
|
|
401
|
+
organization: {
|
|
402
|
+
id: OWN_ORG_ID,
|
|
403
|
+
name: "Acme",
|
|
404
|
+
slug: "acme",
|
|
405
|
+
workspaces: [
|
|
406
|
+
{ id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" },
|
|
407
|
+
{ id: SECOND_WORKSPACE_ID, name: "Acme Two", slug: "acme-two" },
|
|
408
|
+
],
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
]);
|
|
412
|
+
const table = seedListingTable([
|
|
413
|
+
// Ambiguous pair at the first site — both refused.
|
|
414
|
+
{
|
|
415
|
+
id: "prjl_ambiguous_a",
|
|
416
|
+
founderEmail: USER_EMAIL,
|
|
417
|
+
siteId: OWN_WORKSPACE_ID,
|
|
418
|
+
ownerUserId: null,
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
id: "prjl_ambiguous_b",
|
|
422
|
+
founderEmail: USER_EMAIL,
|
|
423
|
+
siteId: OWN_WORKSPACE_ID,
|
|
424
|
+
ownerUserId: null,
|
|
425
|
+
},
|
|
426
|
+
// The only row carrying the address at the SECOND site — attributable.
|
|
427
|
+
{
|
|
428
|
+
id: "prjl_unambiguous",
|
|
429
|
+
founderEmail: USER_EMAIL,
|
|
430
|
+
siteId: SECOND_WORKSPACE_ID,
|
|
431
|
+
ownerUserId: null,
|
|
432
|
+
},
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
436
|
+
userId: USER_ID,
|
|
437
|
+
})) as Extract<PromoteListingsResult, { ok: true }>;
|
|
438
|
+
|
|
439
|
+
expect(result.count).toBe(1);
|
|
440
|
+
expect(result.promoted).toEqual([
|
|
441
|
+
{ listingId: "prjl_unambiguous", siteId: SECOND_WORKSPACE_ID },
|
|
442
|
+
]);
|
|
443
|
+
expect(table.find((r) => r.id === "prjl_ambiguous_a")?.ownerUserId).toBeNull();
|
|
444
|
+
expect(table.find((r) => r.id === "prjl_ambiguous_b")?.ownerUserId).toBeNull();
|
|
445
|
+
expect(table.find((r) => r.id === "prjl_unambiguous")?.ownerUserId).toBe(USER_ID);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The write repeats `ownerUserId: null`, so a run that loses a race matches 0
|
|
450
|
+
* rows instead of transferring a row away from the user who already claimed
|
|
451
|
+
* it. The candidate read alone cannot establish this: both runs read the row
|
|
452
|
+
* as un-linked before either wrote.
|
|
453
|
+
*/
|
|
454
|
+
it("a row claimed between the candidate read and the write is not transferred away", async () => {
|
|
455
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
456
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
457
|
+
const table = seedListingTable([
|
|
458
|
+
{
|
|
459
|
+
id: "prjl_raced",
|
|
460
|
+
founderEmail: USER_EMAIL,
|
|
461
|
+
siteId: OWN_WORKSPACE_ID,
|
|
462
|
+
ownerUserId: null,
|
|
463
|
+
},
|
|
464
|
+
]);
|
|
465
|
+
|
|
466
|
+
// Simulate the interleaving: another verifier claims the row after this
|
|
467
|
+
// call's candidate read returns it and before its update runs.
|
|
468
|
+
const findMany = prisma.projectListing.findMany as jest.Mock;
|
|
469
|
+
const realFindMany = findMany.getMockImplementation();
|
|
470
|
+
findMany.mockImplementation(async (args: Parameters<typeof realFindMany>[0]) => {
|
|
471
|
+
const rows = await realFindMany!(args);
|
|
472
|
+
const raced = table.find((r) => r.id === "prjl_raced");
|
|
473
|
+
if (raced) raced.ownerUserId = "user_00000000-0000-7000-8000-0000000000a9";
|
|
474
|
+
return rows;
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
478
|
+
|
|
479
|
+
expect(result).toEqual<PromoteListingsResult>({
|
|
480
|
+
ok: true,
|
|
481
|
+
count: 0,
|
|
482
|
+
promoted: [],
|
|
483
|
+
});
|
|
484
|
+
// The first claimant keeps the row.
|
|
485
|
+
expect(table.find((r) => r.id === "prjl_raced")?.ownerUserId).toBe(
|
|
486
|
+
"user_00000000-0000-7000-8000-0000000000a9",
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The denial-of-link the ambiguity refusal would otherwise open, found by the
|
|
492
|
+
* security reviewer after the refusal landed.
|
|
493
|
+
*
|
|
494
|
+
* `POST /v1/project-listings` is anonymous and the concurrent-listing quota is
|
|
495
|
+
* enforced at SUBMIT, not at start, so anyone with a site's public key — which
|
|
496
|
+
* ships in browser JavaScript by design — can create unbounded `draft` rows
|
|
497
|
+
* carrying any address. One planted draft would be enough to make every
|
|
498
|
+
* genuine row at that site ambiguous, permanently blocking the real founder
|
|
499
|
+
* from ever claiming their own listing. Excluding drafts from candidacy is
|
|
500
|
+
* what stops one anonymous request from doing that.
|
|
501
|
+
*/
|
|
502
|
+
it("a planted anonymous DRAFT neither links nor blocks the founder's own submitted row", async () => {
|
|
503
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
504
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
505
|
+
const table = seedListingTable([
|
|
506
|
+
{
|
|
507
|
+
id: "prjl_planted_draft",
|
|
508
|
+
founderEmail: USER_EMAIL,
|
|
509
|
+
siteId: OWN_WORKSPACE_ID,
|
|
510
|
+
ownerUserId: null,
|
|
511
|
+
status: "draft",
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
id: "prjl_genuine_submitted",
|
|
515
|
+
founderEmail: USER_EMAIL,
|
|
516
|
+
siteId: OWN_WORKSPACE_ID,
|
|
517
|
+
ownerUserId: null,
|
|
518
|
+
status: "requested",
|
|
519
|
+
},
|
|
520
|
+
]);
|
|
521
|
+
|
|
522
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
523
|
+
userId: USER_ID,
|
|
524
|
+
})) as Extract<PromoteListingsResult, { ok: true }>;
|
|
525
|
+
|
|
526
|
+
// The draft is not a candidate, so the group is NOT ambiguous and the
|
|
527
|
+
// founder's own submitted row still links.
|
|
528
|
+
expect(result.count).toBe(1);
|
|
529
|
+
expect(result.promoted).toEqual([
|
|
530
|
+
{ listingId: "prjl_genuine_submitted", siteId: OWN_WORKSPACE_ID },
|
|
531
|
+
]);
|
|
532
|
+
expect(table.find((r) => r.id === "prjl_planted_draft")?.ownerUserId).toBeNull();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("a rejected listing is still the founder's own and still links", async () => {
|
|
536
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
537
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
538
|
+
seedListingTable([
|
|
539
|
+
{
|
|
540
|
+
id: "prjl_rejected",
|
|
541
|
+
founderEmail: USER_EMAIL,
|
|
542
|
+
siteId: OWN_WORKSPACE_ID,
|
|
543
|
+
ownerUserId: null,
|
|
544
|
+
status: "rejected",
|
|
545
|
+
},
|
|
546
|
+
]);
|
|
547
|
+
|
|
548
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
549
|
+
userId: USER_ID,
|
|
550
|
+
})) as Extract<PromoteListingsResult, { ok: true }>;
|
|
551
|
+
|
|
552
|
+
expect(result.count).toBe(1);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// ── Case + whitespace: the user side is normalized the way writes are ────
|
|
556
|
+
it("normalizes the user's address the way startProjectListingDraft normalizes founderEmail", async () => {
|
|
557
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(
|
|
558
|
+
verifiedUser({ email: " Founder@Example.com " }),
|
|
559
|
+
);
|
|
560
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
561
|
+
seedListingTable([
|
|
562
|
+
{
|
|
563
|
+
id: "prjl_normalized",
|
|
564
|
+
founderEmail: USER_EMAIL,
|
|
565
|
+
siteId: OWN_WORKSPACE_ID,
|
|
566
|
+
ownerUserId: null,
|
|
567
|
+
},
|
|
568
|
+
]);
|
|
569
|
+
|
|
570
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
571
|
+
userId: USER_ID,
|
|
572
|
+
})) as Extract<PromoteListingsResult, { ok: true }>;
|
|
573
|
+
|
|
574
|
+
expect(result.count).toBe(1);
|
|
575
|
+
expect(prisma.projectListing.findMany).toHaveBeenCalledWith(
|
|
576
|
+
expect.objectContaining({
|
|
577
|
+
where: expect.objectContaining({ founderEmail: USER_EMAIL }),
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ── Idempotent ───────────────────────────────────────────────────────────
|
|
583
|
+
it("a listing linked on the first run is excluded on the second run", async () => {
|
|
584
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
585
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
586
|
+
seedListingTable([
|
|
587
|
+
{
|
|
588
|
+
id: "prjl_first_run",
|
|
589
|
+
founderEmail: USER_EMAIL,
|
|
590
|
+
siteId: OWN_WORKSPACE_ID,
|
|
591
|
+
ownerUserId: null,
|
|
592
|
+
},
|
|
593
|
+
]);
|
|
594
|
+
|
|
595
|
+
const first = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
596
|
+
expect(first).toEqual<PromoteListingsResult>({
|
|
597
|
+
ok: true,
|
|
598
|
+
count: 1,
|
|
599
|
+
promoted: [{ listingId: "prjl_first_run", siteId: OWN_WORKSPACE_ID }],
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const second = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
603
|
+
expect(second).toEqual<PromoteListingsResult>({
|
|
604
|
+
ok: true,
|
|
605
|
+
count: 0,
|
|
606
|
+
promoted: [],
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// ── No match → count:0 ───────────────────────────────────────────────────
|
|
611
|
+
it("no matching listing → count:0 with no update", async () => {
|
|
612
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
613
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
614
|
+
seedListingTable([]);
|
|
615
|
+
|
|
616
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
617
|
+
|
|
618
|
+
expect(result).toEqual<PromoteListingsResult>({
|
|
619
|
+
ok: true,
|
|
620
|
+
count: 0,
|
|
621
|
+
promoted: [],
|
|
622
|
+
});
|
|
623
|
+
expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ── No workspaces → no-op, never queries the listing table ───────────────
|
|
627
|
+
it("a user with no workspaces is a no-op — never queries inbox.ProjectListing", async () => {
|
|
628
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
629
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
|
|
630
|
+
|
|
631
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
632
|
+
|
|
633
|
+
expect(result).toEqual<PromoteListingsResult>({
|
|
634
|
+
ok: true,
|
|
635
|
+
count: 0,
|
|
636
|
+
promoted: [],
|
|
637
|
+
});
|
|
638
|
+
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// ── Unknown user → ok:false, user_not_found ──────────────────────────────
|
|
642
|
+
it("an unknown userId returns ok:false with kind user_not_found", async () => {
|
|
643
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
644
|
+
|
|
645
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
646
|
+
userId: "user_does-not-exist",
|
|
647
|
+
})) as Extract<PromoteListingsResult, { ok: false }>;
|
|
648
|
+
|
|
649
|
+
expect(result.ok).toBe(false);
|
|
650
|
+
expect(result.error.kind).toBe("user_not_found");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// ── A DB failure is a result, never a throw ──────────────────────────────
|
|
654
|
+
it("a DB failure returns ok:false internal_error rather than throwing", async () => {
|
|
655
|
+
(prisma.user.findUnique as jest.Mock).mockRejectedValue(
|
|
656
|
+
new Error("connection terminated"),
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
const result = (await promoteListingsForVerifiedUser({
|
|
660
|
+
userId: USER_ID,
|
|
661
|
+
})) as Extract<PromoteListingsResult, { ok: false }>;
|
|
662
|
+
|
|
663
|
+
expect(result.ok).toBe(false);
|
|
664
|
+
expect(result.error.kind).toBe("internal_error");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// ── PII non-leak — the result never carries the user's raw email ─────────
|
|
668
|
+
it("PII non-leak — the result never carries the founder's raw email", async () => {
|
|
669
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
670
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
|
|
671
|
+
seedListingTable([
|
|
672
|
+
{
|
|
673
|
+
id: "prjl_pii",
|
|
674
|
+
founderEmail: USER_EMAIL,
|
|
675
|
+
siteId: OWN_WORKSPACE_ID,
|
|
676
|
+
ownerUserId: null,
|
|
677
|
+
},
|
|
678
|
+
]);
|
|
679
|
+
|
|
680
|
+
const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
|
|
681
|
+
|
|
682
|
+
expect(JSON.stringify(result)).not.toContain(USER_EMAIL);
|
|
683
|
+
});
|
|
684
|
+
});
|