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,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file me.test.ts
|
|
3
|
+
*
|
|
4
|
+
* THE ACCEPTANCE GATE for the founder's own listings read (#5295).
|
|
5
|
+
* Registration mirrors server.ts: REAL `authPreHandlerPlugin ->
|
|
6
|
+
* projectListingMeRoute` (auth only, NO tenant guard), mocked `lib/prisma.js`.
|
|
7
|
+
*
|
|
8
|
+
* Proves the security properties the owner link exists for:
|
|
9
|
+
* - two users sharing a `founderEmail` value never see each other's rows;
|
|
10
|
+
* - a row whose owner link was never set is invisible to the person whose
|
|
11
|
+
* address it carries — the read never falls back to an email comparison;
|
|
12
|
+
* - the `where` clause carries `ownerUserId` and nothing resembling an email
|
|
13
|
+
* (a clause carrying one is treated as a contract violation and returns
|
|
14
|
+
* nothing, the `flow-checkouts` test's own discipline);
|
|
15
|
+
* - a signed-in caller who owns nothing gets `200 []`, never a 403;
|
|
16
|
+
* - `PROJECT_LISTING_ROLES` is untouched and this route never consults it.
|
|
17
|
+
*/
|
|
18
|
+
import Fastify from "fastify";
|
|
19
|
+
import type { FastifyInstance } from "fastify";
|
|
20
|
+
import { projectListingMeResponseSchema } from "@working-theory/validation";
|
|
21
|
+
|
|
22
|
+
// `services/project-listings.ts` imports `routeLead` from `lead-routing.js` at
|
|
23
|
+
// module scope, which transitively imports `@working-theory/email` and
|
|
24
|
+
// `@working-theory/license` — mocked here purely so that import chain resolves,
|
|
25
|
+
// mirroring `tenant-isolation.test.ts`'s discipline.
|
|
26
|
+
jest.mock("@working-theory/email", () => ({
|
|
27
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
28
|
+
}));
|
|
29
|
+
jest.mock("@working-theory/license", () => ({
|
|
30
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
31
|
+
tier: "free",
|
|
32
|
+
plan: null,
|
|
33
|
+
features: [],
|
|
34
|
+
expiresAt: null,
|
|
35
|
+
revoked: false,
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
40
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
41
|
+
prisma: {
|
|
42
|
+
session: { findUnique: jest.fn() },
|
|
43
|
+
apiToken: {
|
|
44
|
+
findUnique: jest.fn(),
|
|
45
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
46
|
+
},
|
|
47
|
+
user: { findUnique: jest.fn() },
|
|
48
|
+
projectListing: { findMany: jest.fn() },
|
|
49
|
+
waitlistScore: { count: jest.fn() },
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
54
|
+
import { authPreHandlerPlugin } from "../../../middleware/auth.js";
|
|
55
|
+
import { PROJECT_LISTING_ROLES } from "../../../services/project-listings.js";
|
|
56
|
+
import { projectListingMeRoute } from "../index.js";
|
|
57
|
+
|
|
58
|
+
const FRONTEND = "https://app.example.com";
|
|
59
|
+
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
|
|
60
|
+
|
|
61
|
+
const SITE_A = "wksp_00000000-0000-7000-8000-000000000001";
|
|
62
|
+
const SITE_B = "wksp_00000000-0000-7000-8000-000000000002";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `ada` and `bruno` are TWO DIFFERENT PEOPLE who typed the SAME address into
|
|
66
|
+
* the anonymous application form. That is the whole point: `founderEmail` is
|
|
67
|
+
* not an identity, and nothing may derive ownership from it.
|
|
68
|
+
*/
|
|
69
|
+
const SHARED_EMAIL = "founder@example.com";
|
|
70
|
+
|
|
71
|
+
const USERS: Record<string, { id: string; email: string; emailVerified: boolean }> = {
|
|
72
|
+
ada: { id: "ada", email: SHARED_EMAIL, emailVerified: true },
|
|
73
|
+
bruno: { id: "bruno", email: SHARED_EMAIL, emailVerified: true },
|
|
74
|
+
// Signed in, verified, owns nothing at all.
|
|
75
|
+
cleo: { id: "cleo", email: "cleo@example.com", emailVerified: true },
|
|
76
|
+
// Signed in with an address that was never verified, and whose own listing
|
|
77
|
+
// WAS linked earlier. See the test that covers this case.
|
|
78
|
+
dane: { id: "dane", email: "dane@example.com", emailVerified: false },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const SESSION_ROWS: Record<string, Record<string, unknown>> = {
|
|
82
|
+
cookie_ada: { id: "sess_ada", userId: "ada", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
83
|
+
cookie_bruno: { id: "sess_bruno", userId: "bruno", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
84
|
+
cookie_cleo: { id: "sess_cleo", userId: "cleo", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
85
|
+
cookie_dane: { id: "sess_dane", userId: "dane", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type ListingFixture = {
|
|
89
|
+
id: string;
|
|
90
|
+
siteId: string;
|
|
91
|
+
founderEmail: string;
|
|
92
|
+
ownerUserId: string | null;
|
|
93
|
+
experimentKey: string | null;
|
|
94
|
+
createdAt: Date;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** A complete stored row at `PROJECT_LISTING_SELECT`'s shape. */
|
|
98
|
+
function row(fixture: ListingFixture) {
|
|
99
|
+
return {
|
|
100
|
+
id: fixture.id,
|
|
101
|
+
siteId: fixture.siteId,
|
|
102
|
+
leadId: `lead_${fixture.id}`,
|
|
103
|
+
slug: fixture.id.replace("prjl_", ""),
|
|
104
|
+
name: "Atlas",
|
|
105
|
+
oneLiner: "A one-liner.",
|
|
106
|
+
problem: "A problem.",
|
|
107
|
+
url: "https://atlas.example.com",
|
|
108
|
+
product: "live-some-users",
|
|
109
|
+
commitment: "full-time",
|
|
110
|
+
teamComposition: "just-me",
|
|
111
|
+
faqs: null,
|
|
112
|
+
anythingElse: null,
|
|
113
|
+
address: "1 Main St",
|
|
114
|
+
addressLine1: null,
|
|
115
|
+
addressLine2: null,
|
|
116
|
+
addressCity: null,
|
|
117
|
+
addressRegion: null,
|
|
118
|
+
addressPostalCode: null,
|
|
119
|
+
addressCountry: null,
|
|
120
|
+
questions: null,
|
|
121
|
+
siteAnswers: null,
|
|
122
|
+
windowDays: 30,
|
|
123
|
+
founderName: "A Founder",
|
|
124
|
+
founderEmail: fixture.founderEmail,
|
|
125
|
+
founderCompany: null,
|
|
126
|
+
headline: "Atlas",
|
|
127
|
+
logoMarkUrl: null,
|
|
128
|
+
logoWordmarkUrl: null,
|
|
129
|
+
featured: false,
|
|
130
|
+
status: "requested",
|
|
131
|
+
consentAt: fixture.createdAt,
|
|
132
|
+
decidedAt: null,
|
|
133
|
+
decidedBy: "operator@example.com",
|
|
134
|
+
issueUrl: "https://github.com/example/repo/issues/1",
|
|
135
|
+
experimentKey: fixture.experimentKey,
|
|
136
|
+
windowEndsAt: null,
|
|
137
|
+
createdAt: fixture.createdAt,
|
|
138
|
+
updatedAt: fixture.createdAt,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const LISTINGS: ListingFixture[] = [
|
|
143
|
+
// ada's own, linked to ada.
|
|
144
|
+
{
|
|
145
|
+
id: "prjl_ada_one",
|
|
146
|
+
siteId: SITE_A,
|
|
147
|
+
founderEmail: SHARED_EMAIL,
|
|
148
|
+
ownerUserId: "ada",
|
|
149
|
+
experimentKey: "atlas-waitlist",
|
|
150
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
151
|
+
},
|
|
152
|
+
// ada again, NEWER, and in a SECOND workspace of ada's — proves the per-site
|
|
153
|
+
// waitlistCount resolution and the newest-first ordering.
|
|
154
|
+
{
|
|
155
|
+
id: "prjl_ada_two",
|
|
156
|
+
siteId: SITE_B,
|
|
157
|
+
founderEmail: SHARED_EMAIL,
|
|
158
|
+
ownerUserId: "ada",
|
|
159
|
+
experimentKey: "atlas-two-waitlist",
|
|
160
|
+
createdAt: new Date("2026-09-05T00:00:00.000Z"),
|
|
161
|
+
},
|
|
162
|
+
// bruno's own — SAME founderEmail as ada's rows, different person.
|
|
163
|
+
{
|
|
164
|
+
id: "prjl_bruno_one",
|
|
165
|
+
siteId: SITE_A,
|
|
166
|
+
founderEmail: SHARED_EMAIL,
|
|
167
|
+
ownerUserId: "bruno",
|
|
168
|
+
experimentKey: null,
|
|
169
|
+
createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
170
|
+
},
|
|
171
|
+
// Carries the shared address and was NEVER linked. Invisible to everyone.
|
|
172
|
+
{
|
|
173
|
+
id: "prjl_unlinked",
|
|
174
|
+
siteId: SITE_A,
|
|
175
|
+
founderEmail: SHARED_EMAIL,
|
|
176
|
+
ownerUserId: null,
|
|
177
|
+
experimentKey: null,
|
|
178
|
+
createdAt: new Date("2026-09-04T00:00:00.000Z"),
|
|
179
|
+
},
|
|
180
|
+
// dane's own, linked while dane's address was still verified.
|
|
181
|
+
{
|
|
182
|
+
id: "prjl_dane_one",
|
|
183
|
+
siteId: SITE_A,
|
|
184
|
+
founderEmail: "dane@example.com",
|
|
185
|
+
ownerUserId: "dane",
|
|
186
|
+
experimentKey: null,
|
|
187
|
+
createdAt: new Date("2026-09-02T00:00:00.000Z"),
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
type ListingWhere = Record<string, unknown> & { ownerUserId?: unknown };
|
|
192
|
+
|
|
193
|
+
const WAITLIST_COUNTS: Record<string, Record<string, number>> = {
|
|
194
|
+
[SITE_A]: { "atlas-waitlist": 7 },
|
|
195
|
+
[SITE_B]: { "atlas-two-waitlist": 3 },
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function installFixtureMocks(): void {
|
|
199
|
+
(prisma.session.findUnique as jest.Mock).mockImplementation(
|
|
200
|
+
({ where }: { where: { token?: string } }) =>
|
|
201
|
+
Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
|
|
202
|
+
);
|
|
203
|
+
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
|
|
204
|
+
(prisma.user.findUnique as jest.Mock).mockImplementation(({ where }: { where: { id: string } }) =>
|
|
205
|
+
Promise.resolve(USERS[where.id] ?? null),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The owner link is the ONLY join the service may use, and this mock is
|
|
210
|
+
* written to FAIL on a regression rather than to absorb one.
|
|
211
|
+
*
|
|
212
|
+
* Two earlier, weaker forms were rejected during review, both of which let a
|
|
213
|
+
* regression keep the security tests green:
|
|
214
|
+
*
|
|
215
|
+
* - `expect(where).not.toHaveProperty("founderEmail")` inspects TOP-LEVEL
|
|
216
|
+
* keys only, so an `OR`-wrapped email fallback
|
|
217
|
+
* (`{ OR: [{ ownerUserId }, { founderEmail }] }`) sails straight past it —
|
|
218
|
+
* and a mock that then filtered on `where.ownerUserId` returns `[]` for
|
|
219
|
+
* that shape, which looks like "nothing leaked".
|
|
220
|
+
* - Filtering on `where.ownerUserId` when it is `undefined` also returns
|
|
221
|
+
* `[]`, while Prisma compiles an `undefined` filter to no filter at all
|
|
222
|
+
* (`WHERE 1=1`) and the REAL read would be unscoped.
|
|
223
|
+
*
|
|
224
|
+
* So: any key other than `ownerUserId` THROWS, an `ownerUserId` that is not a
|
|
225
|
+
* string THROWS, and the filter is exact equality on it. A widened read now
|
|
226
|
+
* fails as a thrown error rather than as a quietly empty result.
|
|
227
|
+
*/
|
|
228
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
229
|
+
({ where }: { where: ListingWhere }) => {
|
|
230
|
+
const keys = Object.keys(where);
|
|
231
|
+
if (keys.length !== 1 || keys[0] !== "ownerUserId") {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`the /me read must scope on ownerUserId and nothing else; got where keys [${keys.join(", ")}]`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (typeof where.ownerUserId !== "string") {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"ownerUserId must be a concrete string; Prisma compiles an undefined filter to no filter, leaving the read unscoped",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const ownerUserId = where.ownerUserId;
|
|
242
|
+
return Promise.resolve(
|
|
243
|
+
LISTINGS.filter((l) => l.ownerUserId === ownerUserId)
|
|
244
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
245
|
+
.map(row),
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
(prisma.waitlistScore.count as jest.Mock).mockImplementation(
|
|
251
|
+
({ where }: { where: { siteId: string; experimentKey: string } }) =>
|
|
252
|
+
Promise.resolve(WAITLIST_COUNTS[where.siteId]?.[where.experimentKey] ?? 0),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
257
|
+
const app = Fastify();
|
|
258
|
+
await app.register(async (authScope) => {
|
|
259
|
+
await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
|
|
260
|
+
await authScope.register(projectListingMeRoute);
|
|
261
|
+
});
|
|
262
|
+
await app.ready();
|
|
263
|
+
return app;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const cookie = (token: string) => ({ cookie: `better-auth.session_token=${token}` });
|
|
267
|
+
const URL_ME = "/v1/project-listings/me";
|
|
268
|
+
|
|
269
|
+
const listingWhere = (call = 0) =>
|
|
270
|
+
(prisma.projectListing.findMany as jest.Mock).mock.calls[call][0].where as ListingWhere;
|
|
271
|
+
|
|
272
|
+
let app: FastifyInstance;
|
|
273
|
+
|
|
274
|
+
beforeEach(async () => {
|
|
275
|
+
jest.clearAllMocks();
|
|
276
|
+
installFixtureMocks();
|
|
277
|
+
app = await buildApp();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
afterEach(async () => {
|
|
281
|
+
await app.close();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("GET /v1/project-listings/me — auth", () => {
|
|
285
|
+
it("no session cookie and no bearer → the preHandler's existing 401; no listing read", async () => {
|
|
286
|
+
const res = await app.inject({ method: "GET", url: URL_ME });
|
|
287
|
+
|
|
288
|
+
expect(res.statusCode).toBe(401);
|
|
289
|
+
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("an expired / unknown cookie → 401", async () => {
|
|
293
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_nobody") });
|
|
294
|
+
|
|
295
|
+
expect(res.statusCode).toBe(401);
|
|
296
|
+
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("GET /v1/project-listings/me — the owner link is the only join", () => {
|
|
301
|
+
/** SECURITY PROPERTY: two users sharing a founderEmail value. */
|
|
302
|
+
it("two users sharing a founderEmail never see each other's listings", async () => {
|
|
303
|
+
const adaRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
304
|
+
const brunoRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
|
|
305
|
+
|
|
306
|
+
expect(adaRes.statusCode).toBe(200);
|
|
307
|
+
expect(brunoRes.statusCode).toBe(200);
|
|
308
|
+
|
|
309
|
+
const adaIds = (adaRes.json().data as Array<{ id: string }>).map((r) => r.id);
|
|
310
|
+
const brunoIds = (brunoRes.json().data as Array<{ id: string }>).map((r) => r.id);
|
|
311
|
+
|
|
312
|
+
// ada sees her two rows, newest first, and NOT bruno's.
|
|
313
|
+
expect(adaIds).toEqual(["prjl_ada_two", "prjl_ada_one"]);
|
|
314
|
+
// bruno sees his one row, and NOT ada's — though every row in play carries
|
|
315
|
+
// the identical founderEmail.
|
|
316
|
+
expect(brunoIds).toEqual(["prjl_bruno_one"]);
|
|
317
|
+
|
|
318
|
+
expect(adaIds).not.toContain("prjl_bruno_one");
|
|
319
|
+
expect(brunoIds).not.toContain("prjl_ada_one");
|
|
320
|
+
expect(brunoIds).not.toContain("prjl_ada_two");
|
|
321
|
+
// And neither of them reaches the un-linked row carrying the same address.
|
|
322
|
+
expect([...adaIds, ...brunoIds]).not.toContain("prjl_unlinked");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/** SECURITY PROPERTY: no read-time email fallback. */
|
|
326
|
+
it("a listing carrying the caller's own address but NO owner link is never returned", async () => {
|
|
327
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
328
|
+
|
|
329
|
+
const ids = (res.json().data as Array<{ id: string }>).map((r) => r.id);
|
|
330
|
+
expect(ids).not.toContain("prjl_unlinked");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("the where clause is the owner link and carries nothing email-shaped", async () => {
|
|
334
|
+
await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
335
|
+
|
|
336
|
+
expect(listingWhere()).toEqual({ ownerUserId: "ada" });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("a query parameter is never read — ?email= and ?ownerUserId= change nothing", async () => {
|
|
340
|
+
const res = await app.inject({
|
|
341
|
+
method: "GET",
|
|
342
|
+
url: `${URL_ME}?email=${encodeURIComponent(SHARED_EMAIL)}&ownerUserId=bruno`,
|
|
343
|
+
headers: cookie("cookie_ada"),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(res.statusCode).toBe(200);
|
|
347
|
+
// Still ada's own rows, scoped by the SESSION's user id.
|
|
348
|
+
expect(listingWhere()).toEqual({ ownerUserId: "ada" });
|
|
349
|
+
expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
|
|
350
|
+
"prjl_ada_two",
|
|
351
|
+
"prjl_ada_one",
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("GET /v1/project-listings/me — the empty case is not a refusal", () => {
|
|
357
|
+
/** SECURITY / UX PROPERTY: owning nothing is an empty list, never a 403. */
|
|
358
|
+
it("a signed-in caller who owns no listing gets 200 with an empty list, not a 403", async () => {
|
|
359
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
|
|
360
|
+
|
|
361
|
+
expect(res.statusCode).toBe(200);
|
|
362
|
+
expect(res.json()).toEqual({ ok: true, data: [] });
|
|
363
|
+
// Specifically NOT the tenant routes' 403 — cleo is legitimately signed in.
|
|
364
|
+
expect(res.statusCode).not.toBe(403);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("no waitlist count is read when the caller owns nothing", async () => {
|
|
368
|
+
await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
|
|
369
|
+
|
|
370
|
+
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe("GET /v1/project-listings/me — the response shape", () => {
|
|
375
|
+
it("every row parses under projectListingMeResponseSchema", async () => {
|
|
376
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
377
|
+
|
|
378
|
+
expect(res.statusCode).toBe(200);
|
|
379
|
+
const parsed = projectListingMeResponseSchema.safeParse(res.json().data);
|
|
380
|
+
expect(parsed.success).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("the five internal references never reach the founder", async () => {
|
|
384
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
385
|
+
|
|
386
|
+
for (const listing of res.json().data as Array<Record<string, unknown>>) {
|
|
387
|
+
expect(listing).not.toHaveProperty("siteId");
|
|
388
|
+
expect(listing).not.toHaveProperty("leadId");
|
|
389
|
+
expect(listing).not.toHaveProperty("decidedBy");
|
|
390
|
+
expect(listing).not.toHaveProperty("issueUrl");
|
|
391
|
+
expect(listing).not.toHaveProperty("experimentKey");
|
|
392
|
+
}
|
|
393
|
+
// No workspace id reaches the wire anywhere in the body.
|
|
394
|
+
expect(res.body).not.toContain(SITE_A);
|
|
395
|
+
expect(res.body).not.toContain(SITE_B);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("waitlistCount is resolved per site, not all under one site id", async () => {
|
|
399
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
400
|
+
|
|
401
|
+
const byId = Object.fromEntries(
|
|
402
|
+
(res.json().data as Array<{ id: string; waitlistCount: number }>).map((r) => [
|
|
403
|
+
r.id,
|
|
404
|
+
r.waitlistCount,
|
|
405
|
+
]),
|
|
406
|
+
);
|
|
407
|
+
// prjl_ada_one's experiment lives at SITE_A (7); prjl_ada_two's at SITE_B (3).
|
|
408
|
+
// One shared site id would have reported 0 for one of them.
|
|
409
|
+
expect(byId["prjl_ada_one"]).toBe(7);
|
|
410
|
+
expect(byId["prjl_ada_two"]).toBe(3);
|
|
411
|
+
expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
|
|
412
|
+
where: { siteId: SITE_A, experimentKey: "atlas-waitlist" },
|
|
413
|
+
});
|
|
414
|
+
expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
|
|
415
|
+
where: { siteId: SITE_B, experimentKey: "atlas-two-waitlist" },
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("the founder's own email is echoed back to the founder who wrote it", async () => {
|
|
420
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
|
|
421
|
+
|
|
422
|
+
expect((res.json().data as Array<{ founderEmail: string }>)[0]?.founderEmail).toBe(
|
|
423
|
+
SHARED_EMAIL,
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("GET /v1/project-listings/me — the read-time verification posture", () => {
|
|
429
|
+
/**
|
|
430
|
+
* A caller whose address is no longer verified still reads the rows their
|
|
431
|
+
* link already covers. This is deliberate, and it is the one place this route
|
|
432
|
+
* diverges from `/v1/flow-checkouts/me`, which re-checks `emailVerified`.
|
|
433
|
+
*
|
|
434
|
+
* The re-check is load-bearing on `/v1/waitlist-signups/me`, where the match
|
|
435
|
+
* IS an email, and it is defence in depth on `/v1/flow-checkouts/me`. Here it
|
|
436
|
+
* would be neither: the link is the proof, it was only ever issuable under
|
|
437
|
+
* verification, and refusing on a later unverified address would hide rows
|
|
438
|
+
* the caller already proved they own (changing your email address is not a
|
|
439
|
+
* reason to lose your own submissions). An unverified address still cannot
|
|
440
|
+
* CREATE a link — `listing-promotion.ts` refuses before any listing read.
|
|
441
|
+
*/
|
|
442
|
+
it("a caller whose address is no longer verified still reads the rows their link already covers", async () => {
|
|
443
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_dane") });
|
|
444
|
+
|
|
445
|
+
expect(res.statusCode).toBe(200);
|
|
446
|
+
expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
|
|
447
|
+
"prjl_dane_one",
|
|
448
|
+
]);
|
|
449
|
+
expect(listingWhere()).toEqual({ ownerUserId: "dane" });
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe("the existing operator role floor is untouched", () => {
|
|
454
|
+
it("PROJECT_LISTING_ROLES is still exactly owner + admin", () => {
|
|
455
|
+
expect([...PROJECT_LISTING_ROLES].sort()).toEqual(["admin", "owner"]);
|
|
456
|
+
expect(PROJECT_LISTING_ROLES.size).toBe(2);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("the /me route never consults the tenant context or the role floor", async () => {
|
|
460
|
+
// The app this suite builds registers NO tenant preHandler at all, so
|
|
461
|
+
// `request.tenant` is undefined for every request above. That every test in
|
|
462
|
+
// this file answers 200 is the proof: a route that consulted the floor
|
|
463
|
+
// would have answered 401/403 instead.
|
|
464
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
465
|
+
|
|
466
|
+
expect(res.statusCode).toBe(200);
|
|
467
|
+
});
|
|
468
|
+
});
|