@openora/core 0.4.1-canary.37 → 0.4.1-canary.39
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/dist/.tsbuildinfo +1 -1
- package/dist/admin-console/__tests__/backoffice.router.test.js +67 -51
- package/dist/admin-console/__tests__/backoffice.router.test.js.map +1 -1
- package/dist/admin-console/__tests__/backoffice.service.test.js +1 -13
- package/dist/admin-console/__tests__/backoffice.service.test.js.map +1 -1
- package/dist/analytics/__tests__/analytics.contract.test.js +12 -0
- package/dist/analytics/__tests__/analytics.contract.test.js.map +1 -1
- package/dist/analytics/__tests__/financial-analytics.service.test.js +3 -3
- package/dist/analytics/__tests__/financial-analytics.service.test.js.map +1 -1
- package/dist/analytics/service/financial-analytics.service.d.ts.map +1 -1
- package/dist/analytics/service/financial-analytics.service.js +14 -17
- package/dist/analytics/service/financial-analytics.service.js.map +1 -1
- package/dist/analytics/service/funnel-analytics.service.d.ts.map +1 -1
- package/dist/analytics/service/funnel-analytics.service.js +8 -11
- package/dist/analytics/service/funnel-analytics.service.js.map +1 -1
- package/dist/audit/__tests__/audit.service.test.js +128 -251
- package/dist/audit/__tests__/audit.service.test.js.map +1 -1
- package/dist/casino/gaming/__tests__/gaming.service.test.js +83 -89
- package/dist/casino/gaming/__tests__/gaming.service.test.js.map +1 -1
- package/dist/casino/lobby/__tests__/lobby.cache.test.js +42 -33
- package/dist/casino/lobby/__tests__/lobby.cache.test.js.map +1 -1
- package/dist/cms/__tests__/cms.reads.test.js +59 -80
- package/dist/cms/__tests__/cms.reads.test.js.map +1 -1
- package/dist/cms/__tests__/cms.router.test.js +62 -20
- package/dist/cms/__tests__/cms.router.test.js.map +1 -1
- package/dist/cms/__tests__/cms.service.test.js +116 -14
- package/dist/cms/__tests__/cms.service.test.js.map +1 -1
- package/dist/common/errors/__tests__/errors.mapper.test.d.ts +2 -0
- package/dist/common/errors/__tests__/errors.mapper.test.d.ts.map +1 -0
- package/dist/common/errors/__tests__/errors.mapper.test.js +59 -0
- package/dist/common/errors/__tests__/errors.mapper.test.js.map +1 -0
- package/dist/compliance/__tests__/assert.test.d.ts +2 -0
- package/dist/compliance/__tests__/assert.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/assert.test.js +34 -0
- package/dist/compliance/__tests__/assert.test.js.map +1 -0
- package/dist/compliance/__tests__/compliance.service.test.js +149 -84
- package/dist/compliance/__tests__/compliance.service.test.js.map +1 -1
- package/dist/compliance/__tests__/kyc.service.test.js +247 -155
- package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
- package/dist/compliance/__tests__/limits.contract.test.d.ts +2 -0
- package/dist/compliance/__tests__/limits.contract.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/limits.contract.test.js +43 -0
- package/dist/compliance/__tests__/limits.contract.test.js.map +1 -0
- package/dist/compliance/__tests__/rg-monitoring.service.test.js +256 -101
- package/dist/compliance/__tests__/rg-monitoring.service.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.contract.test.js +1 -20
- package/dist/compliance/__tests__/rg.contract.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.router.test.js +113 -65
- package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.service.test.js +277 -235
- package/dist/compliance/__tests__/rg.service.test.js.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.js +11 -8
- package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
- package/dist/contracts/__tests__/compose-contract.test.d.ts +2 -0
- package/dist/contracts/__tests__/compose-contract.test.d.ts.map +1 -0
- package/dist/contracts/__tests__/compose-contract.test.js +27 -0
- package/dist/contracts/__tests__/compose-contract.test.js.map +1 -0
- package/dist/contracts/adapters/__tests__/token.test.d.ts +2 -0
- package/dist/contracts/adapters/__tests__/token.test.d.ts.map +1 -0
- package/dist/contracts/adapters/__tests__/token.test.js +14 -0
- package/dist/contracts/adapters/__tests__/token.test.js.map +1 -0
- package/dist/contracts/schemas/__tests__/events.test.d.ts +2 -0
- package/dist/contracts/schemas/__tests__/events.test.d.ts.map +1 -0
- package/dist/contracts/schemas/__tests__/events.test.js +51 -0
- package/dist/contracts/schemas/__tests__/events.test.js.map +1 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts +2 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts.map +1 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.js +38 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.js.map +1 -0
- package/dist/contracts/schemas/reporting.d.ts +7 -2
- package/dist/contracts/schemas/reporting.d.ts.map +1 -1
- package/dist/contracts/schemas/reporting.js +2 -1
- package/dist/contracts/schemas/reporting.js.map +1 -1
- package/dist/engagement/chat/__tests__/chat.service.test.js +524 -364
- package/dist/engagement/chat/__tests__/chat.service.test.js.map +1 -1
- package/dist/engagement/notifications/__tests__/notifications.service.test.js +125 -68
- package/dist/engagement/notifications/__tests__/notifications.service.test.js.map +1 -1
- package/dist/iam/__tests__/iam.service.test.js +367 -596
- package/dist/iam/__tests__/iam.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/admin-user-directory.test.js +144 -68
- package/dist/pam/identity/__tests__/admin-user-directory.test.js.map +1 -1
- package/dist/pam/identity/__tests__/identity.rate-limit.test.js +33 -22
- package/dist/pam/identity/__tests__/identity.rate-limit.test.js.map +1 -1
- package/dist/pam/identity/__tests__/identity.service.test.js +217 -298
- package/dist/pam/identity/__tests__/identity.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/login-enforcement.service.test.js +97 -32
- package/dist/pam/identity/__tests__/login-enforcement.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/phone-login.service.test.js +163 -134
- package/dist/pam/identity/__tests__/phone-login.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/rg-guard.test.d.ts +2 -0
- package/dist/pam/identity/__tests__/rg-guard.test.d.ts.map +1 -0
- package/dist/pam/identity/__tests__/rg-guard.test.js +21 -0
- package/dist/pam/identity/__tests__/rg-guard.test.js.map +1 -0
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +73 -26
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.router.test.js +63 -46
- package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.service.test.js +207 -111
- package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
- package/dist/pam/profile/__tests__/profile.service.test.js +89 -41
- package/dist/pam/profile/__tests__/profile.service.test.js.map +1 -1
- package/dist/pam/shared/__tests__/shared-mappers.test.d.ts +2 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.d.ts.map +1 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.js +55 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.js.map +1 -0
- package/dist/pam/tag/__tests__/get-active-tag-keys.test.js +72 -30
- package/dist/pam/tag/__tests__/get-active-tag-keys.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +308 -235
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-rule.service.test.js +136 -140
- package/dist/pam/tag/__tests__/tag-rule.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.router.test.js +76 -25
- package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.service.test.js +224 -247
- package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
- package/dist/server/auth/__tests__/admin-guard.test.js +107 -117
- package/dist/server/auth/__tests__/admin-guard.test.js.map +1 -1
- package/dist/server/auth/__tests__/permission-levels.test.d.ts +2 -0
- package/dist/server/auth/__tests__/permission-levels.test.d.ts.map +1 -0
- package/dist/server/auth/__tests__/permission-levels.test.js +89 -0
- package/dist/server/auth/__tests__/permission-levels.test.js.map +1 -0
- package/dist/server/auth/__tests__/session-resolver.test.js +45 -13
- package/dist/server/auth/__tests__/session-resolver.test.js.map +1 -1
- package/dist/server/db/__tests__/query-helpers.test.js +72 -1
- package/dist/server/db/__tests__/query-helpers.test.js.map +1 -1
- package/dist/server/db/outbox/__tests__/relay.test.js +74 -81
- package/dist/server/db/outbox/__tests__/relay.test.js.map +1 -1
- package/dist/server/kernel/__tests__/bullmq-job-queue.test.js +204 -154
- package/dist/server/kernel/__tests__/bullmq-job-queue.test.js.map +1 -1
- package/dist/server/kernel/__tests__/cache.test.js +26 -13
- package/dist/server/kernel/__tests__/cache.test.js.map +1 -1
- package/dist/server/kernel/__tests__/domain-error.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/domain-error.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/domain-error.test.js +35 -0
- package/dist/server/kernel/__tests__/domain-error.test.js.map +1 -0
- package/dist/server/kernel/__tests__/error-reporter.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/error-reporter.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/error-reporter.test.js +52 -0
- package/dist/server/kernel/__tests__/error-reporter.test.js.map +1 -0
- package/dist/server/kernel/__tests__/event-bus.test.js +60 -20
- package/dist/server/kernel/__tests__/event-bus.test.js.map +1 -1
- package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.js +54 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.js.map +1 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.js +112 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.js.map +1 -0
- package/dist/server/kernel/__tests__/rate-limiter.test.js +22 -13
- package/dist/server/kernel/__tests__/rate-limiter.test.js.map +1 -1
- package/dist/server/kernel/__tests__/realtime-transport.test.d.ts.map +1 -0
- package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-cache.test.js +34 -34
- package/dist/server/kernel/__tests__/redis-cache.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-rate-limiter.test.js +49 -44
- package/dist/server/kernel/__tests__/redis-rate-limiter.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-streams-broker.test.js +141 -262
- package/dist/server/kernel/__tests__/redis-streams-broker.test.js.map +1 -1
- package/dist/server/kernel/__tests__/request-context.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/request-context.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/request-context.test.js +64 -0
- package/dist/server/kernel/__tests__/request-context.test.js.map +1 -0
- package/dist/server/kernel/__tests__/router-utils.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/router-utils.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/router-utils.test.js +80 -0
- package/dist/server/kernel/__tests__/router-utils.test.js.map +1 -0
- package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
- package/dist/server/kernel/bullmq-job-queue.js +8 -2
- package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
- package/dist/server/runtime/__tests__/create-app.test.js +26 -18
- package/dist/server/runtime/__tests__/create-app.test.js.map +1 -1
- package/dist/testing/index.d.ts +2 -6
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +2 -10
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +28 -16
- package/dist/testing/mock.d.ts.map +1 -1
- package/dist/testing/mock.js +58 -37
- package/dist/testing/mock.js.map +1 -1
- package/dist/testing/real-infra.d.ts +47 -0
- package/dist/testing/real-infra.d.ts.map +1 -0
- package/dist/testing/real-infra.js +122 -0
- package/dist/testing/real-infra.js.map +1 -0
- package/dist/wallet/__tests__/admin-reporting.test.js +201 -90
- package/dist/wallet/__tests__/admin-reporting.test.js.map +1 -1
- package/dist/wallet/__tests__/rail-for.test.d.ts +2 -0
- package/dist/wallet/__tests__/rail-for.test.d.ts.map +1 -0
- package/dist/wallet/__tests__/rail-for.test.js +22 -0
- package/dist/wallet/__tests__/rail-for.test.js.map +1 -0
- package/dist/wallet/__tests__/wallet-auto-rule.router.test.js +82 -63
- package/dist/wallet/__tests__/wallet-auto-rule.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +414 -437
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-commands.service.test.js +116 -155
- package/dist/wallet/__tests__/wallet-commands.service.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-kyc-gate.test.js +63 -42
- package/dist/wallet/__tests__/wallet-kyc-gate.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-webhook.router.test.js +129 -51
- package/dist/wallet/__tests__/wallet-webhook.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.rate-limit.test.js +74 -21
- package/dist/wallet/__tests__/wallet.rate-limit.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.router.test.js +56 -36
- package/dist/wallet/__tests__/wallet.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.service.test.js +623 -887
- package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
- package/package.json +1 -2
- package/dist/casino/lobby/__tests__/lobby.service.test.d.ts +0 -2
- package/dist/casino/lobby/__tests__/lobby.service.test.d.ts.map +0 -1
- package/dist/casino/lobby/__tests__/lobby.service.test.js +0 -11
- package/dist/casino/lobby/__tests__/lobby.service.test.js.map +0 -1
- package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts +0 -2
- package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts.map +0 -1
- package/dist/contracts/schemas/__tests__/identity-schemas.test.js +0 -43
- package/dist/contracts/schemas/__tests__/identity-schemas.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/cache.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/cache.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/cache.test.js +0 -89
- package/dist/testing/fakes/__tests__/cache.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/job-queue.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/job-queue.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/job-queue.test.js +0 -135
- package/dist/testing/fakes/__tests__/job-queue.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/rate-limiter.test.js +0 -83
- package/dist/testing/fakes/__tests__/rate-limiter.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.js +0 -19
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-transport.test.d.ts.map +0 -1
- package/dist/testing/fakes/cache.d.ts +0 -14
- package/dist/testing/fakes/cache.d.ts.map +0 -1
- package/dist/testing/fakes/cache.js +0 -56
- package/dist/testing/fakes/cache.js.map +0 -1
- package/dist/testing/fakes/event-bus.d.ts +0 -17
- package/dist/testing/fakes/event-bus.d.ts.map +0 -1
- package/dist/testing/fakes/event-bus.js +0 -30
- package/dist/testing/fakes/event-bus.js.map +0 -1
- package/dist/testing/fakes/job-queue.d.ts +0 -40
- package/dist/testing/fakes/job-queue.d.ts.map +0 -1
- package/dist/testing/fakes/job-queue.js +0 -200
- package/dist/testing/fakes/job-queue.js.map +0 -1
- package/dist/testing/fakes/rate-limiter.d.ts +0 -11
- package/dist/testing/fakes/rate-limiter.d.ts.map +0 -1
- package/dist/testing/fakes/rate-limiter.js +0 -39
- package/dist/testing/fakes/rate-limiter.js.map +0 -1
- package/dist/testing/fakes/realtime-authorizer.d.ts +0 -10
- package/dist/testing/fakes/realtime-authorizer.d.ts.map +0 -1
- package/dist/testing/fakes/realtime-authorizer.js +0 -10
- package/dist/testing/fakes/realtime-authorizer.js.map +0 -1
- package/dist/testing/fakes/realtime-transport.d.ts +0 -2
- package/dist/testing/fakes/realtime-transport.d.ts.map +0 -1
- package/dist/testing/fakes/realtime-transport.js +0 -2
- package/dist/testing/fakes/realtime-transport.js.map +0 -1
- /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.d.ts +0 -0
- /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js +0 -0
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
3
|
+
import { eq, sql } from 'drizzle-orm';
|
|
4
|
+
import { ORPCError } from '@orpc/server';
|
|
5
|
+
import { createTestDb, createTestRedis } from '@openora/core/testing';
|
|
6
|
+
import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
|
|
2
7
|
import { IdentityService, SESSION_DURATION_IN_SECONDS, } from '../service/identity.service.js';
|
|
3
8
|
import { UnsupportedLanguageError } from '../../shared/language.js';
|
|
4
|
-
import {
|
|
5
|
-
import { mock } from '../../../testing/mock.js';
|
|
6
|
-
import { InProcessCache } from '../../../testing/fakes/cache.js';
|
|
9
|
+
import { user, session } from '../schema/index.js';
|
|
10
|
+
import { mock, makeEventBus } from '../../../testing/mock.js';
|
|
7
11
|
const { signInEmailMock, getSessionMock, updateUserMock, requestPasswordResetEmailOTPMock, checkVerificationOTPMock, resetPasswordEmailOTPMock, capturedAuthOptions, } = vi.hoisted(() => ({
|
|
8
12
|
signInEmailMock: vi.fn(),
|
|
9
13
|
getSessionMock: vi.fn().mockResolvedValue(null),
|
|
@@ -38,35 +42,23 @@ vi.mock('@openora/core/server', async (importOriginal) => ({
|
|
|
38
42
|
};
|
|
39
43
|
}),
|
|
40
44
|
}));
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}),
|
|
49
|
-
}),
|
|
50
|
-
}));
|
|
51
|
-
const select = vi.fn(() => ({
|
|
52
|
-
from: () => ({ where: () => ({ limit: () => Promise.resolve(selectQueue.shift() ?? []) }) }),
|
|
53
|
-
}));
|
|
54
|
-
return mock({
|
|
55
|
-
db: { select, update },
|
|
56
|
-
update,
|
|
57
|
-
select,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
function makeEvents() {
|
|
61
|
-
return mock({ emit: vi.fn(), on: vi.fn() });
|
|
62
|
-
}
|
|
63
|
-
// createAuth() is mocked below, so the renderer is never actually invoked - this fake only
|
|
64
|
-
// satisfies IdentityService's required `templateRenderer` dependency at construction time.
|
|
45
|
+
const { RedisCache } = await import('@openora/core/server');
|
|
46
|
+
let db;
|
|
47
|
+
let redis;
|
|
48
|
+
const allowLimiter = () => mock({
|
|
49
|
+
consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }),
|
|
50
|
+
reset: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
});
|
|
65
52
|
const testTemplateRenderer = {
|
|
66
53
|
render: () => ({ subject: 'subject', body: 'body' }),
|
|
67
54
|
};
|
|
68
|
-
function
|
|
69
|
-
return new IdentityService({
|
|
55
|
+
function buildService(deps = {}) {
|
|
56
|
+
return new IdentityService({
|
|
57
|
+
templateRenderer: testTemplateRenderer,
|
|
58
|
+
drizzle: db.drizzle,
|
|
59
|
+
events: makeEventBus(),
|
|
60
|
+
...deps,
|
|
61
|
+
});
|
|
70
62
|
}
|
|
71
63
|
function jsonResponse(body, status) {
|
|
72
64
|
return new Response(JSON.stringify(body), {
|
|
@@ -82,72 +74,95 @@ const betterAuthUser = {
|
|
|
82
74
|
createdAt: '2020-01-01T00:00:00.000Z',
|
|
83
75
|
updatedAt: '2020-01-01T00:00:00.000Z',
|
|
84
76
|
};
|
|
85
|
-
|
|
77
|
+
const EMAIL = 'a@b.dev';
|
|
78
|
+
async function seedUser(over = {}) {
|
|
79
|
+
const [row] = await db.drizzle.db
|
|
80
|
+
.insert(user)
|
|
81
|
+
.values({ name: 'A', email: EMAIL, emailVerified: true, ...over })
|
|
82
|
+
.returning();
|
|
83
|
+
return row;
|
|
84
|
+
}
|
|
85
|
+
async function readUser(userId) {
|
|
86
|
+
const [row] = await db.drizzle.db.select().from(user).where(eq(user.id, userId));
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
const realCache = () => new RedisCache(redis.client);
|
|
90
|
+
const signInSuccess = (userId) => jsonResponse({
|
|
91
|
+
user: { ...betterAuthUser, id: userId },
|
|
92
|
+
token: 'tok',
|
|
93
|
+
session: { expiresAt: '2020-02-01T00:00:00.000Z' },
|
|
94
|
+
}, 200);
|
|
95
|
+
beforeAll(async () => {
|
|
96
|
+
db = await createTestDb([migrateIdentity]);
|
|
97
|
+
redis = await createTestRedis();
|
|
98
|
+
});
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
await db.drop();
|
|
101
|
+
await redis.quit();
|
|
102
|
+
});
|
|
103
|
+
beforeEach(async () => {
|
|
86
104
|
vi.clearAllMocks();
|
|
87
105
|
getSessionMock.mockResolvedValue(null);
|
|
106
|
+
await db.drizzle.db.execute(sql `TRUNCATE ${user}, ${session} RESTART IDENTITY CASCADE`);
|
|
107
|
+
await redis.flush();
|
|
88
108
|
});
|
|
89
|
-
describe('IdentityService
|
|
90
|
-
it('
|
|
91
|
-
|
|
92
|
-
drizzle: makeDrizzle(),
|
|
93
|
-
events: makeEvents(),
|
|
94
|
-
});
|
|
95
|
-
expect(svc).toBeInstanceOf(IdentityService);
|
|
96
|
-
});
|
|
97
|
-
it('constructs with a stub SendEmailPort and exposes it for use', () => {
|
|
98
|
-
const stub = { send: vi.fn().mockResolvedValue(undefined) };
|
|
99
|
-
const svc = withTemplateRenderer({
|
|
100
|
-
drizzle: makeDrizzle(),
|
|
101
|
-
events: makeEvents(),
|
|
102
|
-
email: stub,
|
|
103
|
-
});
|
|
104
|
-
expect(svc).toBeInstanceOf(IdentityService);
|
|
105
|
-
});
|
|
106
|
-
it('me returns null when no session exists', async () => {
|
|
107
|
-
const svc = withTemplateRenderer({
|
|
108
|
-
drizzle: makeDrizzle(),
|
|
109
|
-
events: makeEvents(),
|
|
110
|
-
});
|
|
111
|
-
const result = await svc.me({});
|
|
112
|
-
expect(result).toBeNull();
|
|
109
|
+
describe('IdentityService.me', () => {
|
|
110
|
+
it('returns null when no session exists', async () => {
|
|
111
|
+
expect(await buildService().me({})).toBeNull();
|
|
113
112
|
});
|
|
114
113
|
});
|
|
115
|
-
describe('IdentityService - login lockout', () => {
|
|
114
|
+
describe('IdentityService - login lockout (real PG + real Redis)', () => {
|
|
116
115
|
it('locks the account once failed attempts reach the threshold and emits lockout.triggered', async () => {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
});
|
|
121
|
-
const events = makeEvents();
|
|
116
|
+
const account = await seedUser({ failedLoginAttempts: 4 });
|
|
117
|
+
const events = makeEventBus();
|
|
118
|
+
const limiter = allowLimiter();
|
|
122
119
|
signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
|
|
123
|
-
const
|
|
124
|
-
consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }),
|
|
125
|
-
reset: vi.fn().mockResolvedValue(undefined),
|
|
126
|
-
});
|
|
127
|
-
const svc = withTemplateRenderer({ drizzle: drizzle, events: events, limiter });
|
|
120
|
+
const svc = buildService({ events, limiter });
|
|
128
121
|
await expect(svc.login({ email: 'A@B.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
|
|
129
|
-
|
|
130
|
-
expect(
|
|
122
|
+
const row = await readUser(account.id);
|
|
123
|
+
expect(row.failedLoginAttempts).toBe(5);
|
|
124
|
+
expect(row.lockoutUntil?.getTime()).toBeGreaterThan(Date.now());
|
|
125
|
+
expect(row.lockoutCount).toBe(1);
|
|
126
|
+
expect(events.emit).toHaveBeenCalledWith('identity.user.lockout.triggered', expect.objectContaining({ userId: account.id, email: EMAIL }));
|
|
127
|
+
expect(limiter.reset).toHaveBeenCalledWith(`login:${EMAIL}`);
|
|
131
128
|
});
|
|
132
|
-
it('
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
it('escalates the lockout duration on a repeat lockout inside the 24h window', async () => {
|
|
130
|
+
const account = await seedUser({
|
|
131
|
+
failedLoginAttempts: 4,
|
|
132
|
+
lockoutCount: 1,
|
|
133
|
+
lastLockoutAt: new Date(Date.now() - 60_000),
|
|
136
134
|
});
|
|
137
|
-
const events = makeEvents();
|
|
138
135
|
signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
|
|
139
|
-
const svc =
|
|
140
|
-
await expect(svc.login({ email:
|
|
141
|
-
|
|
136
|
+
const svc = buildService();
|
|
137
|
+
await expect(svc.login({ email: EMAIL, password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
|
|
138
|
+
const row = await readUser(account.id);
|
|
139
|
+
expect(row.lockoutCount).toBe(2);
|
|
140
|
+
expect(row.lockoutUntil?.getTime()).toBeGreaterThan(Date.now() + 60_000);
|
|
141
|
+
});
|
|
142
|
+
it('emits login.failed (not lockout) while still below the threshold', async () => {
|
|
143
|
+
const account = await seedUser();
|
|
144
|
+
const events = makeEventBus();
|
|
145
|
+
signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
|
|
146
|
+
const svc = buildService({ events });
|
|
147
|
+
await expect(svc.login({ email: EMAIL, password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
|
|
148
|
+
const row = await readUser(account.id);
|
|
149
|
+
expect(row.failedLoginAttempts).toBe(1);
|
|
150
|
+
expect(row.lockoutUntil).toBeNull();
|
|
151
|
+
expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: EMAIL, reason: 'invalid_credentials' }));
|
|
142
152
|
expect(events.emit).not.toHaveBeenCalledWith('identity.user.lockout.triggered', expect.anything());
|
|
143
153
|
});
|
|
154
|
+
it('counts concurrent credential failures exactly once each (atomic SQL increment)', async () => {
|
|
155
|
+
const account = await seedUser();
|
|
156
|
+
signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
|
|
157
|
+
const svc = buildService();
|
|
158
|
+
await Promise.allSettled(Array.from({ length: 3 }, () => svc.login({ email: EMAIL, password: 'wrongpass1' }, {}, new Headers())));
|
|
159
|
+
expect((await readUser(account.id)).failedLoginAttempts).toBe(3);
|
|
160
|
+
});
|
|
144
161
|
it('rejects a currently-locked account before attempting sign-in', async () => {
|
|
145
162
|
const future = new Date(Date.now() + 60_000);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
149
|
-
const svc = withTemplateRenderer({ drizzle: drizzle, events: makeEvents() });
|
|
150
|
-
const promise = svc.login({ email: 'a@b.dev', password: 'whatever1' }, {}, new Headers());
|
|
163
|
+
await seedUser({ failedLoginAttempts: 5, lockoutUntil: future });
|
|
164
|
+
const svc = buildService();
|
|
165
|
+
const promise = svc.login({ email: EMAIL, password: 'whatever1' }, {}, new Headers());
|
|
151
166
|
await expect(promise).rejects.toThrow(ORPCError);
|
|
152
167
|
await expect(promise).rejects.toMatchObject({
|
|
153
168
|
code: 'UNAUTHORIZED',
|
|
@@ -160,74 +175,57 @@ describe('IdentityService - login lockout', () => {
|
|
|
160
175
|
expect(signInEmailMock).not.toHaveBeenCalled();
|
|
161
176
|
});
|
|
162
177
|
it('clears the counter and emits login on success', async () => {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
|
|
169
|
-
const result = await svc.login({ email: 'a@b.dev', password: 'rightpass1' }, {}, new Headers());
|
|
178
|
+
const account = await seedUser({ failedLoginAttempts: 2 });
|
|
179
|
+
const events = makeEventBus();
|
|
180
|
+
signInEmailMock.mockResolvedValue(signInSuccess(account.id));
|
|
181
|
+
const svc = buildService({ events });
|
|
182
|
+
const result = await svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers());
|
|
170
183
|
expect(result).toMatchObject({ session: { token: 'tok' } });
|
|
171
|
-
expect(
|
|
172
|
-
expect(
|
|
184
|
+
expect((await readUser(account.id)).failedLoginAttempts).toBe(0);
|
|
185
|
+
expect(events.emit).toHaveBeenCalledWith('identity.user.login', expect.objectContaining({ userId: account.id }));
|
|
173
186
|
});
|
|
174
187
|
it('bypasses lockout for admins if configured with bypassForAdmins: true', async () => {
|
|
175
|
-
const
|
|
176
|
-
|
|
188
|
+
const account = await seedUser({
|
|
189
|
+
email: 'admin@b.dev',
|
|
190
|
+
role: 'admin',
|
|
191
|
+
failedLoginAttempts: 4,
|
|
177
192
|
});
|
|
178
|
-
const events =
|
|
193
|
+
const events = makeEventBus();
|
|
179
194
|
signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
|
|
180
|
-
const svc =
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
options: {
|
|
184
|
-
lockout: {
|
|
185
|
-
enabled: true,
|
|
186
|
-
bypassForAdmins: true,
|
|
187
|
-
},
|
|
188
|
-
},
|
|
195
|
+
const svc = buildService({
|
|
196
|
+
events,
|
|
197
|
+
options: { lockout: { enabled: true, bypassForAdmins: true } },
|
|
189
198
|
});
|
|
190
199
|
await expect(svc.login({ email: 'admin@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
|
|
191
|
-
|
|
192
|
-
expect(
|
|
200
|
+
const row = await readUser(account.id);
|
|
201
|
+
expect(row.failedLoginAttempts).toBe(4);
|
|
202
|
+
expect(row.lockoutUntil).toBeNull();
|
|
193
203
|
expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: 'admin@b.dev' }));
|
|
194
204
|
expect(events.emit).not.toHaveBeenCalledWith('identity.user.lockout.triggered', expect.anything());
|
|
195
205
|
});
|
|
196
206
|
it('does not bypass lockout for other backoffice roles if bypassForAdmins is true', async () => {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
],
|
|
207
|
+
const account = await seedUser({
|
|
208
|
+
email: 'support@b.dev',
|
|
209
|
+
role: 'support',
|
|
210
|
+
failedLoginAttempts: 4,
|
|
202
211
|
});
|
|
203
|
-
const events =
|
|
212
|
+
const events = makeEventBus();
|
|
204
213
|
signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
|
|
205
|
-
const svc =
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
options: {
|
|
209
|
-
lockout: {
|
|
210
|
-
enabled: true,
|
|
211
|
-
bypassForAdmins: true,
|
|
212
|
-
maxAttempts: 5,
|
|
213
|
-
},
|
|
214
|
-
},
|
|
214
|
+
const svc = buildService({
|
|
215
|
+
events,
|
|
216
|
+
options: { lockout: { enabled: true, bypassForAdmins: true, maxAttempts: 5 } },
|
|
215
217
|
});
|
|
216
218
|
await expect(svc.login({ email: 'support@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
|
|
217
|
-
|
|
218
|
-
expect(
|
|
219
|
-
expect(
|
|
219
|
+
const row = await readUser(account.id);
|
|
220
|
+
expect(row.failedLoginAttempts).toBe(5);
|
|
221
|
+
expect(row.lockoutUntil?.getTime()).toBeGreaterThan(Date.now());
|
|
222
|
+
expect(events.emit).toHaveBeenCalledWith('identity.user.lockout.triggered', expect.objectContaining({ userId: account.id, email: 'support@b.dev' }));
|
|
220
223
|
});
|
|
221
224
|
it('anti-enumeration: locks a nonexistent email once failed attempts reach the threshold, mirroring a real account, without emitting lockout.triggered', async () => {
|
|
222
|
-
const
|
|
223
|
-
const
|
|
225
|
+
const events = makeEventBus();
|
|
226
|
+
const limiter = allowLimiter();
|
|
224
227
|
signInEmailMock.mockImplementation(async () => jsonResponse({ message: 'Invalid' }, 401));
|
|
225
|
-
const
|
|
226
|
-
consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }),
|
|
227
|
-
reset: vi.fn().mockResolvedValue(undefined),
|
|
228
|
-
});
|
|
229
|
-
const cache = new InProcessCache();
|
|
230
|
-
const svc = withTemplateRenderer({ drizzle, events, limiter, cache });
|
|
228
|
+
const svc = buildService({ events, limiter, cache: realCache() });
|
|
231
229
|
for (let i = 0; i < 4; i++) {
|
|
232
230
|
await expect(svc.login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toMatchObject({ code: 'UNAUTHORIZED', message: 'Invalid' });
|
|
233
231
|
}
|
|
@@ -238,11 +236,18 @@ describe('IdentityService - login lockout', () => {
|
|
|
238
236
|
expect(limiter.reset).toHaveBeenCalledWith('login:nobody@b.dev');
|
|
239
237
|
expect(events.emit).not.toHaveBeenCalledWith('identity.user.lockout.triggered', expect.anything());
|
|
240
238
|
});
|
|
239
|
+
it('anti-enumeration: the shadow lockout survives a fresh service instance (state lives in Redis, not memory)', async () => {
|
|
240
|
+
signInEmailMock.mockImplementation(async () => jsonResponse({ message: 'Invalid' }, 401));
|
|
241
|
+
for (let i = 0; i < 5; i++) {
|
|
242
|
+
const svc = buildService({ cache: realCache() });
|
|
243
|
+
await expect(svc.login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
|
|
244
|
+
}
|
|
245
|
+
await expect(buildService({ cache: realCache() }).login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toMatchObject({ data: { code: 'ACCOUNT_LOCKED' } });
|
|
246
|
+
});
|
|
241
247
|
it('never locks a nonexistent email when cache is not provided (degrades to the pre-mirroring behavior)', async () => {
|
|
242
|
-
const
|
|
243
|
-
const events = makeEvents();
|
|
248
|
+
const events = makeEventBus();
|
|
244
249
|
signInEmailMock.mockImplementation(async () => jsonResponse({ message: 'Invalid' }, 401));
|
|
245
|
-
const svc =
|
|
250
|
+
const svc = buildService({ events });
|
|
246
251
|
for (let i = 0; i < 6; i++) {
|
|
247
252
|
await expect(svc.login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toMatchObject({ code: 'UNAUTHORIZED', message: 'Invalid' });
|
|
248
253
|
}
|
|
@@ -251,178 +256,107 @@ describe('IdentityService - login lockout', () => {
|
|
|
251
256
|
});
|
|
252
257
|
describe('IdentityService - login banned-user 403 (not the RG block)', () => {
|
|
253
258
|
it('surfaces a banned-user 403 from signInEmail as FORBIDDEN and still emits login.failed', async () => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
[
|
|
257
|
-
{
|
|
258
|
-
id: 'u1',
|
|
259
|
-
failedLoginAttempts: 0,
|
|
260
|
-
lockoutUntil: null,
|
|
261
|
-
rgBlocked: false,
|
|
262
|
-
rgBlockedUntil: null,
|
|
263
|
-
},
|
|
264
|
-
],
|
|
265
|
-
],
|
|
266
|
-
});
|
|
267
|
-
const events = makeEvents();
|
|
259
|
+
await seedUser();
|
|
260
|
+
const events = makeEventBus();
|
|
268
261
|
signInEmailMock.mockResolvedValue(jsonResponse({ message: 'BANNED_USER' }, 403));
|
|
269
|
-
const svc =
|
|
270
|
-
await expect(svc.login({ email:
|
|
271
|
-
expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email:
|
|
262
|
+
const svc = buildService({ events });
|
|
263
|
+
await expect(svc.login({ email: EMAIL, password: 'whatever1' }, {}, new Headers())).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
264
|
+
expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: EMAIL, reason: 'error' }));
|
|
272
265
|
expect(events.emit).not.toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.anything());
|
|
273
266
|
});
|
|
274
267
|
});
|
|
275
|
-
describe('IdentityService - RG login gate', () => {
|
|
276
|
-
it('blocks a self-excluded login AFTER credentials verify and emits rg.exclusion.login_blocked', async () => {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
lockoutUntil: null,
|
|
284
|
-
rgBlocked: true,
|
|
285
|
-
rgBlockedUntil: null,
|
|
286
|
-
},
|
|
287
|
-
],
|
|
288
|
-
],
|
|
289
|
-
});
|
|
290
|
-
const events = makeEvents();
|
|
268
|
+
describe('IdentityService - RG login gate (real PG)', () => {
|
|
269
|
+
it('blocks a self-excluded login AFTER credentials verify, expires the issued session, and emits rg.exclusion.login_blocked', async () => {
|
|
270
|
+
const account = await seedUser({ rgBlocked: true, rgBlockedUntil: null });
|
|
271
|
+
const live = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
272
|
+
await db.drizzle.db
|
|
273
|
+
.insert(session)
|
|
274
|
+
.values({ userId: account.id, token: randomUUID(), expiresAt: live });
|
|
275
|
+
const events = makeEventBus();
|
|
291
276
|
// Valid credentials (200) - the gate must still block.
|
|
292
|
-
signInEmailMock.mockResolvedValue(
|
|
293
|
-
const svc =
|
|
294
|
-
await expect(svc.login({ email:
|
|
277
|
+
signInEmailMock.mockResolvedValue(signInSuccess(account.id));
|
|
278
|
+
const svc = buildService({ events });
|
|
279
|
+
await expect(svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers())).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
295
280
|
expect(signInEmailMock).toHaveBeenCalled();
|
|
296
|
-
expect(events.emit).toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.objectContaining({ userId:
|
|
281
|
+
expect(events.emit).toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.objectContaining({ userId: account.id }));
|
|
297
282
|
expect(events.emit).not.toHaveBeenCalledWith('identity.user.login', expect.anything());
|
|
298
283
|
expect(events.emit).not.toHaveBeenCalledWith('identity.user.login.failed', expect.anything());
|
|
299
|
-
|
|
300
|
-
|
|
284
|
+
const [revoked] = await db.drizzle.db
|
|
285
|
+
.select()
|
|
286
|
+
.from(session)
|
|
287
|
+
.where(eq(session.userId, account.id));
|
|
288
|
+
expect(revoked.expiresAt.getTime()).toBeLessThanOrEqual(Date.now());
|
|
301
289
|
});
|
|
302
290
|
it('allows login once a lapsed cooling-off block has elapsed', async () => {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
rgBlocked: true,
|
|
312
|
-
rgBlockedUntil: past,
|
|
313
|
-
},
|
|
314
|
-
],
|
|
315
|
-
],
|
|
316
|
-
});
|
|
317
|
-
const events = makeEvents();
|
|
318
|
-
signInEmailMock.mockResolvedValue(jsonResponse({ user: betterAuthUser, token: 'tok', session: {} }, 200));
|
|
319
|
-
const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
|
|
320
|
-
const result = await svc.login({ email: 'a@b.dev', password: 'rightpass1' }, {}, new Headers());
|
|
291
|
+
const account = await seedUser({
|
|
292
|
+
rgBlocked: true,
|
|
293
|
+
rgBlockedUntil: new Date(Date.now() - 60_000),
|
|
294
|
+
});
|
|
295
|
+
const events = makeEventBus();
|
|
296
|
+
signInEmailMock.mockResolvedValue(signInSuccess(account.id));
|
|
297
|
+
const svc = buildService({ events });
|
|
298
|
+
const result = await svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers());
|
|
321
299
|
expect(result).toMatchObject({ session: { token: 'tok' } });
|
|
322
|
-
expect(events.emit).toHaveBeenCalledWith('identity.user.login', expect.objectContaining({ userId:
|
|
300
|
+
expect(events.emit).toHaveBeenCalledWith('identity.user.login', expect.objectContaining({ userId: account.id }));
|
|
323
301
|
expect(events.emit).not.toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.anything());
|
|
324
302
|
});
|
|
325
303
|
});
|
|
326
|
-
describe('IdentityService
|
|
327
|
-
it('clears the lockout and emits unlocked with the prior state', async () => {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
email: 'a@b.dev',
|
|
334
|
-
failedLoginAttempts: 5,
|
|
335
|
-
lockoutUntil: new Date('2020-01-01T00:00:00.000Z'),
|
|
336
|
-
},
|
|
337
|
-
],
|
|
338
|
-
],
|
|
339
|
-
});
|
|
340
|
-
const events = makeEvents();
|
|
341
|
-
const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
|
|
342
|
-
const res = await svc.unlockUser('u1', 'admin1');
|
|
304
|
+
describe('IdentityService.unlockUser (real PG)', () => {
|
|
305
|
+
it('clears the lockout row and emits unlocked with the prior state', async () => {
|
|
306
|
+
const lockedUntil = new Date('2020-01-01T00:00:00.000Z');
|
|
307
|
+
const account = await seedUser({ failedLoginAttempts: 5, lockoutUntil: lockedUntil });
|
|
308
|
+
const events = makeEventBus();
|
|
309
|
+
const svc = buildService({ events });
|
|
310
|
+
const res = await svc.unlockUser(account.id, 'admin1');
|
|
343
311
|
expect(res).toEqual({ success: true });
|
|
344
|
-
|
|
312
|
+
const row = await readUser(account.id);
|
|
313
|
+
expect(row.failedLoginAttempts).toBe(0);
|
|
314
|
+
expect(row.lockoutUntil).toBeNull();
|
|
345
315
|
expect(events.emit).toHaveBeenCalledWith('identity.user.unlocked', expect.objectContaining({
|
|
346
|
-
userId:
|
|
316
|
+
userId: account.id,
|
|
347
317
|
actorId: 'admin1',
|
|
348
318
|
previousFailedAttempts: 5,
|
|
349
|
-
previousLockoutUntil:
|
|
319
|
+
previousLockoutUntil: lockedUntil.toISOString(),
|
|
350
320
|
}));
|
|
351
321
|
});
|
|
352
322
|
it('throws when the user does not exist', async () => {
|
|
353
|
-
|
|
354
|
-
const svc = withTemplateRenderer({ drizzle: drizzle, events: makeEvents() });
|
|
355
|
-
await expect(svc.unlockUser('missing', 'admin1')).rejects.toThrow();
|
|
323
|
+
await expect(buildService().unlockUser(randomUUID(), 'admin1')).rejects.toThrow();
|
|
356
324
|
});
|
|
357
325
|
it('resets the login rate-limit window for the unlocked user', async () => {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
},
|
|
367
|
-
],
|
|
368
|
-
],
|
|
369
|
-
});
|
|
370
|
-
const resetMock = vi.fn().mockResolvedValue(undefined);
|
|
371
|
-
const limiter = {
|
|
372
|
-
consume: vi.fn(),
|
|
373
|
-
reset: resetMock,
|
|
374
|
-
};
|
|
375
|
-
const svc = withTemplateRenderer({
|
|
376
|
-
drizzle: drizzle,
|
|
377
|
-
events: makeEvents(),
|
|
378
|
-
limiter,
|
|
379
|
-
});
|
|
380
|
-
await svc.unlockUser('u1', 'admin1');
|
|
381
|
-
expect(resetMock).toHaveBeenCalledWith('login:a@b.dev');
|
|
326
|
+
const account = await seedUser({
|
|
327
|
+
failedLoginAttempts: 5,
|
|
328
|
+
lockoutUntil: new Date('2020-01-01'),
|
|
329
|
+
});
|
|
330
|
+
const limiter = allowLimiter();
|
|
331
|
+
const svc = buildService({ limiter });
|
|
332
|
+
await svc.unlockUser(account.id, 'admin1');
|
|
333
|
+
expect(limiter.reset).toHaveBeenCalledWith(`login:${EMAIL}`);
|
|
382
334
|
});
|
|
383
335
|
});
|
|
384
336
|
describe('IdentityService.requestPasswordReset', () => {
|
|
385
337
|
it('calls the non-deprecated requestPasswordResetEmailOTP endpoint and returns SUCCESS', async () => {
|
|
386
338
|
requestPasswordResetEmailOTPMock.mockResolvedValue(jsonResponse({ success: true }, 200));
|
|
387
|
-
const
|
|
388
|
-
drizzle: makeDrizzle(),
|
|
389
|
-
events: makeEvents(),
|
|
390
|
-
});
|
|
391
|
-
const result = await svc.requestPasswordReset({ email: 'a@b.dev' });
|
|
339
|
+
const result = await buildService().requestPasswordReset({ email: EMAIL });
|
|
392
340
|
expect(result).toEqual({ success: true });
|
|
393
|
-
expect(requestPasswordResetEmailOTPMock).toHaveBeenCalledWith(expect.objectContaining({ body: { email:
|
|
341
|
+
expect(requestPasswordResetEmailOTPMock).toHaveBeenCalledWith(expect.objectContaining({ body: { email: EMAIL } }));
|
|
394
342
|
});
|
|
395
343
|
it('swallows a failure and still returns SUCCESS (anti-enumeration)', async () => {
|
|
396
344
|
requestPasswordResetEmailOTPMock.mockRejectedValue(new Error('boom'));
|
|
397
|
-
|
|
398
|
-
drizzle: makeDrizzle(),
|
|
399
|
-
events: makeEvents(),
|
|
400
|
-
});
|
|
401
|
-
await expect(svc.requestPasswordReset({ email: 'unregistered@x.dev' })).resolves.toEqual({
|
|
402
|
-
success: true,
|
|
403
|
-
});
|
|
345
|
+
await expect(buildService().requestPasswordReset({ email: 'unregistered@x.dev' })).resolves.toEqual({ success: true });
|
|
404
346
|
});
|
|
405
347
|
});
|
|
406
348
|
describe('IdentityService.verifyPasswordResetOtp', () => {
|
|
407
349
|
it('calls checkVerificationOTP with type forget-password and returns SUCCESS', async () => {
|
|
408
350
|
checkVerificationOTPMock.mockResolvedValue(jsonResponse({ success: true }, 200));
|
|
409
|
-
const
|
|
410
|
-
drizzle: makeDrizzle(),
|
|
411
|
-
events: makeEvents(),
|
|
412
|
-
});
|
|
413
|
-
const result = await svc.verifyPasswordResetOtp({ email: 'a@b.dev', otp: '123456' });
|
|
351
|
+
const result = await buildService().verifyPasswordResetOtp({ email: EMAIL, otp: '123456' });
|
|
414
352
|
expect(result).toEqual({ success: true });
|
|
415
353
|
expect(checkVerificationOTPMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
416
|
-
body: { email:
|
|
354
|
+
body: { email: EMAIL, type: 'forget-password', otp: '123456' },
|
|
417
355
|
}));
|
|
418
356
|
});
|
|
419
357
|
it('surfaces an invalid OTP (400) response as ORPCError BAD_REQUEST with a generic message', async () => {
|
|
420
358
|
checkVerificationOTPMock.mockResolvedValue(jsonResponse({ message: 'INVALID_OTP' }, 400));
|
|
421
|
-
|
|
422
|
-
drizzle: makeDrizzle(),
|
|
423
|
-
events: makeEvents(),
|
|
424
|
-
});
|
|
425
|
-
await expect(svc.verifyPasswordResetOtp({ email: 'a@b.dev', otp: '000000' })).rejects.toMatchObject({
|
|
359
|
+
await expect(buildService().verifyPasswordResetOtp({ email: EMAIL, otp: '000000' })).rejects.toMatchObject({
|
|
426
360
|
code: 'BAD_REQUEST',
|
|
427
361
|
message: 'Invalid or expired verification code',
|
|
428
362
|
});
|
|
@@ -432,65 +366,52 @@ describe('IdentityService.verifyPasswordResetOtp', () => {
|
|
|
432
366
|
// unregistered email produces a distinct raw message ("User not found") that must never
|
|
433
367
|
// reach the caller - it would let an unauthenticated caller learn whether an email exists.
|
|
434
368
|
checkVerificationOTPMock.mockResolvedValue(jsonResponse({ message: 'User not found' }, 400));
|
|
435
|
-
|
|
436
|
-
drizzle: makeDrizzle(),
|
|
437
|
-
events: makeEvents(),
|
|
438
|
-
});
|
|
439
|
-
await expect(svc.verifyPasswordResetOtp({ email: 'unregistered@x.dev', otp: '000000' })).rejects.toMatchObject({
|
|
369
|
+
await expect(buildService().verifyPasswordResetOtp({ email: 'unregistered@x.dev', otp: '000000' })).rejects.toMatchObject({
|
|
440
370
|
code: 'BAD_REQUEST',
|
|
441
371
|
message: 'Invalid or expired verification code',
|
|
442
372
|
});
|
|
443
373
|
});
|
|
444
374
|
it('surfaces a TOO_MANY_ATTEMPTS (403) response as ORPCError BAD_REQUEST (masked)', async () => {
|
|
445
375
|
checkVerificationOTPMock.mockResolvedValue(jsonResponse({ message: 'TOO_MANY_ATTEMPTS' }, 403));
|
|
446
|
-
|
|
447
|
-
drizzle: makeDrizzle(),
|
|
448
|
-
events: makeEvents(),
|
|
449
|
-
});
|
|
450
|
-
await expect(svc.verifyPasswordResetOtp({ email: 'a@b.dev', otp: '000000' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
376
|
+
await expect(buildService().verifyPasswordResetOtp({ email: EMAIL, otp: '000000' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
451
377
|
});
|
|
452
378
|
});
|
|
453
379
|
describe('IdentityService.resetPassword', () => {
|
|
454
380
|
it('calls resetPasswordEmailOTP with the right body and returns SUCCESS', async () => {
|
|
455
381
|
resetPasswordEmailOTPMock.mockResolvedValue(jsonResponse({ success: true }, 200));
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
events: makeEvents(),
|
|
459
|
-
});
|
|
460
|
-
const result = await svc.resetPassword({
|
|
461
|
-
email: 'a@b.dev',
|
|
382
|
+
const result = await buildService().resetPassword({
|
|
383
|
+
email: EMAIL,
|
|
462
384
|
otp: '123456',
|
|
463
385
|
newPassword: 'newpassword1',
|
|
464
386
|
});
|
|
465
387
|
expect(result).toEqual({ success: true });
|
|
466
388
|
expect(resetPasswordEmailOTPMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
467
|
-
body: { email:
|
|
389
|
+
body: { email: EMAIL, otp: '123456', password: 'newpassword1' },
|
|
468
390
|
}));
|
|
469
391
|
});
|
|
470
392
|
it('surfaces a TOO_MANY_ATTEMPTS (403) response as ORPCError BAD_REQUEST (masked)', async () => {
|
|
471
393
|
resetPasswordEmailOTPMock.mockResolvedValue(jsonResponse({ message: 'TOO_MANY_ATTEMPTS' }, 403));
|
|
472
|
-
|
|
473
|
-
drizzle: makeDrizzle(),
|
|
474
|
-
events: makeEvents(),
|
|
475
|
-
});
|
|
476
|
-
await expect(svc.resetPassword({ email: 'a@b.dev', otp: '000000', newPassword: 'newpassword1' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
394
|
+
await expect(buildService().resetPassword({ email: EMAIL, otp: '000000', newPassword: 'newpassword1' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
477
395
|
});
|
|
478
396
|
});
|
|
479
397
|
describe('IdentityService onPasswordReset hook (wired via createAuth)', () => {
|
|
480
|
-
it('clears the lockout and emits identity.password.reset when better-auth invokes the hook', async () => {
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
398
|
+
it('clears the lockout row and emits identity.password.reset when better-auth invokes the hook', async () => {
|
|
399
|
+
const account = await seedUser({
|
|
400
|
+
failedLoginAttempts: 5,
|
|
401
|
+
lockoutUntil: new Date(Date.now() + 60_000),
|
|
402
|
+
});
|
|
403
|
+
const events = makeEventBus();
|
|
404
|
+
buildService({ events });
|
|
405
|
+
await capturedAuthOptions.current?.onPasswordReset?.({ id: account.id, email: EMAIL });
|
|
406
|
+
expect(events.emit).toHaveBeenCalledWith('identity.password.reset', expect.objectContaining({ userId: account.id }));
|
|
407
|
+
const row = await readUser(account.id);
|
|
408
|
+
expect(row.failedLoginAttempts).toBe(0);
|
|
409
|
+
expect(row.lockoutUntil).toBeNull();
|
|
487
410
|
});
|
|
488
411
|
});
|
|
489
412
|
describe('IdentityService.updateProfile language validation', () => {
|
|
490
413
|
it('rejects an unsupported language before calling updateUser', async () => {
|
|
491
|
-
const svc =
|
|
492
|
-
drizzle: makeDrizzle(),
|
|
493
|
-
events: makeEvents(),
|
|
414
|
+
const svc = buildService({
|
|
494
415
|
platformConfig: mock({ supportedLanguages: ['en', 'fr'] }),
|
|
495
416
|
});
|
|
496
417
|
await expect(svc.updateProfile({ language: 'de' }, {}, new Headers())).rejects.toThrow(UnsupportedLanguageError);
|
|
@@ -499,9 +420,7 @@ describe('IdentityService.updateProfile language validation', () => {
|
|
|
499
420
|
it('accepts a supported language and forwards it to updateUser', async () => {
|
|
500
421
|
updateUserMock.mockResolvedValue(jsonResponse({ status: true }, 200));
|
|
501
422
|
getSessionMock.mockResolvedValueOnce({ user: { ...betterAuthUser, language: 'fr' } });
|
|
502
|
-
const svc =
|
|
503
|
-
drizzle: makeDrizzle(),
|
|
504
|
-
events: makeEvents(),
|
|
423
|
+
const svc = buildService({
|
|
505
424
|
platformConfig: mock({ supportedLanguages: ['en', 'fr'] }),
|
|
506
425
|
});
|
|
507
426
|
const result = await svc.updateProfile({ language: 'fr' }, {}, new Headers());
|