Skip to content

Commit 3c0d51c

Browse files
authored
fix(auth): set up new user defaults in betterAuth hook (#3806)
1 parent d6eb259 commit 3c0d51c

3 files changed

Lines changed: 461 additions & 48 deletions

File tree

__tests__/routes/betterAuth.ts

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,57 @@
11
import request from 'supertest';
22
import { FastifyInstance } from 'fastify';
33
import { DataSource } from 'typeorm';
4+
import type { Pool } from 'pg';
45
import createOrGetConnection from '../../src/db';
56
import { saveFixtures } from '../helpers';
67
import { User } from '../../src/entity/user/User';
8+
import { DeletedUser } from '../../src/entity/user/DeletedUser';
9+
import { Source } from '../../src/entity/Source';
10+
import { sourcesFixture } from '../fixture/source';
11+
import { Feed } from '../../src/entity/Feed';
12+
import { UserAction, UserActionType } from '../../src/entity/user/UserAction';
13+
import { DigestPost } from '../../src/entity/posts/DigestPost';
14+
import { DIGEST_SOURCE } from '../../src/entity/Source';
15+
import {
16+
ClaimableItem,
17+
ClaimableItemTypes,
18+
} from '../../src/entity/ClaimableItem';
19+
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
20+
import { OpportunityUser } from '../../src/entity/opportunities/user';
21+
import { OpportunityUserType } from '../../src/entity/opportunities/types';
22+
import { OpportunityState } from '@dailydotdev/schema';
23+
import {
24+
DEFAULT_NOTIFICATION_SETTINGS,
25+
NotificationPreferenceStatus,
26+
NotificationType,
27+
} from '../../src/notifications/common';
28+
import { generateShortId } from '../../src/ids';
729
import { usersFixture } from '../fixture';
830
import { ioRedisPool } from '../../src/redis';
931
import * as betterAuthModule from '../../src/betterAuth';
1032

33+
jest.mock('../../src/common/paddle/index.ts', () => ({
34+
...(jest.requireActual('../../src/common/paddle/index.ts') as Record<
35+
string,
36+
unknown
37+
>),
38+
paddleInstance: {
39+
subscriptions: {
40+
update: jest.fn().mockResolvedValue({}),
41+
},
42+
},
43+
}));
44+
45+
jest.mock('../../src/cio', () => ({
46+
...(jest.requireActual('../../src/cio') as Record<string, unknown>),
47+
identifyAnonymousFunnelSubscription: jest.fn().mockResolvedValue(undefined),
48+
}));
49+
50+
jest.mock('better-auth/api', () => ({
51+
...(jest.requireActual('better-auth/api') as Record<string, unknown>),
52+
getOAuthState: jest.fn().mockResolvedValue(null),
53+
}));
54+
1155
let app: FastifyInstance;
1256
let con: DataSource;
1357

@@ -36,6 +80,7 @@ afterAll(async () => {
3680
beforeEach(async () => {
3781
jest.clearAllMocks();
3882
await ioRedisPool.execute((client) => client.flushall());
83+
await saveFixtures(con, Source, sourcesFixture);
3984
await saveFixtures(con, User, usersFixture);
4085
});
4186

@@ -69,6 +114,261 @@ describe('betterAuth routes', () => {
69114
});
70115
});
71116

117+
describe('user.create.before hook', () => {
118+
const getBeforeHook = async () => {
119+
const { getBetterAuthOptions } = await import('../../src/betterAuth');
120+
const options = getBetterAuthOptions(
121+
(con.driver as unknown as { master: Pool }).master,
122+
);
123+
const before = options.databaseHooks?.user?.create?.before;
124+
if (!before) {
125+
throw new Error('before hook not configured');
126+
}
127+
return before;
128+
};
129+
130+
it('should regenerate id when tracking cookie matches a deleted user', async () => {
131+
const before = await getBeforeHook();
132+
const deletedUserId = 'aBcDeFgHiJkLmNoPqRsTu';
133+
await con.getRepository(DeletedUser).save({ id: deletedUserId });
134+
135+
const result = await before(
136+
{
137+
email: 'new-signup@example.com',
138+
name: 'New Signup',
139+
emailVerified: false,
140+
createdAt: new Date(),
141+
updatedAt: new Date(),
142+
},
143+
{
144+
request: new Request('http://localhost/auth/sign-up/email', {
145+
headers: { cookie: `da2=${deletedUserId}` },
146+
}),
147+
body: {},
148+
},
149+
);
150+
151+
const assignedId = (result as { data: { id: string } }).data.id;
152+
expect(assignedId).not.toEqual(deletedUserId);
153+
154+
const stillDeleted = await con
155+
.getRepository(DeletedUser)
156+
.findOneBy({ id: deletedUserId });
157+
expect(stillDeleted).not.toBeNull();
158+
});
159+
});
160+
161+
describe('user.create.after hook', () => {
162+
const getAfterHook = async () => {
163+
const { getBetterAuthOptions } = await import('../../src/betterAuth');
164+
const options = getBetterAuthOptions(
165+
(con.driver as unknown as { master: Pool }).master,
166+
);
167+
const after = options.databaseHooks?.user?.create?.after;
168+
if (!after) {
169+
throw new Error('after hook not configured');
170+
}
171+
return after;
172+
};
173+
174+
const makeContext = (
175+
overrides: {
176+
body?: Record<string, unknown>;
177+
cookie?: string;
178+
ip?: string;
179+
} = {},
180+
) => {
181+
const headers: Record<string, string> = {};
182+
if (overrides.cookie) {
183+
headers.cookie = overrides.cookie;
184+
}
185+
if (overrides.ip) {
186+
headers['x-forwarded-for'] = overrides.ip;
187+
}
188+
return {
189+
request: new Request('http://localhost/auth/sign-up/email', {
190+
headers: Object.keys(headers).length ? headers : undefined,
191+
}),
192+
body: overrides.body,
193+
};
194+
};
195+
196+
const createBaseUser = async () => {
197+
const id = await generateShortId();
198+
const email = `hook-${id}@test.com`;
199+
await con.getRepository(User).save({
200+
id,
201+
name: 'Hook User',
202+
email,
203+
username: `u_${id}`,
204+
image: 'https://daily.dev/fake.jpg',
205+
createdAt: new Date(),
206+
});
207+
return { id, email };
208+
};
209+
210+
it('should add feed with id equal to user id', async () => {
211+
const after = await getAfterHook();
212+
const user = await createBaseUser();
213+
214+
await after(user, makeContext());
215+
216+
const feed = await con.getRepository(Feed).findOneBy({ id: user.id });
217+
expect(feed).not.toBeNull();
218+
expect(feed!.id).toEqual(user.id);
219+
expect(feed!.userId).toEqual(user.id);
220+
});
221+
222+
it('should create a DigestPost stub', async () => {
223+
const after = await getAfterHook();
224+
const user = await createBaseUser();
225+
226+
await after(user, makeContext());
227+
228+
const digestPost = await con
229+
.getRepository(DigestPost)
230+
.findOneBy({ authorId: user.id });
231+
expect(digestPost).not.toBeNull();
232+
expect(digestPost!.sourceId).toBe(DIGEST_SOURCE);
233+
expect(digestPost!.visible).toBeFalsy();
234+
expect(digestPost!.private).toBeTruthy();
235+
});
236+
237+
it('should set UserAction for cores role when ip is present', async () => {
238+
const after = await getAfterHook();
239+
const user = await createBaseUser();
240+
241+
await after(user, makeContext({ ip: '1.2.3.4' }));
242+
243+
const userAction = await con.getRepository(UserAction).findOneBy({
244+
userId: user.id,
245+
type: UserActionType.CheckedCoresRole,
246+
});
247+
expect(userAction).not.toBeNull();
248+
});
249+
250+
it('should mute marketing notifications when acceptedMarketing is false', async () => {
251+
const after = await getAfterHook();
252+
const user = await createBaseUser();
253+
254+
await after(user, makeContext({ body: { acceptedMarketing: false } }));
255+
256+
const persisted = await con
257+
.getRepository(User)
258+
.findOneBy({ id: user.id });
259+
expect(
260+
persisted!.notificationFlags?.[NotificationType.Marketing],
261+
).toEqual({
262+
email: NotificationPreferenceStatus.Muted,
263+
inApp: NotificationPreferenceStatus.Muted,
264+
});
265+
});
266+
267+
it('should use default notification flags when acceptedMarketing is true', async () => {
268+
const after = await getAfterHook();
269+
const user = await createBaseUser();
270+
271+
await after(user, makeContext({ body: { acceptedMarketing: true } }));
272+
273+
const persisted = await con
274+
.getRepository(User)
275+
.findOneBy({ id: user.id });
276+
expect(persisted!.notificationFlags).toEqual(
277+
DEFAULT_NOTIFICATION_SETTINGS,
278+
);
279+
});
280+
281+
it('should claim opportunities that user created as anonymous', async () => {
282+
const after = await getAfterHook();
283+
const user = await createBaseUser();
284+
285+
const opportunity = await con.getRepository(OpportunityJob).save(
286+
con.getRepository(OpportunityJob).create({
287+
title: 'Test',
288+
tldr: 'Test',
289+
state: OpportunityState.DRAFT,
290+
}),
291+
);
292+
293+
await con.getRepository(ClaimableItem).save({
294+
identifier: user.id,
295+
type: ClaimableItemTypes.Opportunity,
296+
flags: {
297+
opportunityId: opportunity.id,
298+
},
299+
});
300+
301+
await after(user, makeContext());
302+
303+
const updatedClaimableItem = await con
304+
.getRepository(ClaimableItem)
305+
.findOneBy({
306+
identifier: user.id,
307+
type: ClaimableItemTypes.Opportunity,
308+
});
309+
expect(updatedClaimableItem).not.toBeNull();
310+
expect(updatedClaimableItem!.claimedAt).toBeInstanceOf(Date);
311+
expect(updatedClaimableItem!.claimedById).toBe(user.id);
312+
313+
const opportunityUser = await con
314+
.getRepository(OpportunityUser)
315+
.findOneBy({
316+
opportunityId: opportunity.id,
317+
userId: user.id,
318+
});
319+
expect(opportunityUser).toEqual({
320+
opportunityId: opportunity.id,
321+
userId: user.id,
322+
type: OpportunityUserType.Recruiter,
323+
});
324+
});
325+
326+
it('should claim opportunities that user created with email', async () => {
327+
const after = await getAfterHook();
328+
const user = await createBaseUser();
329+
330+
const opportunity = await con.getRepository(OpportunityJob).save(
331+
con.getRepository(OpportunityJob).create({
332+
title: 'Test',
333+
tldr: 'Test',
334+
state: OpportunityState.DRAFT,
335+
}),
336+
);
337+
338+
await con.getRepository(ClaimableItem).save({
339+
identifier: user.email,
340+
type: ClaimableItemTypes.Opportunity,
341+
flags: {
342+
opportunityId: opportunity.id,
343+
},
344+
});
345+
346+
await after(user, makeContext());
347+
348+
const updatedClaimableItem = await con
349+
.getRepository(ClaimableItem)
350+
.findOneBy({
351+
identifier: user.email,
352+
type: ClaimableItemTypes.Opportunity,
353+
});
354+
expect(updatedClaimableItem).not.toBeNull();
355+
expect(updatedClaimableItem!.claimedAt).toBeInstanceOf(Date);
356+
expect(updatedClaimableItem!.claimedById).toBe(user.id);
357+
358+
const opportunityUser = await con
359+
.getRepository(OpportunityUser)
360+
.findOneBy({
361+
opportunityId: opportunity.id,
362+
userId: user.id,
363+
});
364+
expect(opportunityUser).toEqual({
365+
opportunityId: opportunity.id,
366+
userId: user.id,
367+
type: OpportunityUserType.Recruiter,
368+
});
369+
});
370+
});
371+
72372
it('should forward native callback routes to BetterAuth handler', async () => {
73373
const getBetterAuthSpy = jest
74374
.spyOn(betterAuthModule, 'getBetterAuth')

0 commit comments

Comments
 (0)