@cosmicdrift/kumiko-bundled-features 0.296.0 → 0.299.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/package.json +9 -9
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +11 -37
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +159 -119
- package/src/billing-foundation/__tests__/webhook-handler.integration.test.ts +174 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +1 -1
- package/src/billing-foundation/index.ts +2 -4
- package/src/billing-foundation/subscription-tier-sync.ts +110 -83
- package/src/billing-foundation/webhook-handler.ts +117 -187
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +52 -81
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/connect-routes.ts +218 -197
- package/src/inbound-mail-foundation/index.ts +1 -2
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +7 -40
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +18 -60
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +0 -200
|
@@ -1,30 +1,22 @@
|
|
|
1
|
-
// createInboundMailConnectRoutes —
|
|
2
|
-
// via `extraRoutes` in
|
|
3
|
-
// billing-foundation/webhook-handler.ts).
|
|
1
|
+
// createInboundMailConnectRoutes — declarative extraRoutes (kumiko-
|
|
2
|
+
// framework#3050) the app owner mounts via `extraRoutes` in buildServer.
|
|
4
3
|
//
|
|
5
|
-
// GET /inbound-mail/connect
|
|
6
|
-
// GET /inbound-mail/oauth/callback (
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// GET /api/inbound-mail/connect (entry:"user" — signed-in caller)
|
|
5
|
+
// GET /inbound-mail/oauth/callback (entry:"signature" — anonymous,
|
|
6
|
+
// outside /api/*; verify() IS the auth,
|
|
7
|
+
// trusting only the HMAC-signed state)
|
|
9
8
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// app.get("/inbound-mail/oauth/callback", routes.callback); // anonymous
|
|
22
|
-
//
|
|
23
|
-
// Provider registrieren KEINE eigenen Routen — ihr oauth-Block wird über
|
|
24
|
-
// resolveInboundProviderForKey aufgelöst. Der Callback vertraut NUR dem
|
|
25
|
-
// HMAC-verifizierten state (CSRF-/Fremd-Claiming-Sperre, oauth-state.ts).
|
|
26
|
-
|
|
27
|
-
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
9
|
+
// Providers register no routes of their own — their oauth block is
|
|
10
|
+
// resolved via resolveInboundProviderForKey. The callback trusts ONLY the
|
|
11
|
+
// HMAC-verified state (CSRF-/foreign-claiming lock, see oauth-state.ts).
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ExtraRouteDefinition,
|
|
15
|
+
SignatureExtraRouteDeps,
|
|
16
|
+
SignatureExtraRouteVerifyRequest,
|
|
17
|
+
UserExtraRouteDeps,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
19
|
+
import { ExtraRouteRejection, signatureRoute } from "@cosmicdrift/kumiko-framework/api";
|
|
28
20
|
import type { Context } from "hono";
|
|
29
21
|
import {
|
|
30
22
|
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
@@ -32,199 +24,228 @@ import {
|
|
|
32
24
|
InboundMailFoundationHandlers,
|
|
33
25
|
inboundCredentialSecretKey,
|
|
34
26
|
} from "./constants";
|
|
35
|
-
import { signOAuthState, verifyOAuthState } from "./oauth-state";
|
|
27
|
+
import { type OAuthStatePayload, signOAuthState, verifyOAuthState } from "./oauth-state";
|
|
36
28
|
import { resolveInboundProviderForKey } from "./provider-factory";
|
|
37
|
-
import type { InboundMailContext } from "./types";
|
|
38
29
|
|
|
39
30
|
const DEFAULT_STATE_TTL_MINUTES = 15;
|
|
31
|
+
const DEFAULT_CONNECT_PATH = "/api/inbound-mail/connect";
|
|
32
|
+
const DEFAULT_CALLBACK_PATH = "/inbound-mail/oauth/callback";
|
|
40
33
|
|
|
41
|
-
export type
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
/** Schreibt durch den Standard-Dispatcher mit auto-konstruiertem
|
|
45
|
-
* SystemUser — muss connect-account als SystemAdmin durchlassen. */
|
|
46
|
-
readonly dispatchWrite: (args: {
|
|
47
|
-
readonly handlerQn: string;
|
|
48
|
-
readonly payload: unknown;
|
|
49
|
-
readonly tenantId: string;
|
|
50
|
-
}) => Promise<{
|
|
51
|
-
readonly isSuccess: boolean;
|
|
52
|
-
readonly data?: unknown;
|
|
53
|
-
readonly error?: unknown;
|
|
54
|
-
}>;
|
|
55
|
-
/** Tenant-Secret-Write für den Refresh-Token (Slot = accountId). */
|
|
56
|
-
readonly secrets: SecretsContext;
|
|
57
|
-
/** HMAC-Secret für den state-Parameter (Deploy-Config aus env —
|
|
58
|
-
* `scope:"system"`-Secrets existieren in secrets-v1 nicht). */
|
|
34
|
+
export type InboundMailConnectRoutesOptions = {
|
|
35
|
+
/** HMAC-secret for the state param (deploy-time env — `scope:"system"`
|
|
36
|
+
* secrets don't exist in secrets-v1). */
|
|
59
37
|
readonly stateSecret: string;
|
|
60
|
-
/** Absolute
|
|
38
|
+
/** Absolute callback URL — must match what's registered with the OAuth
|
|
39
|
+
* provider exactly. */
|
|
61
40
|
readonly callbackUrl: string;
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
41
|
+
/** Where the browser redirects after a successful connect. Omitted →
|
|
42
|
+
* the callback answers with JSON instead (API-/test-mode). */
|
|
64
43
|
readonly successRedirectUrl?: string;
|
|
65
44
|
readonly stateTtlMinutes?: number;
|
|
45
|
+
/** Default "/api/inbound-mail/connect". */
|
|
46
|
+
readonly connectPath?: string;
|
|
47
|
+
/** Default "/inbound-mail/oauth/callback". */
|
|
48
|
+
readonly callbackPath?: string;
|
|
66
49
|
};
|
|
67
50
|
|
|
68
|
-
function errorJson(c: Context, status: 400 | 401 | 404 | 502, code: string, message: string) {
|
|
51
|
+
function errorJson(c: Context, status: 400 | 401 | 404 | 500 | 502, code: string, message: string) {
|
|
69
52
|
return c.json({ error: { code, message } }, status);
|
|
70
53
|
}
|
|
71
54
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
type VerifiedCallback = { readonly code: string; readonly state: OAuthStatePayload };
|
|
56
|
+
|
|
57
|
+
function verifyCallbackRequest(
|
|
58
|
+
request: SignatureExtraRouteVerifyRequest,
|
|
59
|
+
stateSecret: string,
|
|
60
|
+
): VerifiedCallback {
|
|
61
|
+
const code = request.query["code"] ?? "";
|
|
62
|
+
const rawState = request.query["state"] ?? "";
|
|
63
|
+
if (!code || !rawState) {
|
|
64
|
+
throw new ExtraRouteRejection(
|
|
65
|
+
400,
|
|
66
|
+
{ error: { code: "invalid_callback", message: "missing code or state" } },
|
|
67
|
+
"inbound-mail oauth callback missing code or state",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const verified = verifyOAuthState(rawState, stateSecret);
|
|
71
|
+
if (!verified.ok) {
|
|
72
|
+
throw new ExtraRouteRejection(
|
|
73
|
+
400,
|
|
74
|
+
{ error: { code: "invalid_state", message: `state rejected: ${verified.reason}` } },
|
|
75
|
+
`inbound-mail oauth callback state rejected: ${verified.reason}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return { code, state: verified.payload };
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
81
|
+
/** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
|
|
82
|
+
* `extraRoutes: createInboundMailConnectRoutes(options)`. */
|
|
83
|
+
export function createInboundMailConnectRoutes(
|
|
84
|
+
options: InboundMailConnectRoutesOptions,
|
|
85
|
+
): readonly ExtraRouteDefinition[] {
|
|
86
|
+
const ttl = options.stateTtlMinutes ?? DEFAULT_STATE_TTL_MINUTES;
|
|
87
|
+
|
|
88
|
+
const connectRoute: ExtraRouteDefinition = {
|
|
89
|
+
method: "GET",
|
|
90
|
+
path: options.connectPath ?? DEFAULT_CONNECT_PATH,
|
|
91
|
+
entry: "user",
|
|
92
|
+
handler: async (c: Context, deps: UserExtraRouteDeps): Promise<Response> => {
|
|
93
|
+
const providerKey = c.req.query("provider") ?? "";
|
|
94
|
+
const scope = c.req.query("scope") ?? "";
|
|
95
|
+
const mailbox = c.req.query("mailbox") ?? "";
|
|
96
|
+
if (!providerKey || (scope !== "user" && scope !== "shared") || !mailbox) {
|
|
97
|
+
return errorJson(
|
|
98
|
+
c,
|
|
99
|
+
400,
|
|
100
|
+
"invalid_connect_request",
|
|
101
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: expected ?provider=<key>&scope=user|shared&mailbox=<address>`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let plugin: ReturnType<typeof resolveInboundProviderForKey>;
|
|
106
|
+
try {
|
|
107
|
+
plugin = resolveInboundProviderForKey({ registry: deps.registry }, providerKey);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return errorJson(
|
|
110
|
+
c,
|
|
111
|
+
404,
|
|
112
|
+
"provider_not_registered",
|
|
113
|
+
e instanceof Error ? e.message : String(e),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (!plugin.oauth) {
|
|
117
|
+
return errorJson(
|
|
118
|
+
c,
|
|
119
|
+
400,
|
|
120
|
+
"provider_has_no_oauth_flow",
|
|
121
|
+
`provider "${providerKey}" connects via credentials form (connect-account write-handler), not OAuth`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const state = signOAuthState(
|
|
126
|
+
{
|
|
127
|
+
tenantId: deps.user.tenantId,
|
|
128
|
+
ownerUserId: scope === "user" ? deps.user.id : null,
|
|
129
|
+
providerKey,
|
|
130
|
+
mailbox,
|
|
131
|
+
},
|
|
132
|
+
ttl,
|
|
133
|
+
options.stateSecret,
|
|
108
134
|
);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
c,
|
|
113
|
-
400,
|
|
114
|
-
"provider_has_no_oauth_flow",
|
|
115
|
-
`provider "${providerKey}" connects via credentials form (connect-account write-handler), not OAuth`,
|
|
135
|
+
const authorizeUrl = await plugin.oauth.buildAuthorizeUrl(
|
|
136
|
+
{ registry: deps.registry },
|
|
137
|
+
{ state, redirectUri: options.callbackUrl },
|
|
116
138
|
);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const state = signOAuthState(
|
|
120
|
-
{
|
|
121
|
-
tenantId: user.tenantId,
|
|
122
|
-
ownerUserId: scope === "user" ? user.id : null,
|
|
123
|
-
providerKey,
|
|
124
|
-
mailbox,
|
|
125
|
-
},
|
|
126
|
-
ttl,
|
|
127
|
-
deps.stateSecret,
|
|
128
|
-
);
|
|
129
|
-
const authorizeUrl = await plugin.oauth.buildAuthorizeUrl(deps.providerCtx, {
|
|
130
|
-
state,
|
|
131
|
-
redirectUri: deps.callbackUrl,
|
|
132
|
-
});
|
|
133
|
-
return c.redirect(authorizeUrl, 302);
|
|
139
|
+
return c.redirect(authorizeUrl, 302);
|
|
140
|
+
},
|
|
134
141
|
};
|
|
135
142
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
143
|
+
const callbackRoute = signatureRoute<VerifiedCallback>({
|
|
144
|
+
method: "GET",
|
|
145
|
+
path: options.callbackPath ?? DEFAULT_CALLBACK_PATH,
|
|
146
|
+
entry: "signature",
|
|
147
|
+
verify: async (request) => verifyCallbackRequest(request, options.stateSecret),
|
|
148
|
+
handler: async (
|
|
149
|
+
c: Context,
|
|
150
|
+
verified: VerifiedCallback,
|
|
151
|
+
deps: SignatureExtraRouteDeps,
|
|
152
|
+
): Promise<Response> => {
|
|
153
|
+
const { code, state } = verified;
|
|
154
|
+
const providerCtx = { registry: deps.registry, secrets: deps.secrets, config: undefined };
|
|
155
|
+
|
|
156
|
+
let plugin: ReturnType<typeof resolveInboundProviderForKey>;
|
|
157
|
+
try {
|
|
158
|
+
plugin = resolveInboundProviderForKey(providerCtx, state.providerKey);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return errorJson(
|
|
161
|
+
c,
|
|
162
|
+
404,
|
|
163
|
+
"provider_not_registered",
|
|
164
|
+
e instanceof Error ? e.message : String(e),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (!plugin.oauth) {
|
|
168
|
+
return errorJson(c, 400, "provider_has_no_oauth_flow", state.providerKey);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let tokens: Awaited<ReturnType<NonNullable<typeof plugin.oauth>["exchangeCode"]>>;
|
|
172
|
+
try {
|
|
173
|
+
tokens = await plugin.oauth.exchangeCode(providerCtx, {
|
|
174
|
+
code,
|
|
175
|
+
redirectUri: options.callbackUrl,
|
|
176
|
+
});
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return errorJson(
|
|
179
|
+
c,
|
|
180
|
+
502,
|
|
181
|
+
"token_exchange_failed",
|
|
182
|
+
e instanceof Error ? e.message : String(e),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!tokens.refreshToken) {
|
|
186
|
+
// No refresh token → no server-side sync possible. Consent must
|
|
187
|
+
// request offline_access (or access_type=offline) — the provider's
|
|
188
|
+
// oauth block owns the scopes.
|
|
189
|
+
return errorJson(
|
|
190
|
+
c,
|
|
191
|
+
502,
|
|
192
|
+
"no_refresh_token",
|
|
193
|
+
`provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Account creation (programmatic SystemUser; the real owner comes from
|
|
198
|
+
// the HMAC-verified state → ownerUserIdOverride).
|
|
199
|
+
const dispatched = await deps.dispatchSystemWrite({
|
|
200
|
+
handlerQn: InboundMailFoundationHandlers.connectAccount,
|
|
201
|
+
tenantId: state.tenantId,
|
|
202
|
+
payload: {
|
|
203
|
+
provider: state.providerKey,
|
|
204
|
+
authMethod: InboundMailAuthMethods.oauth,
|
|
205
|
+
displayName: state.mailbox,
|
|
206
|
+
address: state.mailbox,
|
|
207
|
+
scope: state.ownerUserId ? "user" : "shared",
|
|
208
|
+
ownerUserIdOverride: state.ownerUserId,
|
|
209
|
+
},
|
|
168
210
|
});
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
if (!dispatched.isSuccess) {
|
|
212
|
+
return errorJson(c, 502, "account_create_failed", JSON.stringify(dispatched.error ?? {}));
|
|
213
|
+
}
|
|
214
|
+
const accountId = (dispatched.data as { accountId?: unknown } | undefined)?.accountId;
|
|
215
|
+
if (typeof accountId !== "string") {
|
|
216
|
+
return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Refresh-token into the per-account secret slot (Slot = accountId).
|
|
220
|
+
// Access tokens (~1h) are NEVER persisted — refresh-before-poll in the
|
|
221
|
+
// sync path. `deps.secrets` is optional on SignatureExtraRouteDeps —
|
|
222
|
+
// without it the token would be silently lost, so fail loud instead.
|
|
223
|
+
if (!deps.secrets) {
|
|
224
|
+
return errorJson(
|
|
225
|
+
c,
|
|
226
|
+
500,
|
|
227
|
+
"secrets_context_missing",
|
|
228
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
await deps.secrets.set(
|
|
232
|
+
state.tenantId,
|
|
233
|
+
inboundCredentialSecretKey(accountId),
|
|
234
|
+
tokens.refreshToken,
|
|
235
|
+
{
|
|
236
|
+
redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
|
|
237
|
+
hint: `OAuth refresh token for inbound mail account ${accountId}`,
|
|
238
|
+
},
|
|
181
239
|
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Account anlegen (programmatic SystemUser; echter Owner kommt aus
|
|
185
|
-
// dem HMAC-verifizierten state → ownerUserIdOverride).
|
|
186
|
-
const dispatched = await deps.dispatchWrite({
|
|
187
|
-
handlerQn: InboundMailFoundationHandlers.connectAccount,
|
|
188
|
-
tenantId: state.tenantId,
|
|
189
|
-
payload: {
|
|
190
|
-
provider: state.providerKey,
|
|
191
|
-
authMethod: InboundMailAuthMethods.oauth,
|
|
192
|
-
displayName: state.mailbox,
|
|
193
|
-
address: state.mailbox,
|
|
194
|
-
scope: state.ownerUserId ? "user" : "shared",
|
|
195
|
-
ownerUserIdOverride: state.ownerUserId,
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
if (!dispatched.isSuccess) {
|
|
199
|
-
return errorJson(c, 502, "account_create_failed", JSON.stringify(dispatched.error ?? {}));
|
|
200
|
-
}
|
|
201
|
-
const accountId = (dispatched.data as { accountId?: unknown } | undefined)?.accountId;
|
|
202
|
-
if (typeof accountId !== "string") {
|
|
203
|
-
return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Refresh-Token in den per-Account-Secret-Slot (Request-Pfad =
|
|
207
|
-
// voller SecretsContext). Access-Tokens (~1h) werden NIE persistiert
|
|
208
|
-
// — refresh-before-poll im Sync-Pfad.
|
|
209
|
-
await deps.secrets.set(
|
|
210
|
-
state.tenantId,
|
|
211
|
-
inboundCredentialSecretKey(accountId),
|
|
212
|
-
tokens.refreshToken,
|
|
213
|
-
{
|
|
214
|
-
redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
|
|
215
|
-
hint: `OAuth refresh token for inbound mail account ${accountId}`,
|
|
216
|
-
},
|
|
217
|
-
);
|
|
218
240
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
241
|
+
if (options.successRedirectUrl) {
|
|
242
|
+
const target = new URL(options.successRedirectUrl);
|
|
243
|
+
target.searchParams.set("accountId", accountId);
|
|
244
|
+
return c.redirect(target.toString(), 302);
|
|
245
|
+
}
|
|
246
|
+
return c.json({ connected: true, accountId }, 200);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
226
249
|
|
|
227
|
-
return
|
|
250
|
+
return [connectRoute, callbackRoute];
|
|
228
251
|
}
|
|
229
|
-
|
|
230
|
-
export type InboundMailConnectRoutes = ReturnType<typeof createInboundMailConnectRoutes>;
|
|
@@ -6,8 +6,7 @@ export {
|
|
|
6
6
|
} from "./aggregate-id";
|
|
7
7
|
export {
|
|
8
8
|
createInboundMailConnectRoutes,
|
|
9
|
-
type
|
|
10
|
-
type InboundMailConnectRoutesDeps,
|
|
9
|
+
type InboundMailConnectRoutesOptions,
|
|
11
10
|
} from "./connect-routes";
|
|
12
11
|
export {
|
|
13
12
|
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
@@ -21,12 +21,10 @@
|
|
|
21
21
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
22
22
|
import {
|
|
23
23
|
billingFoundationFeature,
|
|
24
|
-
|
|
25
|
-
type SubscriptionProviderPlugin,
|
|
24
|
+
createSubscriptionWebhookRoute,
|
|
26
25
|
subscriptionAggregateId,
|
|
27
26
|
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
28
27
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
29
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
30
28
|
import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
31
29
|
import {
|
|
32
30
|
createTestUser,
|
|
@@ -39,7 +37,6 @@ import type {
|
|
|
39
37
|
Payment as MolliePayment,
|
|
40
38
|
Subscription as MollieSubscription,
|
|
41
39
|
} from "@mollie/api-client";
|
|
42
|
-
import { Hono } from "hono";
|
|
43
40
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
44
41
|
import { createConfigFeature } from "../../config";
|
|
45
42
|
import { createTenantFeature } from "../../tenant/feature";
|
|
@@ -125,7 +122,6 @@ const PRICE_TO_CONFIG = {
|
|
|
125
122
|
|
|
126
123
|
let stack: TestStack;
|
|
127
124
|
let db: DbConnection;
|
|
128
|
-
let webhookApp: Hono;
|
|
129
125
|
|
|
130
126
|
beforeAll(async () => {
|
|
131
127
|
// Echte factory mit injection-port. Das beweist factory-Logik
|
|
@@ -147,41 +143,12 @@ beforeAll(async () => {
|
|
|
147
143
|
billingFoundationFeature,
|
|
148
144
|
mollieFeature,
|
|
149
145
|
],
|
|
146
|
+
extraRoutes: [createSubscriptionWebhookRoute()],
|
|
150
147
|
});
|
|
151
148
|
db = stack.db;
|
|
152
149
|
// subscriptionsProjectionTable wird von setupTestStack automatisch
|
|
153
150
|
// gepusht (r.projection mit `table`-Property → auto-push).
|
|
154
151
|
await unsafeCreateEntityTable(db, tenantEntity);
|
|
155
|
-
|
|
156
|
-
webhookApp = new Hono();
|
|
157
|
-
webhookApp.post(
|
|
158
|
-
"/api/subscription/webhook/:providerName",
|
|
159
|
-
createSubscriptionWebhookHandler({
|
|
160
|
-
dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
|
|
161
|
-
const systemUser = createTestUser({
|
|
162
|
-
id: 1,
|
|
163
|
-
tenantId: tenantId as TenantId,
|
|
164
|
-
roles: ["SystemAdmin"],
|
|
165
|
-
});
|
|
166
|
-
const res = await stack.http.write(handlerQn, payload, systemUser);
|
|
167
|
-
const body = (await res.json()) as {
|
|
168
|
-
isSuccess?: boolean;
|
|
169
|
-
data?: unknown;
|
|
170
|
-
error?: unknown;
|
|
171
|
-
};
|
|
172
|
-
return body.isSuccess
|
|
173
|
-
? { isSuccess: true, ...(body.data !== undefined && { data: body.data }) }
|
|
174
|
-
: { isSuccess: false, ...(body.error !== undefined && { error: body.error }) };
|
|
175
|
-
},
|
|
176
|
-
resolveProvider: (providerName) => {
|
|
177
|
-
const usage = stack.registry
|
|
178
|
-
.getExtensionUsages("subscriptionProvider")
|
|
179
|
-
.find((u) => u.entityName === providerName);
|
|
180
|
-
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
181
|
-
return usage?.options as SubscriptionProviderPlugin | undefined;
|
|
182
|
-
},
|
|
183
|
-
}),
|
|
184
|
-
);
|
|
185
152
|
});
|
|
186
153
|
|
|
187
154
|
afterAll(async () => {
|
|
@@ -227,7 +194,7 @@ function buildMockSubscription(
|
|
|
227
194
|
}
|
|
228
195
|
|
|
229
196
|
async function postMollieWebhook(id: string) {
|
|
230
|
-
return
|
|
197
|
+
return stack.app.request("/api/subscription/webhook/mollie", {
|
|
231
198
|
method: "POST",
|
|
232
199
|
body: `id=${id}`,
|
|
233
200
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
@@ -386,15 +353,15 @@ describe("scenario 3: idempotency via Mollie-retry", () => {
|
|
|
386
353
|
});
|
|
387
354
|
|
|
388
355
|
describe("scenario 4: error + ignored paths", () => {
|
|
389
|
-
test("body ohne id → 401 (Plugin throws,
|
|
390
|
-
const res = await
|
|
356
|
+
test("body ohne id → 401 (Plugin throws, extraRoute mapped auf extra_route_signature_invalid)", async () => {
|
|
357
|
+
const res = await stack.app.request("/api/subscription/webhook/mollie", {
|
|
391
358
|
method: "POST",
|
|
392
359
|
body: "no-id-field",
|
|
393
360
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
394
361
|
});
|
|
395
362
|
expect(res.status).toBe(401);
|
|
396
363
|
const body = (await res.json()) as { error: { code: string } };
|
|
397
|
-
expect(body.error.code).toBe("
|
|
364
|
+
expect(body.error.code).toBe("extra_route_signature_invalid");
|
|
398
365
|
});
|
|
399
366
|
|
|
400
367
|
test("sub_xxx-direct-event → 200 ignored, kein DB-write", async () => {
|
|
@@ -422,7 +389,7 @@ describe("scenario 4: error + ignored paths", () => {
|
|
|
422
389
|
// handler refactored sodass er ALLE requests an das erste plugin
|
|
423
390
|
// routet, wäre dieser Test grün — bricht aber wenn der Test einen
|
|
424
391
|
// unbekannten provider-name fordert.
|
|
425
|
-
const res = await
|
|
392
|
+
const res = await stack.app.request("/api/subscription/webhook/paypal", {
|
|
426
393
|
method: "POST",
|
|
427
394
|
body: "id=tr_dummy",
|
|
428
395
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|