@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +24 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
- package/src/lib/crud/factory.ts +33 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- package/src/modules/widgets/component-registry.ts +14 -0
|
@@ -2,11 +2,14 @@ import { BasicQueryEngine } from '../engine'
|
|
|
2
2
|
import { SortDir } from '../types'
|
|
3
3
|
import { registerModules } from '../../i18n/server'
|
|
4
4
|
import { clearSearchTokenPresenceCache } from '../../search/availability'
|
|
5
|
+
import { clearEncryptedLikeFieldsCache, clearColumnExistsCache, columnExistsCacheSize } from '../engine'
|
|
5
6
|
|
|
6
7
|
// The token-presence answer is cached process-wide (TTL); without clearing it,
|
|
7
8
|
// probe-count assertions would observe hits from earlier tests in this file.
|
|
8
9
|
beforeEach(() => {
|
|
9
10
|
clearSearchTokenPresenceCache()
|
|
11
|
+
clearEncryptedLikeFieldsCache()
|
|
12
|
+
clearColumnExistsCache()
|
|
10
13
|
})
|
|
11
14
|
|
|
12
15
|
// Mock modules with one entity extension
|
|
@@ -176,6 +179,13 @@ function createFakeKysely(overrides?: FakeData) {
|
|
|
176
179
|
return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
|
|
177
180
|
}
|
|
178
181
|
if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
|
|
182
|
+
// An aggregate over a recorded subquery (the capped-count probe) counts
|
|
183
|
+
// the subquery's source rows bounded by its LIMIT, mirroring Postgres.
|
|
184
|
+
if (localOps.subquery) {
|
|
185
|
+
const sourceRows = (data[localOps.subquery.table] || []).length
|
|
186
|
+
const innerLimit = localOps.subquery.limits
|
|
187
|
+
return { count: String(innerLimit ? Math.min(sourceRows, innerLimit) : sourceRows) }
|
|
188
|
+
}
|
|
179
189
|
return { count: String((data[localOps.table] || []).length) }
|
|
180
190
|
}
|
|
181
191
|
const rows = data[localOps.table] || []
|
|
@@ -188,6 +198,22 @@ function createFakeKysely(overrides?: FakeData) {
|
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
function builderFor(tableArg: any): any {
|
|
201
|
+
if (tableArg && typeof tableArg === 'object' && tableArg._ops) {
|
|
202
|
+
// selectFrom(subquery.as(alias)) — the capped-count probe shape.
|
|
203
|
+
const ops = {
|
|
204
|
+
table: '__subquery__',
|
|
205
|
+
alias: tableArg._ops.alias ?? null,
|
|
206
|
+
subquery: tableArg._ops,
|
|
207
|
+
wheres: [] as any[],
|
|
208
|
+
joins: [] as any[],
|
|
209
|
+
selects: [] as any[],
|
|
210
|
+
orderBys: [] as any[],
|
|
211
|
+
groups: [] as any[],
|
|
212
|
+
limits: 0,
|
|
213
|
+
offsets: 0,
|
|
214
|
+
}
|
|
215
|
+
return makeBuilder(ops, true)
|
|
216
|
+
}
|
|
191
217
|
const parsed = parseTableSpec(tableArg)
|
|
192
218
|
const ops = {
|
|
193
219
|
table: parsed.table,
|
|
@@ -693,10 +719,10 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
693
719
|
|
|
694
720
|
expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
|
|
695
721
|
const baseCalls = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
696
|
-
expect(baseCalls.length).toBe(
|
|
697
|
-
// qFull ('full' projection) is built first (used for
|
|
698
|
-
//
|
|
699
|
-
const [phase2Call, phase1Call] = baseCalls
|
|
722
|
+
expect(baseCalls.length).toBe(3)
|
|
723
|
+
// qFull ('full' projection) is built first (used for phase 2), then the
|
|
724
|
+
// 'count' projection (the bounded count probe), then 'sortKeys' (phase 1).
|
|
725
|
+
const [phase2Call, , phase1Call] = baseCalls
|
|
700
726
|
// Phase 1 (slim id+sort-column scan): no SQL order/limit — the full candidate
|
|
701
727
|
// set is fetched, decrypted, and sorted in memory.
|
|
702
728
|
expect(phase1Call._ops.orderBys).toEqual([])
|
|
@@ -878,7 +904,7 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
878
904
|
page: { page: 1, pageSize: 2 },
|
|
879
905
|
})
|
|
880
906
|
expect(result.meta?.encryptedSortRowCapWarning).toBeUndefined()
|
|
881
|
-
const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
907
|
+
const [, , phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
882
908
|
expect(phase1Call._ops.limits).toBe(0)
|
|
883
909
|
})
|
|
884
910
|
|
|
@@ -912,10 +938,37 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
912
938
|
maxRows: 3,
|
|
913
939
|
totalMatched: 5,
|
|
914
940
|
})
|
|
915
|
-
const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
916
|
-
|
|
941
|
+
const [, , phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
942
|
+
// cap + 1 probe: truncation is detected from the candidate scan itself,
|
|
943
|
+
// not by comparing against a (possibly capped) total.
|
|
944
|
+
expect(phase1Call._ops.limits).toBe(4)
|
|
917
945
|
expect(phase1Call._ops.orderBys).toEqual([['customer_entities.id', 'asc']])
|
|
918
946
|
})
|
|
947
|
+
|
|
948
|
+
test('still warns when the list count itself is capped below the matched set', async () => {
|
|
949
|
+
// The old detection compared `total > sortCap`; with OM_LIST_COUNT_CAP at or
|
|
950
|
+
// below the sort cap that comparison can never fire. The probe must warn anyway.
|
|
951
|
+
process.env.OM_ENCRYPTED_SORT_MAX_ROWS = '3'
|
|
952
|
+
process.env.OM_LIST_COUNT_CAP = '3'
|
|
953
|
+
try {
|
|
954
|
+
const { engine } = buildFixture()
|
|
955
|
+
const result = await engine.query('customers:customer_entity', {
|
|
956
|
+
tenantId: 't1',
|
|
957
|
+
organizationId: 'org1',
|
|
958
|
+
fields: ['id', 'display_name'],
|
|
959
|
+
sort: [{ field: 'display_name', dir: SortDir.Asc }],
|
|
960
|
+
page: { page: 1, pageSize: 2 },
|
|
961
|
+
})
|
|
962
|
+
expect(result.total).toBe(3)
|
|
963
|
+
expect(result.meta?.listCountCapWarning).toEqual({ entity: 'customers:customer_entity', cap: 3 })
|
|
964
|
+
expect(result.meta?.encryptedSortRowCapWarning).toMatchObject({
|
|
965
|
+
entity: 'customers:customer_entity',
|
|
966
|
+
maxRows: 3,
|
|
967
|
+
})
|
|
968
|
+
} finally {
|
|
969
|
+
delete process.env.OM_LIST_COUNT_CAP
|
|
970
|
+
}
|
|
971
|
+
})
|
|
919
972
|
})
|
|
920
973
|
|
|
921
974
|
test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
|
|
@@ -954,6 +1007,80 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
954
1007
|
expect(baseCall._ops.offsets).toBe(10)
|
|
955
1008
|
})
|
|
956
1009
|
|
|
1010
|
+
describe('OM_LIST_COUNT_CAP boundary', () => {
|
|
1011
|
+
const originalCap = process.env.OM_LIST_COUNT_CAP
|
|
1012
|
+
afterEach(() => {
|
|
1013
|
+
if (originalCap === undefined) delete process.env.OM_LIST_COUNT_CAP
|
|
1014
|
+
else process.env.OM_LIST_COUNT_CAP = originalCap
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
function buildFixture(rowCount: number) {
|
|
1018
|
+
const rows = Array.from({ length: rowCount }, (_, i) => ({
|
|
1019
|
+
id: String(i + 1), tenant_id: 't1', organization_id: 'org1', display_name: `Row ${i + 1}`,
|
|
1020
|
+
}))
|
|
1021
|
+
const fakeDb = createFakeKysely({
|
|
1022
|
+
customer_entities: rows,
|
|
1023
|
+
'information_schema.columns': [
|
|
1024
|
+
{ table_name: 'customer_entities', column_name: 'id' },
|
|
1025
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1026
|
+
{ table_name: 'customer_entities', column_name: 'organization_id' },
|
|
1027
|
+
{ table_name: 'customer_entities', column_name: 'deleted_at' },
|
|
1028
|
+
{ table_name: 'customer_entities', column_name: 'display_name' },
|
|
1029
|
+
],
|
|
1030
|
+
})
|
|
1031
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1032
|
+
return { fakeDb, engine }
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const query = (engine: BasicQueryEngine) => engine.query('customers:customer_entity', {
|
|
1036
|
+
tenantId: 't1',
|
|
1037
|
+
organizationId: 'org1',
|
|
1038
|
+
fields: ['id'],
|
|
1039
|
+
page: { page: 1, pageSize: 2 },
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
test('cap - 1 matching rows: exact total, no flag', async () => {
|
|
1043
|
+
process.env.OM_LIST_COUNT_CAP = '5'
|
|
1044
|
+
const { engine } = buildFixture(4)
|
|
1045
|
+
const result = await query(engine)
|
|
1046
|
+
expect(result.total).toBe(4)
|
|
1047
|
+
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
test('exactly cap matching rows: exact total, no flag — a genuine total of cap is not mislabeled', async () => {
|
|
1051
|
+
process.env.OM_LIST_COUNT_CAP = '5'
|
|
1052
|
+
const { engine } = buildFixture(5)
|
|
1053
|
+
const result = await query(engine)
|
|
1054
|
+
expect(result.total).toBe(5)
|
|
1055
|
+
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
test('cap + 1 matching rows: total reports cap (not the probe value) with the warning', async () => {
|
|
1059
|
+
process.env.OM_LIST_COUNT_CAP = '5'
|
|
1060
|
+
const { engine } = buildFixture(6)
|
|
1061
|
+
const result = await query(engine)
|
|
1062
|
+
expect(result.total).toBe(5)
|
|
1063
|
+
expect(result.meta?.listCountCapWarning).toEqual({ entity: 'customers:customer_entity', cap: 5 })
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
test('OM_LIST_COUNT_CAP=0: exact totals however large the set', async () => {
|
|
1067
|
+
process.env.OM_LIST_COUNT_CAP = '0'
|
|
1068
|
+
const { engine } = buildFixture(7)
|
|
1069
|
+
const result = await query(engine)
|
|
1070
|
+
expect(result.total).toBe(7)
|
|
1071
|
+
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
test('unparseable value falls back to the default cap rather than disabling it', async () => {
|
|
1075
|
+
process.env.OM_LIST_COUNT_CAP = 'not-a-number'
|
|
1076
|
+
const { fakeDb, engine } = buildFixture(3)
|
|
1077
|
+
const result = await query(engine)
|
|
1078
|
+
expect(result.total).toBe(3)
|
|
1079
|
+
const outer = fakeDb._calls.find((call: any) => call._ops.table === '__subquery__')
|
|
1080
|
+
expect(outer._ops.subquery.limits).toBe(10_001)
|
|
1081
|
+
})
|
|
1082
|
+
})
|
|
1083
|
+
|
|
957
1084
|
// A tiebreak sort is only worth configuring if the engine actually emits it.
|
|
958
1085
|
// `list.tiebreakSortField` (used by the sales line routes to keep lines with an
|
|
959
1086
|
// equal `line_number` in a repeatable order) is the first caller to pass more
|
|
@@ -1134,3 +1261,314 @@ describe('BasicQueryEngine entity-extension joins', () => {
|
|
|
1134
1261
|
expect(join.aliasObj).toEqual({ ext_example_session_note: 'example_session_notes' })
|
|
1135
1262
|
})
|
|
1136
1263
|
})
|
|
1264
|
+
|
|
1265
|
+
describe('BasicQueryEngine like/ilike routing by column encryption', () => {
|
|
1266
|
+
// The gate is opt-in: OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS defaults to false and the
|
|
1267
|
+
// legacy rewrite-everything behavior stays. These cases flip it on; the last one pins the
|
|
1268
|
+
// default off.
|
|
1269
|
+
beforeEach(() => {
|
|
1270
|
+
process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS = 'true'
|
|
1271
|
+
})
|
|
1272
|
+
afterEach(() => {
|
|
1273
|
+
delete process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
// The token rewrite exists because ILIKE against ciphertext cannot match. On a plaintext
|
|
1277
|
+
// column SQL ILIKE is exact, and the rewrite silently changes the result set: tokenization
|
|
1278
|
+
// splits on non-alphanumerics and drops tokens shorter than minTokenLength, so a
|
|
1279
|
+
// document-number search like "ZK 1/2026" degrades to the tokens {202, 2026} and matches
|
|
1280
|
+
// every record from that year instead of the one document.
|
|
1281
|
+
const fakeDbWithTokens = () => createFakeKysely({
|
|
1282
|
+
customer_entities: [],
|
|
1283
|
+
search_tokens: [{ one: 1 }],
|
|
1284
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
1285
|
+
'information_schema.columns': [
|
|
1286
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1287
|
+
{ table_name: 'customer_entities', column_name: 'display_name' },
|
|
1288
|
+
],
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
test('a plaintext base column keeps exact SQL ILIKE even when tokens are available', async () => {
|
|
1292
|
+
const fakeDb = fakeDbWithTokens()
|
|
1293
|
+
const engine = new BasicQueryEngine(
|
|
1294
|
+
{} as any,
|
|
1295
|
+
() => fakeDb as any,
|
|
1296
|
+
() => ({ getEncryptedFieldNames: async () => [] }) as any,
|
|
1297
|
+
)
|
|
1298
|
+
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
1299
|
+
|
|
1300
|
+
await engine.query('customers:customer_entity', {
|
|
1301
|
+
tenantId: 't1',
|
|
1302
|
+
fields: ['id'],
|
|
1303
|
+
filters: { display_name: { $ilike: '%ZK 1/2026%' } },
|
|
1304
|
+
page: { page: 1, pageSize: 10 },
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
expect(applySearchTokensSpy).not.toHaveBeenCalled()
|
|
1308
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
1309
|
+
expect(baseCall).toBeTruthy()
|
|
1310
|
+
const ilikeWhere = baseCall._ops.wheres.some(
|
|
1311
|
+
(w: any) => Array.isArray(w) && String(w[0]).includes('display_name') && w[1] === 'ilike' && w[2] === '%ZK 1/2026%',
|
|
1312
|
+
)
|
|
1313
|
+
expect(ilikeWhere).toBe(true)
|
|
1314
|
+
})
|
|
1315
|
+
|
|
1316
|
+
test('an encrypted base column still routes through search tokens', async () => {
|
|
1317
|
+
const engine = new BasicQueryEngine(
|
|
1318
|
+
{} as any,
|
|
1319
|
+
() => fakeDbWithTokens() as any,
|
|
1320
|
+
() => ({ getEncryptedFieldNames: async () => ['display_name'] }) as any,
|
|
1321
|
+
)
|
|
1322
|
+
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
1323
|
+
|
|
1324
|
+
await engine.query('customers:customer_entity', {
|
|
1325
|
+
tenantId: 't1',
|
|
1326
|
+
fields: ['id'],
|
|
1327
|
+
filters: { display_name: { $ilike: '%avision%' } },
|
|
1328
|
+
page: { page: 1, pageSize: 10 },
|
|
1329
|
+
})
|
|
1330
|
+
|
|
1331
|
+
expect(applySearchTokensSpy).toHaveBeenCalled()
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
test('no service + encryption disabled: nothing is ciphertext, ILIKE stays exact', async () => {
|
|
1335
|
+
process.env.TENANT_DATA_ENCRYPTION = 'no'
|
|
1336
|
+
try {
|
|
1337
|
+
const fakeDb = fakeDbWithTokens()
|
|
1338
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1339
|
+
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
1340
|
+
|
|
1341
|
+
await engine.query('customers:customer_entity', {
|
|
1342
|
+
tenantId: 't1',
|
|
1343
|
+
fields: ['id'],
|
|
1344
|
+
filters: { display_name: { $ilike: '%avision%' } },
|
|
1345
|
+
page: { page: 1, pageSize: 10 },
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
expect(applySearchTokensSpy).not.toHaveBeenCalled()
|
|
1349
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
1350
|
+
const ilikeWhere = baseCall._ops.wheres.some(
|
|
1351
|
+
(w: any) => Array.isArray(w) && String(w[0]).includes('display_name') && w[1] === 'ilike' && w[2] === '%avision%',
|
|
1352
|
+
)
|
|
1353
|
+
expect(ilikeWhere).toBe(true)
|
|
1354
|
+
} finally {
|
|
1355
|
+
delete process.env.TENANT_DATA_ENCRYPTION
|
|
1356
|
+
}
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
test('no service + encryption enabled: the map is unknown, the token rewrite is kept', async () => {
|
|
1360
|
+
// A swallowed DI failure looks exactly like "no service". Guessing "plaintext" here would
|
|
1361
|
+
// run ILIKE against ciphertext (zero rows, silently) -- so the gate stays inert instead.
|
|
1362
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDbWithTokens() as any)
|
|
1363
|
+
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
1364
|
+
|
|
1365
|
+
await engine.query('customers:customer_entity', {
|
|
1366
|
+
tenantId: 't1',
|
|
1367
|
+
fields: ['id'],
|
|
1368
|
+
filters: { display_name: { $ilike: '%avision%' } },
|
|
1369
|
+
page: { page: 1, pageSize: 10 },
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
expect(applySearchTokensSpy).toHaveBeenCalled()
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
test('a camelCase encryption-map entry still routes its column through tokens', async () => {
|
|
1376
|
+
// Encryption maps may declare `displayName` while the filter carries the column name
|
|
1377
|
+
// `display_name` (TenantDataEncryptionService resolves both). A raw name comparison would
|
|
1378
|
+
// misread that ciphertext column as plaintext and run ILIKE against ciphertext -- zero rows,
|
|
1379
|
+
// silently. The gate must match across name shapes.
|
|
1380
|
+
const engine = new BasicQueryEngine(
|
|
1381
|
+
{} as any,
|
|
1382
|
+
() => fakeDbWithTokens() as any,
|
|
1383
|
+
() => ({ getEncryptedFieldNames: async () => ['displayName'] }) as any,
|
|
1384
|
+
)
|
|
1385
|
+
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
1386
|
+
|
|
1387
|
+
await engine.query('customers:customer_entity', {
|
|
1388
|
+
tenantId: 't1',
|
|
1389
|
+
fields: ['id'],
|
|
1390
|
+
filters: { display_name: { $ilike: '%avision%' } },
|
|
1391
|
+
page: { page: 1, pageSize: 10 },
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
expect(applySearchTokensSpy).toHaveBeenCalled()
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
test('with the flag off (default) the token rewrite is kept even for plaintext columns', () => {
|
|
1398
|
+
delete process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS
|
|
1399
|
+
const fakeDb = fakeDbWithTokens()
|
|
1400
|
+
const engine = new BasicQueryEngine(
|
|
1401
|
+
{} as any,
|
|
1402
|
+
() => fakeDb as any,
|
|
1403
|
+
() => ({ getEncryptedFieldNames: async () => [] }) as any,
|
|
1404
|
+
)
|
|
1405
|
+
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
1406
|
+
|
|
1407
|
+
return engine
|
|
1408
|
+
.query('customers:customer_entity', {
|
|
1409
|
+
tenantId: 't1',
|
|
1410
|
+
fields: ['id'],
|
|
1411
|
+
filters: { display_name: { $ilike: '%avision%' } },
|
|
1412
|
+
page: { page: 1, pageSize: 10 },
|
|
1413
|
+
})
|
|
1414
|
+
.then(() => {
|
|
1415
|
+
expect(applySearchTokensSpy).toHaveBeenCalled()
|
|
1416
|
+
})
|
|
1417
|
+
})
|
|
1418
|
+
})
|
|
1419
|
+
|
|
1420
|
+
describe('module-scoped column-existence cache (#5605)', () => {
|
|
1421
|
+
const columnProbes = (fakeDb: any) =>
|
|
1422
|
+
fakeDb._calls.filter((b: any) => b._ops.table === 'information_schema.columns')
|
|
1423
|
+
|
|
1424
|
+
const columnProbesFor = (fakeDb: any, column: string) =>
|
|
1425
|
+
columnProbes(fakeDb).filter((b: any) =>
|
|
1426
|
+
b._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'column_name' && w[1] === '=' && w[2] === column)
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
const hasTenantGuard = (fakeDb: any) => {
|
|
1430
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
1431
|
+
return !!baseCall?._ops.wheres.some(
|
|
1432
|
+
(w: any) => Array.isArray(w) && w[0] === 'customer_entities.tenant_id' && w[1] === '=' && w[2] === 't1',
|
|
1433
|
+
)
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const originalTtl = process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS
|
|
1437
|
+
const originalMaxEntries = process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES
|
|
1438
|
+
|
|
1439
|
+
afterEach(() => {
|
|
1440
|
+
if (originalTtl === undefined) delete process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS
|
|
1441
|
+
else process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS = originalTtl
|
|
1442
|
+
if (originalMaxEntries === undefined) delete process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES
|
|
1443
|
+
else process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES = originalMaxEntries
|
|
1444
|
+
jest.restoreAllMocks()
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
test('the column-existence cache is shared across separate engine instances', async () => {
|
|
1448
|
+
// `createRequestContainer()` builds a fresh `BasicQueryEngine` per HTTP request.
|
|
1449
|
+
// The cache must live on the module, not the instance, so a later "request" (a
|
|
1450
|
+
// second engine here) reuses the first request's answer instead of re-probing
|
|
1451
|
+
// `information_schema.columns`.
|
|
1452
|
+
const fakeDb = createFakeKysely({
|
|
1453
|
+
customer_entities: [],
|
|
1454
|
+
'information_schema.columns': [
|
|
1455
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1456
|
+
],
|
|
1457
|
+
})
|
|
1458
|
+
const queryOpts = { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 10 } }
|
|
1459
|
+
|
|
1460
|
+
const engine1 = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1461
|
+
await engine1.query('customers:customer_entity', queryOpts)
|
|
1462
|
+
const probesAfterFirstEngine = columnProbes(fakeDb).length
|
|
1463
|
+
expect(probesAfterFirstEngine).toBeGreaterThan(0)
|
|
1464
|
+
|
|
1465
|
+
const engine2 = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1466
|
+
await engine2.query('customers:customer_entity', queryOpts)
|
|
1467
|
+
expect(columnProbes(fakeDb).length).toBe(probesAfterFirstEngine)
|
|
1468
|
+
})
|
|
1469
|
+
|
|
1470
|
+
test('a missing column is remembered as false instead of being re-queried on every call', async () => {
|
|
1471
|
+
// Before the fix, `columnExists` deleted a `false` result instead of caching it,
|
|
1472
|
+
// so `organization_id` (absent here) was re-queried once per query projection
|
|
1473
|
+
// ('full' and 'count') within a single `.query()` call.
|
|
1474
|
+
const fakeDb = createFakeKysely({
|
|
1475
|
+
customer_entities: [],
|
|
1476
|
+
'information_schema.columns': [],
|
|
1477
|
+
})
|
|
1478
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1479
|
+
|
|
1480
|
+
await engine.query('customers:customer_entity', {
|
|
1481
|
+
tenantId: 't1',
|
|
1482
|
+
organizationId: 'org1',
|
|
1483
|
+
fields: ['id'],
|
|
1484
|
+
page: { page: 1, pageSize: 10 },
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
expect(columnProbesFor(fakeDb, 'organization_id').length).toBe(1)
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
test('a cached negative expires, so a migrated-in scope column is picked up without a process restart', async () => {
|
|
1491
|
+
// A cached `false` is consumed where the tenant/organization/soft-delete predicates
|
|
1492
|
+
// are applied, so a stale one does not merely slow a query down — it silently drops
|
|
1493
|
+
// a scope guard. The TTL bounds that window: a migration applied against a running
|
|
1494
|
+
// process (`yarn dev`, or pods not recycled by a separate migration release step)
|
|
1495
|
+
// converges once the entry expires instead of never.
|
|
1496
|
+
const now = 1_700_000_000_000
|
|
1497
|
+
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(now)
|
|
1498
|
+
|
|
1499
|
+
const beforeMigration = createFakeKysely({
|
|
1500
|
+
customer_entities: [],
|
|
1501
|
+
'information_schema.columns': [],
|
|
1502
|
+
})
|
|
1503
|
+
const queryOpts = { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 10 } }
|
|
1504
|
+
await new BasicQueryEngine({} as any, () => beforeMigration as any).query('customers:customer_entity', queryOpts)
|
|
1505
|
+
expect(columnProbesFor(beforeMigration, 'tenant_id').length).toBe(1)
|
|
1506
|
+
expect(hasTenantGuard(beforeMigration)).toBe(false)
|
|
1507
|
+
|
|
1508
|
+
// Same process, same cache — a second request while the entry is still fresh reuses
|
|
1509
|
+
// the negative and does not re-probe, even though the column now exists.
|
|
1510
|
+
const afterMigration = createFakeKysely({
|
|
1511
|
+
customer_entities: [],
|
|
1512
|
+
'information_schema.columns': [
|
|
1513
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1514
|
+
],
|
|
1515
|
+
})
|
|
1516
|
+
await new BasicQueryEngine({} as any, () => afterMigration as any).query('customers:customer_entity', queryOpts)
|
|
1517
|
+
expect(columnProbesFor(afterMigration, 'tenant_id').length).toBe(0)
|
|
1518
|
+
expect(hasTenantGuard(afterMigration)).toBe(false)
|
|
1519
|
+
|
|
1520
|
+
nowSpy.mockReturnValue(now + 300_001)
|
|
1521
|
+
|
|
1522
|
+
const afterTtl = createFakeKysely({
|
|
1523
|
+
customer_entities: [],
|
|
1524
|
+
'information_schema.columns': [
|
|
1525
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1526
|
+
],
|
|
1527
|
+
})
|
|
1528
|
+
await new BasicQueryEngine({} as any, () => afterTtl as any).query('customers:customer_entity', queryOpts)
|
|
1529
|
+
expect(columnProbesFor(afterTtl, 'tenant_id').length).toBe(1)
|
|
1530
|
+
expect(hasTenantGuard(afterTtl)).toBe(true)
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
test('the cache is bounded, so caller-supplied sort fields cannot grow it without limit', async () => {
|
|
1534
|
+
// `columnExists` is reached with raw request input through `resolveBaseColumn` —
|
|
1535
|
+
// `sortField` arrives from the query string and many list schemas type it as a plain
|
|
1536
|
+
// string. Without a cap, one distinct name per request would be retained for the
|
|
1537
|
+
// lifetime of the process.
|
|
1538
|
+
process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES = '4'
|
|
1539
|
+
const fakeDb = createFakeKysely({
|
|
1540
|
+
customer_entities: [],
|
|
1541
|
+
'information_schema.columns': [],
|
|
1542
|
+
})
|
|
1543
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1544
|
+
|
|
1545
|
+
for (let index = 0; index < 20; index += 1) {
|
|
1546
|
+
await engine.query('customers:customer_entity', {
|
|
1547
|
+
tenantId: 't1',
|
|
1548
|
+
fields: ['id'],
|
|
1549
|
+
sort: [{ field: `attacker_supplied_${index}`, dir: SortDir.Asc }],
|
|
1550
|
+
page: { page: 1, pageSize: 10 },
|
|
1551
|
+
})
|
|
1552
|
+
expect(columnExistsCacheSize()).toBeLessThanOrEqual(4)
|
|
1553
|
+
}
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
test('OM_QUERY_COLUMN_EXISTS_CACHE_MS=0 disables the memo and probes per request again', async () => {
|
|
1557
|
+
process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS = '0'
|
|
1558
|
+
const fakeDb = createFakeKysely({
|
|
1559
|
+
customer_entities: [],
|
|
1560
|
+
'information_schema.columns': [
|
|
1561
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1562
|
+
],
|
|
1563
|
+
})
|
|
1564
|
+
const queryOpts = { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 10 } }
|
|
1565
|
+
|
|
1566
|
+
await new BasicQueryEngine({} as any, () => fakeDb as any).query('customers:customer_entity', queryOpts)
|
|
1567
|
+
const probesAfterFirstEngine = columnProbesFor(fakeDb, 'tenant_id').length
|
|
1568
|
+
expect(probesAfterFirstEngine).toBeGreaterThan(0)
|
|
1569
|
+
expect(columnExistsCacheSize()).toBe(0)
|
|
1570
|
+
|
|
1571
|
+
await new BasicQueryEngine({} as any, () => fakeDb as any).query('customers:customer_entity', queryOpts)
|
|
1572
|
+
expect(columnProbesFor(fakeDb, 'tenant_id').length).toBeGreaterThan(probesAfterFirstEngine)
|
|
1573
|
+
})
|
|
1574
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { parseNumberWithDefault } from '../number'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LIST_COUNT_CAP = 10_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cap on how many matching rows a list COUNT may visit before reporting
|
|
7
|
+
* `total: cap` with `meta.listCountCapWarning` (surfaced to clients as
|
|
8
|
+
* `totalIsCapped`). Returns the cap as a number, or `null` when capping is
|
|
9
|
+
* disabled.
|
|
10
|
+
*
|
|
11
|
+
* Resolution of `OM_LIST_COUNT_CAP`:
|
|
12
|
+
* - unset / blank / unparseable → `DEFAULT_LIST_COUNT_CAP` (the cap is on by
|
|
13
|
+
* default, and bad input must not silently disable it)
|
|
14
|
+
* - `0` (or negative) → `null` — capping disabled, exact counts everywhere
|
|
15
|
+
*/
|
|
16
|
+
export function resolveListCountCap(): number | null {
|
|
17
|
+
const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true })
|
|
18
|
+
return parsed <= 0 ? null : parsed
|
|
19
|
+
}
|