@hogsend/plugin-discord 0.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/LICENSE +93 -0
- package/README.md +235 -0
- package/package.json +50 -0
- package/src/__tests__/connector-transform.test.ts +178 -0
- package/src/__tests__/gateway-forward.test.ts +94 -0
- package/src/__tests__/interactions-followup.test.ts +94 -0
- package/src/__tests__/interactions.test.ts +585 -0
- package/src/__tests__/member-link.test.ts +58 -0
- package/src/__tests__/oauth.test.ts +214 -0
- package/src/connect/interactions-followup.ts +59 -0
- package/src/connect/interactions.ts +864 -0
- package/src/connect/member-link.ts +79 -0
- package/src/connect/oauth.ts +159 -0
- package/src/connect/patch-application.ts +67 -0
- package/src/connector.ts +541 -0
- package/src/constants.ts +47 -0
- package/src/destination.ts +95 -0
- package/src/env.ts +25 -0
- package/src/events.ts +16 -0
- package/src/gateway/index.ts +7 -0
- package/src/gateway/ingress.ts +50 -0
- package/src/gateway/worker.ts +180 -0
- package/src/index.ts +71 -0
- package/src/types.ts +61 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { editInteractionResponse } from "../connect/interactions-followup.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Covers the 404-race retry in {@link editInteractionResponse} (commit 43fb4f4):
|
|
6
|
+
* the deferred (type-5) ack is delivered by the engine route AFTER the handler
|
|
7
|
+
* returns, so a fast follow-up can reach Discord BEFORE the deferral registers
|
|
8
|
+
* and the `@original` message 404s. The retry loop re-PATCHes ONLY on 404 (short
|
|
9
|
+
* backoff, MAX_ATTEMPTS=5); any other status fails fast.
|
|
10
|
+
*
|
|
11
|
+
* Fake timers keep the `setTimeout` backoff from actually sleeping —
|
|
12
|
+
* `vi.runAllTimersAsync()` drains the pending sleep so the loop advances
|
|
13
|
+
* synchronously to the test.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const baseArgs = {
|
|
17
|
+
applicationId: "app1",
|
|
18
|
+
token: "interaction-token",
|
|
19
|
+
body: { content: "done" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function jsonResponse(status: number): Response {
|
|
23
|
+
return new Response(status === 200 ? "{}" : "err", { status });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("editInteractionResponse 404-race retry", () => {
|
|
36
|
+
it("(a) retries a 404 then resolves on the following 200", async () => {
|
|
37
|
+
const fetchSpy = vi
|
|
38
|
+
.spyOn(globalThis, "fetch")
|
|
39
|
+
.mockResolvedValueOnce(jsonResponse(404))
|
|
40
|
+
.mockResolvedValueOnce(jsonResponse(200));
|
|
41
|
+
|
|
42
|
+
const promise = editInteractionResponse(baseArgs);
|
|
43
|
+
// Drain the backoff sleep between attempt 1 (404) and attempt 2 (200).
|
|
44
|
+
await vi.runAllTimersAsync();
|
|
45
|
+
await expect(promise).resolves.toBeUndefined();
|
|
46
|
+
|
|
47
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
48
|
+
// No bot token / Authorization header — interaction-webhook endpoint only.
|
|
49
|
+
const init = fetchSpy.mock.calls[0]?.[1];
|
|
50
|
+
expect(
|
|
51
|
+
(init?.headers as Record<string, string>).Authorization,
|
|
52
|
+
).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("(b) throws after MAX_ATTEMPTS (5) on a persistent 404", async () => {
|
|
56
|
+
const fetchSpy = vi
|
|
57
|
+
.spyOn(globalThis, "fetch")
|
|
58
|
+
.mockResolvedValue(jsonResponse(404));
|
|
59
|
+
|
|
60
|
+
const promise = editInteractionResponse(baseArgs);
|
|
61
|
+
// Surface the eventual rejection without an unhandled-rejection warning
|
|
62
|
+
// while the timers are still being drained.
|
|
63
|
+
const settled = promise.then(
|
|
64
|
+
() => ({ ok: true }) as const,
|
|
65
|
+
(err: unknown) => ({ ok: false, err }) as const,
|
|
66
|
+
);
|
|
67
|
+
await vi.runAllTimersAsync();
|
|
68
|
+
const outcome = await settled;
|
|
69
|
+
|
|
70
|
+
expect(outcome.ok).toBe(false);
|
|
71
|
+
expect((outcome as { err: Error }).err).toBeInstanceOf(Error);
|
|
72
|
+
expect((outcome as { err: Error }).err.message).toContain("(404)");
|
|
73
|
+
// 5 attempts total — the 5th does not sleep/retry, it throws.
|
|
74
|
+
expect(fetchSpy).toHaveBeenCalledTimes(5);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("(c) throws IMMEDIATELY on a 401 with no retry", async () => {
|
|
78
|
+
const fetchSpy = vi
|
|
79
|
+
.spyOn(globalThis, "fetch")
|
|
80
|
+
.mockResolvedValue(jsonResponse(401));
|
|
81
|
+
|
|
82
|
+
await expect(editInteractionResponse(baseArgs)).rejects.toThrow("(401)");
|
|
83
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("(c) throws IMMEDIATELY on a 500 with no retry", async () => {
|
|
87
|
+
const fetchSpy = vi
|
|
88
|
+
.spyOn(globalThis, "fetch")
|
|
89
|
+
.mockResolvedValue(jsonResponse(500));
|
|
90
|
+
|
|
91
|
+
await expect(editInteractionResponse(baseArgs)).rejects.toThrow("(500)");
|
|
92
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sign as edSign,
|
|
3
|
+
generateKeyPairSync,
|
|
4
|
+
type KeyObject,
|
|
5
|
+
} from "node:crypto";
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
ComponentType,
|
|
9
|
+
CustomIds,
|
|
10
|
+
handleInteraction,
|
|
11
|
+
InteractionCallbackFlags,
|
|
12
|
+
type InteractionDeps,
|
|
13
|
+
InteractionResponseType,
|
|
14
|
+
InteractionType,
|
|
15
|
+
type LinkMintResult,
|
|
16
|
+
type LinkRedeemResult,
|
|
17
|
+
verifyInteractionSignature,
|
|
18
|
+
} from "../connect/interactions.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate a real Ed25519 keypair, sign `timestamp || body` exactly as Discord
|
|
22
|
+
* does, and exercise the `node:crypto`-based verifier. No tweetnacl, no mocks.
|
|
23
|
+
*/
|
|
24
|
+
function makeSigner() {
|
|
25
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
26
|
+
// Discord publishes the RAW 32-byte public key as hex — extract it from the
|
|
27
|
+
// SPKI DER (the last 32 bytes) to feed our raw-key verifier.
|
|
28
|
+
const spki = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
|
29
|
+
const publicKeyHex = spki.subarray(spki.length - 32).toString("hex");
|
|
30
|
+
return { publicKeyHex, privateKey };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sign(privateKey: KeyObject, timestamp: string, rawBody: string) {
|
|
34
|
+
return edSign(
|
|
35
|
+
null,
|
|
36
|
+
Buffer.concat([Buffer.from(timestamp), Buffer.from(rawBody)]),
|
|
37
|
+
privateKey,
|
|
38
|
+
).toString("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A timestamp inside the replay window (now, in unix seconds). */
|
|
42
|
+
function nowTs(): string {
|
|
43
|
+
return String(Math.floor(Date.now() / 1000));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("verifyInteractionSignature", () => {
|
|
47
|
+
it("accepts a valid signature over timestamp || body", () => {
|
|
48
|
+
const { publicKeyHex, privateKey } = makeSigner();
|
|
49
|
+
const timestamp = nowTs();
|
|
50
|
+
const rawBody = JSON.stringify({ type: 1 });
|
|
51
|
+
const signatureHex = sign(privateKey, timestamp, rawBody);
|
|
52
|
+
|
|
53
|
+
expect(
|
|
54
|
+
verifyInteractionSignature({
|
|
55
|
+
publicKeyHex,
|
|
56
|
+
signatureHex,
|
|
57
|
+
timestamp,
|
|
58
|
+
rawBody,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects a tampered body (signature no longer covers it)", () => {
|
|
64
|
+
const { publicKeyHex, privateKey } = makeSigner();
|
|
65
|
+
const timestamp = nowTs();
|
|
66
|
+
const signatureHex = sign(privateKey, timestamp, JSON.stringify({ a: 1 }));
|
|
67
|
+
|
|
68
|
+
expect(
|
|
69
|
+
verifyInteractionSignature({
|
|
70
|
+
publicKeyHex,
|
|
71
|
+
signatureHex,
|
|
72
|
+
timestamp,
|
|
73
|
+
rawBody: JSON.stringify({ a: 2 }),
|
|
74
|
+
}),
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects a stale timestamp (replay window) even with a valid signature", () => {
|
|
79
|
+
const { publicKeyHex, privateKey } = makeSigner();
|
|
80
|
+
// 10 minutes ago — beyond the 5-minute replay window.
|
|
81
|
+
const timestamp = String(Math.floor(Date.now() / 1000) - 600);
|
|
82
|
+
const rawBody = JSON.stringify({ type: 1 });
|
|
83
|
+
const signatureHex = sign(privateKey, timestamp, rawBody);
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
verifyInteractionSignature({
|
|
87
|
+
publicKeyHex,
|
|
88
|
+
signatureHex,
|
|
89
|
+
timestamp,
|
|
90
|
+
rawBody,
|
|
91
|
+
}),
|
|
92
|
+
).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects a non-numeric timestamp", () => {
|
|
96
|
+
const { publicKeyHex, privateKey } = makeSigner();
|
|
97
|
+
const timestamp = "not-a-number";
|
|
98
|
+
const rawBody = JSON.stringify({ type: 1 });
|
|
99
|
+
const signatureHex = sign(privateKey, timestamp, rawBody);
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
verifyInteractionSignature({
|
|
103
|
+
publicKeyHex,
|
|
104
|
+
signatureHex,
|
|
105
|
+
timestamp,
|
|
106
|
+
rawBody,
|
|
107
|
+
}),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fails closed on a missing signature or timestamp", () => {
|
|
112
|
+
const { publicKeyHex } = makeSigner();
|
|
113
|
+
expect(
|
|
114
|
+
verifyInteractionSignature({
|
|
115
|
+
publicKeyHex,
|
|
116
|
+
signatureHex: "",
|
|
117
|
+
timestamp: nowTs(),
|
|
118
|
+
rawBody: "{}",
|
|
119
|
+
}),
|
|
120
|
+
).toBe(false);
|
|
121
|
+
expect(
|
|
122
|
+
verifyInteractionSignature({
|
|
123
|
+
publicKeyHex,
|
|
124
|
+
signatureHex: "aa".repeat(64),
|
|
125
|
+
timestamp: "",
|
|
126
|
+
rawBody: "{}",
|
|
127
|
+
}),
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("fails closed on a malformed public key", () => {
|
|
132
|
+
expect(
|
|
133
|
+
verifyInteractionSignature({
|
|
134
|
+
publicKeyHex: "not-hex-and-wrong-length",
|
|
135
|
+
signatureHex: "aa".repeat(64),
|
|
136
|
+
timestamp: nowTs(),
|
|
137
|
+
rawBody: "{}",
|
|
138
|
+
}),
|
|
139
|
+
).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("handleInteraction", () => {
|
|
144
|
+
it("answers PING with PONG", async () => {
|
|
145
|
+
expect(await handleInteraction({ type: InteractionType.PING })).toEqual({
|
|
146
|
+
type: InteractionResponseType.PONG,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("defers any non-PING interaction with no deps", async () => {
|
|
151
|
+
expect(
|
|
152
|
+
await handleInteraction({ type: InteractionType.APPLICATION_COMMAND }),
|
|
153
|
+
).toEqual({
|
|
154
|
+
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** Let queued microtasks (the fire-and-forget modal follow-ups) settle. */
|
|
160
|
+
async function flush() {
|
|
161
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
162
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type DepOverrides = Partial<InteractionDeps>;
|
|
166
|
+
|
|
167
|
+
/** Build a fully-mocked {@link InteractionDeps} with sensible happy defaults. */
|
|
168
|
+
function makeDeps(overrides: DepOverrides = {}) {
|
|
169
|
+
const mintCode = vi.fn(
|
|
170
|
+
async (): Promise<LinkMintResult> => ({ ok: true, code: "428917" }),
|
|
171
|
+
);
|
|
172
|
+
const sendLinkCode = vi.fn(async () => {});
|
|
173
|
+
const redeemCode = vi.fn(
|
|
174
|
+
async (): Promise<LinkRedeemResult> => ({
|
|
175
|
+
ok: true,
|
|
176
|
+
email: "ada@example.com",
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
const resolveContact = vi.fn(async () => {});
|
|
180
|
+
// The generalized follow-up editor: a full message `body`, NOT a `content`
|
|
181
|
+
// string (the V2 success card / Enter-code button need `components`/`flags`).
|
|
182
|
+
const editResponse = vi.fn(
|
|
183
|
+
async (_args: {
|
|
184
|
+
applicationId: string;
|
|
185
|
+
token: string;
|
|
186
|
+
body: Record<string, unknown>;
|
|
187
|
+
}) => {},
|
|
188
|
+
);
|
|
189
|
+
const logger = { error: vi.fn() };
|
|
190
|
+
const deps: InteractionDeps = {
|
|
191
|
+
applicationId: "app-1",
|
|
192
|
+
mintCode,
|
|
193
|
+
sendLinkCode,
|
|
194
|
+
redeemCode,
|
|
195
|
+
resolveContact,
|
|
196
|
+
editResponse,
|
|
197
|
+
logger,
|
|
198
|
+
...overrides,
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
deps,
|
|
202
|
+
mintCode,
|
|
203
|
+
sendLinkCode,
|
|
204
|
+
redeemCode,
|
|
205
|
+
resolveContact,
|
|
206
|
+
editResponse,
|
|
207
|
+
logger,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A `/link` APPLICATION_COMMAND payload (guild context → member.user.id). */
|
|
212
|
+
function linkPayload(opts: { userId?: string } = {}) {
|
|
213
|
+
return {
|
|
214
|
+
type: InteractionType.APPLICATION_COMMAND,
|
|
215
|
+
token: "tok-link",
|
|
216
|
+
data: { name: "link" },
|
|
217
|
+
member: { user: { id: opts.userId ?? "discord-user-1" } },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** An email MODAL_SUBMIT payload (legacy Action Row > Text Input shape). */
|
|
222
|
+
function emailSubmitPayload(email: string, opts: { userId?: string } = {}) {
|
|
223
|
+
return {
|
|
224
|
+
type: InteractionType.MODAL_SUBMIT,
|
|
225
|
+
token: "tok-email",
|
|
226
|
+
data: {
|
|
227
|
+
custom_id: CustomIds.EMAIL_MODAL,
|
|
228
|
+
components: [
|
|
229
|
+
{
|
|
230
|
+
type: ComponentType.ACTION_ROW,
|
|
231
|
+
components: [
|
|
232
|
+
{
|
|
233
|
+
type: ComponentType.TEXT_INPUT,
|
|
234
|
+
custom_id: "email",
|
|
235
|
+
value: email,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
member: { user: { id: opts.userId ?? "discord-user-1" } },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** An "Enter code" MESSAGE_COMPONENT (button-click) payload. */
|
|
246
|
+
function enterCodePayload(opts: { userId?: string } = {}) {
|
|
247
|
+
return {
|
|
248
|
+
type: InteractionType.MESSAGE_COMPONENT,
|
|
249
|
+
token: "tok-button",
|
|
250
|
+
data: { custom_id: CustomIds.ENTER_CODE_BUTTON },
|
|
251
|
+
member: { user: { id: opts.userId ?? "discord-user-1" } },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A code MODAL_SUBMIT payload (Label-wrapper shape, to exercise the walk). */
|
|
256
|
+
function codeSubmitPayload(code: string, opts: { userId?: string } = {}) {
|
|
257
|
+
return {
|
|
258
|
+
type: InteractionType.MODAL_SUBMIT,
|
|
259
|
+
token: "tok-code",
|
|
260
|
+
data: {
|
|
261
|
+
custom_id: CustomIds.CODE_MODAL,
|
|
262
|
+
// Modern Label (type 18) wrapping a single nested Text Input — readModalValue
|
|
263
|
+
// must descend into `component`, not just an Action Row's `components`.
|
|
264
|
+
components: [
|
|
265
|
+
{
|
|
266
|
+
type: ComponentType.LABEL,
|
|
267
|
+
component: {
|
|
268
|
+
type: ComponentType.TEXT_INPUT,
|
|
269
|
+
custom_id: "code",
|
|
270
|
+
value: code,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
member: { user: { id: opts.userId ?? "discord-user-1" } },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
describe("handleInteraction — /link (open the email modal)", () => {
|
|
280
|
+
it("opens the email modal (type 9), does NO work", async () => {
|
|
281
|
+
const { deps, mintCode, sendLinkCode } = makeDeps();
|
|
282
|
+
const res = await handleInteraction(linkPayload(), deps);
|
|
283
|
+
|
|
284
|
+
expect(res.type).toBe(InteractionResponseType.MODAL);
|
|
285
|
+
expect(res.data?.custom_id).toBe(CustomIds.EMAIL_MODAL);
|
|
286
|
+
// A modal is inherently private — it takes NO flags.
|
|
287
|
+
expect(res.data?.flags).toBeUndefined();
|
|
288
|
+
// Single Text Input collecting the email by inner custom_id "email".
|
|
289
|
+
const rows = res.data?.components as Array<{
|
|
290
|
+
components: Array<{ custom_id: string }>;
|
|
291
|
+
}>;
|
|
292
|
+
expect(rows?.[0]?.components?.[0]?.custom_id).toBe("email");
|
|
293
|
+
|
|
294
|
+
await flush();
|
|
295
|
+
expect(mintCode).not.toHaveBeenCalled();
|
|
296
|
+
expect(sendLinkCode).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("handleInteraction — email modal submit (defer → mint → send)", () => {
|
|
301
|
+
it("DEFERS ephemerally, then mints + emails + PATCHes the button", async () => {
|
|
302
|
+
const { deps, mintCode, sendLinkCode, editResponse } = makeDeps();
|
|
303
|
+
const res = await handleInteraction(
|
|
304
|
+
emailSubmitPayload("Ada@Example.com"),
|
|
305
|
+
deps,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// Immediate response is a type-5 EPHEMERAL deferred ack (within 3s).
|
|
309
|
+
expect(res.type).toBe(
|
|
310
|
+
InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
311
|
+
);
|
|
312
|
+
expect(res.data?.flags).toBe(InteractionCallbackFlags.EPHEMERAL);
|
|
313
|
+
|
|
314
|
+
await flush();
|
|
315
|
+
|
|
316
|
+
// Email normalized (trim + lowercase) and bound to the invoking user.
|
|
317
|
+
expect(mintCode).toHaveBeenCalledWith({
|
|
318
|
+
discordUserId: "discord-user-1",
|
|
319
|
+
email: "ada@example.com",
|
|
320
|
+
});
|
|
321
|
+
expect(sendLinkCode).toHaveBeenCalledWith({
|
|
322
|
+
email: "ada@example.com",
|
|
323
|
+
code: "428917",
|
|
324
|
+
});
|
|
325
|
+
// The deferred ack is PATCHed into the "check your inbox" + button message.
|
|
326
|
+
expect(editResponse).toHaveBeenCalledTimes(1);
|
|
327
|
+
const editArg = editResponse.mock.calls[0]?.[0];
|
|
328
|
+
expect(editArg?.applicationId).toBe("app-1");
|
|
329
|
+
expect(editArg?.token).toBe("tok-email");
|
|
330
|
+
// The email is NEVER echoed in the rendered message.
|
|
331
|
+
expect(JSON.stringify(editArg?.body)).not.toContain("ada@example.com");
|
|
332
|
+
// The Enter-code button is present, carrying the static bridge custom_id.
|
|
333
|
+
const comps = editArg?.body?.components as Array<{
|
|
334
|
+
components: Array<{ custom_id: string }>;
|
|
335
|
+
}>;
|
|
336
|
+
expect(comps?.[0]?.components?.[0]?.custom_id).toBe(
|
|
337
|
+
CustomIds.ENTER_CODE_BUTTON,
|
|
338
|
+
);
|
|
339
|
+
expect(editArg?.body?.content).toContain("Check your inbox");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("rejects an over-length email (>254) INLINE: no defer, no mint", async () => {
|
|
343
|
+
const { deps, mintCode, sendLinkCode, editResponse } = makeDeps();
|
|
344
|
+
// 250-char local part → > 254 total once the domain is appended.
|
|
345
|
+
const overLong = `${"a".repeat(250)}@example.com`;
|
|
346
|
+
const res = await handleInteraction(emailSubmitPayload(overLong), deps);
|
|
347
|
+
await flush();
|
|
348
|
+
|
|
349
|
+
// Synchronous inline ephemeral error (type 4) — NOT a deferral, so no racy
|
|
350
|
+
// follow-up PATCH is needed for the most common mistake.
|
|
351
|
+
expect(res.type).toBe(InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE);
|
|
352
|
+
expect(res.data?.flags).toBe(InteractionCallbackFlags.EPHEMERAL);
|
|
353
|
+
expect(res.data?.content).toContain("valid email");
|
|
354
|
+
expect(mintCode).not.toHaveBeenCalled();
|
|
355
|
+
expect(sendLinkCode).not.toHaveBeenCalled();
|
|
356
|
+
expect(editResponse).not.toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("rejects a malformed email INLINE: no defer, no mint", async () => {
|
|
360
|
+
const { deps, mintCode, sendLinkCode, editResponse } = makeDeps();
|
|
361
|
+
const res = await handleInteraction(
|
|
362
|
+
emailSubmitPayload("not-an-email"),
|
|
363
|
+
deps,
|
|
364
|
+
);
|
|
365
|
+
await flush();
|
|
366
|
+
|
|
367
|
+
expect(res.type).toBe(InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE);
|
|
368
|
+
expect(res.data?.flags).toBe(InteractionCallbackFlags.EPHEMERAL);
|
|
369
|
+
expect(res.data?.content).toContain("valid email");
|
|
370
|
+
expect(mintCode).not.toHaveBeenCalled();
|
|
371
|
+
expect(sendLinkCode).not.toHaveBeenCalled();
|
|
372
|
+
expect(editResponse).not.toHaveBeenCalled();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("over-throttle: no send, PATCHes a 'too many codes' reply", async () => {
|
|
376
|
+
const mintCode = vi.fn(
|
|
377
|
+
async (): Promise<LinkMintResult> => ({
|
|
378
|
+
ok: false,
|
|
379
|
+
reason: "throttled",
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
const { deps, sendLinkCode, editResponse } = makeDeps({ mintCode });
|
|
383
|
+
await handleInteraction(emailSubmitPayload("ada@example.com"), deps);
|
|
384
|
+
await flush();
|
|
385
|
+
|
|
386
|
+
expect(sendLinkCode).not.toHaveBeenCalled();
|
|
387
|
+
expect(editResponse).toHaveBeenCalledTimes(1);
|
|
388
|
+
expect(editResponse.mock.calls[0]?.[0]?.body?.content).toContain(
|
|
389
|
+
"too many codes",
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("fails closed on a mint throw: logs, PATCHes an apology, no send", async () => {
|
|
394
|
+
const mintCode = vi.fn(async (): Promise<LinkMintResult> => {
|
|
395
|
+
throw new Error("db down");
|
|
396
|
+
});
|
|
397
|
+
const { deps, sendLinkCode, editResponse, logger } = makeDeps({ mintCode });
|
|
398
|
+
await handleInteraction(emailSubmitPayload("ada@example.com"), deps);
|
|
399
|
+
await flush();
|
|
400
|
+
|
|
401
|
+
expect(sendLinkCode).not.toHaveBeenCalled();
|
|
402
|
+
expect(logger.error).toHaveBeenCalled();
|
|
403
|
+
// Never log the email / any provider detail — only a short reason.
|
|
404
|
+
const meta = logger.error.mock.calls[0]?.[1] as { error?: string };
|
|
405
|
+
expect(meta?.error).toBe("db down");
|
|
406
|
+
expect(editResponse.mock.calls[0]?.[0]?.body?.content).toContain(
|
|
407
|
+
"went wrong",
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("handleInteraction — Enter-code button (open the code modal)", () => {
|
|
413
|
+
it("opens the code modal (type 9) from the bridge button", async () => {
|
|
414
|
+
const { deps, redeemCode } = makeDeps();
|
|
415
|
+
const res = await handleInteraction(enterCodePayload(), deps);
|
|
416
|
+
|
|
417
|
+
expect(res.type).toBe(InteractionResponseType.MODAL);
|
|
418
|
+
expect(res.data?.custom_id).toBe(CustomIds.CODE_MODAL);
|
|
419
|
+
const rows = res.data?.components as Array<{
|
|
420
|
+
components: Array<{ custom_id: string }>;
|
|
421
|
+
}>;
|
|
422
|
+
expect(rows?.[0]?.components?.[0]?.custom_id).toBe("code");
|
|
423
|
+
|
|
424
|
+
await flush();
|
|
425
|
+
expect(redeemCode).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe("handleInteraction — code modal submit (defer → redeem → card)", () => {
|
|
430
|
+
it("DEFERS, then redeems + resolves + PATCHes a V2 success card", async () => {
|
|
431
|
+
const { deps, redeemCode, resolveContact, editResponse } = makeDeps();
|
|
432
|
+
const res = await handleInteraction(codeSubmitPayload(" 428917 "), deps);
|
|
433
|
+
|
|
434
|
+
expect(res.type).toBe(
|
|
435
|
+
InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
436
|
+
);
|
|
437
|
+
expect(res.data?.flags).toBe(InteractionCallbackFlags.EPHEMERAL);
|
|
438
|
+
|
|
439
|
+
await flush();
|
|
440
|
+
|
|
441
|
+
// Code trimmed (Label-wrapper value read) and bound to the invoking user.
|
|
442
|
+
expect(redeemCode).toHaveBeenCalledWith({
|
|
443
|
+
discordUserId: "discord-user-1",
|
|
444
|
+
code: "428917",
|
|
445
|
+
});
|
|
446
|
+
expect(resolveContact).toHaveBeenCalledWith({
|
|
447
|
+
discordId: "discord-user-1",
|
|
448
|
+
email: "ada@example.com",
|
|
449
|
+
});
|
|
450
|
+
// The PATCH is a Components-V2 ephemeral success card: flags 64 | 32768.
|
|
451
|
+
const editArg = editResponse.mock.calls[0]?.[0];
|
|
452
|
+
expect(editArg?.token).toBe("tok-code");
|
|
453
|
+
expect(editArg?.body?.flags).toBe(
|
|
454
|
+
InteractionCallbackFlags.EPHEMERAL |
|
|
455
|
+
InteractionCallbackFlags.IS_COMPONENTS_V2,
|
|
456
|
+
);
|
|
457
|
+
// The success card NEVER prints the email.
|
|
458
|
+
expect(JSON.stringify(editArg?.body)).not.toContain("ada@example.com");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("invalid/used/expired collapse to one non-leaking text edit", async () => {
|
|
462
|
+
const redeemCode = vi.fn(
|
|
463
|
+
async (): Promise<LinkRedeemResult> => ({ ok: false, reason: "invalid" }),
|
|
464
|
+
);
|
|
465
|
+
const { deps, resolveContact, editResponse } = makeDeps({ redeemCode });
|
|
466
|
+
await handleInteraction(codeSubmitPayload("000000"), deps);
|
|
467
|
+
await flush();
|
|
468
|
+
|
|
469
|
+
const body = editResponse.mock.calls[0]?.[0]?.body;
|
|
470
|
+
// Failure is a PLAIN text edit (no V2 flag).
|
|
471
|
+
expect(body?.flags).toBeUndefined();
|
|
472
|
+
expect(body?.content).toContain("invalid, expired, or already used");
|
|
473
|
+
expect(resolveContact).not.toHaveBeenCalled();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("wrong_user is rejected without attaching (no identity grafting)", async () => {
|
|
477
|
+
const redeemCode = vi.fn(
|
|
478
|
+
async (): Promise<LinkRedeemResult> => ({
|
|
479
|
+
ok: false,
|
|
480
|
+
reason: "wrong_user",
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
const { deps, resolveContact, editResponse } = makeDeps({ redeemCode });
|
|
484
|
+
await handleInteraction(codeSubmitPayload("428917"), deps);
|
|
485
|
+
await flush();
|
|
486
|
+
|
|
487
|
+
expect(editResponse.mock.calls[0]?.[0]?.body?.content).toContain(
|
|
488
|
+
"invalid, expired, or already used",
|
|
489
|
+
);
|
|
490
|
+
expect(resolveContact).not.toHaveBeenCalled();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("attempt-throttle blocks BEFORE redeem when over cap", async () => {
|
|
494
|
+
const recordVerifyAttempt = vi.fn(async () => ({ throttled: true }));
|
|
495
|
+
const { deps, redeemCode, editResponse } = makeDeps({
|
|
496
|
+
recordVerifyAttempt,
|
|
497
|
+
});
|
|
498
|
+
await handleInteraction(codeSubmitPayload("428917"), deps);
|
|
499
|
+
await flush();
|
|
500
|
+
|
|
501
|
+
expect(editResponse.mock.calls[0]?.[0]?.body?.content).toContain(
|
|
502
|
+
"Too many verification attempts",
|
|
503
|
+
);
|
|
504
|
+
expect(recordVerifyAttempt).toHaveBeenCalledWith({
|
|
505
|
+
discordUserId: "discord-user-1",
|
|
506
|
+
});
|
|
507
|
+
expect(redeemCode).not.toHaveBeenCalled();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("fail-OPEN: a throttle THROW still proceeds to redeem", async () => {
|
|
511
|
+
const recordVerifyAttempt = vi.fn(async () => {
|
|
512
|
+
throw new Error("redis down");
|
|
513
|
+
});
|
|
514
|
+
const { deps, redeemCode, resolveContact, editResponse, logger } = makeDeps(
|
|
515
|
+
{
|
|
516
|
+
recordVerifyAttempt,
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
await handleInteraction(codeSubmitPayload("428917"), deps);
|
|
520
|
+
await flush();
|
|
521
|
+
|
|
522
|
+
// The throttle threw, was logged, and the redeem STILL ran (fail-open).
|
|
523
|
+
expect(logger.error).toHaveBeenCalled();
|
|
524
|
+
expect(redeemCode).toHaveBeenCalledWith({
|
|
525
|
+
discordUserId: "discord-user-1",
|
|
526
|
+
code: "428917",
|
|
527
|
+
});
|
|
528
|
+
expect(resolveContact).toHaveBeenCalled();
|
|
529
|
+
// Success card still rendered.
|
|
530
|
+
expect(editResponse.mock.calls[0]?.[0]?.body?.flags).toBe(
|
|
531
|
+
InteractionCallbackFlags.EPHEMERAL |
|
|
532
|
+
InteractionCallbackFlags.IS_COMPONENTS_V2,
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
/** A `/verify <code>` APPLICATION_COMMAND payload (the slash fallback). */
|
|
538
|
+
function verifyPayload(code: string, opts: { userId?: string } = {}) {
|
|
539
|
+
return {
|
|
540
|
+
type: InteractionType.APPLICATION_COMMAND,
|
|
541
|
+
token: "tok-verify",
|
|
542
|
+
data: { name: "verify", options: [{ name: "code", value: code }] },
|
|
543
|
+
member: { user: { id: opts.userId ?? "discord-user-1" } },
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
describe("handleInteraction — /verify slash fallback", () => {
|
|
548
|
+
it("redeems, attaches the email, replies ephemerally (inline)", async () => {
|
|
549
|
+
const { deps, redeemCode, resolveContact } = makeDeps();
|
|
550
|
+
const res = await handleInteraction(verifyPayload(" 428917 "), deps);
|
|
551
|
+
|
|
552
|
+
// INLINE ephemeral reply (type 4) — no deferral for the slash fallback.
|
|
553
|
+
expect(res.type).toBe(InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE);
|
|
554
|
+
expect(res.data?.flags).toBe(InteractionCallbackFlags.EPHEMERAL);
|
|
555
|
+
expect(res.data?.content).toContain("all set");
|
|
556
|
+
|
|
557
|
+
expect(redeemCode).toHaveBeenCalledWith({
|
|
558
|
+
discordUserId: "discord-user-1",
|
|
559
|
+
code: "428917",
|
|
560
|
+
});
|
|
561
|
+
expect(resolveContact).toHaveBeenCalledWith({
|
|
562
|
+
discordId: "discord-user-1",
|
|
563
|
+
email: "ada@example.com",
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("rejects an empty code without redeeming", async () => {
|
|
568
|
+
const { deps, redeemCode } = makeDeps();
|
|
569
|
+
const res = await handleInteraction(verifyPayload(" "), deps);
|
|
570
|
+
|
|
571
|
+
expect(res.data?.content).toContain("invalid, expired, or already used");
|
|
572
|
+
expect(redeemCode).not.toHaveBeenCalled();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("invalid collapses to one non-leaking reply, no attach", async () => {
|
|
576
|
+
const redeemCode = vi.fn(
|
|
577
|
+
async (): Promise<LinkRedeemResult> => ({ ok: false, reason: "invalid" }),
|
|
578
|
+
);
|
|
579
|
+
const { deps, resolveContact } = makeDeps({ redeemCode });
|
|
580
|
+
const res = await handleInteraction(verifyPayload("000000"), deps);
|
|
581
|
+
|
|
582
|
+
expect(res.data?.content).toContain("invalid, expired, or already used");
|
|
583
|
+
expect(resolveContact).not.toHaveBeenCalled();
|
|
584
|
+
});
|
|
585
|
+
});
|