@proteinjs/user-server 1.21.0 → 1.22.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/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/src/authentication/PasswordResetToken.d.ts +45 -0
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -0
- package/dist/src/authentication/PasswordResetToken.js +138 -0
- package/dist/src/authentication/PasswordResetToken.js.map +1 -0
- 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 +4 -4
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +27 -26
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +2 -2
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +13 -16
- package/dist/src/routes/validateResetPasswordToken.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.d.ts +2 -0
- package/dist/test/ExecutePasswordReset.test.d.ts.map +1 -0
- package/dist/test/ExecutePasswordReset.test.js +394 -0
- package/dist/test/ExecutePasswordReset.test.js.map +1 -0
- package/dist/test/ValidateResetToken.test.js +30 -7
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/generated/index.ts +5 -1
- package/package.json +3 -3
- package/src/authentication/PasswordResetToken.ts +96 -0
- package/src/routes/devMail.ts +72 -0
- package/src/routes/executePasswordReset.ts +25 -24
- package/src/routes/initiatePasswordReset.ts +2 -2
- package/src/routes/validateResetPasswordToken.ts +12 -15
- package/test/DevMail.test.ts +151 -0
- package/test/ExecutePasswordReset.test.ts +224 -0
- package/test/ValidateResetToken.test.ts +24 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/user-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "User server components",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@proteinjs/db": "^1.47.0",
|
|
26
26
|
"@proteinjs/db-file": "^1.5.3",
|
|
27
|
-
"@proteinjs/email-server": "^1.
|
|
27
|
+
"@proteinjs/email-server": "^1.4.0",
|
|
28
28
|
"@proteinjs/logger": "^1.0.21",
|
|
29
29
|
"@proteinjs/reflection": "^1.2.0",
|
|
30
30
|
"@proteinjs/server": "^3.5.1",
|
|
@@ -59,5 +59,5 @@
|
|
|
59
59
|
},
|
|
60
60
|
"main": "./dist/generated/index.js",
|
|
61
61
|
"types": "./dist/generated/index.d.ts",
|
|
62
|
-
"gitHead": "
|
|
62
|
+
"gitHead": "37d5b1a621fbaf23e1571282b4bbbd466a33ede5"
|
|
63
63
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
2
|
+
import moment, { Moment } from 'moment';
|
|
3
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
4
|
+
import { tables, User } from '@proteinjs/user';
|
|
5
|
+
|
|
6
|
+
export type PasswordResetResolution =
|
|
7
|
+
| { status: 'malformed' }
|
|
8
|
+
| { status: 'unknown' }
|
|
9
|
+
| { status: 'expired'; user: User }
|
|
10
|
+
| { status: 'live'; user: User; token: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The password-reset token's one owner: its shape, its mint, the lookup that maps a presented
|
|
14
|
+
* value back to the user it was issued to, and its single-use redemption.
|
|
15
|
+
*
|
|
16
|
+
* A token is 32 bytes from the platform CSPRNG, hex-encoded (64 lowercase hex characters),
|
|
17
|
+
* stored on the user row beside its expiry until it is redeemed or expires.
|
|
18
|
+
*
|
|
19
|
+
* Resolution refuses anything that is not a value of that shape BEFORE any lookup. A query
|
|
20
|
+
* filter built from a request value that is not a token does not compare the way a token does:
|
|
21
|
+
* `null` renders as `IS NULL` and would match every account with no pending reset, an empty
|
|
22
|
+
* string matches an emptied column, and other types reach the driver. The row the lookup
|
|
23
|
+
* returns is then re-checked in code — its stored token must be a string equal to the presented
|
|
24
|
+
* one (compared in constant time) and its expiry a real timestamp still in the future — so the
|
|
25
|
+
* outcome never rests on how the storage compares.
|
|
26
|
+
*/
|
|
27
|
+
export class PasswordResetToken {
|
|
28
|
+
private static readonly SHAPE = /^[0-9a-f]{64}$/;
|
|
29
|
+
|
|
30
|
+
mint(): string {
|
|
31
|
+
return randomBytes(32).toString('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async resolve(presented: unknown): Promise<PasswordResetResolution> {
|
|
35
|
+
const token = this.parse(presented);
|
|
36
|
+
if (token === undefined) {
|
|
37
|
+
return { status: 'malformed' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const user = await getDbAsSystem().get(tables.User, { passwordResetToken: token });
|
|
41
|
+
if (!user || !this.matches(user.passwordResetToken, token)) {
|
|
42
|
+
return { status: 'unknown' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!this.isLive(user.passwordResetTokenExpiration)) {
|
|
46
|
+
return { status: 'expired', user };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { status: 'live', user, token };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 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 succeed.
|
|
55
|
+
* Resolves false when the token was already redeemed.
|
|
56
|
+
*/
|
|
57
|
+
async redeem(user: User, token: string, hashedPassword: string): Promise<boolean> {
|
|
58
|
+
const updated = await getDbAsSystem().update(
|
|
59
|
+
tables.User,
|
|
60
|
+
{ password: hashedPassword, passwordResetToken: null, passwordResetTokenExpiration: null },
|
|
61
|
+
{ id: user.id, passwordResetToken: token }
|
|
62
|
+
);
|
|
63
|
+
return updated === 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A log-safe reference to a presented token: a short digest of a well-formed one, never the value itself. */
|
|
67
|
+
fingerprint(presented: unknown): string | undefined {
|
|
68
|
+
const token = this.parse(presented);
|
|
69
|
+
if (token === undefined) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 12);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private parse(presented: unknown): string | undefined {
|
|
77
|
+
return typeof presented === 'string' && PasswordResetToken.SHAPE.test(presented) ? presented : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private matches(stored: string | null | undefined, presented: string): boolean {
|
|
81
|
+
if (typeof stored !== 'string' || stored.length !== presented.length) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return timingSafeEqual(Buffer.from(stored), Buffer.from(presented));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private isLive(expiration: Moment | null | undefined): boolean {
|
|
89
|
+
if (!expiration) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const expiresAt = moment(expiration);
|
|
94
|
+
return expiresAt.isValid() && moment().isBefore(expiresAt);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
|
-
import {
|
|
3
|
-
import { routes, tables } from '@proteinjs/user';
|
|
2
|
+
import { routes } from '@proteinjs/user';
|
|
4
3
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
-
import moment from 'moment';
|
|
6
4
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
5
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Route handler for executing a password reset.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Resolves the presented token through `PasswordResetToken` — which refuses anything but a
|
|
11
|
+
* well-formed token before any lookup — checks its expiry, and redeems it: the new password is
|
|
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
14
|
*
|
|
14
15
|
* @bodyParam {string} token - The password reset token.
|
|
15
16
|
* @bodyParam {string} newPassword - The new password for the user.
|
|
16
|
-
*
|
|
17
|
-
* @throws {Error} If there's an issue with the database operations or if the token is invalid or expired.
|
|
18
17
|
*/
|
|
19
18
|
export const executePasswordReset: Route = {
|
|
20
19
|
path: routes.executePasswordReset.path,
|
|
@@ -22,33 +21,35 @@ export const executePasswordReset: Route = {
|
|
|
22
21
|
onRequest: async (request, response): Promise<void> => {
|
|
23
22
|
const logger = new Logger({ name: 'executePasswordReset' });
|
|
24
23
|
const { token, newPassword } = request.body;
|
|
25
|
-
|
|
24
|
+
if (typeof newPassword !== 'string' || newPassword.length === 0) {
|
|
25
|
+
response.status(400).send({ error: 'New password cannot be blank' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
logger.info({
|
|
29
|
+
const resetToken = new PasswordResetToken();
|
|
30
|
+
const resolution = await resetToken.resolve(token);
|
|
31
|
+
if (resolution.status === 'malformed' || resolution.status === 'unknown') {
|
|
32
|
+
logger.info({
|
|
33
|
+
message: `Invalid reset token used`,
|
|
34
|
+
obj: { reason: resolution.status, token: resetToken.fingerprint(token) },
|
|
35
|
+
});
|
|
31
36
|
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
32
37
|
return;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const tokenExpiration = moment(user.passwordResetTokenExpiration);
|
|
38
|
-
if (currentTime.isAfter(tokenExpiration)) {
|
|
39
|
-
logger.info({ message: `Expired reset token used`, obj: { email: user.email } });
|
|
40
|
+
if (resolution.status === 'expired') {
|
|
41
|
+
logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
|
|
40
42
|
response.status(400).send({ error: 'Reset token has expired' });
|
|
41
43
|
return;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
const { user } = resolution;
|
|
45
47
|
const hashedPassword = await new PasswordHasher().hash(newPassword);
|
|
46
|
-
await
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
48
|
+
if (!(await resetToken.redeem(user, resolution.token, hashedPassword))) {
|
|
49
|
+
logger.info({ message: `Reset token already redeemed`, obj: { email: user.email } });
|
|
50
|
+
response.status(400).send({ error: 'Invalid or expired reset token' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
52
53
|
|
|
53
54
|
logger.info({ message: `Password successfully reset`, obj: { email: user.email } });
|
|
54
55
|
response.send({ message: 'Password has been successfully reset' });
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
EmailSender,
|
|
8
8
|
getDefaultPasswordResetEmailConfigFactory as getDefaultConfigFactory,
|
|
9
9
|
} from '@proteinjs/email-server';
|
|
10
|
-
import {
|
|
10
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Route for initiating a password reset process.
|
|
@@ -62,7 +62,7 @@ export const initiatePasswordReset: Route = {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Generate reset token
|
|
65
|
-
const passwordResetToken =
|
|
65
|
+
const passwordResetToken = new PasswordResetToken().mint();
|
|
66
66
|
const passwordResetTokenExpiration = moment().add(1, 'hour');
|
|
67
67
|
|
|
68
68
|
try {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
|
-
import {
|
|
3
|
-
import { routes, tables } from '@proteinjs/user';
|
|
2
|
+
import { routes } from '@proteinjs/user';
|
|
4
3
|
import { Logger } from '@proteinjs/logger';
|
|
5
|
-
import
|
|
4
|
+
import { PasswordResetToken } from '../authentication/PasswordResetToken';
|
|
6
5
|
|
|
7
6
|
export const validateResetPasswordToken: Route = {
|
|
8
7
|
path: routes.validateResetToken.path,
|
|
@@ -10,26 +9,24 @@ export const validateResetPasswordToken: Route = {
|
|
|
10
9
|
onRequest: async (request, response): Promise<void> => {
|
|
11
10
|
const logger = new Logger({ name: 'validateResetToken' });
|
|
12
11
|
const { token } = request.query;
|
|
13
|
-
const db = getDbAsSystem();
|
|
14
|
-
|
|
15
12
|
if (!token) {
|
|
16
13
|
response.status(400).send({ isValid: false, message: 'No token provided' });
|
|
17
14
|
return;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
logger.info({
|
|
17
|
+
const resetToken = new PasswordResetToken();
|
|
18
|
+
const resolution = await resetToken.resolve(token);
|
|
19
|
+
if (resolution.status === 'malformed' || resolution.status === 'unknown') {
|
|
20
|
+
logger.info({
|
|
21
|
+
message: `Invalid reset token used`,
|
|
22
|
+
obj: { reason: resolution.status, token: resetToken.fingerprint(token) },
|
|
23
|
+
});
|
|
24
24
|
response.status(200).send({ isValid: false, message: 'Invalid token' });
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const tokenExpiration = moment(user.passwordResetTokenExpiration);
|
|
31
|
-
if (currentTime.isAfter(tokenExpiration)) {
|
|
32
|
-
logger.info({ message: `Expired reset token used`, obj: { email: user.email } });
|
|
28
|
+
if (resolution.status === 'expired') {
|
|
29
|
+
logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
|
|
33
30
|
response.status(200).send({ isValid: false, message: 'Token has expired' });
|
|
34
31
|
return;
|
|
35
32
|
}
|
|
@@ -38,6 +35,6 @@ export const validateResetPasswordToken: Route = {
|
|
|
38
35
|
// read-only `autocomplete="username"` field so password managers associate the updated
|
|
39
36
|
// password with the stored credential. The token was delivered to this very inbox, so a
|
|
40
37
|
// valid-token holder learns nothing new; invalid/expired verdicts stay email-free.
|
|
41
|
-
response.status(200).send({ isValid: true, email: user.email });
|
|
38
|
+
response.status(200).send({ isValid: true, email: resolution.user.email });
|
|
42
39
|
},
|
|
43
40
|
};
|
|
@@ -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
|
+
});
|