@proteinjs/user-server 1.20.1 → 1.21.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.
@@ -0,0 +1,163 @@
1
+ import moment from 'moment';
2
+
3
+ /**
4
+ * Re-sending an invite (`SignupService.resendInvite`), against the emulator — outcomes on the row
5
+ * and on the transport fake, never call counts alone:
6
+ *
7
+ * - the token in the earlier email stops resolving, the fresh one resolves to the SAME invite;
8
+ * - the row count for the address stays one, its expiry re-stamped from the TTL knob, its inviter kept;
9
+ * - exactly one email goes out for the re-send, to the address, carrying the fresh signup link;
10
+ * - an address with no standing invite is refused: nothing written, nothing sent;
11
+ * - an address that already has an account is refused the same way (the invite was used).
12
+ *
13
+ * The email transport is stubbed at the module boundary (the SignupInvite.test.ts pattern) — SMTP
14
+ * is an external transport; the config fake echoes the signup path so the link can be asserted.
15
+ * The service door (`serviceMetadata.auth`) is pinned here too: re-send rides the 'users' permission
16
+ * exactly like send and revoke — the earlier gap that left invite minting public must not reopen.
17
+ */
18
+
19
+ const sendEmail = jest.fn();
20
+ jest.mock('@proteinjs/email-server', () => ({
21
+ EmailSender: jest.fn().mockImplementation(() => ({ sendEmail })),
22
+ getDefaultInviteEmailConfigFactory: () => ({
23
+ getConfig: () => ({
24
+ options: { subject: 'Your invite' },
25
+ getEmailContent: (signupPathWithToken: string) => ({
26
+ text: `Accept: /${signupPathWithToken}`,
27
+ html: `<a href="/${signupPathWithToken}">Accept</a>`,
28
+ }),
29
+ }),
30
+ }),
31
+ getDefaultSignupConfirmationEmailConfigFactory: () => ({
32
+ getConfig: () => ({ getNewUserEmailContent: () => ({ text: 'welcome', html: '<p>welcome</p>' }) }),
33
+ }),
34
+ }));
35
+
36
+ import { getDbAsSystem } from '@proteinjs/db';
37
+ import { SourceRepository } from '@proteinjs/reflection';
38
+ import { tables, UserAuth, USER_PERMISSIONS, type Invite, type User } from '@proteinjs/user';
39
+ import { INVITE_TOKEN_TTL_DAYS, Signup } from '../src/services/Signup';
40
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
41
+
42
+ const TIMEOUT = 60_000;
43
+
44
+ /** Tolerance for the clock advancing between the stamp and the assertion. */
45
+ const CLOCK_SKEW_TOLERANCE_SECONDS = 60;
46
+
47
+ const inviteRows = async (email: string): Promise<Invite[]> =>
48
+ (await getDbAsSystem().query(tables.Invite, { email })) as Invite[];
49
+
50
+ /** The re-send's own transport call: the one addressed to `email` after the earlier `before` calls. */
51
+ const emailsTo = (email: string) =>
52
+ sendEmail.mock.calls.map(([message]) => message).filter((message) => message.to === email);
53
+
54
+ describe('SignupService.resendInvite — against the emulator', () => {
55
+ const env = new UserServerTestEnvironment();
56
+ let inviter: User;
57
+
58
+ beforeAll(async () => {
59
+ await env.beforeAll();
60
+ // The invite-mode factory the token lookup consults (`initializeSignup`) — seeded like the
61
+ // environment seeds its own loadables (the suites load src, never the generated source
62
+ // graph, so an unseeded lookup has no graph to walk); invite-optional, the library default.
63
+ (SourceRepository.get() as unknown as { objectCache: Record<string, unknown[]> }).objectCache[
64
+ '@proteinjs/user-server/DefaultInviteConfigFactory'
65
+ ] = [{ getConfig: () => ({ isInviteOnly: false }) }];
66
+ inviter = await env.createUser({ name: 'Inviter', email: 'inviter@example.com', roles: ['admin'] });
67
+ env.actAs(inviter);
68
+ }, TIMEOUT);
69
+
70
+ afterAll(async () => {
71
+ await env.afterAll();
72
+ }, TIMEOUT);
73
+
74
+ beforeEach(() => {
75
+ sendEmail.mockClear();
76
+ });
77
+
78
+ it(
79
+ 'retires the earlier token, mints a fresh one on the SAME row, and emails the fresh link exactly once',
80
+ async () => {
81
+ const email = 'guest@example.com';
82
+ const sent = await new Signup().sendInvite(email);
83
+ expect(sent).toEqual({ sent: true });
84
+ const [standing] = await inviteRows(email);
85
+ const earlierToken = standing.token as string;
86
+ const earlierExpiry = moment(standing.tokenExpiresAt);
87
+ expect(emailsTo(email)).toHaveLength(1);
88
+
89
+ const resent = await new Signup().resendInvite('Guest@Example.com');
90
+ expect(resent).toEqual({ sent: true });
91
+
92
+ // One row — the same record, a fresh token, the inviter kept.
93
+ const rows = await inviteRows(email);
94
+ expect(rows).toHaveLength(1);
95
+ const [refreshed] = rows;
96
+ expect(refreshed.id).toBe(standing.id);
97
+ expect(refreshed.token).not.toBe(earlierToken);
98
+ expect(refreshed.invitedBy?._id).toBe(inviter.id);
99
+ // The expiry is re-stamped from the knob (fresh window), never left at the earlier stamp.
100
+ expect(moment(refreshed.tokenExpiresAt).isSameOrAfter(earlierExpiry)).toBe(true);
101
+ expect(
102
+ Math.abs(moment(refreshed.tokenExpiresAt).diff(moment().add(INVITE_TOKEN_TTL_DAYS, 'days'), 'seconds'))
103
+ ).toBeLessThan(CLOCK_SKEW_TOLERANCE_SECONDS);
104
+
105
+ // The earlier link is dead; the fresh one opens signup for this invite.
106
+ const earlier = await new Signup().initializeSignup(earlierToken);
107
+ expect(earlier.isReady).toBe(false);
108
+ expect(earlier.error).toMatch(/no longer valid/i);
109
+ const fresh = await new Signup().initializeSignup(refreshed.token as string);
110
+ expect(fresh.isReady).toBe(true);
111
+ expect(fresh.invite?.email).toBe(email);
112
+
113
+ // Exactly one more email, to the address, carrying the fresh token and none of the earlier one.
114
+ const messages = emailsTo(email);
115
+ expect(messages).toHaveLength(2);
116
+ const resend = messages[1];
117
+ expect(resend.subject).toBe('Your invite');
118
+ expect(resend.text).toContain(`signup?token=${refreshed.token}`);
119
+ expect(resend.html).toContain(`signup?token=${refreshed.token}`);
120
+ expect(resend.text).not.toContain(earlierToken);
121
+ },
122
+ TIMEOUT
123
+ );
124
+
125
+ it(
126
+ 'refuses an address with no standing invite — nothing written, nothing sent',
127
+ async () => {
128
+ const email = 'nobody@example.com';
129
+ const response = await new Signup().resendInvite(email);
130
+ expect(response.sent).toBe(false);
131
+ expect(response.error).toMatch(/no invite/i);
132
+ expect(await inviteRows(email)).toHaveLength(0);
133
+ expect(emailsTo(email)).toHaveLength(0);
134
+ },
135
+ TIMEOUT
136
+ );
137
+
138
+ it(
139
+ 'refuses an address that already has an account — the invite was used',
140
+ async () => {
141
+ const email = 'member@example.com';
142
+ await env.createUser({ name: 'Member', email });
143
+ const response = await new Signup().resendInvite(email);
144
+ expect(response.sent).toBe(false);
145
+ expect(response.error).toMatch(/already exists/i);
146
+ expect(emailsTo(email)).toHaveLength(0);
147
+ },
148
+ TIMEOUT
149
+ );
150
+
151
+ it('rides the users-permission door like send and revoke (never public)', () => {
152
+ const hasPermission = jest.spyOn(UserAuth, 'hasPermission');
153
+ try {
154
+ hasPermission.mockReturnValue(false);
155
+ expect(new Signup().serviceMetadata.auth.canAccess('resendInvite', ['x@example.com'])).toBe(false);
156
+ hasPermission.mockReturnValue(true);
157
+ expect(new Signup().serviceMetadata.auth.canAccess('resendInvite', ['x@example.com'])).toBe(true);
158
+ expect(hasPermission).toHaveBeenCalledWith(USER_PERMISSIONS.users);
159
+ } finally {
160
+ hasPermission.mockRestore();
161
+ }
162
+ });
163
+ });