@classytic/arc 2.28.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +7 -0
- package/TRADEMARK.md +22 -0
- package/dist/{BaseController-B65zbz9m.mjs → BaseController-De7GqHck.mjs} +25 -24
- package/dist/{EventTransport-QmY7A258.d.mts → EventTransport-2uVV34WU.d.mts} +1 -0
- package/dist/EventTransport-9mlTgqzE.mjs +104 -0
- package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-BcmPyq5I.mjs} +16 -35
- package/dist/{QueryCache-DOMT_iq7.d.mts → QueryCache-DFA-QrU9.d.mts} +2 -1
- package/dist/{ResourceRegistry-y9Hnypus.mjs → ResourceRegistry-qq0mn2s9.mjs} +3 -2
- package/dist/{actionPermissions-CAphcmUG.mjs → actionPermissions-BE5NwaQk.mjs} +1 -0
- package/dist/audit/index.d.mts +3 -2
- package/dist/audit/index.mjs +3 -2
- package/dist/auth/audit.d.mts +1 -0
- package/dist/auth/audit.mjs +1 -0
- package/dist/auth/index.d.mts +5 -4
- package/dist/auth/index.mjs +6 -5
- package/dist/auth/redis-session.d.mts +2 -1
- package/dist/auth/redis-session.mjs +1 -0
- package/dist/authorizationDecision-ChvUQGG1.mjs +266 -0
- package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-CDrCJzmB.mjs} +34 -7
- package/dist/cache/index.d.mts +4 -3
- package/dist/cache/index.mjs +4 -3
- package/dist/{caching-_NJeDd1_.mjs → caching-BFHA6Scf.mjs} +2 -1
- package/dist/cleanup/index.d.mts +4 -3
- package/dist/cleanup/index.mjs +9 -13
- package/dist/cli/commands/describe.d.mts +2 -1
- package/dist/cli/commands/describe.mjs +3 -2
- package/dist/cli/commands/docs.d.mts +1 -0
- package/dist/cli/commands/docs.mjs +3 -2
- package/dist/cli/commands/doctor.d.mts +1 -0
- package/dist/cli/commands/doctor.mjs +1 -0
- package/dist/cli/commands/generate.d.mts +2 -1
- package/dist/cli/commands/generate.mjs +2 -1
- package/dist/cli/commands/init.d.mts +2 -1
- package/dist/cli/commands/init.mjs +2 -1
- package/dist/cli/commands/introspect.d.mts +1 -0
- package/dist/cli/commands/introspect.mjs +2 -1
- package/dist/cli/index.d.mts +3 -2
- package/dist/cli/index.mjs +3 -2
- package/dist/{constants-DNre0wRg.mjs → constants-D3Dv4LAV.mjs} +1 -0
- package/dist/context/index.d.mts +1 -0
- package/dist/context/index.mjs +3 -2
- package/dist/core/index.d.mts +3 -2
- package/dist/core/index.mjs +7 -5
- package/dist/{core-BGa8TXJR.mjs → core-BGpIxINN.mjs} +4 -49
- package/dist/{createActionRouter-C-N7z2EL.mjs → createActionRouter-CDXEdasl.mjs} +8 -7
- package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-KrQDzx-k.mjs} +10 -22
- package/dist/{createApp-Du5G54TM.mjs → createApp-Df89BEni.mjs} +505 -209
- package/dist/defineAggregation-DbPPS8RI.mjs +48 -0
- package/dist/{defineResource-2zvVVpxg.mjs → defineResource-Bv6EtN1x.mjs} +90 -72
- package/dist/discovery/index.d.mts +1 -0
- package/dist/discovery/index.mjs +1 -0
- package/dist/docs/index.d.mts +3 -2
- package/dist/docs/index.mjs +3 -2
- package/dist/{elevation-C9s5hFP_.d.mts → elevation-BXCfVcNG.d.mts} +1 -0
- package/dist/{elevation-CIz1GjGm.mjs → elevation-D2l1mh4g.mjs} +4 -3
- package/dist/encryption/index.d.mts +2 -1
- package/dist/encryption/index.mjs +3 -2
- package/dist/{errorHandler-Bq2OeEQL.mjs → errorHandler-C5NeD6bT.mjs} +3 -2
- package/dist/{errorHandler-C6yv4ZTW.d.mts → errorHandler-CpYUJmPA.d.mts} +1 -0
- package/dist/{errors-DHcHVAgk.d.mts → errors-BGdVwP25.d.mts} +1 -0
- package/dist/{errors-DkO8Nk6-.mjs → errors-Cj42AntO.mjs} +1 -0
- package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-CkffEihm.mjs} +6 -106
- package/dist/{eventPlugin-Bg3cP2_u.d.mts → eventPlugin-GF5nnBIq.d.mts} +2 -1
- package/dist/events/index.d.mts +4 -3
- package/dist/events/index.mjs +17 -340
- package/dist/events/transports/redis-stream-entry.d.mts +2 -1
- package/dist/events/transports/redis-stream-entry.mjs +1 -0
- package/dist/events/transports/redis.d.mts +2 -1
- package/dist/events/transports/redis.mjs +1 -0
- package/dist/{externalPaths-BE6wFpYv.d.mts → externalPaths-CmGOxOtD.d.mts} +1 -0
- package/dist/factory/index.d.mts +9 -5
- package/dist/factory/index.mjs +7 -6
- package/dist/{fastify-augmentation-OzYGIiU1.d.mts → fastify-augmentation-zh0_AYHa.d.mts} +13 -4
- package/dist/{applyPermissionResult-BnyVE9eD.mjs → fields-CB5Cuzk9.mjs} +2 -107
- package/dist/{fields-D3lhgzhD.d.mts → fields-I_EeRWND.d.mts} +1 -0
- package/dist/{generate-BnsL9GBm.d.mts → generate-DCGuEbqI.d.mts} +1 -0
- package/dist/{generate-D0wZupBg.mjs → generate-EcA6ui4i.mjs} +2 -1
- package/dist/{headers-BvlQSDMB.mjs → headers-3zvE0rM3.mjs} +2 -1
- package/dist/{health-CB_m4S32.mjs → health-DjZYjM7H.mjs} +1 -0
- package/dist/hooks/index.d.mts +2 -1
- package/dist/hooks/index.mjs +2 -1
- package/dist/idempotency/index.d.mts +3 -2
- package/dist/idempotency/index.mjs +5 -4
- package/dist/idempotency/redis.d.mts +2 -1
- package/dist/idempotency/redis.mjs +1 -0
- package/dist/{index-CjVg-Epg.d.mts → index-Bc2ox8_U.d.mts} +6 -5
- package/dist/{index-CG4yqMDB.d.mts → index-C-481Zf2.d.mts} +74 -18
- package/dist/{index-BMnbzvtO.d.mts → index-CEJNZFtX.d.mts} +242 -24
- package/dist/index-CFThn1K9.d.mts +224 -0
- package/dist/{index-DPRH0Cme.d.mts → index-CpzxqePp.d.mts} +203 -97
- package/dist/index.d.mts +9 -8
- package/dist/index.mjs +11 -9
- package/dist/{init-tlRSt667.mjs → init-BEiNZBsJ.mjs} +19 -23
- package/dist/{init-BPEu8cs-.d.mts → init-Cscs4lab.d.mts} +1 -0
- package/dist/integrations/event-gateway.d.mts +3 -2
- package/dist/integrations/event-gateway.mjs +2 -1
- package/dist/integrations/index.d.mts +4 -3
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs/index.d.mts +2 -1
- package/dist/integrations/jobs/index.mjs +108 -32
- package/dist/integrations/mcp/index.d.mts +9 -9
- package/dist/integrations/mcp/index.mjs +2 -1
- package/dist/integrations/mcp/testing.d.mts +3 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +1 -0
- package/dist/integrations/streamline.mjs +3 -2
- package/dist/integrations/webhooks.d.mts +1 -0
- package/dist/integrations/webhooks.mjs +1 -0
- package/dist/integrations/websocket-pushref-redis.d.mts +2 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -0
- package/dist/integrations/websocket-redis.d.mts +2 -1
- package/dist/integrations/websocket-redis.mjs +1 -0
- package/dist/integrations/websocket.d.mts +3 -2
- package/dist/integrations/websocket.mjs +1 -0
- package/dist/{interface-BGZCzOGL.d.mts → interface-BOk3YnTz.d.mts} +1 -0
- package/dist/{interface-Uk8ONJv7.d.mts → interface-DEWNOMza.d.mts} +1 -0
- package/dist/{interface-n5hpKg1d.mjs → interface-DYzM0q-u.mjs} +1 -0
- package/dist/{jsonBody-BlzAoGCe.mjs → jsonBody-BYbLDi5D.mjs} +2 -1
- package/dist/{keys-Cd316u_y.mjs → keys-C0_pa1tY.mjs} +1 -0
- package/dist/{loadResources-DpU8pfjV.mjs → loadResources-yawi9tAE.mjs} +2 -1
- package/dist/{loadResourcesFromEntry-kvGT6EcO.mjs → loadResourcesFromEntry-BAjYZonx.mjs} +2 -1
- package/dist/logger/index.d.mts +1 -0
- package/dist/logger/index.mjs +1 -0
- package/dist/{mcpPlugin-Cs6nRGCt.mjs → mcpPlugin-B_es0hiF.mjs} +52 -60
- package/dist/{memory-CmnzXE5Z.mjs → memory-DR6wpa9F.mjs} +2 -1
- package/dist/{metrics-fdxI5mn_.mjs → metrics-CUdw_wLB.mjs} +2 -1
- package/dist/middleware/index.d.mts +2 -1
- package/dist/middleware/index.mjs +3 -2
- package/dist/migrations/index.d.mts +1 -3
- package/dist/migrations/index.mjs +2 -4
- package/dist/module-0pFJA6YW.mjs +78 -0
- package/dist/{multipartBody-D0eBvkeq.mjs → multipartBody-wOgj_fnj.mjs} +1 -0
- package/dist/{openapi-BLV4OMXb.mjs → openapi-B2L4js1v.mjs} +6 -5
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +6 -4
- package/dist/{permissions-BrtiBKGs.mjs → permissions-L--QDAir.mjs} +518 -316
- package/dist/{pipe-BOLmGIxa.mjs → pipe-DRDNsI2T.mjs} +2 -1
- package/dist/pipeline/index.d.mts +2 -1
- package/dist/pipeline/index.mjs +2 -1
- package/dist/{pipeline-BGRnV-To.mjs → pipeline-UPQFatit.mjs} +7 -20
- package/dist/plugins/index.d.mts +8 -7
- package/dist/plugins/index.mjs +16 -15
- package/dist/plugins/response-cache.d.mts +1 -0
- package/dist/plugins/response-cache.mjs +2 -1
- package/dist/plugins/tracing-entry.d.mts +2 -1
- package/dist/plugins/tracing-entry.mjs +2 -1
- package/dist/{pluralize-BhrgwZb9.mjs → pluralize-O0FZgESl.mjs} +1 -0
- package/dist/{preloadResources-CM4uUaFv.d.mts → preloadResources-CyoJMSiO.d.mts} +117 -12
- package/dist/{preloadResources-DSY5HGpS.mjs → preloadResources-Dz6BjzM6.mjs} +1 -0
- package/dist/presets/filesUpload.d.mts +6 -5
- package/dist/presets/filesUpload.mjs +9 -11
- package/dist/presets/index.d.mts +15 -2
- package/dist/presets/index.mjs +2 -1
- package/dist/presets/multiTenant.d.mts +2 -1
- package/dist/presets/multiTenant.mjs +3 -2
- package/dist/presets/search.d.mts +3 -2
- package/dist/presets/search.mjs +2 -1
- package/dist/{presets-DwFDVKP0.mjs → presets-C4-g4_4P.mjs} +27 -10
- package/dist/projection-BHw6xlOQ.mjs +18 -0
- package/dist/{pushref-registry-BbcnpNTm.d.mts → pushref-registry-Dov9l4qE.d.mts} +1 -0
- package/dist/{queryCachePlugin-DSGuSVLW.mjs → queryCachePlugin-BZr8v46E.mjs} +5 -4
- package/dist/{queryCachePlugin-br4otiqF.d.mts → queryCachePlugin-BzqxconG.d.mts} +3 -2
- package/dist/{rateLimit-DrED-C4v.mjs → rateLimit-CMPY5Qms.mjs} +7 -3
- package/dist/{rateLimitKey-BoWRH3e7.mjs → rateLimitKey-Dn5JNmkj.mjs} +1 -0
- package/dist/{realtime-DEuDu9io.mjs → realtime-C5NoxWFY.mjs} +20 -8
- package/dist/{redis-UDtLaNj6.d.mts → redis-CduhKsJi.d.mts} +2 -1
- package/dist/{redis-stream-uIaauNpZ.d.mts → redis-stream-qN6A-RFv.d.mts} +2 -1
- package/dist/{registerRoute-DTp18Ah_.mjs → registerRoute-CBN84fc7.mjs} +5 -2
- package/dist/registry/index.d.mts +3 -2
- package/dist/registry/index.mjs +3 -2
- package/dist/{registry-9km_w1aP.mjs → registry-4213U4zb.mjs} +3 -2
- package/dist/{renewingLease-3cuQH7h8.mjs → renewingLease-Dll8mGgc.mjs} +1 -0
- package/dist/{replyHelpers-67tRawQZ.mjs → replyHelpers-DIiZKGUt.mjs} +2 -1
- package/dist/repositoryFilter-f9eAfq_l.mjs +45 -0
- package/dist/{requestContext-MrlJwECf.mjs → requestContext-2TxXOJ7T.mjs} +1 -0
- package/dist/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-CUsjNX9R.mjs} +1 -0
- package/dist/{schedules-CjOd2MG9.mjs → schedules-DdtlICTf.mjs} +3 -2
- package/dist/{schemaConverter-DJvodiHj.mjs → schemaConverter-D--wdJM-.mjs} +70 -17
- package/dist/{schemaIR-Ca7ySNPx.mjs → schemaIR-Bm2enbnh.mjs} +2 -1
- package/dist/schemas/index.d.mts +1 -0
- package/dist/schemas/index.mjs +1 -0
- package/dist/scim/index.d.mts +1 -0
- package/dist/scim/index.mjs +2 -1
- package/dist/scope/index.d.mts +3 -2
- package/dist/scope/index.mjs +5 -4
- package/dist/{scope-DfaPntpo.mjs → scope-DHx5Fous.mjs} +4 -3
- package/dist/{sessionManager-BHOM3N_0.d.mts → sessionManager-qimlKV0P.d.mts} +1 -0
- package/dist/{simpleEqualityMatcher-Cg05HPsn.mjs → simpleEqualityMatcher-DjQJ7FKA.mjs} +1 -0
- package/dist/{sse-BQ1w0dfl.mjs → sse-CUTo9y0X.mjs} +5 -4
- package/dist/{sseStream-BQ7Qaynp.mjs → sseStream-B_5XcVVd.mjs} +2 -1
- package/dist/{storage-DLUZ04kK.d.mts → storage-Cx9Sz3Ou.d.mts} +1 -0
- package/dist/{store-helpers-DY6xq4sM.mjs → store-helpers-D1MP_gRG.mjs} +1 -0
- package/dist/{streaming-BQcW6_Kq.mjs → streaming-PSZwi8JV.mjs} +1 -0
- package/dist/subscribe-helpers-C0Tb2aUp.mjs +340 -0
- package/dist/sync/changelog.d.mts +1 -0
- package/dist/sync/changelog.mjs +1 -0
- package/dist/testing/cleanupStoreContract.d.mts +2 -1
- package/dist/testing/cleanupStoreContract.mjs +1 -0
- package/dist/testing/index.d.mts +167 -12
- package/dist/testing/index.mjs +522 -205
- package/dist/testing/outboxStoreContract.d.mts +1 -0
- package/dist/testing/outboxStoreContract.mjs +1 -0
- package/dist/testing/storageContract.d.mts +2 -1
- package/dist/testing/storageContract.mjs +1 -0
- package/dist/{tracing-CsGKQJGg.d.mts → tracing-Dro5eH6j.d.mts} +1 -0
- package/dist/{transactionContext-PNdoyLLm.mjs → transactionContext-dAfBVUrk.mjs} +1 -0
- package/dist/{typeGuards-C9KXUly6.mjs → typeGuards-B0ryPo3I.mjs} +1 -0
- package/dist/types/index.d.mts +6 -5
- package/dist/types/index.mjs +1 -0
- package/dist/types/storage.d.mts +2 -1
- package/dist/types/storage.mjs +1 -0
- package/dist/{types-Dmw-6Qeo.d.mts → types-B7XeLEgK.d.mts} +127 -90
- package/dist/{types-Cc91nXuA.d.mts → types-ByHn9rEO.d.mts} +1 -0
- package/dist/{types-C_ZZRcD6.d.mts → types-C4QE8qnm.d.mts} +1 -0
- package/dist/{types-Cu6vVJmh.d.mts → types-CBgvkp8O.d.mts} +82 -12
- package/dist/{types-DjC-KUCz.d.mts → types-ChuShpCP.d.mts} +1 -0
- package/dist/{types-QFGc3Cjv.d.mts → types-CwcUQ610.d.mts} +2 -6
- package/dist/{types-CSyCxl2V.mjs → types-Cys0ko9Z.mjs} +4 -3
- package/dist/usage/index.d.mts +3 -2
- package/dist/usage/index.mjs +0 -0
- package/dist/{userHelpers-Cg_y3fBz.mjs → userHelpers-ChYJ6CdK.mjs} +1 -0
- package/dist/utils/index.d.mts +16 -10
- package/dist/utils/index.mjs +10 -9
- package/dist/{utils-taUVD4p3.mjs → utils-vL8BrDE2.mjs} +3 -2
- package/dist/{validate-Bk44F2yT.mjs → validate-_KbifpHF.mjs} +2 -1
- package/dist/{versioning-gKWyryT3.mjs → versioning-CHDTor3P.mjs} +3 -2
- package/dist/{versioning-DXgY5BUm.d.mts → versioning-D_rhHfZm.d.mts} +5 -2
- package/dist/{websocket-_Pd0CgR0.d.mts → websocket-BOXVggMf.d.mts} +2 -1
- package/package.json +15 -7
- package/skills/arc/SKILL.md +22 -8
- package/skills/arc/references/api-reference.md +4 -0
- package/skills/arc/references/auth.md +3 -0
- package/skills/arc/references/integrations.md +33 -0
- package/skills/arc/references/mcp.md +6 -7
- package/skills/arc/references/production.md +24 -25
- package/skills/arc-code-review/references/anti-patterns.md +3 -3
- package/dist/index-CZY4EGga.d.mts +0 -107
- package/dist/module-_-M7C5ES.mjs +0 -29
package/dist/testing/index.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
/*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as CRUD_OPERATIONS } from "../constants-D3Dv4LAV.mjs";
|
|
3
|
+
import { g as getUserId } from "../types-Cys0ko9Z.mjs";
|
|
4
|
+
import { t as BaseController } from "../BaseController-De7GqHck.mjs";
|
|
5
|
+
import { t as defineResource } from "../defineResource-Bv6EtN1x.mjs";
|
|
6
|
+
import { t as defineAggregation } from "../defineAggregation-DbPPS8RI.mjs";
|
|
7
|
+
import { S as requireOrgRole } from "../permissions-L--QDAir.mjs";
|
|
8
|
+
import { n as createApp } from "../createApp-Df89BEni.mjs";
|
|
9
|
+
import { n as getModuleExports } from "../module-0pFJA6YW.mjs";
|
|
10
|
+
import { n as preloadResourcesAsync, t as preloadResources } from "../preloadResources-Dz6BjzM6.mjs";
|
|
5
11
|
import { runStorageContract } from "./storageContract.mjs";
|
|
6
12
|
import Fastify from "fastify";
|
|
7
13
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
@@ -125,6 +131,339 @@ function expectArc(response) {
|
|
|
125
131
|
return assertion;
|
|
126
132
|
}
|
|
127
133
|
//#endregion
|
|
134
|
+
//#region src/testing/mocks.ts
|
|
135
|
+
/**
|
|
136
|
+
* Create a mock repository for testing
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* const mockRepo = createMockRepository<Product>({
|
|
140
|
+
* getById: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
|
|
141
|
+
* create: vi.fn().mockImplementation(data => Promise.resolve({ id: '1', ...data })),
|
|
142
|
+
* });
|
|
143
|
+
*
|
|
144
|
+
* await mockRepo.getById('1'); // Returns mocked product
|
|
145
|
+
*/
|
|
146
|
+
function createMockRepository(overrides = {}) {
|
|
147
|
+
return {
|
|
148
|
+
getAll: vi.fn().mockResolvedValue({
|
|
149
|
+
method: "offset",
|
|
150
|
+
data: [],
|
|
151
|
+
total: 0,
|
|
152
|
+
page: 1,
|
|
153
|
+
limit: 20,
|
|
154
|
+
pages: 0,
|
|
155
|
+
hasNext: false,
|
|
156
|
+
hasPrev: false
|
|
157
|
+
}),
|
|
158
|
+
getById: vi.fn().mockResolvedValue(null),
|
|
159
|
+
create: vi.fn().mockImplementation((data) => Promise.resolve({
|
|
160
|
+
_id: "mock-id",
|
|
161
|
+
...data
|
|
162
|
+
})),
|
|
163
|
+
update: vi.fn().mockImplementation((_id, data) => Promise.resolve({
|
|
164
|
+
_id: "mock-id",
|
|
165
|
+
...data
|
|
166
|
+
})),
|
|
167
|
+
delete: vi.fn().mockResolvedValue({
|
|
168
|
+
success: true,
|
|
169
|
+
message: "Deleted"
|
|
170
|
+
}),
|
|
171
|
+
updateMany: vi.fn().mockResolvedValue({
|
|
172
|
+
acknowledged: true,
|
|
173
|
+
matchedCount: 0,
|
|
174
|
+
modifiedCount: 0
|
|
175
|
+
}),
|
|
176
|
+
deleteMany: vi.fn().mockResolvedValue({
|
|
177
|
+
acknowledged: true,
|
|
178
|
+
deletedCount: 0
|
|
179
|
+
}),
|
|
180
|
+
claim: vi.fn().mockResolvedValue(null),
|
|
181
|
+
claimVersion: vi.fn().mockResolvedValue(null),
|
|
182
|
+
capabilities: {
|
|
183
|
+
transactions: false,
|
|
184
|
+
nestedTransactions: false,
|
|
185
|
+
upsert: false,
|
|
186
|
+
duplicateKeyError: false,
|
|
187
|
+
distinct: false,
|
|
188
|
+
aggregate: false,
|
|
189
|
+
getOrCreate: false,
|
|
190
|
+
countAndExists: false
|
|
191
|
+
},
|
|
192
|
+
getBySlug: vi.fn().mockResolvedValue(null),
|
|
193
|
+
getDeleted: vi.fn().mockResolvedValue([]),
|
|
194
|
+
restore: vi.fn().mockResolvedValue(null),
|
|
195
|
+
getTree: vi.fn().mockResolvedValue([]),
|
|
196
|
+
getChildren: vi.fn().mockResolvedValue([]),
|
|
197
|
+
...overrides
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Create a mock user for authentication testing
|
|
202
|
+
*/
|
|
203
|
+
function createMockUser(overrides = {}) {
|
|
204
|
+
return {
|
|
205
|
+
_id: "mock-user-id",
|
|
206
|
+
id: "mock-user-id",
|
|
207
|
+
email: "test@example.com",
|
|
208
|
+
roles: ["user"],
|
|
209
|
+
organizationId: null,
|
|
210
|
+
...overrides
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Create a mock Fastify request
|
|
215
|
+
*/
|
|
216
|
+
function createMockRequest(overrides = {}) {
|
|
217
|
+
return {
|
|
218
|
+
body: {},
|
|
219
|
+
params: {},
|
|
220
|
+
query: {},
|
|
221
|
+
headers: {},
|
|
222
|
+
user: createMockUser(),
|
|
223
|
+
context: {},
|
|
224
|
+
log: {
|
|
225
|
+
info: vi.fn(),
|
|
226
|
+
warn: vi.fn(),
|
|
227
|
+
error: vi.fn(),
|
|
228
|
+
debug: vi.fn()
|
|
229
|
+
},
|
|
230
|
+
...overrides
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Create a mock Fastify reply
|
|
235
|
+
*/
|
|
236
|
+
function createMockReply() {
|
|
237
|
+
return {
|
|
238
|
+
code: vi.fn().mockReturnThis(),
|
|
239
|
+
send: vi.fn().mockReturnThis(),
|
|
240
|
+
header: vi.fn().mockReturnThis(),
|
|
241
|
+
headers: vi.fn().mockReturnThis(),
|
|
242
|
+
status: vi.fn().mockReturnThis(),
|
|
243
|
+
type: vi.fn().mockReturnThis(),
|
|
244
|
+
redirect: vi.fn().mockReturnThis(),
|
|
245
|
+
callNotFound: vi.fn().mockReturnThis(),
|
|
246
|
+
sent: false
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Create a mock controller for testing
|
|
251
|
+
*/
|
|
252
|
+
function createMockController(repository) {
|
|
253
|
+
return {
|
|
254
|
+
repository,
|
|
255
|
+
list: vi.fn(),
|
|
256
|
+
get: vi.fn(),
|
|
257
|
+
create: vi.fn(),
|
|
258
|
+
update: vi.fn(),
|
|
259
|
+
delete: vi.fn()
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Create mock data factory
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* const productFactory = createDataFactory<Product>({
|
|
267
|
+
* name: () => faker.commerce.productName(),
|
|
268
|
+
* price: () => faker.number.int({ min: 10, max: 1000 }),
|
|
269
|
+
* sku: (i) => `SKU-${i}`,
|
|
270
|
+
* });
|
|
271
|
+
*
|
|
272
|
+
* const product = productFactory.build();
|
|
273
|
+
* const products = productFactory.buildMany(10);
|
|
274
|
+
*/
|
|
275
|
+
function createDataFactory(template) {
|
|
276
|
+
let counter = 0;
|
|
277
|
+
return {
|
|
278
|
+
build(overrides = {}) {
|
|
279
|
+
const index = counter++;
|
|
280
|
+
const data = {};
|
|
281
|
+
for (const [key, generator] of Object.entries(template)) data[key] = generator(index);
|
|
282
|
+
return {
|
|
283
|
+
...data,
|
|
284
|
+
...overrides
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
buildMany(count, overrides = {}) {
|
|
288
|
+
return Array.from({ length: count }, () => this.build(overrides));
|
|
289
|
+
},
|
|
290
|
+
reset() {
|
|
291
|
+
counter = 0;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Create a spy that tracks function calls
|
|
297
|
+
*
|
|
298
|
+
* Useful for testing side effects without full mocking
|
|
299
|
+
*/
|
|
300
|
+
function createSpy(_name = "spy") {
|
|
301
|
+
const calls = [];
|
|
302
|
+
const spy = vi.fn((...args) => {
|
|
303
|
+
calls.push(args);
|
|
304
|
+
});
|
|
305
|
+
spy.getCalls = () => calls;
|
|
306
|
+
spy.getLastCall = () => calls[calls.length - 1] || [];
|
|
307
|
+
return spy;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Wait until `condition` holds — the standard way to assert on asynchronous
|
|
311
|
+
* effects in arc's suites.
|
|
312
|
+
*
|
|
313
|
+
* **Prefer this over `await sleep(n)` + assert.** A fixed delay encodes a guess
|
|
314
|
+
* about scheduling: too short and it flakes the moment the pool is busy, too
|
|
315
|
+
* long and every run pays for the worst case. Polling returns as soon as the
|
|
316
|
+
* effect lands, so the fast path stays fast and a loaded machine simply waits —
|
|
317
|
+
* and the failure mode becomes "this never happened", which is the thing worth
|
|
318
|
+
* asserting.
|
|
319
|
+
*
|
|
320
|
+
* Supply `label`: without it a timeout reports only its own duration, which is
|
|
321
|
+
* the least useful sentence a flaky test can print.
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* await waitFor(() => runs >= 3, { label: "3 scheduler ticks" });
|
|
325
|
+
*/
|
|
326
|
+
async function waitFor(condition, options = {}) {
|
|
327
|
+
const { timeout = 5e3, interval = 25, label } = options;
|
|
328
|
+
const startTime = Date.now();
|
|
329
|
+
while (Date.now() - startTime < timeout) {
|
|
330
|
+
if (await condition()) return;
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
332
|
+
}
|
|
333
|
+
throw new Error(label ? `Timed out after ${timeout}ms waiting for: ${label}` : `Timed out after ${timeout}ms waiting for a condition (pass "label" to name it)`);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Create a test timer that can be controlled
|
|
337
|
+
*/
|
|
338
|
+
function createTestTimer() {
|
|
339
|
+
let time = Date.now();
|
|
340
|
+
return {
|
|
341
|
+
now: () => time,
|
|
342
|
+
advance: (ms) => {
|
|
343
|
+
time += ms;
|
|
344
|
+
},
|
|
345
|
+
set: (timestamp) => {
|
|
346
|
+
time = timestamp;
|
|
347
|
+
},
|
|
348
|
+
reset: () => {
|
|
349
|
+
time = Date.now();
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/testing/authorizationConformance.ts
|
|
355
|
+
/**
|
|
356
|
+
* Cross-surface authorization conformance (arc 2.30).
|
|
357
|
+
*
|
|
358
|
+
* The regression wall that keeps "partial enforcement" from ever coming back:
|
|
359
|
+
* run the SAME permission through more than one arc surface and prove it enforces
|
|
360
|
+
* identically. Reviews kept finding aggregation (and MCP) drifting from CRUD —
|
|
361
|
+
* this makes that a one-call assertion for arc AND for host resources.
|
|
362
|
+
*
|
|
363
|
+
* `runAuthorizationConformance` boots a throwaway Fastify app with one resource
|
|
364
|
+
* whose `list` op AND an `aggregation` share the given permission, backed by a
|
|
365
|
+
* capturing mock repo (no DB). It returns, per surface, the HTTP status and the
|
|
366
|
+
* exact filter arc handed the repository — so the caller asserts:
|
|
367
|
+
* - deny → both surfaces fail closed with the SAME status;
|
|
368
|
+
* - allow + `policy` → the row policy reaches BOTH the list query and the
|
|
369
|
+
* aggregation `$match`, identically.
|
|
370
|
+
*
|
|
371
|
+
* Runner-agnostic: it returns data, it does not assert. Pair with your test
|
|
372
|
+
* framework's expectations (see `tests/core/authorization-conformance.test.ts`).
|
|
373
|
+
*/
|
|
374
|
+
/**
|
|
375
|
+
* Boot a one-resource app gated by `permission` on both list + an aggregation,
|
|
376
|
+
* hit both surfaces anonymously, and report what arc enforced on each.
|
|
377
|
+
*/
|
|
378
|
+
async function runAuthorizationConformance(opts) {
|
|
379
|
+
const rows = opts.rows ?? [{ _id: "1" }];
|
|
380
|
+
let listFilter;
|
|
381
|
+
let getFilter;
|
|
382
|
+
let aggFilter;
|
|
383
|
+
const repo = createMockRepository({
|
|
384
|
+
getAll: (async (params = {}) => {
|
|
385
|
+
listFilter = params.filters;
|
|
386
|
+
return {
|
|
387
|
+
method: "offset",
|
|
388
|
+
data: rows,
|
|
389
|
+
total: rows.length,
|
|
390
|
+
page: 1,
|
|
391
|
+
limit: 20,
|
|
392
|
+
pages: 1,
|
|
393
|
+
hasNext: false,
|
|
394
|
+
hasPrev: false
|
|
395
|
+
};
|
|
396
|
+
}),
|
|
397
|
+
getOne: (async (filter) => {
|
|
398
|
+
getFilter = filter;
|
|
399
|
+
return rows[0] ?? null;
|
|
400
|
+
}),
|
|
401
|
+
aggregate: (async (req = {}) => {
|
|
402
|
+
aggFilter = req.filter;
|
|
403
|
+
return { rows: [{ tally: rows.length }] };
|
|
404
|
+
}),
|
|
405
|
+
capabilities: {
|
|
406
|
+
count: false,
|
|
407
|
+
exists: false,
|
|
408
|
+
distinct: false,
|
|
409
|
+
aggregate: true,
|
|
410
|
+
transactions: false,
|
|
411
|
+
lookup: false,
|
|
412
|
+
softDelete: false
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
const resource = defineResource({
|
|
416
|
+
name: "conf",
|
|
417
|
+
prefix: "/confs",
|
|
418
|
+
adapter: { repository: repo },
|
|
419
|
+
controller: new BaseController(repo, {
|
|
420
|
+
resourceName: "conf",
|
|
421
|
+
tenantField: false
|
|
422
|
+
}),
|
|
423
|
+
permissions: {
|
|
424
|
+
list: opts.permission,
|
|
425
|
+
get: opts.permission,
|
|
426
|
+
create: opts.permission,
|
|
427
|
+
update: opts.permission,
|
|
428
|
+
delete: opts.permission
|
|
429
|
+
},
|
|
430
|
+
aggregations: { tally: defineAggregation({
|
|
431
|
+
measures: { tally: { op: "count" } },
|
|
432
|
+
permissions: opts.permission
|
|
433
|
+
}) }
|
|
434
|
+
});
|
|
435
|
+
const app = Fastify({ logger: false });
|
|
436
|
+
await app.register(resource.toPlugin());
|
|
437
|
+
await app.ready();
|
|
438
|
+
const list = await app.inject({
|
|
439
|
+
method: "GET",
|
|
440
|
+
url: "/confs"
|
|
441
|
+
});
|
|
442
|
+
const get = await app.inject({
|
|
443
|
+
method: "GET",
|
|
444
|
+
url: "/confs/1"
|
|
445
|
+
});
|
|
446
|
+
const aggregation = await app.inject({
|
|
447
|
+
method: "GET",
|
|
448
|
+
url: "/confs/aggregations/tally"
|
|
449
|
+
});
|
|
450
|
+
return {
|
|
451
|
+
list: {
|
|
452
|
+
status: list.statusCode,
|
|
453
|
+
filter: listFilter
|
|
454
|
+
},
|
|
455
|
+
get: {
|
|
456
|
+
status: get.statusCode,
|
|
457
|
+
filter: getFilter
|
|
458
|
+
},
|
|
459
|
+
aggregation: {
|
|
460
|
+
status: aggregation.statusCode,
|
|
461
|
+
filter: aggFilter
|
|
462
|
+
},
|
|
463
|
+
close: () => app.close()
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
128
467
|
//#region src/testing/authSession.ts
|
|
129
468
|
function buildHeaders(token, orgId, extra) {
|
|
130
469
|
const headers = { authorization: `Bearer ${token}` };
|
|
@@ -464,6 +803,127 @@ async function setupBetterAuthTestApp(input) {
|
|
|
464
803
|
};
|
|
465
804
|
}
|
|
466
805
|
//#endregion
|
|
806
|
+
//#region src/testing/testActor.ts
|
|
807
|
+
/**
|
|
808
|
+
* Test-only actor installation — how a package's HTTP tests say "run this
|
|
809
|
+
* request as a manager in org X".
|
|
810
|
+
*
|
|
811
|
+
* Identity in arc comes from `request.scope` and only from `request.scope`;
|
|
812
|
+
* headers like `x-role` are not identity. That rule left tests with no seam —
|
|
813
|
+
* `bootModuleApp` boots with `auth: false`, so every injected request arrived as
|
|
814
|
+
* `PUBLIC_SCOPE` and any role-gated route answered 401 with no supported way to
|
|
815
|
+
* be somebody.
|
|
816
|
+
*
|
|
817
|
+
* The `x-test-actor` header is a TRANSPORT for the test's intent, not a trusted
|
|
818
|
+
* claim. The authenticator that reads it is installed by `bootModuleApp` alone,
|
|
819
|
+
* behind the `@classytic/arc/testing` entrypoint; `createApp` never registers
|
|
820
|
+
* it, so no production build turns a forged header into identity. It is
|
|
821
|
+
* per-request rather than boot-only because the interesting authorization tests
|
|
822
|
+
* are the ones where a cashier is refused what a manager is allowed.
|
|
823
|
+
*
|
|
824
|
+
* Gate the routes under test with arc's own primitives — `requireRoles`,
|
|
825
|
+
* `requireOrgMembership`, `allOf(...)`. A test-specific gate would be a second
|
|
826
|
+
* implementation of authorization whose semantics could drift from the one
|
|
827
|
+
* actually shipping.
|
|
828
|
+
*/
|
|
829
|
+
/** The header the testing harness uses to carry a request's actor. */
|
|
830
|
+
const TEST_ACTOR_HEADER = "x-test-actor";
|
|
831
|
+
/**
|
|
832
|
+
* Build the headers that make an injected request act as `actor`.
|
|
833
|
+
*
|
|
834
|
+
* A string is shorthand for a single role. `null` means "stay public", so a test
|
|
835
|
+
* can prove an endpoint is reachable anonymously.
|
|
836
|
+
*
|
|
837
|
+
* @example
|
|
838
|
+
* ```ts
|
|
839
|
+
* t.app.inject({ method: 'GET', url: '/pos/shifts/current',
|
|
840
|
+
* headers: testActorHeaders('manager', ORG) });
|
|
841
|
+
* ```
|
|
842
|
+
*/
|
|
843
|
+
function testActorHeaders(actor, organizationId) {
|
|
844
|
+
if (actor === null) return {};
|
|
845
|
+
const org = organizationId !== void 0 ? { organizationId } : {};
|
|
846
|
+
const spec = typeof actor === "string" ? {
|
|
847
|
+
roles: [actor],
|
|
848
|
+
...org
|
|
849
|
+
} : {
|
|
850
|
+
...actor,
|
|
851
|
+
...org
|
|
852
|
+
};
|
|
853
|
+
return { [TEST_ACTOR_HEADER]: JSON.stringify(spec) };
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Turn a spec into a `RequestScope`.
|
|
857
|
+
*
|
|
858
|
+
* `orgRoles` defaults to `roles`: a test that says "manager" means it at both
|
|
859
|
+
* levels, and making the caller repeat itself only produces tests that pass a
|
|
860
|
+
* global gate and mysteriously fail an org one.
|
|
861
|
+
*/
|
|
862
|
+
function scopeFromTestActor(spec) {
|
|
863
|
+
const roles = spec.roles ?? [];
|
|
864
|
+
const userId = spec.userId ?? `test-${roles.join("-") || "anon"}`;
|
|
865
|
+
if (spec.elevated !== void 0 && spec.elevated !== false) return {
|
|
866
|
+
kind: "elevated",
|
|
867
|
+
userId,
|
|
868
|
+
elevatedBy: spec.elevated === true ? "test" : spec.elevated,
|
|
869
|
+
...spec.organizationId !== void 0 ? { organizationId: spec.organizationId } : {},
|
|
870
|
+
...spec.context !== void 0 ? { context: Object.freeze({ ...spec.context }) } : {}
|
|
871
|
+
};
|
|
872
|
+
if (spec.organizationId !== void 0) return {
|
|
873
|
+
kind: "member",
|
|
874
|
+
userId,
|
|
875
|
+
userRoles: roles,
|
|
876
|
+
organizationId: spec.organizationId,
|
|
877
|
+
orgRoles: spec.orgRoles ?? roles,
|
|
878
|
+
...spec.teamId !== void 0 ? { teamId: spec.teamId } : {},
|
|
879
|
+
...spec.context !== void 0 ? { context: Object.freeze({ ...spec.context }) } : {}
|
|
880
|
+
};
|
|
881
|
+
return {
|
|
882
|
+
kind: "authenticated",
|
|
883
|
+
userId,
|
|
884
|
+
userRoles: roles
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Build the `auth` option that makes the test actor arc's authenticator.
|
|
889
|
+
*
|
|
890
|
+
* It must be an authenticator, not a hook. Fastify encapsulates plugins and
|
|
891
|
+
* modules register DURING `createApp`, so a hook added to the root instance
|
|
892
|
+
* afterwards never runs for their routes — every request still resolves as
|
|
893
|
+
* public and every role gate still answers 401, which reads exactly like a
|
|
894
|
+
* broken authorization rule in the code under test. Registering as
|
|
895
|
+
* `auth: { type: 'authenticator' }` puts the scope where arc's own auth would,
|
|
896
|
+
* at the phase arc expects, so tenant filtering and permission checks behave
|
|
897
|
+
* identically to production.
|
|
898
|
+
*
|
|
899
|
+
* `defaultActor` applies to requests carrying no header. A malformed header
|
|
900
|
+
* REJECTS rather than falling back to public — a test that meant to be an admin
|
|
901
|
+
* and got quietly demoted is the most expensive way to fail.
|
|
902
|
+
*/
|
|
903
|
+
function testActorAuth(defaultActor) {
|
|
904
|
+
const authenticate = async (request) => {
|
|
905
|
+
const req = request;
|
|
906
|
+
const raw = req.headers[TEST_ACTOR_HEADER];
|
|
907
|
+
let spec = defaultActor;
|
|
908
|
+
if (typeof raw === "string") try {
|
|
909
|
+
spec = JSON.parse(raw);
|
|
910
|
+
} catch {
|
|
911
|
+
throw new Error(`[arc-testing] malformed ${TEST_ACTOR_HEADER} header: ${raw}. Build it with testActorHeaders() rather than by hand.`);
|
|
912
|
+
}
|
|
913
|
+
if (spec === void 0) return;
|
|
914
|
+
req.scope = scopeFromTestActor(spec);
|
|
915
|
+
req.user = {
|
|
916
|
+
id: getUserId(req.scope) ?? "test-anon",
|
|
917
|
+
...spec.roles !== void 0 ? { role: spec.roles } : {},
|
|
918
|
+
...spec.organizationId !== void 0 ? { organizationId: spec.organizationId } : {}
|
|
919
|
+
};
|
|
920
|
+
};
|
|
921
|
+
return {
|
|
922
|
+
type: "authenticator",
|
|
923
|
+
authenticate
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
//#endregion
|
|
467
927
|
//#region src/testing/bootModuleApp.ts
|
|
468
928
|
/**
|
|
469
929
|
* The built-in DB factory: in-memory MongoDB via lazy devDep imports.
|
|
@@ -496,7 +956,6 @@ async function bootModuleApp(modules, options = {}) {
|
|
|
496
956
|
const db = await (options.database ?? mongoMemoryDatabase)({ replset: options.replset ?? false });
|
|
497
957
|
const ctx = {
|
|
498
958
|
uri: db.uri,
|
|
499
|
-
mongoUri: db.uri,
|
|
500
959
|
connection: db.connection
|
|
501
960
|
};
|
|
502
961
|
const resolved = typeof modules === "function" ? await modules(ctx) : modules;
|
|
@@ -505,7 +964,17 @@ async function bootModuleApp(modules, options = {}) {
|
|
|
505
964
|
try {
|
|
506
965
|
app = await createApp({
|
|
507
966
|
preset: "testing",
|
|
508
|
-
|
|
967
|
+
/**
|
|
968
|
+
* The test actor is arc's AUTHENTICATOR rather than a bolted-on hook, so the scope
|
|
969
|
+
* lands at the phase the rest of the pipeline expects — tenant filtering and
|
|
970
|
+
* permission checks then behave exactly as they do in production.
|
|
971
|
+
*
|
|
972
|
+
* This replaces the old `auth: false`. The authenticator is inert for a request
|
|
973
|
+
* carrying no `x-test-actor` header and no configured default, so a suite that
|
|
974
|
+
* never mentions an actor behaves as it did before. `options.app` still wins, so a
|
|
975
|
+
* suite exercising real auth can override it.
|
|
976
|
+
*/
|
|
977
|
+
auth: testActorAuth(options.actor),
|
|
509
978
|
sensible: false,
|
|
510
979
|
...options.app,
|
|
511
980
|
modules: list
|
|
@@ -519,7 +988,6 @@ async function bootModuleApp(modules, options = {}) {
|
|
|
519
988
|
return {
|
|
520
989
|
app,
|
|
521
990
|
uri: db.uri,
|
|
522
|
-
mongoUri: db.uri,
|
|
523
991
|
connection: db.connection,
|
|
524
992
|
exports: (name) => getModuleExports(app, name),
|
|
525
993
|
close: async () => {
|
|
@@ -845,212 +1313,61 @@ function createHttpTestHarness(resource, optionsOrGetter) {
|
|
|
845
1313
|
return new HttpTestHarness(resource, optionsOrGetter);
|
|
846
1314
|
}
|
|
847
1315
|
//#endregion
|
|
848
|
-
//#region src/testing/
|
|
1316
|
+
//#region src/testing/scopeRoleGate.ts
|
|
849
1317
|
/**
|
|
850
|
-
*
|
|
1318
|
+
* `scopeRoleGate` — the ONE role gate for package tests.
|
|
851
1319
|
*
|
|
852
|
-
*
|
|
853
|
-
* const mockRepo = createMockRepository<Product>({
|
|
854
|
-
* getById: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
|
|
855
|
-
* create: vi.fn().mockImplementation(data => Promise.resolve({ id: '1', ...data })),
|
|
856
|
-
* });
|
|
1320
|
+
* ## Why this is in the framework
|
|
857
1321
|
*
|
|
858
|
-
*
|
|
859
|
-
|
|
860
|
-
function createMockRepository(overrides = {}) {
|
|
861
|
-
return {
|
|
862
|
-
getAll: vi.fn().mockResolvedValue({
|
|
863
|
-
method: "offset",
|
|
864
|
-
data: [],
|
|
865
|
-
total: 0,
|
|
866
|
-
page: 1,
|
|
867
|
-
limit: 20,
|
|
868
|
-
pages: 0,
|
|
869
|
-
hasNext: false,
|
|
870
|
-
hasPrev: false
|
|
871
|
-
}),
|
|
872
|
-
getById: vi.fn().mockResolvedValue(null),
|
|
873
|
-
create: vi.fn().mockImplementation((data) => Promise.resolve({
|
|
874
|
-
_id: "mock-id",
|
|
875
|
-
...data
|
|
876
|
-
})),
|
|
877
|
-
update: vi.fn().mockImplementation((_id, data) => Promise.resolve({
|
|
878
|
-
_id: "mock-id",
|
|
879
|
-
...data
|
|
880
|
-
})),
|
|
881
|
-
delete: vi.fn().mockResolvedValue({
|
|
882
|
-
success: true,
|
|
883
|
-
message: "Deleted"
|
|
884
|
-
}),
|
|
885
|
-
updateMany: vi.fn().mockResolvedValue({
|
|
886
|
-
acknowledged: true,
|
|
887
|
-
matchedCount: 0,
|
|
888
|
-
modifiedCount: 0
|
|
889
|
-
}),
|
|
890
|
-
deleteMany: vi.fn().mockResolvedValue({
|
|
891
|
-
acknowledged: true,
|
|
892
|
-
deletedCount: 0
|
|
893
|
-
}),
|
|
894
|
-
claim: vi.fn().mockResolvedValue(null),
|
|
895
|
-
claimVersion: vi.fn().mockResolvedValue(null),
|
|
896
|
-
capabilities: {
|
|
897
|
-
transactions: false,
|
|
898
|
-
nestedTransactions: false,
|
|
899
|
-
upsert: false,
|
|
900
|
-
duplicateKeyError: false,
|
|
901
|
-
distinct: false,
|
|
902
|
-
aggregate: false,
|
|
903
|
-
getOrCreate: false,
|
|
904
|
-
countAndExists: false
|
|
905
|
-
},
|
|
906
|
-
getBySlug: vi.fn().mockResolvedValue(null),
|
|
907
|
-
getDeleted: vi.fn().mockResolvedValue([]),
|
|
908
|
-
restore: vi.fn().mockResolvedValue(null),
|
|
909
|
-
getTree: vi.fn().mockResolvedValue([]),
|
|
910
|
-
getChildren: vi.fn().mockResolvedValue([]),
|
|
911
|
-
...overrides
|
|
912
|
-
};
|
|
913
|
-
}
|
|
914
|
-
/**
|
|
915
|
-
* Create a mock user for authentication testing
|
|
916
|
-
*/
|
|
917
|
-
function createMockUser(overrides = {}) {
|
|
918
|
-
return {
|
|
919
|
-
_id: "mock-user-id",
|
|
920
|
-
id: "mock-user-id",
|
|
921
|
-
email: "test@example.com",
|
|
922
|
-
roles: ["user"],
|
|
923
|
-
organizationId: null,
|
|
924
|
-
...overrides
|
|
925
|
-
};
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Create a mock Fastify request
|
|
929
|
-
*/
|
|
930
|
-
function createMockRequest(overrides = {}) {
|
|
931
|
-
return {
|
|
932
|
-
body: {},
|
|
933
|
-
params: {},
|
|
934
|
-
query: {},
|
|
935
|
-
headers: {},
|
|
936
|
-
user: createMockUser(),
|
|
937
|
-
context: {},
|
|
938
|
-
log: {
|
|
939
|
-
info: vi.fn(),
|
|
940
|
-
warn: vi.fn(),
|
|
941
|
-
error: vi.fn(),
|
|
942
|
-
debug: vi.fn()
|
|
943
|
-
},
|
|
944
|
-
...overrides
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
/**
|
|
948
|
-
* Create a mock Fastify reply
|
|
949
|
-
*/
|
|
950
|
-
function createMockReply() {
|
|
951
|
-
return {
|
|
952
|
-
code: vi.fn().mockReturnThis(),
|
|
953
|
-
send: vi.fn().mockReturnThis(),
|
|
954
|
-
header: vi.fn().mockReturnThis(),
|
|
955
|
-
headers: vi.fn().mockReturnThis(),
|
|
956
|
-
status: vi.fn().mockReturnThis(),
|
|
957
|
-
type: vi.fn().mockReturnThis(),
|
|
958
|
-
redirect: vi.fn().mockReturnThis(),
|
|
959
|
-
callNotFound: vi.fn().mockReturnThis(),
|
|
960
|
-
sent: false
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
/**
|
|
964
|
-
* Create a mock controller for testing
|
|
965
|
-
*/
|
|
966
|
-
function createMockController(repository) {
|
|
967
|
-
return {
|
|
968
|
-
repository,
|
|
969
|
-
list: vi.fn(),
|
|
970
|
-
get: vi.fn(),
|
|
971
|
-
create: vi.fn(),
|
|
972
|
-
update: vi.fn(),
|
|
973
|
-
delete: vi.fn()
|
|
974
|
-
};
|
|
975
|
-
}
|
|
976
|
-
/**
|
|
977
|
-
* Create mock data factory
|
|
1322
|
+
* Nine `@classytic/arc-*` packages had their own copy of this helper, and all nine were
|
|
1323
|
+
* wrong in the same two ways:
|
|
978
1324
|
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
1325
|
+
* 1. They read the role from an `x-role` HEADER and INSTALLED the request scope from
|
|
1326
|
+
* inside the check — so identity came from the caller's own claim, and it arrived too
|
|
1327
|
+
* late for arc's tenant-filtering pipeline to see it.
|
|
1328
|
+
* 2. They returned the pre-2.30 `{ granted: boolean }` shape, which is neither a boolean
|
|
1329
|
+
* nor an `AuthorizationDecision`. It passed through `normalizeToDecision` untouched,
|
|
1330
|
+
* arrived with `effect: undefined`, and was read as DENY — the gate said yes and arc
|
|
1331
|
+
* refused anyway. Nothing type-errored, because the gate is cast at the call site.
|
|
985
1332
|
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
|
|
989
|
-
function createDataFactory(template) {
|
|
990
|
-
let counter = 0;
|
|
991
|
-
return {
|
|
992
|
-
build(overrides = {}) {
|
|
993
|
-
const index = counter++;
|
|
994
|
-
const data = {};
|
|
995
|
-
for (const [key, generator] of Object.entries(template)) data[key] = generator(index);
|
|
996
|
-
return {
|
|
997
|
-
...data,
|
|
998
|
-
...overrides
|
|
999
|
-
};
|
|
1000
|
-
},
|
|
1001
|
-
buildMany(count, overrides = {}) {
|
|
1002
|
-
return Array.from({ length: count }, () => this.build(overrides));
|
|
1003
|
-
},
|
|
1004
|
-
reset() {
|
|
1005
|
-
counter = 0;
|
|
1006
|
-
}
|
|
1007
|
-
};
|
|
1008
|
-
}
|
|
1009
|
-
/**
|
|
1010
|
-
* Create a spy that tracks function calls
|
|
1333
|
+
* Two of the nine had already been fixed by hand, each with a docstring describing this
|
|
1334
|
+
* exact discovery. The knowledge reached neither of the other seven. That is the whole
|
|
1335
|
+
* case for a shared seam: a copy does not fail, it diverges.
|
|
1011
1336
|
*
|
|
1012
|
-
*
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
});
|
|
1019
|
-
spy.getCalls = () => calls;
|
|
1020
|
-
spy.getLastCall = () => calls[calls.length - 1] || [];
|
|
1021
|
-
return spy;
|
|
1022
|
-
}
|
|
1023
|
-
/**
|
|
1024
|
-
* Wait for a condition to be true
|
|
1337
|
+
* A test role gate is generic — "does the verified scope hold one of these roles" has no
|
|
1338
|
+
* domain in it — so it belongs here, beside the contract it depends on.
|
|
1339
|
+
*
|
|
1340
|
+
* Identity itself comes from `testActorHeaders()` + `bootModuleApp`'s authenticator
|
|
1341
|
+
* (see `testActor.ts`), which puts the scope where production puts it. This stays a pure
|
|
1342
|
+
* predicate over what arc verified.
|
|
1025
1343
|
*
|
|
1026
|
-
*
|
|
1344
|
+
* Kept in its own module rather than beside `testActor.ts` so the two can be edited
|
|
1345
|
+
* independently.
|
|
1027
1346
|
*/
|
|
1028
|
-
async function waitFor(condition, options = {}) {
|
|
1029
|
-
const { timeout = 5e3, interval = 100 } = options;
|
|
1030
|
-
const startTime = Date.now();
|
|
1031
|
-
while (Date.now() - startTime < timeout) {
|
|
1032
|
-
if (await condition()) return;
|
|
1033
|
-
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1034
|
-
}
|
|
1035
|
-
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
|
|
1036
|
-
}
|
|
1037
1347
|
/**
|
|
1038
|
-
*
|
|
1348
|
+
* Grants iff the caller is an org member holding one of `allowed` as an ORG role.
|
|
1349
|
+
*
|
|
1350
|
+
* This is `requireOrgRole` under a name package tests reach for — deliberately a
|
|
1351
|
+
* delegation, not a reimplementation. It previously read `scope.userRoles` while
|
|
1352
|
+
* requiring an organization, so it disagreed with production for any actor whose
|
|
1353
|
+
* global and org roles differ: given
|
|
1354
|
+
* `{ roles: ["employee"], orgRoles: ["manager"], organizationId: "org-1" }`,
|
|
1355
|
+
* `requireOrgRole("manager")` allowed and this denied. The tests never caught it
|
|
1356
|
+
* because `scopeFromTestActor` defaults `orgRoles` to `roles`, making the two
|
|
1357
|
+
* dimensions identical for the common actor.
|
|
1358
|
+
*
|
|
1359
|
+
* Delegating also inherits the semantics a hand-rolled gate kept missing:
|
|
1360
|
+
* elevated scopes pass, and a service (API-key) identity is refused with a
|
|
1361
|
+
* message naming `requireServiceScope` instead of silently failing a role test.
|
|
1362
|
+
*
|
|
1363
|
+
* **Pair it with an org-bearing actor.** `testActorHeaders('manager', ORG)`
|
|
1364
|
+
* yields a `member` scope and passes; `testActorHeaders('manager')` yields an
|
|
1365
|
+
* `authenticated` scope with no tenant, which is refused — arc's generated CRUD
|
|
1366
|
+
* filters by tenant, so a gate that waves a tenant-less request through is how a
|
|
1367
|
+
* green test reads another org's rows.
|
|
1039
1368
|
*/
|
|
1040
|
-
function
|
|
1041
|
-
|
|
1042
|
-
return {
|
|
1043
|
-
now: () => time,
|
|
1044
|
-
advance: (ms) => {
|
|
1045
|
-
time += ms;
|
|
1046
|
-
},
|
|
1047
|
-
set: (timestamp) => {
|
|
1048
|
-
time = timestamp;
|
|
1049
|
-
},
|
|
1050
|
-
reset: () => {
|
|
1051
|
-
time = Date.now();
|
|
1052
|
-
}
|
|
1053
|
-
};
|
|
1369
|
+
function scopeRoleGate(...allowed) {
|
|
1370
|
+
return requireOrgRole(...allowed);
|
|
1054
1371
|
}
|
|
1055
1372
|
//#endregion
|
|
1056
1373
|
//#region src/testing/testApp.ts
|
|
@@ -1118,7 +1435,7 @@ function pickDefaultAuth(authMode, callerAuth) {
|
|
|
1118
1435
|
};
|
|
1119
1436
|
}
|
|
1120
1437
|
async function createTestApp(options = {}) {
|
|
1121
|
-
const { createApp } = await import("../createApp-
|
|
1438
|
+
const { createApp } = await import("../createApp-Df89BEni.mjs").then((n) => n.r);
|
|
1122
1439
|
const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
|
|
1123
1440
|
let dbHandle;
|
|
1124
1441
|
let dbUri;
|
|
@@ -1188,4 +1505,4 @@ function createMinimalTestApp(options = {}) {
|
|
|
1188
1505
|
});
|
|
1189
1506
|
}
|
|
1190
1507
|
//#endregion
|
|
1191
|
-
export { HttpTestHarness, bootModuleApp, createBetterAuthProvider, createBetterAuthTestHelpers, createCustomAuthProvider, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSpy, createTestApp, createTestFixtures, createTestTimer, expectArc, mongoMemoryDatabase, preloadResources, preloadResourcesAsync, runStorageContract, safeParseBody, setupBetterAuthTestApp, waitFor };
|
|
1508
|
+
export { HttpTestHarness, TEST_ACTOR_HEADER, bootModuleApp, createBetterAuthProvider, createBetterAuthTestHelpers, createCustomAuthProvider, createDataFactory, createHttpTestHarness, createJwtAuthProvider, createMinimalTestApp, createMockController, createMockReply, createMockRepository, createMockRequest, createMockUser, createSpy, createTestApp, createTestFixtures, createTestTimer, expectArc, mongoMemoryDatabase, preloadResources, preloadResourcesAsync, runAuthorizationConformance, runStorageContract, safeParseBody, scopeFromTestActor, scopeRoleGate, setupBetterAuthTestApp, testActorAuth, testActorHeaders, waitFor };
|