@stamhoofd/backend 2.138.1 → 2.139.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.
Files changed (37) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +165 -0
  3. package/src/crons/fake-settlements.ts +117 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
  6. package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
  8. package/src/endpoints/auth/MFA.test.ts +187 -3
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +1 -1
  10. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
  11. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
  16. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  17. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
  18. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
  19. package/src/excel-loaders/balance-item-payments.ts +145 -0
  20. package/src/excel-loaders/index.ts +1 -0
  21. package/src/excel-loaders/payments.ts +45 -37
  22. package/src/helpers/StripeInvoicer.ts +1 -1
  23. package/src/helpers/TwoFactorHelper.ts +91 -9
  24. package/src/helpers/breakdownRelations.ts +48 -0
  25. package/src/helpers/getNextPageRequest.ts +24 -0
  26. package/src/helpers/streamForBreakdown.test.ts +107 -0
  27. package/src/helpers/streamForBreakdown.ts +104 -0
  28. package/src/services/PasswordForgotService.ts +31 -1
  29. package/src/services/PaymentService.ts +1 -1
  30. package/src/services/SSOService.ts +14 -1
  31. package/src/sql-filters/balance-item-payments-root.ts +48 -0
  32. package/src/sql-filters/balance-items.ts +55 -0
  33. package/src/sql-filters/payment-settlement.test.ts +68 -0
  34. package/src/sql-filters/payment-settlement.ts +43 -0
  35. package/src/sql-filters/payments.ts +93 -31
  36. package/src/sql-sorters/balance-item-payments.ts +32 -0
  37. package/tests/helpers/ExportSlice.ts +71 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.138.1",
3
+ "version": "2.139.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -65,20 +65,20 @@
65
65
  "@simonbackx/simple-errors": "1.5.0",
66
66
  "@simonbackx/simple-logging": "1.0.1",
67
67
  "@simplewebauthn/server": "13.3.2",
68
- "@stamhoofd/backend-env": "2.138.1",
69
- "@stamhoofd/backend-i18n": "2.138.1",
70
- "@stamhoofd/backend-middleware": "2.138.1",
71
- "@stamhoofd/crons": "2.138.1",
72
- "@stamhoofd/email": "2.138.1",
73
- "@stamhoofd/excel-writer": "2.138.1",
74
- "@stamhoofd/logging": "2.138.1",
75
- "@stamhoofd/models": "2.138.1",
76
- "@stamhoofd/object-differ": "2.138.1",
77
- "@stamhoofd/queues": "2.138.1",
78
- "@stamhoofd/sql": "2.138.1",
79
- "@stamhoofd/structures": "2.138.1",
80
- "@stamhoofd/types": "2.138.1",
81
- "@stamhoofd/utility": "2.138.1",
68
+ "@stamhoofd/backend-env": "2.139.0",
69
+ "@stamhoofd/backend-i18n": "2.139.0",
70
+ "@stamhoofd/backend-middleware": "2.139.0",
71
+ "@stamhoofd/crons": "2.139.0",
72
+ "@stamhoofd/email": "2.139.0",
73
+ "@stamhoofd/excel-writer": "2.139.0",
74
+ "@stamhoofd/logging": "2.139.0",
75
+ "@stamhoofd/models": "2.139.0",
76
+ "@stamhoofd/object-differ": "2.139.0",
77
+ "@stamhoofd/queues": "2.139.0",
78
+ "@stamhoofd/sql": "2.139.0",
79
+ "@stamhoofd/structures": "2.139.0",
80
+ "@stamhoofd/types": "2.139.0",
81
+ "@stamhoofd/utility": "2.139.0",
82
82
  "archiver": "7.0.1",
83
83
  "argon2": "0.44.0",
84
84
  "axios": "1.18.1",
@@ -102,7 +102,7 @@
102
102
  "uuid": "14.0.1"
103
103
  },
104
104
  "devDependencies": {
105
- "@stamhoofd/test-utils": "2.138.1",
105
+ "@stamhoofd/test-utils": "2.139.0",
106
106
  "@types/cookie": "0.6.0",
107
107
  "@types/luxon": "3.7.2",
108
108
  "@types/mailparser": "3.4.6",
@@ -117,5 +117,5 @@
117
117
  "publishConfig": {
118
118
  "access": "public"
119
119
  },
120
- "gitHead": "ea8e0dcbc22e86969883fa09e920931896110781"
120
+ "gitHead": "8c2cb66c10f96ca53aa07c20f410d6b8fa5a3291"
121
121
  }
@@ -0,0 +1,165 @@
1
+ import type { Organization } from '@stamhoofd/models';
2
+ import { OrganizationFactory, Payment, StripeAccount } from '@stamhoofd/models';
3
+ import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType, Settlement } from '@stamhoofd/structures';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import { Formatter } from '@stamhoofd/utility';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { createFakeSettlements } from './fake-settlements.js';
8
+
9
+ describe('Cron.fake-settlements', () => {
10
+ let organization: Organization;
11
+
12
+ beforeEach(async () => {
13
+ TestUtils.setEnvironment('environment', 'development');
14
+ organization = await new OrganizationFactory({}).create();
15
+ });
16
+
17
+ /**
18
+ * A moment in a week that has already ended. Week 0 is the current week, so anything from week 2
19
+ * on is a week that is paid out no matter which day the test runs on.
20
+ */
21
+ const inWeek = (weeksAgo: number) => {
22
+ return Formatter.luxon(new Date()).startOf('week').minus({ weeks: weeksAgo }).plus({ days: 2, hours: 12 }).toJSDate();
23
+ };
24
+
25
+ const createPayment = async ({ price = 50_0000, paidAt = inWeek(2), provider = PaymentProvider.Mollie as PaymentProvider | null, status = PaymentStatus.Succeeded, method = PaymentMethod.Bancontact, type = PaymentType.Payment, organizationId = undefined as string | undefined, stripeAccountId = null as string | null }: {
26
+ price?: number;
27
+ paidAt?: Date | null;
28
+ provider?: PaymentProvider | null;
29
+ status?: PaymentStatus;
30
+ method?: PaymentMethod;
31
+ type?: PaymentType;
32
+ organizationId?: string;
33
+ stripeAccountId?: string | null;
34
+ } = {}) => {
35
+ const payment = new Payment();
36
+ payment.organizationId = organizationId ?? organization.id;
37
+ payment.method = method;
38
+ payment.provider = provider;
39
+ payment.status = status;
40
+ payment.type = type;
41
+ payment.price = price;
42
+ payment.paidAt = paidAt;
43
+ payment.stripeAccountId = stripeAccountId;
44
+ await payment.save();
45
+ return payment;
46
+ };
47
+
48
+ const createStripeAccount = async (forOrganization: Organization) => {
49
+ const stripeAccount = new StripeAccount();
50
+ stripeAccount.organizationId = forOrganization.id;
51
+ stripeAccount.accountId = 'acct_' + uuidv4();
52
+ await stripeAccount.save();
53
+ return stripeAccount;
54
+ };
55
+
56
+ const getSettlement = async (payment: Payment) => {
57
+ const updated = await Payment.getByID(payment.id);
58
+ return updated!.settlement;
59
+ };
60
+
61
+ test('Payments of the same week and provider are paid out together', async () => {
62
+ const first = await createPayment({ price: 50_0000 });
63
+ const second = await createPayment({ price: 25_0000 });
64
+ const refund = await createPayment({ price: -10_0000, type: PaymentType.Refund });
65
+
66
+ await createFakeSettlements();
67
+
68
+ const settlement = await getSettlement(first);
69
+ expect(settlement).toMatchObject({
70
+ settledAt: Formatter.luxon(inWeek(2)).startOf('week').plus({ weeks: 1, days: 2 }).toJSDate(),
71
+
72
+ // Refunds are taken out of the payout, just like the provider does
73
+ amount: 65_0000,
74
+ });
75
+
76
+ // All payments of the week point to the same payout
77
+ expect(await getSettlement(second)).toEqual(settlement);
78
+ expect(await getSettlement(refund)).toEqual(settlement);
79
+ });
80
+
81
+ test('Every week gets its own payout', async () => {
82
+ const older = await createPayment({ paidAt: inWeek(3) });
83
+ const newer = await createPayment({ paidAt: inWeek(2) });
84
+
85
+ await createFakeSettlements();
86
+
87
+ const olderSettlement = await getSettlement(older);
88
+ const newerSettlement = await getSettlement(newer);
89
+
90
+ expect(olderSettlement).not.toBeNull();
91
+ expect(newerSettlement).not.toBeNull();
92
+ expect(olderSettlement!.id).not.toBe(newerSettlement!.id);
93
+ expect(olderSettlement!.reference).not.toBe(newerSettlement!.reference);
94
+ expect(olderSettlement!.settledAt.getTime()).toBeLessThan(newerSettlement!.settledAt.getTime());
95
+ });
96
+
97
+ test('Money held on another account is paid out separately', async () => {
98
+ const otherOrganization = await new OrganizationFactory({}).create();
99
+ const stripeAccount = await createStripeAccount(organization);
100
+ const otherStripeAccount = await createStripeAccount(organization);
101
+
102
+ const mollie = await createPayment({ price: 50_0000 });
103
+ const otherMollie = await createPayment({ price: 30_0000, organizationId: otherOrganization.id });
104
+ const stripe = await createPayment({ price: 20_0000, provider: PaymentProvider.Stripe, stripeAccountId: stripeAccount.id });
105
+ const otherStripe = await createPayment({ price: 15_0000, provider: PaymentProvider.Stripe, stripeAccountId: otherStripeAccount.id });
106
+
107
+ await createFakeSettlements();
108
+
109
+ const settlements = [
110
+ await getSettlement(mollie),
111
+ await getSettlement(otherMollie),
112
+ await getSettlement(stripe),
113
+ await getSettlement(otherStripe),
114
+ ];
115
+
116
+ // Same week, but four accounts: four payouts, each holding only its own money
117
+ expect(settlements.map(s => s!.amount)).toEqual([50_0000, 30_0000, 20_0000, 15_0000]);
118
+ expect(new Set(settlements.map(s => s!.id)).size).toBe(4);
119
+ expect(new Set(settlements.map(s => s!.reference)).size).toBe(4);
120
+ });
121
+
122
+ test('Payments that a provider would still be holding on to are not paid out', async () => {
123
+ // The payout of the current week didn't happen yet
124
+ const thisWeek = await createPayment({ paidAt: new Date() });
125
+
126
+ const pending = await createPayment({ status: PaymentStatus.Pending });
127
+ const failed = await createPayment({ status: PaymentStatus.Failed });
128
+
129
+ // These providers don't hold on to the money
130
+ const transfer = await createPayment({ method: PaymentMethod.Transfer, provider: null });
131
+ const payconiq = await createPayment({ provider: PaymentProvider.Payconiq, method: PaymentMethod.Payconiq });
132
+
133
+ await createFakeSettlements();
134
+
135
+ for (const payment of [thisWeek, pending, failed, transfer, payconiq]) {
136
+ expect(await getSettlement(payment)).toBeNull();
137
+ }
138
+ });
139
+
140
+ test('A payment that was already paid out keeps its own payout', async () => {
141
+ const settlement = Settlement.create({
142
+ id: 'stl_1',
143
+ reference: '1234.1234.1234',
144
+ settledAt: inWeek(1),
145
+ amount: 100_0000,
146
+ });
147
+
148
+ const payment = await createPayment();
149
+ payment.settlement = settlement;
150
+ await payment.save();
151
+
152
+ await createFakeSettlements();
153
+
154
+ expect(await getSettlement(payment)).toEqual(settlement);
155
+ });
156
+
157
+ test('Nothing is paid out outside development', async () => {
158
+ TestUtils.setEnvironment('environment', 'staging');
159
+ const payment = await createPayment();
160
+
161
+ await createFakeSettlements();
162
+
163
+ expect(await getSettlement(payment)).toBeNull();
164
+ });
165
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Development only: payment providers pay out on their own schedule and those payouts never reach a
3
+ * local machine, so online payments stay 'not paid out yet' forever. This mimics a weekly payout per
4
+ * provider account, so everything built on top of payouts has data to show.
5
+ */
6
+
7
+ import { registerCron } from '@stamhoofd/crons';
8
+ import { Order, Payment } from '@stamhoofd/models';
9
+ import { SQL } from '@stamhoofd/sql';
10
+ import { PaymentStatus, Settlement } from '@stamhoofd/structures';
11
+ import { SETTLING_PAYMENT_PROVIDERS } from '@stamhoofd/structures/PaymentSettlement.js';
12
+ import { Formatter } from '@stamhoofd/utility';
13
+ import type { DateTime } from 'luxon';
14
+
15
+ registerCron('fake-settlements', createFakeSettlements);
16
+
17
+ /**
18
+ * Days between the end of a week and the payout of the payments of that week.
19
+ */
20
+ const PAYOUT_DELAY_DAYS = 2;
21
+
22
+ /**
23
+ * Maximum number of payments that are settled in one go: a development database can be a copy of a
24
+ * production one, so a week can hold a lot of payments.
25
+ */
26
+ const BATCH_SIZE = 1000;
27
+
28
+ export async function createFakeSettlements() {
29
+ if (STAMHOOFD.environment !== 'development') {
30
+ return;
31
+ }
32
+
33
+ const now = new Date();
34
+
35
+ // Pay out week per week, oldest first, until we reach a week that isn't paid out yet
36
+ for (;;) {
37
+ const oldest = await selectUnsettledPayments().orderBy('paidAt', 'ASC').first(false);
38
+ if (!oldest?.paidAt) {
39
+ break;
40
+ }
41
+
42
+ const weekStart = Formatter.luxon(oldest.paidAt).startOf('week');
43
+ const weekEnd = weekStart.plus({ weeks: 1 });
44
+ const settledAt = weekEnd.plus({ days: PAYOUT_DELAY_DAYS }).toJSDate();
45
+
46
+ if (settledAt > now) {
47
+ // Every remaining payment is in this week or a later one, so all of them still have to
48
+ // be paid out
49
+ break;
50
+ }
51
+
52
+ // Nothing is left before this week, so this selects exactly the payments of this week. What
53
+ // doesn't fit in one batch is picked up by the next round, in the same payout
54
+ const payments = await selectUnsettledPayments().where('paidAt', '<', weekEnd.toJSDate()).limit(BATCH_SIZE).fetch();
55
+ await settleWeek(payments, weekStart, settledAt);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Succeeded payments of a provider that pays out, that weren't paid out yet.
61
+ */
62
+ function selectUnsettledPayments() {
63
+ return Payment.select().where(
64
+ SQL.where('status', PaymentStatus.Succeeded)
65
+ .and('provider', SETTLING_PAYMENT_PROVIDERS)
66
+ .and('settlement', null)
67
+ .and('paidAt', '>', null),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * A provider pays out per account it holds the money on: a Stripe account, or the Mollie account of
73
+ * the organization that received the payment.
74
+ */
75
+ function getPayoutAccount(payment: Payment): string {
76
+ return payment.provider + '-' + (payment.stripeAccountId ?? payment.organizationId ?? 'platform');
77
+ }
78
+
79
+ async function settleWeek(payments: Payment[], weekStart: DateTime, settledAt: Date) {
80
+ const groups = new Map<string, Payment[]>();
81
+
82
+ for (const payment of payments) {
83
+ const account = getPayoutAccount(payment);
84
+ const group = groups.get(account);
85
+
86
+ if (group) {
87
+ group.push(payment);
88
+ } else {
89
+ groups.set(account, [payment]);
90
+ }
91
+ }
92
+
93
+ const week = weekStart.weekYear + '-W' + weekStart.weekNumber.toString().padStart(2, '0');
94
+
95
+ for (const [account, group] of groups) {
96
+ const id = 'fake-settlement-' + account + '-' + week;
97
+
98
+ // Every account gets its own payout, so the reference has to tell them apart
99
+ const reference = 'DEV ' + week + ' (' + account.slice(-8) + ')';
100
+ const amount = group.reduce((total, payment) => total + payment.price, 0);
101
+
102
+ for (const payment of group) {
103
+ payment.settlement = Settlement.create({ id, reference, settledAt, amount });
104
+ await payment.save();
105
+
106
+ // Mark order as 'updated', or the frontend won't pull in the updates
107
+ const order = await Order.getForPayment(null, payment.id);
108
+ if (order) {
109
+ order.updatedAt = new Date();
110
+ order.forceSaveProperty('updatedAt');
111
+ await order.save();
112
+ }
113
+ }
114
+
115
+ console.log('Created settlement ' + reference + ' of ' + Formatter.price(amount) + ' for ' + group.length + ' payments');
116
+ }
117
+ }
@@ -9,6 +9,7 @@ import './delete-expired-mfa-tokens.js';
9
9
  import './delete-archived-data.js';
10
10
  import './mollie-chargebacks.js';
11
11
  import './mollie-refunds.js';
12
+ import './fake-settlements.js';
12
13
  import './invoices.js';
13
14
  import './service-fees.js';
14
15
  import './members-fees.js';
@@ -150,7 +150,7 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
150
150
  // Second factor / forced enrollment: block the session until the user
151
151
  // completes (or first sets up) a second factor. Shared with every other
152
152
  // grant that mints a session from a single primary credential.
153
- await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
153
+ await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion(), { loginMethod: 'password', i18n: request.i18n });
154
154
 
155
155
  const token = await Token.createToken(user, new Date());
156
156
 
@@ -311,7 +311,10 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
311
311
  // Forced enrollment (the user has no factor yet) additionally returns a
312
312
  // temporary session token: this flow is also used to choose a first
313
313
  // password (invites), which the client can only do with a session.
314
- await TwoFactorHelper.assertSecondFactorOrThrow(passwordToken.user, organization, request.request.getVersion(), { allowTemporarySession: true });
314
+ //
315
+ // The link was emailed to the account, so this is also how a long-inactive
316
+ // admin confirms their email address and gets to enroll after all.
317
+ await TwoFactorHelper.assertSecondFactorOrThrow(passwordToken.user, organization, request.request.getVersion(), { loginMethod: 'email', i18n: request.i18n, allowTemporarySession: true });
315
318
 
316
319
  // Important to create a new token before adjusting the old token
317
320
  const token = await Token.createToken(passwordToken.user, new Date());
@@ -0,0 +1,45 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import { EmailTemplateFactory, OrganizationFactory, PasswordToken, UserFactory } from '@stamhoofd/models';
4
+ import { EmailTemplateType } from '@stamhoofd/structures';
5
+
6
+ import { testServer } from '../../../tests/helpers/TestServer.js';
7
+ import { ForgotPasswordEndpoint } from './ForgotPasswordEndpoint.js';
8
+
9
+ const endpoint = new ForgotPasswordEndpoint();
10
+
11
+ describe('Endpoint.ForgotPassword', () => {
12
+ beforeEach(async () => {
13
+ await new EmailTemplateFactory({ type: EmailTemplateType.ForgotPassword }).create();
14
+ await new EmailTemplateFactory({ type: EmailTemplateType.ForgotPasswordButNoAccount }).create();
15
+ });
16
+
17
+ function request(host: string, email: string) {
18
+ return Request.buildJson('POST', '/forgot-password', host, { email });
19
+ }
20
+
21
+ test('the recovery link is emailed to the stored address, not the one that was typed', async () => {
22
+ const organization = await new OrganizationFactory({}).create();
23
+ const user = await new UserFactory({ organization, password: 'test-password-1234' }).create();
24
+
25
+ await testServer.test(endpoint, request(organization.getApiHost(), user.email.toUpperCase()));
26
+
27
+ const emails = await EmailMocker.transactional.getSucceededEmails();
28
+ expect(emails).toHaveLength(1);
29
+ expect(emails[0].to).toContain(user.email);
30
+
31
+ const [passwordToken] = await PasswordToken.select().where('userId', user.id).fetch();
32
+ expect(passwordToken).toBeDefined();
33
+ expect(emails[0].html).toContain(encodeURIComponent(passwordToken.token));
34
+ });
35
+
36
+ test('an unknown address still gets an email, so it does not leak who has an account', async () => {
37
+ const organization = await new OrganizationFactory({}).create();
38
+
39
+ await testServer.test(endpoint, request(organization.getApiHost(), 'nobody@example.com'));
40
+
41
+ const emails = await EmailMocker.transactional.getSucceededEmails();
42
+ expect(emails).toHaveLength(1);
43
+ expect(emails[0].to).toContain('nobody@example.com');
44
+ });
45
+ });
@@ -3,7 +3,7 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
3
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
4
  import { Platform, User } from '@stamhoofd/models';
5
5
  import { sendEmailTemplate } from '../../helpers/EmailBuilder.js';
6
- import { EmailTemplateType, ForgotPasswordRequest, LoginMethod, Recipient, Replacement } from '@stamhoofd/structures';
6
+ import { EmailTemplateType, ForgotPasswordRequest, LoginMethod, Recipient } from '@stamhoofd/structures';
7
7
 
8
8
  import { SimpleError } from '@simonbackx/simple-errors';
9
9
  import { Context } from '../../helpers/Context.js';
@@ -74,28 +74,7 @@ export class ForgotPasswordEndpoint extends Endpoint<Params, Query, Body, Respon
74
74
  return new Response(undefined);
75
75
  }
76
76
 
77
- const recoveryUrl = await PasswordForgotService.getPasswordRecoveryUrl(user, organization, request.i18n);
78
-
79
- // Create e-mail builder
80
- await sendEmailTemplate(organization, {
81
- recipients: [
82
- Recipient.create({
83
- firstName: user.firstName,
84
- lastName: user.lastName,
85
- email: request.body.email,
86
- replacements: [
87
- Replacement.create({
88
- token: 'resetUrl',
89
- value: recoveryUrl,
90
- }),
91
- ],
92
- }),
93
- ],
94
- template: {
95
- type: EmailTemplateType.ForgotPassword,
96
- },
97
- type: 'transactional',
98
- });
77
+ await PasswordForgotService.sendPasswordRecoveryEmail(user, organization, request.i18n);
99
78
 
100
79
  return new Response(undefined);
101
80
  }
@@ -1,15 +1,17 @@
1
1
  import { Endpoint, Request } from '@simonbackx/simple-endpoints';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
- import { AuditLog, EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, PasswordToken, Platform, Token, User, UserFactory, OrganizationFactory, WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
4
- import { AuditLogReplacementType, AuditLogType, MFAMethodType, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
3
+ import { EmailMocker } from '@stamhoofd/email';
4
+ import { AuditLog, EmailTemplateFactory, EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, PasswordToken, Platform, Token, User, UserFactory, OrganizationFactory, WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
5
+ import { AuditLogReplacementType, AuditLogType, EmailTemplateType, MFAMethodType, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
5
6
  import { authenticator } from 'otplib';
6
7
  import crypto from 'crypto';
7
8
 
8
9
  import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
9
10
  import { testServer } from '../../../tests/helpers/TestServer.js';
10
11
  import { RECOVERY_CODE_ALPHABET, RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
11
- import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
12
+ import { INACTIVE_ADMIN_ENROLLMENT_DAYS, TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
12
13
  import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
14
+ import { PasswordForgotService } from '../../services/PasswordForgotService.js';
13
15
  import { ConfirmTOTPEndpoint } from './ConfirmTOTPEndpoint.js';
14
16
  import { CreateTokenEndpoint } from './CreateTokenEndpoint.js';
15
17
  import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
@@ -511,6 +513,188 @@ describe('MFA', () => {
511
513
  });
512
514
  });
513
515
 
516
+ // -----------------------------------------------------------------------
517
+ // Forced enrollment of accounts that were not used for a long time
518
+ // -----------------------------------------------------------------------
519
+ describe('inactive admins confirm their email before enrolling', () => {
520
+ const day = 24 * 60 * 60 * 1000;
521
+
522
+ beforeEach(async () => {
523
+ // Without a template no email is built at all, and the assertions below would
524
+ // pass on a version that never sends one.
525
+ await new EmailTemplateFactory({ type: EmailTemplateType.ForgotPassword }).create();
526
+ });
527
+
528
+ async function inactiveAdmin({ inactiveDays = INACTIVE_ADMIN_ENROLLMENT_DAYS + 1, requireTwoFactor = true }: { inactiveDays?: number; requireTwoFactor?: boolean } = {}) {
529
+ const organization = await new OrganizationFactory({}).create();
530
+ organization.privateMeta.requireTwoFactor = requireTwoFactor;
531
+ await organization.save();
532
+
533
+ const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
534
+ user.lastActiveAt = new Date(Date.now() - inactiveDays * day);
535
+ await user.save();
536
+ return { organization, user };
537
+ }
538
+
539
+ async function passwordTokensFor(user: User): Promise<PasswordToken[]> {
540
+ return await PasswordToken.select().where('userId', user.id).fetch();
541
+ }
542
+
543
+ async function recoveryEmailsFor(user: User) {
544
+ return (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.to.includes(user.email));
545
+ }
546
+
547
+ test('a long inactive admin is locked out and gets a password recovery link', async () => {
548
+ const { organization, user } = await inactiveAdmin();
549
+
550
+ const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
551
+ expect(err.code).toBe('require_email_confirmation');
552
+ expect(err.statusCode).toBe(403);
553
+
554
+ const [passwordToken] = await passwordTokensFor(user);
555
+ expect(passwordToken).toBeDefined();
556
+
557
+ // The link in the email is the one that gets them back in.
558
+ const emails = await recoveryEmailsFor(user);
559
+ expect(emails).toHaveLength(1);
560
+ expect(emails[0].html).toContain(encodeURIComponent(passwordToken.token));
561
+
562
+ // No setup token was handed out: the enrollment may not start from the password alone.
563
+ expect(await MFAToken.select().where('userId', user.id).fetch()).toHaveLength(0);
564
+ expect((await User.getByID(user.id))!.lastActiveAt!.getTime()).toBeLessThan(Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * day);
565
+ });
566
+
567
+ test('an admin who signed in recently is only forced to enroll', async () => {
568
+ const { organization, user } = await inactiveAdmin({ inactiveDays: INACTIVE_ADMIN_ENROLLMENT_DAYS - 1 });
569
+
570
+ const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
571
+ expect(err.code).toBe('require_mfa_setup');
572
+ expect(await passwordTokensFor(user)).toHaveLength(0);
573
+ expect(await recoveryEmailsFor(user)).toHaveLength(0);
574
+ });
575
+
576
+ test('a long inactive platform admin is locked out too', async () => {
577
+ await setPlatformRequiresTwoFactor(true);
578
+ const user = await new UserFactory({ password, globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
579
+ user.lastActiveAt = new Date(Date.now() - (INACTIVE_ADMIN_ENROLLMENT_DAYS + 1) * day);
580
+ await user.save();
581
+
582
+ const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(null, user.email, password)));
583
+ expect(err.code).toBe('require_email_confirmation');
584
+ });
585
+
586
+ test('an inactive admin who already enrolled is challenged as usual', async () => {
587
+ const { organization, user } = await inactiveAdmin();
588
+ const { secret } = await addConfirmedTOTP(user);
589
+
590
+ const challenge = await requireMfa(organization, user.email, password);
591
+ const response = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) }));
592
+ expect(response.body).toBeInstanceOf(TokenStruct);
593
+ expect(await passwordTokensFor(user)).toHaveLength(0);
594
+ });
595
+
596
+ test('an inactive user without permissions logs in normally', async () => {
597
+ const organization = await new OrganizationFactory({}).create();
598
+ organization.privateMeta.requireTwoFactor = true;
599
+ await organization.save();
600
+
601
+ const user = await new UserFactory({ organization, password }).create();
602
+ user.lastActiveAt = new Date(Date.now() - 400 * day);
603
+ await user.save();
604
+
605
+ const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
606
+ expect(response.body).toBeInstanceOf(TokenStruct);
607
+ });
608
+
609
+ test('an inactive admin is not blocked when 2FA is not required', async () => {
610
+ const { organization, user } = await inactiveAdmin({ requireTwoFactor: false });
611
+
612
+ const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
613
+ expect(response.body).toBeInstanceOf(TokenStruct);
614
+ });
615
+
616
+ test('an admin who never signed in is judged on the creation date', async () => {
617
+ const { organization, user } = await inactiveAdmin();
618
+ user.lastActiveAt = null;
619
+ await user.save();
620
+
621
+ const fresh = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
622
+ expect(fresh.code).toBe('require_mfa_setup');
623
+
624
+ user.createdAt = new Date(Date.now() - (INACTIVE_ADMIN_ENROLLMENT_DAYS + 1) * day);
625
+ await user.save();
626
+
627
+ const stale = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
628
+ expect(stale.code).toBe('require_email_confirmation');
629
+ });
630
+
631
+ test('the emailed link confirms the email address and unlocks the enrollment', async () => {
632
+ const { organization, user } = await inactiveAdmin();
633
+ await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
634
+
635
+ const [passwordToken] = await passwordTokensFor(user);
636
+ expect(passwordToken).toBeDefined();
637
+
638
+ const err = await captureError(testServer.test(tokenEndpoint, Request.buildJson('POST', '/oauth/token', organization.getApiHost(), { grant_type: 'password_token', token: passwordToken.token })));
639
+ expect(err.code).toBe('require_mfa_setup');
640
+ expect((err.meta as { setupToken: string }).setupToken).toBeTruthy();
641
+ });
642
+
643
+ test('an email verification code also unlocks the enrollment', async () => {
644
+ const { organization, user } = await inactiveAdmin();
645
+
646
+ const code = await EmailVerificationCode.createFor(user, user.email);
647
+ const err = await captureError(testServer.test(new VerifyEmailEndpoint(), Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code })));
648
+ expect(err.code).toBe('require_mfa_setup');
649
+ });
650
+
651
+ test('repeated logins stop sending new recovery links', async () => {
652
+ const { organization, user } = await inactiveAdmin();
653
+
654
+ for (let i = 0; i < 6; i++) {
655
+ const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
656
+ expect(err.code).toBe('require_email_confirmation');
657
+ }
658
+
659
+ expect(await passwordTokensFor(user)).toHaveLength(3);
660
+ expect(await recoveryEmailsFor(user)).toHaveLength(3);
661
+ });
662
+
663
+ test('a failing email does not turn the lockout into a server error', async () => {
664
+ const { organization, user } = await inactiveAdmin();
665
+ const send = vi.spyOn(PasswordForgotService, 'sendPasswordRecoveryEmail').mockRejectedValue(new Error('Mail server down'));
666
+
667
+ try {
668
+ const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
669
+ expect(err.code).toBe('require_email_confirmation');
670
+ expect(send).toHaveBeenCalledOnce();
671
+ }
672
+ finally {
673
+ send.mockRestore();
674
+ }
675
+ });
676
+
677
+ test('the limit is only crossed after a full 45 days', async () => {
678
+ const { user } = await inactiveAdmin();
679
+
680
+ // A minute on either side of the limit, so the datetime column losing
681
+ // milliseconds cannot decide the outcome.
682
+ user.lastActiveAt = new Date(Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * day + 60 * 1000);
683
+ expect(TwoFactorHelper.isInactiveForEnrollment(user)).toBe(false);
684
+
685
+ user.lastActiveAt = new Date(Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * day - 60 * 1000);
686
+ expect(TwoFactorHelper.isInactiveForEnrollment(user)).toBe(true);
687
+ });
688
+
689
+ test('an SSO login of an inactive admin is not blocked', async () => {
690
+ // The identity provider authenticated the user, so there is nothing to confirm.
691
+ const { organization, user } = await inactiveAdmin();
692
+
693
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
694
+ expect(requirement.type).toBe('setup');
695
+ });
696
+ });
697
+
514
698
  // -----------------------------------------------------------------------
515
699
  // Enrollment + management while logged in (happy paths)
516
700
  // -----------------------------------------------------------------------