@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,750 +1,521 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
3
|
+
import { sql, eq } from 'drizzle-orm';
|
|
4
|
+
import { mock, NO_CLIENT_META, makeEventBus } from '../../testing/mock.js';
|
|
5
|
+
import { statement, findOneOrThrow, RedisCache } from '@openora/core/server';
|
|
6
|
+
import { createTestDb, createTestRedis } from '@openora/core/testing';
|
|
7
|
+
import { migrate as migrateIam } from '@openora/core/iam/migrate';
|
|
8
|
+
import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
|
|
9
|
+
import { user } from '@openora/core/pam/schema/identity';
|
|
10
|
+
import { adminRole, adminRolePermission, adminRoleAssignment, adminInvitation, } from '../schema/index.js';
|
|
6
11
|
import { IamService, DbAdminPermissionResolver, RoleNotFoundError, InvalidGrantError, NotSuperAdminError, ProtectedRoleError, LastSuperAdminError, InvitationConflictError, AdminUserNotFoundError, NotAnAdminUserError, } from '../service/iam.service.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
let db;
|
|
13
|
+
let redis;
|
|
14
|
+
const makeEmail = () => mock({ send: vi.fn().mockResolvedValue(undefined) });
|
|
15
|
+
// Bootstrap super-admin: no DB assignment row + user.role === 'admin' passes the static fallback.
|
|
16
|
+
const ADMIN_CALLER = { userId: randomUUID(), role: 'admin', ...NO_CLIENT_META };
|
|
17
|
+
const SUPPORT_CALLER = { userId: randomUUID(), role: 'support', ...NO_CLIENT_META };
|
|
18
|
+
const TOTAL_GRANTS = Object.keys(statement).reduce((n, r) => n + statement[r].length, 0);
|
|
19
|
+
async function seedRole(overrides = {}) {
|
|
20
|
+
const [row] = await db.drizzle.db
|
|
21
|
+
.insert(adminRole)
|
|
22
|
+
.values({
|
|
23
|
+
name: overrides.name ?? 'Ops',
|
|
24
|
+
key: overrides.key ?? null,
|
|
25
|
+
isSystem: overrides.isSystem ?? false,
|
|
26
|
+
isSuperAdmin: overrides.isSuperAdmin ?? false,
|
|
27
|
+
})
|
|
28
|
+
.returning();
|
|
29
|
+
return row;
|
|
20
30
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
async function seedUser(role) {
|
|
32
|
+
const [row] = await db.drizzle.db
|
|
33
|
+
.insert(user)
|
|
34
|
+
.values({ name: 'U', email: `${randomUUID()}@x.dev`, role })
|
|
35
|
+
.returning();
|
|
36
|
+
return row;
|
|
25
37
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
38
|
+
async function seedAssignment(userId, roleId) {
|
|
39
|
+
await db.drizzle.db.insert(adminRoleAssignment).values({ userId, roleId });
|
|
28
40
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let table = {};
|
|
32
|
-
let lastSelect;
|
|
33
|
-
const chain = {
|
|
34
|
-
select: vi.fn().mockImplementation((sel) => {
|
|
35
|
-
lastSelect = sel;
|
|
36
|
-
return chain;
|
|
37
|
-
}),
|
|
38
|
-
from: vi.fn().mockImplementation((tbl) => {
|
|
39
|
-
table = tbl ?? {};
|
|
40
|
-
return chain;
|
|
41
|
-
}),
|
|
42
|
-
innerJoin: vi.fn().mockReturnThis(),
|
|
43
|
-
leftJoin: vi.fn().mockReturnThis(),
|
|
44
|
-
where: vi.fn().mockImplementation(() => {
|
|
45
|
-
const route = () => {
|
|
46
|
-
// getGrants' single join (select includes isSuperAdmin). Synthesize the joined
|
|
47
|
-
// rows from the decomposed byTable so existing test data keeps working: one row
|
|
48
|
-
// per (role x permission), super roles collapse to a bypass row, empty -> [].
|
|
49
|
-
if (lastSelect && 'isSuperAdmin' in lastSelect) {
|
|
50
|
-
const assignments = (byTable.assignment ?? []);
|
|
51
|
-
if (assignments.length === 0) {
|
|
52
|
-
return [];
|
|
53
|
-
}
|
|
54
|
-
const superIds = new Set((byTable.superRole ?? []).map((r) => r.id));
|
|
55
|
-
if (assignments.some((a) => superIds.has(a.roleId))) {
|
|
56
|
-
return [{ isSuperAdmin: true, resource: null, level: null }];
|
|
57
|
-
}
|
|
58
|
-
const perms = (byTable.permission ?? []);
|
|
59
|
-
if (perms.length === 0) {
|
|
60
|
-
return [{ isSuperAdmin: false, resource: null, level: null }];
|
|
61
|
-
}
|
|
62
|
-
return perms.map((p) => ({ isSuperAdmin: false, resource: p.resource, level: p.level }));
|
|
63
|
-
}
|
|
64
|
-
if ('emailVerified' in table) {
|
|
65
|
-
return byTable.user ?? [];
|
|
66
|
-
}
|
|
67
|
-
if ('level' in table) {
|
|
68
|
-
return byTable.permission ?? [];
|
|
69
|
-
}
|
|
70
|
-
// adminRoleAssignment has `userId` column. A { roleId }-only select is the
|
|
71
|
-
// assignment read used by both getGrants and isSuperAdmin(caller); a
|
|
72
|
-
// full-row select is the dedup existing-check. When a test needs the two
|
|
73
|
-
// to differ it sets `callerAssignment` (else both share `assignment`).
|
|
74
|
-
if ('userId' in table) {
|
|
75
|
-
if (lastSelect && 'roleId' in lastSelect && Object.keys(lastSelect).length === 1) {
|
|
76
|
-
return byTable.callerAssignment ?? byTable.assignment ?? [];
|
|
77
|
-
}
|
|
78
|
-
return byTable.assignment ?? [];
|
|
79
|
-
}
|
|
80
|
-
// adminRole: distinguish the isSuperAdmin probe (selects only id) from the
|
|
81
|
-
// role-existence read (selects full row) via the select shape.
|
|
82
|
-
if (lastSelect && 'id' in lastSelect && Object.keys(lastSelect).length === 1) {
|
|
83
|
-
return byTable.superRole ?? [];
|
|
84
|
-
}
|
|
85
|
-
return byTable.role ?? [];
|
|
86
|
-
};
|
|
87
|
-
const rows = route();
|
|
88
|
-
const thenable = {
|
|
89
|
-
then: (res) => Promise.resolve(rows).then(res),
|
|
90
|
-
for: () => thenable,
|
|
91
|
-
limit: () => thenable,
|
|
92
|
-
returning: (...args) => chain.returning(...args),
|
|
93
|
-
};
|
|
94
|
-
return thenable;
|
|
95
|
-
}),
|
|
96
|
-
insert: vi.fn().mockReturnThis(),
|
|
97
|
-
values: vi.fn().mockResolvedValue(undefined),
|
|
98
|
-
update: vi.fn().mockReturnThis(),
|
|
99
|
-
set: vi.fn().mockReturnThis(),
|
|
100
|
-
delete: vi.fn().mockReturnThis(),
|
|
101
|
-
returning: vi.fn().mockResolvedValue([]),
|
|
102
|
-
transaction: vi.fn().mockImplementation((fn) => fn(chain)),
|
|
103
|
-
};
|
|
104
|
-
return mockDb(chain);
|
|
41
|
+
async function seedPermission(roleId, resource, level) {
|
|
42
|
+
await db.drizzle.db.insert(adminRolePermission).values({ roleId, resource, level });
|
|
105
43
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
expect(levelToActions('player', 'no_access')).toEqual([]);
|
|
118
|
-
expect(levelToActions('player', 'read')).toEqual(['view']);
|
|
119
|
-
expect([...levelToActions('player', 'read_write')]).toEqual([...statement.player]);
|
|
120
|
-
expect(actionsToLevel('player', [])).toBe('no_access');
|
|
121
|
-
expect(actionsToLevel('player', ['view'])).toBe('read');
|
|
122
|
-
expect(actionsToLevel('player', [...statement.player])).toBe('read_write');
|
|
123
|
-
});
|
|
124
|
-
it('content has no read level (no view action) - read expands to empty', () => {
|
|
125
|
-
expect(levelToActions('content', 'read')).toEqual([]);
|
|
126
|
-
expect([...levelToActions('content', 'read_write')]).toEqual([...statement.content]);
|
|
127
|
-
expect(actionsToLevel('content', ['create'])).toBe('no_access');
|
|
128
|
-
expect(actionsToLevel('content', [...statement.content])).toBe('read_write');
|
|
129
|
-
});
|
|
130
|
-
it('expands the two new modules', () => {
|
|
131
|
-
expect(levelToActions('sportsbook', 'read')).toEqual(['view']);
|
|
132
|
-
expect([...levelToActions('sportsbook', 'read_write')]).toEqual([...statement.sportsbook]);
|
|
133
|
-
expect([...levelToActions('affiliate', 'read_write')]).toEqual([...statement.affiliate]);
|
|
134
|
-
});
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
db = await createTestDb([migrateIam, migrateIdentity]);
|
|
46
|
+
redis = await createTestRedis();
|
|
47
|
+
});
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await db.drop();
|
|
50
|
+
await redis.quit();
|
|
51
|
+
});
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await db.drizzle.db.execute(sql `TRUNCATE ${adminRole}, ${adminRolePermission}, ${adminRoleAssignment}, ${adminInvitation}, ${user} RESTART IDENTITY CASCADE`);
|
|
54
|
+
await redis.flush();
|
|
135
55
|
});
|
|
136
56
|
describe('IamService.listCatalog', () => {
|
|
137
|
-
it('omits the admin module from the assignable catalog',
|
|
138
|
-
const
|
|
139
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
57
|
+
it('omits the admin module from the assignable catalog', () => {
|
|
58
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
140
59
|
const catalog = svc.listCatalog();
|
|
141
60
|
expect(catalog.modules.some((m) => m.resource === 'admin')).toBe(false);
|
|
142
61
|
expect(catalog.modules.some((m) => m.resource === 'player')).toBe(true);
|
|
143
62
|
});
|
|
144
63
|
});
|
|
145
|
-
describe('DbAdminPermissionResolver', () => {
|
|
146
|
-
it('returns null when user has no assignment', async () => {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
const result = await resolver.getGrants('user-no-role');
|
|
150
|
-
expect(result).toBeNull();
|
|
64
|
+
describe('DbAdminPermissionResolver (real PG)', () => {
|
|
65
|
+
it('returns null when the user has no assignment', async () => {
|
|
66
|
+
const resolver = new DbAdminPermissionResolver(db.drizzle);
|
|
67
|
+
expect(await resolver.getGrants(randomUUID())).toBeNull();
|
|
151
68
|
});
|
|
152
69
|
it('super-admin assignment returns ALL grants (bypass)', async () => {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
superRole: [],
|
|
167
|
-
permission: [
|
|
168
|
-
{ resource: 'player', level: 'read' },
|
|
169
|
-
{ resource: 'withdrawal', level: 'read_write' },
|
|
170
|
-
],
|
|
171
|
-
});
|
|
172
|
-
const resolver = new DbAdminPermissionResolver(drizzle);
|
|
173
|
-
const grants = await inContext(() => resolver.getGrants('u'));
|
|
70
|
+
const role = await seedRole({ isSuperAdmin: true });
|
|
71
|
+
const userId = randomUUID();
|
|
72
|
+
await seedAssignment(userId, role.id);
|
|
73
|
+
const grants = await new DbAdminPermissionResolver(db.drizzle).getGrants(userId);
|
|
74
|
+
expect(grants).toHaveLength(TOTAL_GRANTS);
|
|
75
|
+
});
|
|
76
|
+
it('expands stored level rows into action grants', async () => {
|
|
77
|
+
const role = await seedRole();
|
|
78
|
+
await seedPermission(role.id, 'player', 'read');
|
|
79
|
+
await seedPermission(role.id, 'withdrawal', 'read_write');
|
|
80
|
+
const userId = randomUUID();
|
|
81
|
+
await seedAssignment(userId, role.id);
|
|
82
|
+
const grants = await new DbAdminPermissionResolver(db.drizzle).getGrants(userId);
|
|
174
83
|
expect(grants).toContainEqual({ resource: 'player', action: 'view' });
|
|
175
84
|
expect(grants).toContainEqual({ resource: 'withdrawal', action: 'approve' });
|
|
176
85
|
// read player must NOT yield write actions.
|
|
177
86
|
expect(grants).not.toContainEqual({ resource: 'player', action: 'ban' });
|
|
178
87
|
});
|
|
179
88
|
});
|
|
180
|
-
describe('DbAdminPermissionResolver caching', () => {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
await resolver.getGrants(
|
|
209
|
-
await resolver.getGrants('u1');
|
|
210
|
-
expect(state.grantsQueries).toBe(1);
|
|
211
|
-
});
|
|
212
|
-
it('without a cache, every lookup queries', async () => {
|
|
213
|
-
const { drizzle, state } = grantsDrizzle();
|
|
214
|
-
const resolver = new DbAdminPermissionResolver(drizzle);
|
|
215
|
-
await resolver.getGrants('u1');
|
|
216
|
-
await resolver.getGrants('u1');
|
|
217
|
-
expect(state.grantsQueries).toBe(2);
|
|
218
|
-
});
|
|
219
|
-
it('invalidateUser purges so the next lookup re-queries', async () => {
|
|
220
|
-
const { drizzle, state } = grantsDrizzle();
|
|
221
|
-
const resolver = new DbAdminPermissionResolver(drizzle, new InProcessCache());
|
|
222
|
-
await resolver.getGrants('u1');
|
|
223
|
-
await resolver.invalidateUser('u1');
|
|
224
|
-
await resolver.getGrants('u1');
|
|
225
|
-
expect(state.grantsQueries).toBe(2);
|
|
89
|
+
describe('DbAdminPermissionResolver caching (real Redis read-through)', () => {
|
|
90
|
+
it('serves a repeat lookup from cache even after the underlying rows change', async () => {
|
|
91
|
+
const role = await seedRole({ isSuperAdmin: true });
|
|
92
|
+
const userId = randomUUID();
|
|
93
|
+
await seedAssignment(userId, role.id);
|
|
94
|
+
const resolver = new DbAdminPermissionResolver(db.drizzle, new RedisCache(redis.client));
|
|
95
|
+
expect(await resolver.getGrants(userId)).toHaveLength(TOTAL_GRANTS);
|
|
96
|
+
// Revoke in the DB behind the cache's back; the cached grants must still be served.
|
|
97
|
+
await db.drizzle.db.delete(adminRoleAssignment).where(eq(adminRoleAssignment.userId, userId));
|
|
98
|
+
expect(await resolver.getGrants(userId)).toHaveLength(TOTAL_GRANTS);
|
|
99
|
+
});
|
|
100
|
+
it('without a cache, every lookup re-queries the DB', async () => {
|
|
101
|
+
const role = await seedRole({ isSuperAdmin: true });
|
|
102
|
+
const userId = randomUUID();
|
|
103
|
+
await seedAssignment(userId, role.id);
|
|
104
|
+
const resolver = new DbAdminPermissionResolver(db.drizzle);
|
|
105
|
+
expect(await resolver.getGrants(userId)).toHaveLength(TOTAL_GRANTS);
|
|
106
|
+
await db.drizzle.db.delete(adminRoleAssignment).where(eq(adminRoleAssignment.userId, userId));
|
|
107
|
+
expect(await resolver.getGrants(userId)).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
it('invalidateUser purges so the next lookup reflects the revoke', async () => {
|
|
110
|
+
const role = await seedRole({ isSuperAdmin: true });
|
|
111
|
+
const userId = randomUUID();
|
|
112
|
+
await seedAssignment(userId, role.id);
|
|
113
|
+
const resolver = new DbAdminPermissionResolver(db.drizzle, new RedisCache(redis.client));
|
|
114
|
+
await resolver.getGrants(userId);
|
|
115
|
+
await db.drizzle.db.delete(adminRoleAssignment).where(eq(adminRoleAssignment.userId, userId));
|
|
116
|
+
await resolver.invalidateUser(userId);
|
|
117
|
+
expect(await resolver.getGrants(userId)).toBeNull();
|
|
226
118
|
});
|
|
227
119
|
it('invalidateRole purges every current holder of the role', async () => {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
await
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
expect(
|
|
120
|
+
const role = await seedRole();
|
|
121
|
+
await seedPermission(role.id, 'player', 'read');
|
|
122
|
+
const u1 = randomUUID();
|
|
123
|
+
const u2 = randomUUID();
|
|
124
|
+
await seedAssignment(u1, role.id);
|
|
125
|
+
await seedAssignment(u2, role.id);
|
|
126
|
+
const resolver = new DbAdminPermissionResolver(db.drizzle, new RedisCache(redis.client));
|
|
127
|
+
// Prime both users' caches at the 'read' level (just the view action).
|
|
128
|
+
expect(await resolver.getGrants(u1)).toEqual([{ resource: 'player', action: 'view' }]);
|
|
129
|
+
expect(await resolver.getGrants(u2)).toEqual([{ resource: 'player', action: 'view' }]);
|
|
130
|
+
// Widen the role to read_write, then invalidate every holder.
|
|
131
|
+
await db.drizzle.db
|
|
132
|
+
.update(adminRolePermission)
|
|
133
|
+
.set({ level: 'read_write' })
|
|
134
|
+
.where(eq(adminRolePermission.roleId, role.id));
|
|
135
|
+
await resolver.invalidateRole(role.id);
|
|
136
|
+
for (const uid of [u1, u2]) {
|
|
137
|
+
const grants = await resolver.getGrants(uid);
|
|
138
|
+
expect(grants).toContainEqual({ resource: 'player', action: 'ban' });
|
|
139
|
+
}
|
|
237
140
|
});
|
|
238
141
|
});
|
|
239
142
|
describe('IamService.setRolePermissions', () => {
|
|
240
143
|
it('rejects a non-super-admin caller', async () => {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
});
|
|
246
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
247
|
-
await expect(inContext(() => svc.setRolePermissions({
|
|
248
|
-
roleId: 'role-1',
|
|
144
|
+
const role = await seedRole();
|
|
145
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
146
|
+
await expect(svc.setRolePermissions({
|
|
147
|
+
roleId: role.id,
|
|
249
148
|
grants: [{ resource: 'player', level: 'read' }],
|
|
250
|
-
caller:
|
|
251
|
-
}))
|
|
252
|
-
});
|
|
253
|
-
it('throws RoleNotFoundError when role does not exist', async () => {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
roleId: 'missing',
|
|
149
|
+
caller: SUPPORT_CALLER,
|
|
150
|
+
})).rejects.toBeInstanceOf(NotSuperAdminError);
|
|
151
|
+
});
|
|
152
|
+
it('throws RoleNotFoundError when the role does not exist', async () => {
|
|
153
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
154
|
+
await expect(svc.setRolePermissions({
|
|
155
|
+
roleId: randomUUID(),
|
|
258
156
|
grants: [{ resource: 'player', level: 'read' }],
|
|
259
157
|
caller: ADMIN_CALLER,
|
|
260
|
-
}))
|
|
158
|
+
})).rejects.toBeInstanceOf(RoleNotFoundError);
|
|
261
159
|
});
|
|
262
160
|
it('throws InvalidGrantError for an unknown module', async () => {
|
|
263
|
-
const
|
|
264
|
-
const svc = new IamService(drizzle,
|
|
265
|
-
await expect(
|
|
266
|
-
roleId:
|
|
161
|
+
const role = await seedRole();
|
|
162
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
163
|
+
await expect(svc.setRolePermissions({
|
|
164
|
+
roleId: role.id,
|
|
267
165
|
grants: [{ resource: 'nonexistent', level: 'read' }],
|
|
268
166
|
caller: ADMIN_CALLER,
|
|
269
|
-
}))
|
|
167
|
+
})).rejects.toBeInstanceOf(InvalidGrantError);
|
|
270
168
|
});
|
|
271
169
|
it('rejects a grant targeting the admin module (super-admin-only, A6)', async () => {
|
|
272
|
-
const
|
|
273
|
-
const svc = new IamService(drizzle,
|
|
274
|
-
await expect(
|
|
275
|
-
roleId:
|
|
170
|
+
const role = await seedRole();
|
|
171
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
172
|
+
await expect(svc.setRolePermissions({
|
|
173
|
+
roleId: role.id,
|
|
276
174
|
grants: [{ resource: 'admin', level: 'read_write' }],
|
|
277
175
|
caller: ADMIN_CALLER,
|
|
278
|
-
}))
|
|
176
|
+
})).rejects.toBeInstanceOf(InvalidGrantError);
|
|
279
177
|
});
|
|
280
178
|
it('blocks editing a super-admin role (always full)', async () => {
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
superRole: [],
|
|
286
|
-
});
|
|
287
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
288
|
-
await expect(inContext(() => svc.setRolePermissions({
|
|
289
|
-
roleId: 'role-super',
|
|
179
|
+
const role = await seedRole({ isSuperAdmin: true, isSystem: true });
|
|
180
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
181
|
+
await expect(svc.setRolePermissions({
|
|
182
|
+
roleId: role.id,
|
|
290
183
|
grants: [{ resource: 'player', level: 'read' }],
|
|
291
184
|
caller: ADMIN_CALLER,
|
|
292
|
-
}))
|
|
293
|
-
});
|
|
294
|
-
it('a super caller may grant any level
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
permission: [{ resource: 'withdrawal', level: 'read_write' }],
|
|
300
|
-
});
|
|
301
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
302
|
-
const result = await inContext(() => svc.setRolePermissions({
|
|
303
|
-
roleId: 'role-1',
|
|
185
|
+
})).rejects.toBeInstanceOf(ProtectedRoleError);
|
|
186
|
+
});
|
|
187
|
+
it('a super caller may grant any level and the rows are persisted', async () => {
|
|
188
|
+
const role = await seedRole();
|
|
189
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
190
|
+
const result = await svc.setRolePermissions({
|
|
191
|
+
roleId: role.id,
|
|
304
192
|
grants: [{ resource: 'withdrawal', level: 'read_write' }],
|
|
305
193
|
caller: ADMIN_CALLER,
|
|
306
|
-
})
|
|
194
|
+
});
|
|
307
195
|
expect(result.permissions).toContainEqual({ resource: 'withdrawal', level: 'read_write' });
|
|
196
|
+
const rows = await db.drizzle.db
|
|
197
|
+
.select()
|
|
198
|
+
.from(adminRolePermission)
|
|
199
|
+
.where(eq(adminRolePermission.roleId, role.id));
|
|
200
|
+
expect(rows).toHaveLength(1);
|
|
201
|
+
expect(rows[0]).toMatchObject({ resource: 'withdrawal', level: 'read_write' });
|
|
308
202
|
});
|
|
309
203
|
it('emits iam.role.permissions.changed with actorId', async () => {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
});
|
|
316
|
-
const events = makeEvents();
|
|
317
|
-
const svc = new IamService(drizzle, events, makeEmail());
|
|
318
|
-
await inContext(() => svc.setRolePermissions({
|
|
319
|
-
roleId: 'role-1',
|
|
204
|
+
const role = await seedRole();
|
|
205
|
+
const events = makeEventBus();
|
|
206
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
207
|
+
await svc.setRolePermissions({
|
|
208
|
+
roleId: role.id,
|
|
320
209
|
grants: [{ resource: 'player', level: 'read' }],
|
|
321
210
|
caller: ADMIN_CALLER,
|
|
322
|
-
})
|
|
323
|
-
expect(events.emit).toHaveBeenCalledWith('iam.role.permissions.changed', expect.objectContaining({ roleId:
|
|
211
|
+
});
|
|
212
|
+
expect(events.emit).toHaveBeenCalledWith('iam.role.permissions.changed', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
|
|
324
213
|
});
|
|
325
214
|
});
|
|
326
215
|
describe('IamService.updateRole', () => {
|
|
327
216
|
it('blocks renaming the super-admin role', async () => {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
superRole: [],
|
|
332
|
-
});
|
|
333
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
334
|
-
await expect(inContext(() => svc.updateRole({ roleId: 'role-1', name: 'Renamed', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(ProtectedRoleError);
|
|
217
|
+
const role = await seedRole({ isSuperAdmin: true, isSystem: true });
|
|
218
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
219
|
+
await expect(svc.updateRole({ roleId: role.id, name: 'Renamed', caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
|
|
335
220
|
});
|
|
336
221
|
it('allows renaming a predefined (isSystem, non-super) role', async () => {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
});
|
|
342
|
-
readPrivate(drizzle.db, 'returning').mockResolvedValue([
|
|
343
|
-
{ ...ROLE_ROW, isSystem: true, name: 'Renamed' },
|
|
344
|
-
]);
|
|
345
|
-
const events = makeEvents();
|
|
346
|
-
const svc = new IamService(drizzle, events, makeEmail());
|
|
347
|
-
const result = await inContext(() => svc.updateRole({ roleId: 'role-1', name: 'Renamed', caller: ADMIN_CALLER }));
|
|
222
|
+
const role = await seedRole({ isSystem: true, isSuperAdmin: false });
|
|
223
|
+
const events = makeEventBus();
|
|
224
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
225
|
+
const result = await svc.updateRole({ roleId: role.id, name: 'Renamed', caller: ADMIN_CALLER });
|
|
348
226
|
expect(result.name).toBe('Renamed');
|
|
349
|
-
expect(events.emit).toHaveBeenCalledWith('iam.role.updated', expect.objectContaining({ roleId:
|
|
227
|
+
expect(events.emit).toHaveBeenCalledWith('iam.role.updated', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
|
|
228
|
+
const [row] = await db.drizzle.db.select().from(adminRole).where(eq(adminRole.id, role.id));
|
|
229
|
+
expect(row.name).toBe('Renamed');
|
|
350
230
|
});
|
|
351
231
|
});
|
|
352
232
|
describe('IamService.deleteRole', () => {
|
|
353
233
|
it('blocks deleting a system role', async () => {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
superRole: [],
|
|
358
|
-
});
|
|
359
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
360
|
-
await expect(inContext(() => svc.deleteRole({ roleId: 'role-1', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(ProtectedRoleError);
|
|
234
|
+
const role = await seedRole({ isSystem: true });
|
|
235
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
236
|
+
await expect(svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
|
|
361
237
|
});
|
|
362
238
|
it('blocks deleting a super-admin role', async () => {
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
superRole: [],
|
|
367
|
-
});
|
|
368
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
369
|
-
await expect(inContext(() => svc.deleteRole({ roleId: 'role-1', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(ProtectedRoleError);
|
|
239
|
+
const role = await seedRole({ isSuperAdmin: true });
|
|
240
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
241
|
+
await expect(svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
|
|
370
242
|
});
|
|
371
243
|
it('deletes a custom role with no assignments and emits only iam.role.deleted', async () => {
|
|
372
|
-
const
|
|
373
|
-
const events =
|
|
374
|
-
const svc = new IamService(drizzle, events, makeEmail());
|
|
375
|
-
const result = await
|
|
244
|
+
const role = await seedRole();
|
|
245
|
+
const events = makeEventBus();
|
|
246
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
247
|
+
const result = await svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER });
|
|
376
248
|
expect(result).toEqual({ success: true });
|
|
377
|
-
expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId:
|
|
249
|
+
expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
|
|
378
250
|
expect(events.emit).not.toHaveBeenCalledWith('iam.role.revoked', expect.anything());
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const events =
|
|
388
|
-
const svc = new IamService(drizzle, events, makeEmail());
|
|
389
|
-
const result = await
|
|
251
|
+
expect(await db.drizzle.db.select().from(adminRole).where(eq(adminRole.id, role.id))).toHaveLength(0);
|
|
252
|
+
});
|
|
253
|
+
it('emits iam.role.revoked once per affected user plus iam.role.deleted, and cascades assignments', async () => {
|
|
254
|
+
const role = await seedRole();
|
|
255
|
+
const userA = randomUUID();
|
|
256
|
+
const userB = randomUUID();
|
|
257
|
+
await seedAssignment(userA, role.id);
|
|
258
|
+
await seedAssignment(userB, role.id);
|
|
259
|
+
const events = makeEventBus();
|
|
260
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
261
|
+
const result = await svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER });
|
|
390
262
|
expect(result).toEqual({ success: true });
|
|
391
263
|
expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', {
|
|
392
|
-
roleId:
|
|
393
|
-
userId:
|
|
394
|
-
actorId:
|
|
395
|
-
|
|
396
|
-
userAgent: null,
|
|
264
|
+
roleId: role.id,
|
|
265
|
+
userId: userA,
|
|
266
|
+
actorId: ADMIN_CALLER.userId,
|
|
267
|
+
...NO_CLIENT_META,
|
|
397
268
|
});
|
|
398
269
|
expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', {
|
|
399
|
-
roleId:
|
|
400
|
-
userId:
|
|
401
|
-
actorId:
|
|
402
|
-
|
|
403
|
-
userAgent: null,
|
|
270
|
+
roleId: role.id,
|
|
271
|
+
userId: userB,
|
|
272
|
+
actorId: ADMIN_CALLER.userId,
|
|
273
|
+
...NO_CLIENT_META,
|
|
404
274
|
});
|
|
405
275
|
const revokeCalls = events.emit.mock.calls.filter((c) => c[0] === 'iam.role.revoked');
|
|
406
276
|
expect(revokeCalls).toHaveLength(2);
|
|
407
|
-
expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId:
|
|
277
|
+
expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
|
|
278
|
+
// ON DELETE CASCADE removed the assignment rows.
|
|
279
|
+
expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(0);
|
|
408
280
|
});
|
|
409
281
|
});
|
|
410
282
|
describe('IamService.assignRole', () => {
|
|
411
|
-
it('dedupes an existing assignment without inserting again', async () => {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
callerAssignment: [],
|
|
422
|
-
assignment: [existingAssignment],
|
|
423
|
-
superRole: [],
|
|
283
|
+
it('dedupes an existing assignment without inserting again (unique index)', async () => {
|
|
284
|
+
const role = await seedRole();
|
|
285
|
+
const target = await seedUser('admin');
|
|
286
|
+
await seedAssignment(target.id, role.id);
|
|
287
|
+
const events = makeEventBus();
|
|
288
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
289
|
+
const result = await svc.assignRole({
|
|
290
|
+
userId: target.id,
|
|
291
|
+
roleId: role.id,
|
|
292
|
+
caller: ADMIN_CALLER,
|
|
424
293
|
});
|
|
425
|
-
const events = makeEvents();
|
|
426
|
-
const svc = new IamService(drizzle, events, makeEmail());
|
|
427
|
-
const result = await inContext(() => svc.assignRole({ userId: 'target', roleId: 'role-1', caller: ADMIN_CALLER }));
|
|
428
|
-
expect(result.id).toBe('a1');
|
|
429
294
|
expect(events.emit).not.toHaveBeenCalledWith('iam.role.assigned', expect.anything());
|
|
295
|
+
// Still exactly one assignment row, and the existing one was returned.
|
|
296
|
+
const rows = await db.drizzle.db.select().from(adminRoleAssignment);
|
|
297
|
+
expect(rows).toHaveLength(1);
|
|
298
|
+
expect(result.id).toBe(rows[0].id);
|
|
430
299
|
});
|
|
431
300
|
it('rejects assigning a role to a non-admin (player) account', async () => {
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
superRole: [],
|
|
437
|
-
});
|
|
438
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
439
|
-
await expect(inContext(() => svc.assignRole({ userId: 'player-1', roleId: 'role-1', caller: ADMIN_CALLER }))).rejects.toThrow(NotAnAdminUserError);
|
|
301
|
+
const role = await seedRole();
|
|
302
|
+
const player = await seedUser('player');
|
|
303
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
304
|
+
await expect(svc.assignRole({ userId: player.id, roleId: role.id, caller: ADMIN_CALLER })).rejects.toThrow(NotAnAdminUserError);
|
|
440
305
|
});
|
|
441
306
|
it('rejects assigning a role to an unknown user', async () => {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
307
|
+
const role = await seedRole();
|
|
308
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
309
|
+
await expect(svc.assignRole({ userId: randomUUID(), roleId: role.id, caller: ADMIN_CALLER })).rejects.toThrow(AdminUserNotFoundError);
|
|
310
|
+
});
|
|
311
|
+
it('the unique index keeps two concurrent assigns from double-inserting', async () => {
|
|
312
|
+
const role = await seedRole();
|
|
313
|
+
const target = await seedUser('admin');
|
|
314
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
315
|
+
await Promise.allSettled([
|
|
316
|
+
svc.assignRole({ userId: target.id, roleId: role.id, caller: ADMIN_CALLER }),
|
|
317
|
+
svc.assignRole({ userId: target.id, roleId: role.id, caller: ADMIN_CALLER }),
|
|
318
|
+
]);
|
|
319
|
+
const rows = await db.drizzle.db
|
|
320
|
+
.select()
|
|
321
|
+
.from(adminRoleAssignment)
|
|
322
|
+
.where(eq(adminRoleAssignment.userId, target.id));
|
|
323
|
+
expect(rows).toHaveLength(1);
|
|
450
324
|
});
|
|
451
325
|
});
|
|
452
326
|
describe('IamService.unassignRole', () => {
|
|
453
327
|
it('rejects removing the last super-admin holder', async () => {
|
|
454
|
-
const superRole = {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}),
|
|
462
|
-
from: vi.fn().mockImplementation((tbl) => {
|
|
463
|
-
table = tbl ?? {};
|
|
464
|
-
return chain;
|
|
465
|
-
}),
|
|
466
|
-
where: vi.fn().mockImplementation(() => {
|
|
467
|
-
const route = () => {
|
|
468
|
-
if ('userId' in table) {
|
|
469
|
-
// assignment probes: isSuperAdmin(caller) -> caller assignments;
|
|
470
|
-
// holders query selects userId+roleId.
|
|
471
|
-
if (lastSelect && 'roleId' in lastSelect && 'userId' in lastSelect) {
|
|
472
|
-
return [{ userId: 'target', roleId: 'role-super' }];
|
|
473
|
-
}
|
|
474
|
-
return [{ roleId: 'role-super' }]; // caller is super
|
|
475
|
-
}
|
|
476
|
-
if (lastSelect && 'id' in lastSelect && Object.keys(lastSelect).length === 1) {
|
|
477
|
-
return [{ id: 'role-super' }]; // super-admin probe / list
|
|
478
|
-
}
|
|
479
|
-
return [superRole]; // role existence
|
|
480
|
-
};
|
|
481
|
-
const rows = route();
|
|
482
|
-
const thenable = {
|
|
483
|
-
then: (res) => Promise.resolve(rows).then(res),
|
|
484
|
-
for: () => thenable,
|
|
485
|
-
returning: () => Promise.resolve([{ id: 'a1' }]),
|
|
486
|
-
};
|
|
487
|
-
return thenable;
|
|
488
|
-
}),
|
|
489
|
-
delete: vi.fn().mockReturnThis(),
|
|
490
|
-
transaction: vi.fn().mockImplementation((fn) => fn(chain)),
|
|
491
|
-
};
|
|
492
|
-
const drizzle = mockDb(chain);
|
|
493
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
494
|
-
await expect(inContext(() => svc.unassignRole({ userId: 'target', roleId: 'role-super', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(LastSuperAdminError);
|
|
328
|
+
const superRole = await seedRole({ isSuperAdmin: true, isSystem: true });
|
|
329
|
+
const target = randomUUID();
|
|
330
|
+
await seedAssignment(target, superRole.id);
|
|
331
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
332
|
+
await expect(svc.unassignRole({ userId: target, roleId: superRole.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(LastSuperAdminError);
|
|
333
|
+
// The holder survives the rejected unassign.
|
|
334
|
+
expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(1);
|
|
495
335
|
});
|
|
496
336
|
it('returns success but emits no revoked event when nothing was deleted', async () => {
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
337
|
+
const role = await seedRole();
|
|
338
|
+
const events = makeEventBus();
|
|
339
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
340
|
+
const result = await svc.unassignRole({
|
|
341
|
+
userId: randomUUID(),
|
|
342
|
+
roleId: role.id,
|
|
343
|
+
caller: ADMIN_CALLER,
|
|
502
344
|
});
|
|
503
|
-
readPrivate(drizzle.db, 'returning').mockResolvedValue([]);
|
|
504
|
-
const events = makeEvents();
|
|
505
|
-
const svc = new IamService(drizzle, events, makeEmail());
|
|
506
|
-
const result = await inContext(() => svc.unassignRole({ userId: 'ghost', roleId: 'role-1', caller: ADMIN_CALLER }));
|
|
507
345
|
expect(result).toEqual({ success: true });
|
|
508
346
|
expect(events.emit).not.toHaveBeenCalledWith('iam.role.revoked', expect.anything());
|
|
509
347
|
});
|
|
510
348
|
it('emits iam.role.revoked when a row was actually deleted', async () => {
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
|
|
349
|
+
const role = await seedRole();
|
|
350
|
+
const target = randomUUID();
|
|
351
|
+
await seedAssignment(target, role.id);
|
|
352
|
+
const events = makeEventBus();
|
|
353
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
354
|
+
await svc.unassignRole({ userId: target, roleId: role.id, caller: ADMIN_CALLER });
|
|
355
|
+
expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', expect.objectContaining({ roleId: role.id, userId: target, actorId: ADMIN_CALLER.userId }));
|
|
356
|
+
expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(0);
|
|
357
|
+
});
|
|
358
|
+
it('under two concurrent unassigns of a two-holder super role, exactly one wins (FOR UPDATE guard)', async () => {
|
|
359
|
+
const superRole = await seedRole({ isSuperAdmin: true, isSystem: true });
|
|
360
|
+
const userA = randomUUID();
|
|
361
|
+
const userB = randomUUID();
|
|
362
|
+
await seedAssignment(userA, superRole.id);
|
|
363
|
+
await seedAssignment(userB, superRole.id);
|
|
364
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
365
|
+
const results = await Promise.allSettled([
|
|
366
|
+
svc.unassignRole({ userId: userA, roleId: superRole.id, caller: ADMIN_CALLER }),
|
|
367
|
+
svc.unassignRole({ userId: userB, roleId: superRole.id, caller: ADMIN_CALLER }),
|
|
519
368
|
]);
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
369
|
+
const rejected = results.filter((r) => r.status === 'rejected');
|
|
370
|
+
expect(rejected).toHaveLength(1);
|
|
371
|
+
expect(rejected[0].reason).toBeInstanceOf(LastSuperAdminError);
|
|
372
|
+
// Exactly one super-admin holder survives - the guard never let both strip it.
|
|
373
|
+
expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(1);
|
|
524
374
|
});
|
|
525
375
|
});
|
|
526
376
|
describe('IamService.previewEffectivePermissions', () => {
|
|
527
377
|
it('returns the max level per module across roles (union)', async () => {
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
});
|
|
536
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
537
|
-
const result = await inContext(() => svc.previewEffectivePermissions({ roleIds: ['r1', 'r2'] }));
|
|
378
|
+
const roleA = await seedRole();
|
|
379
|
+
const roleB = await seedRole();
|
|
380
|
+
await seedPermission(roleA.id, 'player', 'read');
|
|
381
|
+
await seedPermission(roleA.id, 'withdrawal', 'read');
|
|
382
|
+
await seedPermission(roleB.id, 'player', 'read_write');
|
|
383
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
384
|
+
const result = await svc.previewEffectivePermissions({ roleIds: [roleA.id, roleB.id] });
|
|
538
385
|
expect(result.permissions).toContainEqual({ resource: 'player', level: 'read_write' });
|
|
539
386
|
expect(result.permissions).toContainEqual({ resource: 'withdrawal', level: 'read' });
|
|
540
387
|
expect(result.permissions.filter((p) => p.resource === 'player')).toHaveLength(1);
|
|
541
388
|
});
|
|
542
389
|
it('super-admin role in the set yields all modules read_write', async () => {
|
|
543
|
-
const
|
|
544
|
-
const svc = new IamService(drizzle,
|
|
545
|
-
const result = await
|
|
390
|
+
const superRole = await seedRole({ isSuperAdmin: true });
|
|
391
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
392
|
+
const result = await svc.previewEffectivePermissions({ roleIds: [superRole.id] });
|
|
546
393
|
expect(result.permissions).toHaveLength(Object.keys(statement).length);
|
|
547
394
|
expect(result.permissions.every((p) => p.level === 'read_write')).toBe(true);
|
|
548
395
|
});
|
|
549
|
-
it('falls back to static role permissions
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
});
|
|
554
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
555
|
-
const result = await inContext(() => svc.previewEffectivePermissions({ userId: 'u1' }));
|
|
556
|
-
// Support has non-empty permissions
|
|
396
|
+
it('falls back to static role permissions when the user has no dynamic role assignments', async () => {
|
|
397
|
+
const u = await seedUser('support');
|
|
398
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
399
|
+
const result = await svc.previewEffectivePermissions({ userId: u.id });
|
|
557
400
|
expect(result.permissions).not.toHaveLength(0);
|
|
558
|
-
// For support role, player access is read (view)
|
|
559
401
|
expect(result.permissions).toContainEqual({ resource: 'player', level: 'read' });
|
|
560
402
|
});
|
|
561
403
|
});
|
|
562
404
|
describe('IamService.acceptInvitation', () => {
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
405
|
+
async function seedInvitation() {
|
|
406
|
+
const role = await seedRole();
|
|
407
|
+
const token = randomUUID();
|
|
408
|
+
await db.drizzle.db.insert(adminInvitation).values({
|
|
566
409
|
email: 'who@admin.com',
|
|
567
|
-
roleId:
|
|
568
|
-
token
|
|
569
|
-
status: '
|
|
570
|
-
expiresAt: new Date(Date.now() +
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
returning,
|
|
580
|
-
};
|
|
581
|
-
const drizzle = mockDb(chain);
|
|
582
|
-
const events = makeEvents();
|
|
583
|
-
const svc = new IamService(drizzle, events, makeEmail());
|
|
584
|
-
const first = await svc.acceptInvitation('tok');
|
|
410
|
+
roleId: role.id,
|
|
411
|
+
token,
|
|
412
|
+
status: 'pending',
|
|
413
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
414
|
+
});
|
|
415
|
+
return token;
|
|
416
|
+
}
|
|
417
|
+
it('accepts once and emits exactly one event; a replay conflicts and emits nothing more', async () => {
|
|
418
|
+
const token = await seedInvitation();
|
|
419
|
+
const events = makeEventBus();
|
|
420
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
421
|
+
const first = await svc.acceptInvitation(token);
|
|
585
422
|
expect(first).toEqual({ success: true, email: 'who@admin.com' });
|
|
586
|
-
await expect(svc.acceptInvitation(
|
|
423
|
+
await expect(svc.acceptInvitation(token)).rejects.toBeInstanceOf(InvitationConflictError);
|
|
424
|
+
expect(events.emit).toHaveBeenCalledTimes(1);
|
|
425
|
+
});
|
|
426
|
+
it('two concurrent accepts: exactly one succeeds, one event (atomic conditional UPDATE)', async () => {
|
|
427
|
+
const token = await seedInvitation();
|
|
428
|
+
const events = makeEventBus();
|
|
429
|
+
const svc = new IamService(db.drizzle, events, makeEmail());
|
|
430
|
+
const results = await Promise.allSettled([
|
|
431
|
+
svc.acceptInvitation(token),
|
|
432
|
+
svc.acceptInvitation(token),
|
|
433
|
+
]);
|
|
434
|
+
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
|
435
|
+
const rejected = results.filter((r) => r.status === 'rejected');
|
|
436
|
+
expect(fulfilled).toHaveLength(1);
|
|
437
|
+
expect(rejected).toHaveLength(1);
|
|
438
|
+
expect(rejected[0].reason).toBeInstanceOf(InvitationConflictError);
|
|
587
439
|
expect(events.emit).toHaveBeenCalledTimes(1);
|
|
588
440
|
});
|
|
589
441
|
});
|
|
590
442
|
describe('IamService.inviteAdmin', () => {
|
|
591
|
-
const invitationRow = {
|
|
592
|
-
id: 'inv-1',
|
|
593
|
-
email: 'new@admin.com',
|
|
594
|
-
roleId: 'role-1',
|
|
595
|
-
token: 'tok-abc',
|
|
596
|
-
status: 'pending',
|
|
597
|
-
expiresAt: new Date(Date.now() + 86400000),
|
|
598
|
-
acceptedAt: null,
|
|
599
|
-
createdAt: new Date(),
|
|
600
|
-
};
|
|
601
443
|
it('creates a pending invitation and calls SEND_EMAIL when caller is super-admin', async () => {
|
|
602
|
-
|
|
603
|
-
let lastSelect;
|
|
604
|
-
const chain = {
|
|
605
|
-
select: vi.fn().mockImplementation((sel) => {
|
|
606
|
-
lastSelect = sel;
|
|
607
|
-
return chain;
|
|
608
|
-
}),
|
|
609
|
-
from: vi.fn().mockImplementation((tbl) => {
|
|
610
|
-
table = tbl ?? {};
|
|
611
|
-
return chain;
|
|
612
|
-
}),
|
|
613
|
-
where: vi.fn().mockImplementation(() => {
|
|
614
|
-
// adminRoleAssignment (has userId col): caller's assignment for isSuperAdmin.
|
|
615
|
-
if ('userId' in table) {
|
|
616
|
-
return Promise.resolve([{ roleId: 'role-super' }]);
|
|
617
|
-
}
|
|
618
|
-
// adminRole id-only probe: the super-admin check.
|
|
619
|
-
if (lastSelect && 'id' in lastSelect && Object.keys(lastSelect).length === 1) {
|
|
620
|
-
return Promise.resolve([{ id: 'role-super' }]);
|
|
621
|
-
}
|
|
622
|
-
// adminRole full row: role-existence lookup.
|
|
623
|
-
return Promise.resolve([ROLE_ROW]);
|
|
624
|
-
}),
|
|
625
|
-
insert: vi.fn().mockReturnThis(),
|
|
626
|
-
values: vi.fn().mockReturnThis(),
|
|
627
|
-
returning: vi.fn().mockResolvedValue([invitationRow]),
|
|
628
|
-
};
|
|
629
|
-
const drizzle = mockDb(chain);
|
|
444
|
+
const role = await seedRole();
|
|
630
445
|
const email = makeEmail();
|
|
631
|
-
const svc = new IamService(drizzle,
|
|
632
|
-
const result = await
|
|
446
|
+
const svc = new IamService(db.drizzle, makeEventBus(), email);
|
|
447
|
+
const result = await svc.inviteAdmin({
|
|
448
|
+
email: 'new@admin.com',
|
|
449
|
+
roleId: role.id,
|
|
450
|
+
caller: ADMIN_CALLER,
|
|
451
|
+
});
|
|
633
452
|
expect(result.status).toBe('pending');
|
|
634
453
|
expect(result.email).toBe('new@admin.com');
|
|
635
454
|
expect(email.send).toHaveBeenCalledOnce();
|
|
636
455
|
expect(email.send).toHaveBeenCalledWith(expect.objectContaining({ to: 'new@admin.com' }));
|
|
456
|
+
const rows = await db.drizzle.db.select().from(adminInvitation);
|
|
457
|
+
expect(rows).toHaveLength(1);
|
|
458
|
+
expect(rows[0]).toMatchObject({ email: 'new@admin.com', status: 'pending' });
|
|
637
459
|
});
|
|
638
460
|
it('rejects a non-super-admin caller', async () => {
|
|
639
|
-
const
|
|
640
|
-
role: [ROLE_ROW],
|
|
641
|
-
callerAssignment: [{ roleId: 'role-x' }],
|
|
642
|
-
superRole: [],
|
|
643
|
-
});
|
|
461
|
+
const role = await seedRole();
|
|
644
462
|
const email = makeEmail();
|
|
645
|
-
const svc = new IamService(drizzle,
|
|
646
|
-
await expect(
|
|
647
|
-
email: 'new@admin.com',
|
|
648
|
-
roleId: 'role-1',
|
|
649
|
-
caller: { userId: 'sup-1', role: 'support', ...NO_CLIENT_META },
|
|
650
|
-
}))).rejects.toBeInstanceOf(NotSuperAdminError);
|
|
463
|
+
const svc = new IamService(db.drizzle, makeEventBus(), email);
|
|
464
|
+
await expect(svc.inviteAdmin({ email: 'new@admin.com', roleId: role.id, caller: SUPPORT_CALLER })).rejects.toBeInstanceOf(NotSuperAdminError);
|
|
651
465
|
expect(email.send).not.toHaveBeenCalled();
|
|
652
466
|
});
|
|
653
467
|
});
|
|
654
|
-
function paginatedDrizzle(rows, total) {
|
|
655
|
-
const db = {
|
|
656
|
-
select: vi.fn().mockImplementation((sel) => {
|
|
657
|
-
const result = sel && 'count' in sel ? [{ count: total }] : rows;
|
|
658
|
-
const chain = {
|
|
659
|
-
then: (res) => Promise.resolve(result).then(res),
|
|
660
|
-
from: vi.fn(() => chain),
|
|
661
|
-
innerJoin: vi.fn(() => chain),
|
|
662
|
-
where: vi.fn(() => chain),
|
|
663
|
-
orderBy: vi.fn(() => chain),
|
|
664
|
-
limit: vi.fn(() => chain),
|
|
665
|
-
offset: vi.fn(() => chain),
|
|
666
|
-
};
|
|
667
|
-
return chain;
|
|
668
|
-
}),
|
|
669
|
-
};
|
|
670
|
-
return mockDb(db);
|
|
671
|
-
}
|
|
672
468
|
describe('IamService paginated lists', () => {
|
|
673
469
|
it('listRoles returns { items, total, page, limit }', async () => {
|
|
674
|
-
const
|
|
675
|
-
const svc = new IamService(drizzle,
|
|
676
|
-
const result = await svc.listRoles({ page:
|
|
470
|
+
const role = await seedRole();
|
|
471
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
472
|
+
const result = await svc.listRoles({ page: 1, limit: 10 });
|
|
677
473
|
expect(result.total).toBe(1);
|
|
678
|
-
expect(result.page).toBe(
|
|
474
|
+
expect(result.page).toBe(1);
|
|
679
475
|
expect(result.limit).toBe(10);
|
|
680
476
|
expect(result.items).toHaveLength(1);
|
|
681
|
-
expect(findOneOrThrow(result.items, new Error('expected an item')).id).toBe(
|
|
477
|
+
expect(findOneOrThrow(result.items, new Error('expected an item')).id).toBe(role.id);
|
|
682
478
|
});
|
|
683
479
|
it('listInvitations returns the paginated wrapper', async () => {
|
|
684
|
-
const
|
|
685
|
-
|
|
480
|
+
const role = await seedRole();
|
|
481
|
+
await db.drizzle.db.insert(adminInvitation).values({
|
|
686
482
|
email: 'a@b.com',
|
|
687
|
-
roleId:
|
|
688
|
-
token:
|
|
483
|
+
roleId: role.id,
|
|
484
|
+
token: randomUUID(),
|
|
689
485
|
status: 'pending',
|
|
690
|
-
expiresAt: new Date(),
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
};
|
|
694
|
-
const drizzle = paginatedDrizzle([invRow], 5);
|
|
695
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
486
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
487
|
+
});
|
|
488
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
696
489
|
const result = await svc.listInvitations({ page: 1, limit: 20 });
|
|
697
|
-
expect(result.total).toBe(
|
|
490
|
+
expect(result.total).toBe(1);
|
|
698
491
|
expect(result.items).toHaveLength(1);
|
|
699
492
|
expect(findOneOrThrow(result.items, new Error('expected an item')).email).toBe('a@b.com');
|
|
700
493
|
});
|
|
701
494
|
it('listAssignments returns the paginated wrapper with joined role fields', async () => {
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
roleId: 'role-1',
|
|
706
|
-
createdAt: new Date(),
|
|
707
|
-
roleName: 'Ops',
|
|
708
|
-
roleKey: null,
|
|
709
|
-
};
|
|
710
|
-
const drizzle = paginatedDrizzle([assignmentRow], 3);
|
|
711
|
-
const svc = new IamService(drizzle, makeEvents(), makeEmail());
|
|
495
|
+
const role = await seedRole({ name: 'Ops' });
|
|
496
|
+
await seedAssignment(randomUUID(), role.id);
|
|
497
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
712
498
|
const result = await svc.listAssignments({ page: 1, limit: 20 });
|
|
713
|
-
expect(result.total).toBe(
|
|
499
|
+
expect(result.total).toBe(1);
|
|
714
500
|
expect(result.items).toHaveLength(1);
|
|
715
501
|
expect(findOneOrThrow(result.items, new Error('expected an item')).roleName).toBe('Ops');
|
|
716
502
|
});
|
|
717
503
|
});
|
|
718
504
|
describe('IamService.forceLogout', () => {
|
|
719
505
|
it('rejects a non-super-admin caller', async () => {
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
await expect(inContext(() => svc.forceLogout({
|
|
727
|
-
userId: 'target-user',
|
|
728
|
-
caller: { userId: 'sup-1', role: 'support', ...NO_CLIENT_META },
|
|
729
|
-
}))).rejects.toBeInstanceOf(NotSuperAdminError);
|
|
730
|
-
});
|
|
731
|
-
it('allows a super-admin caller to delete sessions for a user and emits identity.sessions.revoked_all', async () => {
|
|
732
|
-
const drizzle = routingDrizzle({
|
|
733
|
-
role: [ROLE_ROW],
|
|
734
|
-
assignment: [],
|
|
735
|
-
superRole: [],
|
|
736
|
-
});
|
|
737
|
-
const events = makeEvents();
|
|
738
|
-
const sessionCommands = {
|
|
506
|
+
const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
|
|
507
|
+
await expect(svc.forceLogout({ userId: randomUUID(), caller: SUPPORT_CALLER })).rejects.toBeInstanceOf(NotSuperAdminError);
|
|
508
|
+
});
|
|
509
|
+
it('allows a super-admin caller to delete sessions for a user via SessionCommands', async () => {
|
|
510
|
+
const events = makeEventBus();
|
|
511
|
+
const sessionCommands = mock({
|
|
739
512
|
revokeAll: vi.fn().mockResolvedValue({ success: true }),
|
|
740
|
-
};
|
|
741
|
-
const svc = new IamService(drizzle, events, makeEmail(), sessionCommands);
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
caller: ADMIN_CALLER,
|
|
745
|
-
}));
|
|
513
|
+
});
|
|
514
|
+
const svc = new IamService(db.drizzle, events, makeEmail(), sessionCommands);
|
|
515
|
+
const targetUserId = randomUUID();
|
|
516
|
+
const result = await svc.forceLogout({ userId: targetUserId, caller: ADMIN_CALLER });
|
|
746
517
|
expect(result).toEqual({ success: true });
|
|
747
|
-
expect(sessionCommands.revokeAll).toHaveBeenCalledWith(
|
|
518
|
+
expect(sessionCommands.revokeAll).toHaveBeenCalledWith(targetUserId, ADMIN_CALLER.userId);
|
|
748
519
|
});
|
|
749
520
|
});
|
|
750
521
|
//# sourceMappingURL=iam.service.test.js.map
|