@proteinjs/user-server 1.21.1 → 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 +11 -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/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/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/generated/index.ts +5 -1
- package/package.json +3 -3
- package/src/routes/devMail.ts +72 -0
- package/test/DevMail.test.ts +151 -0
|
@@ -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
|
+
});
|