@proteinjs/user-server 1.22.3 → 1.22.4

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,230 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import { MailSink, MailSinkRecord } from '@proteinjs/email-server';
4
+ import { SourceRepository } from '@proteinjs/reflection';
5
+ import { tables } from '@proteinjs/user';
6
+ import { PasswordHasher } from '../src/authentication/PasswordHasher';
7
+ import { signup } from '../src/routes/signup';
8
+ import { Signup } from '../src/services/Signup';
9
+ import { RequestDigests } from '../src/throttle/RequestDigests';
10
+ import { LogCapture } from './LogCapture';
11
+ import { createPassportRequest } from './passportSessionHarness';
12
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
13
+
14
+ /**
15
+ * Two sign-ups for one address at the same moment. The signup door has one rule for an address
16
+ * that already has an account: the caller gets the response a new account gets, byte for byte, no
17
+ * session is minted, the address's owner is told by mail, and no log line names the address. A
18
+ * race used to break it: both requests passed the existence check before either wrote, the unique
19
+ * index on the address refused the second insert, and the database's sentence — which names the
20
+ * address — came back as the loser's response and its log line.
21
+ *
22
+ * Driven end to end against the Spanner emulator: the real route, the real passport session
23
+ * machinery, the real mail sink (`EMAIL_TRANSPORT=sink`), the log captured as the default writer
24
+ * emits it. Each create's password hash is held until both creates have arrived, so both have
25
+ * passed the existence check and both inserts race.
26
+ */
27
+
28
+ const testEnv = new UserServerTestEnvironment();
29
+
30
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
31
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
32
+
33
+ const CONFIG_KEYS = [
34
+ '@proteinjs/email-server/DefaultEmailConfigFactory',
35
+ '@proteinjs/email-server/DefaultSignupConfirmationEmailConfigFactory',
36
+ '@proteinjs/user-server/DefaultInviteConfigFactory',
37
+ ];
38
+
39
+ const WELCOME_SUBJECT = 'Welcome';
40
+ const EXISTS_SUBJECT = 'Account already exists';
41
+
42
+ type SignupOutcome = {
43
+ status: number;
44
+ /** The body as the wire carries it (express serializes an object body as JSON). */
45
+ bytes: string;
46
+ loggedInAs?: string;
47
+ sessionEvents: string[];
48
+ };
49
+
50
+ /** One sign-up through the route, the session read back off the request as passport left it. */
51
+ const signUp = async (body: Record<string, unknown>): Promise<SignupOutcome> => {
52
+ const { request, events } = await createPassportRequest({ body });
53
+ const outcome: SignupOutcome = { status: 200, bytes: '', sessionEvents: events };
54
+ const response = {
55
+ status(code: number) {
56
+ outcome.status = code;
57
+ return this;
58
+ },
59
+ send(sent?: unknown) {
60
+ outcome.bytes = JSON.stringify(sent);
61
+ },
62
+ };
63
+ await signup.onRequest(request as never, response as never);
64
+ outcome.loggedInAs = request.session.passport?.user;
65
+ return outcome;
66
+ };
67
+
68
+ /**
69
+ * Runs `run` with every account creation's password hash held until `count` hashes have started —
70
+ * each create has passed the existence check by then, so their inserts race.
71
+ */
72
+ const withCreatesHeldTogether = async <T>(count: number, run: () => Promise<T>): Promise<T> => {
73
+ const hash = PasswordHasher.prototype.hash;
74
+ let arrived = 0;
75
+ let release!: () => void;
76
+ const allArrived = new Promise<void>((resolve) => (release = resolve));
77
+ const held = jest.spyOn(PasswordHasher.prototype, 'hash').mockImplementation(async function (
78
+ this: PasswordHasher,
79
+ password: string
80
+ ) {
81
+ const hashed = await hash.call(this, password);
82
+ if (++arrived === count) {
83
+ release();
84
+ }
85
+ await allArrived;
86
+ return hashed;
87
+ });
88
+ try {
89
+ return await run();
90
+ } finally {
91
+ held.mockRestore();
92
+ }
93
+ };
94
+
95
+ /** The mail the sink holds for `address`, oldest first. */
96
+ const mailTo = (address: string): MailSinkRecord[] =>
97
+ MailSink.get()
98
+ .list()
99
+ .filter((record) => record.to.includes(address))
100
+ .reverse();
101
+
102
+ describe('two sign-ups for one address at once', () => {
103
+ let originalTransport: string | undefined;
104
+
105
+ beforeAll(async () => {
106
+ await testEnv.beforeAll();
107
+ originalTransport = process.env.EMAIL_TRANSPORT;
108
+ process.env.EMAIL_TRANSPORT = 'sink';
109
+ const cache = objectCache();
110
+ cache['@proteinjs/email-server/DefaultEmailConfigFactory'] = [
111
+ {
112
+ getEmailConfig: () => ({
113
+ host: 'smtp.test.local',
114
+ port: 465,
115
+ secure: true,
116
+ from: '"Example" <hi@example.com>',
117
+ }),
118
+ },
119
+ ];
120
+ cache['@proteinjs/email-server/DefaultSignupConfirmationEmailConfigFactory'] = [
121
+ {
122
+ getConfig: () => ({
123
+ newUserSubject: WELCOME_SUBJECT,
124
+ existingUserSubject: EXISTS_SUBJECT,
125
+ getNewUserEmailContent: () => ({ text: 'welcome' }),
126
+ getExistingUserEmailContent: () => ({ text: 'someone tried to sign up with your address' }),
127
+ }),
128
+ },
129
+ ];
130
+ // Invite-optional, the library default — the suites load src, never the generated source graph.
131
+ cache['@proteinjs/user-server/DefaultInviteConfigFactory'] = [{ getConfig: () => ({ isInviteOnly: false }) }];
132
+ }, 120000);
133
+
134
+ afterAll(async () => {
135
+ const cache = objectCache();
136
+ for (const key of CONFIG_KEYS) {
137
+ delete cache[key];
138
+ }
139
+ if (originalTransport === undefined) {
140
+ delete process.env.EMAIL_TRANSPORT;
141
+ } else {
142
+ process.env.EMAIL_TRANSPORT = originalTransport;
143
+ }
144
+ await testEnv.afterAll();
145
+ });
146
+
147
+ beforeEach(() => {
148
+ MailSink.get().clear();
149
+ });
150
+
151
+ it('the loser answers exactly as a plain existing address does: the same bytes, no session, one mail to the owner, no address in the log', async () => {
152
+ // Typed with capitals, stored lowercased — the address a person types.
153
+ const local = `race-${randomBytes(4).toString('hex')}`;
154
+ const address = `${local}@test.local`;
155
+ const body = { name: 'Racer', email: `${local.toUpperCase()}@Test.local`, password: 'a-password' };
156
+
157
+ let raced: SignupOutcome[] = [];
158
+ const log = await LogCapture.during(async () => {
159
+ raced = await withCreatesHeldTogether(2, () => Promise.all([signUp(body), signUp(body)]));
160
+ });
161
+ const racedMail = mailTo(address);
162
+
163
+ // A plain sign-up for the address that now exists: the response the rule promises.
164
+ const plain = await signUp(body);
165
+ expect(plain.loggedInAs).toBeUndefined();
166
+
167
+ // Both racers got that response, byte for byte.
168
+ expect(raced.map((outcome) => [outcome.status, outcome.bytes])).toEqual([
169
+ [plain.status, plain.bytes],
170
+ [plain.status, plain.bytes],
171
+ ]);
172
+ // One account; one session, for the racer that created it; the loser none at all.
173
+ expect((await getDbAsSystem().query(tables.User, { email: address })).length).toBe(1);
174
+ expect(raced.filter((outcome) => outcome.loggedInAs === address)).toHaveLength(1);
175
+ const loser = raced.find((outcome) => outcome.loggedInAs === undefined);
176
+ expect(loser?.sessionEvents).toEqual([]);
177
+ // The owner's mail: the winner's welcome and ONE notice that someone tried the address again.
178
+ expect(racedMail.map((record) => record.subject).sort()).toEqual([EXISTS_SUBJECT, WELCOME_SUBJECT].sort());
179
+
180
+ // No line this process wrote names the address (its local part, in any case or encoding) except the database
181
+ // driver's own line for the refused statement: the driver's version (the one a package lock
182
+ // pins) decides whether that line prints bound values; it is the driver's to keep clean.
183
+ const lines = log.lines.filter((line) => !line.includes('[SpannerDriver]'));
184
+ expect(lines.length).toBeGreaterThan(0);
185
+ expect(lines.filter((line) => line.toLowerCase().includes(local))).toEqual([]);
186
+ // The account lines name the account by its digest: the winner's `Created user` and the
187
+ // loser's `already exists`, each carrying the same digest for the one address.
188
+ const digest = new RequestDigests().account(address);
189
+ expect(lines.filter((line) => line.includes('Created user') && line.includes(digest))).toHaveLength(1);
190
+ expect(lines.filter((line) => line.includes('already exists') && line.includes(digest))).toHaveLength(1);
191
+ });
192
+
193
+ it('a create that loses the race to the same address reports it as existing, the account row the winner wrote untouched', async () => {
194
+ const local = `race-${randomBytes(4).toString('hex')}`;
195
+ const address = `${local}@test.local`;
196
+ // Both creates carry the address as typed (capitals): the row is stored lowercased, and the
197
+ // loser's re-read must ask for the address the way the row holds it.
198
+ const account = (name: string) => ({
199
+ name,
200
+ email: `${local.toUpperCase()}@Test.local`,
201
+ password: 'a-password',
202
+ emailVerified: false,
203
+ invitedBy: null,
204
+ });
205
+
206
+ const outcomes = await withCreatesHeldTogether(2, () =>
207
+ Promise.all([new Signup().createAccount(account('First')), new Signup().createAccount(account('Second'))])
208
+ );
209
+
210
+ expect(outcomes.slice().sort()).toEqual(['created', 'exists']);
211
+ const rows = await getDbAsSystem().query(tables.User, { email: address });
212
+ expect(rows).toHaveLength(1);
213
+ expect(rows[0].name).toBe(outcomes[0] === 'created' ? 'First' : 'Second');
214
+ });
215
+
216
+ it('an insert the database refuses while the address has no account is a real failure: it throws, never "exists"', async () => {
217
+ const address = `race-${randomBytes(4).toString('hex')}@test.local`;
218
+ // The name column holds 255 characters; the database refuses a longer one.
219
+ const create = new Signup().createAccount({
220
+ name: 'n'.repeat(300),
221
+ email: address,
222
+ password: 'a-password',
223
+ emailVerified: false,
224
+ invitedBy: null,
225
+ });
226
+
227
+ await expect(create).rejects.toThrow();
228
+ expect(await getDbAsSystem().query(tables.User, { email: address })).toHaveLength(0);
229
+ });
230
+ });