@proteinjs/user-server 1.21.1 → 1.22.1
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/CHANGELOG.md +22 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +5 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/PasswordResetToken.d.ts +43 -12
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
- package/dist/src/authentication/PasswordResetToken.js +86 -21
- package/dist/src/authentication/PasswordResetToken.js.map +1 -1
- package/dist/src/routes/devLogin.d.ts +1 -1
- package/dist/src/routes/devLogin.js +1 -1
- package/dist/src/routes/devMail.d.ts +4 -0
- package/dist/src/routes/devMail.d.ts.map +1 -0
- package/dist/src/routes/devMail.js +119 -0
- package/dist/src/routes/devMail.js.map +1 -0
- package/dist/src/routes/executePasswordReset.d.ts +2 -1
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +9 -7
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +3 -2
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +30 -27
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/test/DbTraffic.d.ts +29 -0
- package/dist/test/DbTraffic.d.ts.map +1 -0
- package/dist/test/DbTraffic.js +106 -0
- package/dist/test/DbTraffic.js.map +1 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
- package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
- package/dist/test/DevMail.test.d.ts +2 -0
- package/dist/test/DevMail.test.d.ts.map +1 -0
- package/dist/test/DevMail.test.js +250 -0
- package/dist/test/DevMail.test.js.map +1 -0
- package/dist/test/ExecutePasswordReset.test.js +513 -124
- package/dist/test/ExecutePasswordReset.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
- package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
- package/dist/test/InitiatePasswordReset.test.js +415 -0
- package/dist/test/InitiatePasswordReset.test.js.map +1 -0
- package/dist/test/LogCapture.d.ts +22 -0
- package/dist/test/LogCapture.d.ts.map +1 -0
- package/dist/test/LogCapture.js +127 -0
- package/dist/test/LogCapture.js.map +1 -0
- package/dist/test/MalformedResetTokens.d.ts +11 -0
- package/dist/test/MalformedResetTokens.d.ts.map +1 -0
- package/dist/test/MalformedResetTokens.js +35 -0
- package/dist/test/MalformedResetTokens.js.map +1 -0
- package/dist/test/ValidateResetToken.test.js +156 -33
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/dist/test/emulatorLock.test.d.ts +2 -0
- package/dist/test/emulatorLock.test.d.ts.map +1 -0
- package/dist/test/emulatorLock.test.js +32 -0
- package/dist/test/emulatorLock.test.js.map +1 -0
- package/dist/test/emulatorLockName.test.d.ts +2 -0
- package/dist/test/emulatorLockName.test.d.ts.map +1 -0
- package/dist/test/emulatorLockName.test.js +36 -0
- package/dist/test/emulatorLockName.test.js.map +1 -0
- package/generated/index.ts +5 -1
- package/index.ts +1 -1
- package/jest.config.js +2 -2
- package/package.json +4 -4
- package/src/authentication/PasswordResetToken.ts +74 -20
- package/src/routes/devLogin.ts +1 -1
- package/src/routes/devMail.ts +72 -0
- package/src/routes/executePasswordReset.ts +3 -2
- package/src/routes/initiatePasswordReset.ts +20 -17
- package/test/DbTraffic.ts +43 -0
- package/test/DevLoginBootstrapAdmin.test.ts +3 -4
- package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
- package/test/DevMail.test.ts +151 -0
- package/test/ExecutePasswordReset.test.ts +308 -70
- package/test/InitiatePasswordReset.test.ts +250 -0
- package/test/LogCapture.ts +54 -0
- package/test/MalformedResetTokens.ts +27 -0
- package/test/ValidateResetToken.test.ts +108 -24
- package/test/emulatorLock.js +5 -6
- package/test/emulatorLock.test.ts +32 -0
- package/test/emulatorLockName.test.ts +33 -0
|
@@ -13,22 +13,42 @@ export type PasswordResetResolution =
|
|
|
13
13
|
* The password-reset token's one owner: its shape, its mint, the lookup that maps a presented
|
|
14
14
|
* value back to the user it was issued to, and its single-use redemption.
|
|
15
15
|
*
|
|
16
|
-
* A token is 32 bytes from the platform CSPRNG, hex-encoded (64 lowercase hex characters)
|
|
17
|
-
*
|
|
16
|
+
* A token is 32 bytes from the platform CSPRNG, hex-encoded (64 lowercase hex characters). The
|
|
17
|
+
* token itself exists only in the link mailed to the account: the user row stores its SHA-256
|
|
18
|
+
* digest (hex) beside the expiry, never the token, so a read of the table yields nothing that
|
|
19
|
+
* can be presented. A digest is not a token — presenting one is hashed again and matches no row.
|
|
20
|
+
* A fast unsalted hash is enough here because the input is 256 bits of CSPRNG output, not a
|
|
21
|
+
* human-chosen secret.
|
|
18
22
|
*
|
|
19
|
-
* Resolution refuses anything that is not a value of
|
|
20
|
-
* filter built from a request value that is not a token does not compare the way a token
|
|
21
|
-
* `null` renders as `IS NULL` and would match every account with no pending reset, an
|
|
22
|
-
* string matches an emptied column, and other types reach the driver. The
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Resolution refuses anything that is not a value of the token's shape BEFORE any lookup. A
|
|
24
|
+
* query filter built from a request value that is not a token does not compare the way a token
|
|
25
|
+
* does: `null` renders as `IS NULL` and would match every account with no pending reset, an
|
|
26
|
+
* empty string matches an emptied column, and other types reach the driver. The presented token
|
|
27
|
+
* is then hashed, the row is looked up by that digest, and the row the lookup returns is
|
|
28
|
+
* re-checked in code — its stored digest must be a string equal to the presented token's digest
|
|
29
|
+
* (compared in constant time) and its expiry a real timestamp still in the future — so the
|
|
25
30
|
* outcome never rests on how the storage compares.
|
|
31
|
+
*
|
|
32
|
+
* No migration accompanies the move from a stored token to a stored digest: a row that still
|
|
33
|
+
* holds a token minted before it never equals the digest of anything presented, so that token
|
|
34
|
+
* fails validation for what remains of its hour and the person asks for a new link.
|
|
26
35
|
*/
|
|
27
36
|
export class PasswordResetToken {
|
|
28
37
|
private static readonly SHAPE = /^[0-9a-f]{64}$/;
|
|
38
|
+
private static readonly LIFETIME_MINUTES = 60;
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Issue a token to `user`: store its digest and its expiry on the row, replacing any token
|
|
42
|
+
* outstanding, and return the token — the only time it exists outside the mailed link.
|
|
43
|
+
*/
|
|
44
|
+
async mint(user: User): Promise<string> {
|
|
45
|
+
const token = randomBytes(32).toString('hex');
|
|
46
|
+
await getDbAsSystem().update(tables.User, {
|
|
47
|
+
id: user.id,
|
|
48
|
+
passwordResetToken: this.digest(token),
|
|
49
|
+
passwordResetTokenExpiration: moment().add(PasswordResetToken.LIFETIME_MINUTES, 'minutes'),
|
|
50
|
+
});
|
|
51
|
+
return token;
|
|
32
52
|
}
|
|
33
53
|
|
|
34
54
|
async resolve(presented: unknown): Promise<PasswordResetResolution> {
|
|
@@ -37,8 +57,9 @@ export class PasswordResetToken {
|
|
|
37
57
|
return { status: 'malformed' };
|
|
38
58
|
}
|
|
39
59
|
|
|
40
|
-
const
|
|
41
|
-
|
|
60
|
+
const digest = this.digest(token);
|
|
61
|
+
const user = await getDbAsSystem().get(tables.User, { passwordResetToken: digest });
|
|
62
|
+
if (!user || !this.matches(user.passwordResetToken, digest)) {
|
|
42
63
|
return { status: 'unknown' };
|
|
43
64
|
}
|
|
44
65
|
|
|
@@ -51,38 +72,71 @@ export class PasswordResetToken {
|
|
|
51
72
|
|
|
52
73
|
/**
|
|
53
74
|
* Write the new credential and clear the token in one conditional update: the row must still
|
|
54
|
-
* carry this token at write time, so two presentations of the same token cannot both
|
|
55
|
-
* Resolves false when the token was already redeemed.
|
|
75
|
+
* carry this token's digest at write time, so two presentations of the same token cannot both
|
|
76
|
+
* succeed. Resolves false when the token was already redeemed.
|
|
56
77
|
*/
|
|
57
78
|
async redeem(user: User, token: string, hashedPassword: string): Promise<boolean> {
|
|
58
79
|
const updated = await getDbAsSystem().update(
|
|
59
80
|
tables.User,
|
|
60
81
|
{ password: hashedPassword, passwordResetToken: null, passwordResetTokenExpiration: null },
|
|
61
|
-
{ id: user.id, passwordResetToken: token }
|
|
82
|
+
{ id: user.id, passwordResetToken: this.digest(token) }
|
|
62
83
|
);
|
|
63
84
|
return updated === 1;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Withdraw a token that never reached its owner (the mail carrying it failed to send): the row
|
|
89
|
+
* is cleared only while it still carries this token's digest, so a newer token is left alone.
|
|
90
|
+
*/
|
|
91
|
+
async revoke(user: User, token: string): Promise<void> {
|
|
92
|
+
await getDbAsSystem().update(
|
|
93
|
+
tables.User,
|
|
94
|
+
{ passwordResetToken: null, passwordResetTokenExpiration: null },
|
|
95
|
+
{ id: user.id, passwordResetToken: this.digest(token) }
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** When the token outstanding on `user` was minted, or undefined when the row carries none. */
|
|
100
|
+
mintedAt(user: User): Moment | undefined {
|
|
101
|
+
if (!user.passwordResetToken || !user.passwordResetTokenExpiration) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return moment(user.passwordResetTokenExpiration).subtract(PasswordResetToken.LIFETIME_MINUTES, 'minutes');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A log-safe reference to a presented token: a short prefix of a well-formed one's digest,
|
|
110
|
+
* never the value itself and never a part of it. The routes log it only for a token that
|
|
111
|
+
* resolved `unknown` — one no row carries — so it never lines up with a digest a row holds.
|
|
112
|
+
* What it is for is telling refusals apart in the log: one stale link presented again reads
|
|
113
|
+
* the same each time, a run of different values reads differently. 48 bits of the SHA-256 of
|
|
114
|
+
* 256 random bits identify nothing and can be presented nowhere.
|
|
115
|
+
*/
|
|
67
116
|
fingerprint(presented: unknown): string | undefined {
|
|
68
117
|
const token = this.parse(presented);
|
|
69
118
|
if (token === undefined) {
|
|
70
119
|
return undefined;
|
|
71
120
|
}
|
|
72
121
|
|
|
73
|
-
return
|
|
122
|
+
return this.digest(token).slice(0, 12);
|
|
74
123
|
}
|
|
75
124
|
|
|
76
125
|
private parse(presented: unknown): string | undefined {
|
|
77
126
|
return typeof presented === 'string' && PasswordResetToken.SHAPE.test(presented) ? presented : undefined;
|
|
78
127
|
}
|
|
79
128
|
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
/** What the row stores in place of a token: its SHA-256, hex-encoded. */
|
|
130
|
+
private digest(token: string): string {
|
|
131
|
+
return createHash('sha256').update(token).digest('hex');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private matches(storedDigest: string | null | undefined, presentedDigest: string): boolean {
|
|
135
|
+
if (typeof storedDigest !== 'string' || storedDigest.length !== presentedDigest.length) {
|
|
82
136
|
return false;
|
|
83
137
|
}
|
|
84
138
|
|
|
85
|
-
return timingSafeEqual(Buffer.from(
|
|
139
|
+
return timingSafeEqual(Buffer.from(storedDigest), Buffer.from(presentedDigest));
|
|
86
140
|
}
|
|
87
141
|
|
|
88
142
|
private isLive(expiration: Moment | null | undefined): boolean {
|
package/src/routes/devLogin.ts
CHANGED
|
@@ -45,7 +45,7 @@ const emailDomain = (address: string) => address.slice(address.lastIndexOf('@')
|
|
|
45
45
|
* call is unchanged; the variable absent = nothing changes; the gates closed = 404 regardless.
|
|
46
46
|
* Test and prod never set it — the omission is the safety, the same idiom as the gates. The
|
|
47
47
|
* outcome is logged as ONE marker line, `Dev bootstrap admin door: <granted|admin-exists>`,
|
|
48
|
-
* which
|
|
48
|
+
* which a consumer's boot proof can read from the server log to PROVE the grant landed.
|
|
49
49
|
*
|
|
50
50
|
* Role-bootstrap door (`DEV_BOOTSTRAP_ROLES='email:role[,role];email:role…'`, the grammar in
|
|
51
51
|
* DevBootstrapRoles.ts): the first-admin door leaves every OTHER account role-less, and a
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Route } from '@proteinjs/server-api';
|
|
2
|
+
import { MailSink } from '@proteinjs/email-server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DEV-ONLY mail door: reads the process's mail sink (`@proteinjs/email-server` MailSink — the
|
|
6
|
+
* messages a development sender recorded instead of transporting), so automated dev-loop testing
|
|
7
|
+
* reads an invite or reset link from the sink instead of needing a real inbox, and nothing a dev
|
|
8
|
+
* server composes ever has to reach a real address to be verified.
|
|
9
|
+
*
|
|
10
|
+
* GET /dev/mail[?n=<count>] the last n messages (default 20, at most MailSink.CAPACITY), newest
|
|
11
|
+
* first: id, at, from, to, subject, the body's first link, refused, url
|
|
12
|
+
* GET /dev/mail/<id> the rendered message — its html part, or its text part as text/plain
|
|
13
|
+
*
|
|
14
|
+
* Double-gated exactly like `/dev/login` (devLogin.ts), acting only when BOTH hold; otherwise the
|
|
15
|
+
* paths answer 404 as if unregistered:
|
|
16
|
+
* 1. `process.env.DEVELOPMENT` — the dev-server switch, never set in prod images.
|
|
17
|
+
* 2. `DEV_AUTO_LOGIN_EMAIL` — the explicit per-launch dev opt-in.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_COUNT = 20;
|
|
20
|
+
|
|
21
|
+
const gatesOpen = (): boolean =>
|
|
22
|
+
!!process.env.DEVELOPMENT && (process.env.DEV_AUTO_LOGIN_EMAIL ?? '').trim().length > 0;
|
|
23
|
+
|
|
24
|
+
export const devMail: Route = {
|
|
25
|
+
path: '/dev/mail',
|
|
26
|
+
method: 'get',
|
|
27
|
+
onRequest: async (request, response): Promise<void> => {
|
|
28
|
+
if (!gatesOpen()) {
|
|
29
|
+
response.status(404).send();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const requested = Number(request.query?.n);
|
|
33
|
+
const count = Number.isInteger(requested) && requested > 0 ? Math.min(requested, MailSink.CAPACITY) : DEFAULT_COUNT;
|
|
34
|
+
const messages = MailSink.get()
|
|
35
|
+
.list(count)
|
|
36
|
+
.map(({ id, at, from, to, subject, link, refused }) => ({
|
|
37
|
+
id,
|
|
38
|
+
at,
|
|
39
|
+
from,
|
|
40
|
+
to,
|
|
41
|
+
subject,
|
|
42
|
+
link,
|
|
43
|
+
refused,
|
|
44
|
+
url: `/dev/mail/${id}`,
|
|
45
|
+
}));
|
|
46
|
+
response.status(200).json({ messages });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const devMailMessage: Route = {
|
|
51
|
+
path: '/dev/mail/:id',
|
|
52
|
+
method: 'get',
|
|
53
|
+
onRequest: async (request, response): Promise<void> => {
|
|
54
|
+
if (!gatesOpen()) {
|
|
55
|
+
response.status(404).send();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const record = MailSink.get().get(String(request.params?.id ?? ''));
|
|
59
|
+
if (!record) {
|
|
60
|
+
response.status(404).send();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (record.html !== undefined) {
|
|
64
|
+
response.status(200).type('html').send(record.html);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
response
|
|
68
|
+
.status(200)
|
|
69
|
+
.type('text')
|
|
70
|
+
.send(record.text ?? '');
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -10,7 +10,8 @@ import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
|
10
10
|
* Resolves the presented token through `PasswordResetToken` — which refuses anything but a
|
|
11
11
|
* well-formed token before any lookup — checks its expiry, and redeems it: the new password is
|
|
12
12
|
* written and the token cleared in one conditional update, so a token resets a password once.
|
|
13
|
-
* The token itself never reaches the log.
|
|
13
|
+
* The token itself never reaches the log. A request that carries no body at all is refused like
|
|
14
|
+
* one that carries a blank password.
|
|
14
15
|
*
|
|
15
16
|
* @bodyParam {string} token - The password reset token.
|
|
16
17
|
* @bodyParam {string} newPassword - The new password for the user.
|
|
@@ -20,7 +21,7 @@ export const executePasswordReset: Route = {
|
|
|
20
21
|
method: routes.executePasswordReset.method,
|
|
21
22
|
onRequest: async (request, response): Promise<void> => {
|
|
22
23
|
const logger = new Logger({ name: 'executePasswordReset' });
|
|
23
|
-
const { token, newPassword } = request.body;
|
|
24
|
+
const { token, newPassword } = request.body ?? {};
|
|
24
25
|
if (typeof newPassword !== 'string' || newPassword.length === 0) {
|
|
25
26
|
response.status(400).send({ error: 'New password cannot be blank' });
|
|
26
27
|
return;
|
|
@@ -12,8 +12,9 @@ import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
|
12
12
|
/**
|
|
13
13
|
* Route for initiating a password reset process.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Mints a reset token through `PasswordResetToken` — which stores only the token's digest and
|
|
16
|
+
* its expiry on the user row — and mails the token to the account as a reset link. The token is
|
|
17
|
+
* withdrawn again when the mail fails to send.
|
|
17
18
|
*
|
|
18
19
|
* Requires an implementation of `DefaultPasswordResetEmailConfigFactory` to build the password reset email with.
|
|
19
20
|
*
|
|
@@ -27,7 +28,13 @@ export const initiatePasswordReset: Route = {
|
|
|
27
28
|
method: routes.initiatePasswordReset.method,
|
|
28
29
|
onRequest: async (request, response): Promise<void> => {
|
|
29
30
|
const logger = new Logger({ name: 'initiatePasswordReset' });
|
|
30
|
-
const email = request.body
|
|
31
|
+
const { email: requestedEmail } = request.body ?? {};
|
|
32
|
+
if (typeof requestedEmail !== 'string' || requestedEmail.length === 0) {
|
|
33
|
+
response.status(400).send({ error: 'Email cannot be blank' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const email = requestedEmail.toLowerCase();
|
|
31
38
|
const db = getDbAsSystem();
|
|
32
39
|
|
|
33
40
|
const genericResponse = { message: 'If an account with that email exists, we have sent a password reset link.' };
|
|
@@ -41,15 +48,12 @@ export const initiatePasswordReset: Route = {
|
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
// Check if there's an existing token and it's less than 5 minutes old
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
response.send(genericResponse);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
51
|
+
const resetToken = new PasswordResetToken();
|
|
52
|
+
const mintedAt = resetToken.mintedAt(user);
|
|
53
|
+
if (mintedAt && moment().diff(mintedAt, 'minutes') < 5) {
|
|
54
|
+
logger.info({ message: `Password reset requested too soon for user`, obj: { email } });
|
|
55
|
+
response.send(genericResponse);
|
|
56
|
+
return;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const emailSender = new EmailSender();
|
|
@@ -61,9 +65,8 @@ export const initiatePasswordReset: Route = {
|
|
|
61
65
|
);
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
//
|
|
65
|
-
const passwordResetToken =
|
|
66
|
-
const passwordResetTokenExpiration = moment().add(1, 'hour');
|
|
68
|
+
// The row now holds the token's digest; the token itself goes only into the mailed link
|
|
69
|
+
const passwordResetToken = await resetToken.mint(user);
|
|
67
70
|
|
|
68
71
|
try {
|
|
69
72
|
const config = defaultConfigFactory.getConfig();
|
|
@@ -78,11 +81,11 @@ export const initiatePasswordReset: Route = {
|
|
|
78
81
|
...config.options,
|
|
79
82
|
});
|
|
80
83
|
|
|
81
|
-
// If email is sent successfully, save reset token to user record
|
|
82
|
-
await db.update(tables.User, { id: user.id, passwordResetToken, passwordResetTokenExpiration });
|
|
83
84
|
response.send(genericResponse);
|
|
84
85
|
} catch (error: any) {
|
|
85
86
|
logger.error({ message: `Failed to send password reset email`, obj: { email }, error });
|
|
87
|
+
// The link never reached the account: withdraw the token so asking again is not throttled
|
|
88
|
+
await resetToken.revoke(user, passwordResetToken);
|
|
86
89
|
response.status(500).send({ error: 'Failed to send password reset email. Please try again later.' });
|
|
87
90
|
}
|
|
88
91
|
},
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Db, DbDriver } from '@proteinjs/db';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What reached the database while a run executed — the outcome a "refused before any lookup"
|
|
5
|
+
* claim rests on. A response code cannot prove it: a value that is wrongly let through still
|
|
6
|
+
* matches no row and still answers 400, so the proof is that nothing was built and nothing ran.
|
|
7
|
+
* - `lookups`: the filter of every read built through the package's `Db` — every `query`, which
|
|
8
|
+
* is also what a `get` is (the first row of one);
|
|
9
|
+
* - `writes`: how many writes were built through it (`insert`, `update`, `delete`);
|
|
10
|
+
* - `statements`: how many statements the driver ran, whoever built them.
|
|
11
|
+
*/
|
|
12
|
+
export class DbTraffic {
|
|
13
|
+
private constructor(
|
|
14
|
+
readonly lookups: unknown[],
|
|
15
|
+
readonly writes: number,
|
|
16
|
+
readonly statements: number
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
/** The traffic of a run that never touched the database: `expect(traffic).toEqual(DbTraffic.NONE)`. */
|
|
20
|
+
static readonly NONE = { lookups: [], writes: 0, statements: 0 };
|
|
21
|
+
|
|
22
|
+
/** Runs `run` and answers what it resolved to beside the database traffic it caused. */
|
|
23
|
+
static async during<T>(driver: DbDriver, run: () => Promise<T>): Promise<{ result: T; traffic: DbTraffic }> {
|
|
24
|
+
const reads = jest.spyOn(Db.prototype, 'query');
|
|
25
|
+
const writes = [
|
|
26
|
+
jest.spyOn(Db.prototype, 'insert'),
|
|
27
|
+
jest.spyOn(Db.prototype, 'update'),
|
|
28
|
+
jest.spyOn(Db.prototype, 'delete'),
|
|
29
|
+
];
|
|
30
|
+
const statements = [jest.spyOn(driver, 'runQuery'), jest.spyOn(driver, 'runDml')];
|
|
31
|
+
try {
|
|
32
|
+
const result = await run();
|
|
33
|
+
const lookups = reads.mock.calls.map(([_table, filter]) => filter);
|
|
34
|
+
return { result, traffic: new DbTraffic(lookups, DbTraffic.callCount(writes), DbTraffic.callCount(statements)) };
|
|
35
|
+
} finally {
|
|
36
|
+
[reads, ...writes, ...statements].forEach((spy) => spy.mockRestore());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private static callCount(spies: { mock: { calls: unknown[] } }[]): number {
|
|
41
|
+
return spies.reduce((count, spy) => count + spy.mock.calls.length, 0);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -6,10 +6,9 @@ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
|
6
6
|
const testEnv = new UserServerTestEnvironment();
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* `DEV_BOOTSTRAP_ADMIN_EMAIL` — the first-admin door INSIDE `/dev/login
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* sanctioned account door mints break-glass, once:
|
|
9
|
+
* `DEV_BOOTSTRAP_ADMIN_EMAIL` — the first-admin door INSIDE `/dev/login`. A development server on
|
|
10
|
+
* a fresh real database has no privileged account to grant from, and a no-raw-database-write rule
|
|
11
|
+
* forbids seed scripts there — so the ONE sanctioned account door mints break-glass, once:
|
|
13
12
|
* - behind the door's existing two gates (DEVELOPMENT AND DEV_AUTO_LOGIN_EMAIL): closed = 404 as
|
|
14
13
|
* before, and the variable changes nothing;
|
|
15
14
|
* - only while NO account carries 'admin' (the same membership test the app's admin checks make);
|
|
@@ -28,7 +28,7 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
28
28
|
* cookies the response set, and the follow-up MUST read as authenticated on the FIRST pass.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
const SESSION_COOKIE_NAME = '
|
|
31
|
+
const SESSION_COOKIE_NAME = 'app.sid.9876'; // a consumer's dev cookie-name shape: <app>.sid.${SERVER_PORT}
|
|
32
32
|
const SESSION_SECRET = 'dev-first-hit-test-secret';
|
|
33
33
|
const ENV_EMAIL = 'dev@test.local';
|
|
34
34
|
/** Store commit latency. Generous so a pre-0.6 runtime (write racing the redirect) loses the
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { EmailSender, MailSink } from '@proteinjs/email-server';
|
|
2
|
+
import { devMail, devMailMessage } from '../src/routes/devMail';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `GET /dev/mail` and `GET /dev/mail/<id>` — the dev-only door onto the mail sink, gated exactly
|
|
6
|
+
* like `/dev/login`: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL`, else 404 as if unregistered. With
|
|
7
|
+
* the gates open, the last invite a development sender recorded is readable — its link off the
|
|
8
|
+
* list, its rendered html off the message — so a lane never needs a real inbox. No database:
|
|
9
|
+
* the sink is the process's own ring.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ENV_EMAIL = 'dev@test.local';
|
|
13
|
+
|
|
14
|
+
type Outcome = { status?: number; body?: unknown; type?: string };
|
|
15
|
+
|
|
16
|
+
const invoke = async (
|
|
17
|
+
route: typeof devMail,
|
|
18
|
+
{ query, params }: { query?: Record<string, unknown>; params?: Record<string, string> } = {}
|
|
19
|
+
): Promise<Outcome> => {
|
|
20
|
+
const outcome: Outcome = {};
|
|
21
|
+
const response = {
|
|
22
|
+
status(code: number) {
|
|
23
|
+
outcome.status = code;
|
|
24
|
+
return this;
|
|
25
|
+
},
|
|
26
|
+
type(kind: string) {
|
|
27
|
+
outcome.type = kind;
|
|
28
|
+
return this;
|
|
29
|
+
},
|
|
30
|
+
send(body?: unknown) {
|
|
31
|
+
outcome.body = body;
|
|
32
|
+
},
|
|
33
|
+
json(body: unknown) {
|
|
34
|
+
outcome.body = body;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
await route.onRequest({ query: query ?? {}, params: params ?? {} } as never, response as never);
|
|
38
|
+
return outcome;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const INVITE_LINK = 'http://localhost:7985/auth/signup?token=abc123';
|
|
42
|
+
const INVITE_HTML = `<html><body><p>You are invited.</p><a href="${INVITE_LINK}">Accept</a></body></html>`;
|
|
43
|
+
|
|
44
|
+
/** The product's path onto the sink: a development sender with a real-looking SMTP config, no opt-in. */
|
|
45
|
+
const sendInviteThroughSender = async (to: string): Promise<void> => {
|
|
46
|
+
await new EmailSender({
|
|
47
|
+
host: 'smtp.test.local',
|
|
48
|
+
port: 465,
|
|
49
|
+
secure: true,
|
|
50
|
+
auth: { user: 'mailbox@test.local', pass: 'unused' },
|
|
51
|
+
from: '"Example" <hi@example.com>',
|
|
52
|
+
}).sendEmail({ to, subject: "You're invited", text: `Accept your invite: ${INVITE_LINK}`, html: INVITE_HTML });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe('devMail routes', () => {
|
|
56
|
+
const originalEnv = {
|
|
57
|
+
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
58
|
+
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
59
|
+
EMAIL_TRANSPORT: process.env.EMAIL_TRANSPORT,
|
|
60
|
+
EMAIL_ALLOW_REAL_SEND: process.env.EMAIL_ALLOW_REAL_SEND,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
process.env.DEVELOPMENT = 'true';
|
|
65
|
+
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
66
|
+
delete process.env.EMAIL_TRANSPORT;
|
|
67
|
+
delete process.env.EMAIL_ALLOW_REAL_SEND;
|
|
68
|
+
MailSink.get().clear();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
delete process.env[key];
|
|
75
|
+
} else {
|
|
76
|
+
process.env[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("lists the last sent messages newest first — the invite's recipient, subject, first link and timestamp — and the message renders its html", async () => {
|
|
82
|
+
await sendInviteThroughSender('earlier@test.local');
|
|
83
|
+
await sendInviteThroughSender('lane-after@test.local');
|
|
84
|
+
|
|
85
|
+
const list = await invoke(devMail);
|
|
86
|
+
expect(list.status).toBe(200);
|
|
87
|
+
const { messages } = list.body as { messages: Array<Record<string, unknown>> };
|
|
88
|
+
expect(messages).toHaveLength(2);
|
|
89
|
+
expect(messages[0]).toMatchObject({
|
|
90
|
+
to: ['lane-after@test.local'],
|
|
91
|
+
subject: "You're invited",
|
|
92
|
+
link: INVITE_LINK,
|
|
93
|
+
from: 'hi@example.com',
|
|
94
|
+
refused: true,
|
|
95
|
+
});
|
|
96
|
+
expect(typeof messages[0].id).toBe('string');
|
|
97
|
+
expect(Date.parse(String(messages[0].at))).not.toBeNaN();
|
|
98
|
+
expect(messages[0].url).toBe(`/dev/mail/${messages[0].id}`);
|
|
99
|
+
expect(messages[1].to).toEqual(['earlier@test.local']);
|
|
100
|
+
|
|
101
|
+
const message = await invoke(devMailMessage, { params: { id: String(messages[0].id) } });
|
|
102
|
+
expect(message.status).toBe(200);
|
|
103
|
+
expect(message.type).toBe('html');
|
|
104
|
+
expect(message.body).toBe(INVITE_HTML);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('?n= bounds the list; a text-only message renders as text; an unknown id is 404', async () => {
|
|
108
|
+
process.env.EMAIL_TRANSPORT = 'sink';
|
|
109
|
+
await new EmailSender({ host: 'h', port: 465, secure: true, from: '"Example" <hi@example.com>' }).sendEmail({
|
|
110
|
+
to: 'a@test.local',
|
|
111
|
+
subject: 'Plain',
|
|
112
|
+
text: 'just text',
|
|
113
|
+
});
|
|
114
|
+
await sendInviteThroughSender('b@test.local');
|
|
115
|
+
|
|
116
|
+
const one = await invoke(devMail, { query: { n: '1' } });
|
|
117
|
+
expect((one.body as { messages: unknown[] }).messages).toHaveLength(1);
|
|
118
|
+
const all = await invoke(devMail, { query: { n: 'garbage' } });
|
|
119
|
+
const { messages } = all.body as { messages: Array<{ id: string; subject: string; refused: boolean }> };
|
|
120
|
+
expect(messages.map((m) => m.subject)).toEqual(["You're invited", 'Plain']);
|
|
121
|
+
expect(messages[1].refused).toBe(false);
|
|
122
|
+
|
|
123
|
+
const plain = await invoke(devMailMessage, { params: { id: messages[1].id } });
|
|
124
|
+
expect(plain.status).toBe(200);
|
|
125
|
+
expect(plain.type).toBe('text');
|
|
126
|
+
expect(plain.body).toBe('just text');
|
|
127
|
+
|
|
128
|
+
expect((await invoke(devMailMessage, { params: { id: 'nope' } })).status).toBe(404);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('DEVELOPMENT unset → 404 on both paths, even with messages in the sink', async () => {
|
|
132
|
+
await sendInviteThroughSender('lane@test.local');
|
|
133
|
+
const [record] = MailSink.get().list();
|
|
134
|
+
delete process.env.DEVELOPMENT;
|
|
135
|
+
|
|
136
|
+
expect((await invoke(devMail)).status).toBe(404);
|
|
137
|
+
expect((await invoke(devMailMessage, { params: { id: record.id } })).status).toBe(404);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('DEV_AUTO_LOGIN_EMAIL unset (or blank) → 404 on both paths', async () => {
|
|
141
|
+
await sendInviteThroughSender('lane@test.local');
|
|
142
|
+
const [record] = MailSink.get().list();
|
|
143
|
+
|
|
144
|
+
delete process.env.DEV_AUTO_LOGIN_EMAIL;
|
|
145
|
+
expect((await invoke(devMail)).status).toBe(404);
|
|
146
|
+
expect((await invoke(devMailMessage, { params: { id: record.id } })).status).toBe(404);
|
|
147
|
+
|
|
148
|
+
process.env.DEV_AUTO_LOGIN_EMAIL = ' ';
|
|
149
|
+
expect((await invoke(devMail)).status).toBe(404);
|
|
150
|
+
});
|
|
151
|
+
});
|