@open-mercato/shared 0.6.8-develop.7099.1.7ba2771d06 → 0.7.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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -4
- package/dist/lib/auth/jwt.js +0 -6
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationAccess.js +4 -7
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/server.js +7 -38
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -6
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +8 -24
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +2 -8
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/location/countries.js +0 -12
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +1 -4
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/engine.js +34 -270
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/config.js +0 -1
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +15 -69
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +0 -15
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +3 -10
- package/src/lib/auth/__tests__/jwt.test.ts +0 -13
- package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
- package/src/lib/auth/__tests__/server.test.ts +0 -104
- package/src/lib/auth/jwt.ts +0 -17
- package/src/lib/auth/organizationAccess.ts +3 -11
- package/src/lib/auth/server.ts +8 -78
- package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
- package/src/lib/commands/command-bus.ts +1 -8
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
- package/src/lib/crud/factory.ts +7 -33
- package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
- package/src/lib/data/engine.ts +1 -7
- package/src/lib/location/__tests__/countries.test.ts +0 -15
- package/src/lib/location/countries.ts +0 -17
- package/src/lib/openapi/crud.ts +0 -3
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
- package/src/lib/query/__tests__/engine.test.ts +7 -445
- package/src/lib/query/engine.ts +54 -434
- package/src/lib/query/types.ts +0 -15
- package/src/lib/search/config.ts +0 -10
- package/src/modules/events/factory.ts +19 -111
- package/src/modules/events/types.ts +0 -17
- package/src/modules/registry.ts +0 -40
- package/src/modules/widgets/component-registry.ts +0 -14
- package/dist/lib/auth/mfaPendingAccess.js +0 -42
- package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
- package/dist/lib/auth/principal-service.js +0 -1
- package/dist/lib/auth/principal-service.js.map +0 -7
- package/dist/lib/html/htmlToPlainText.js +0 -16
- package/dist/lib/html/htmlToPlainText.js.map +0 -7
- package/dist/lib/query/count-cap.js +0 -11
- package/dist/lib/query/count-cap.js.map +0 -7
- package/dist/lib/queue/dispatchOrigin.js +0 -20
- package/dist/lib/queue/dispatchOrigin.js.map +0 -7
- package/dist/lib/search/entityAccess.js +0 -44
- package/dist/lib/search/entityAccess.js.map +0 -7
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
- package/src/lib/auth/mfaPendingAccess.ts +0 -70
- package/src/lib/auth/principal-service.ts +0 -110
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
- package/src/lib/html/htmlToPlainText.ts +0 -17
- package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
- package/src/lib/query/__tests__/count-cap.test.ts +0 -41
- package/src/lib/query/count-cap.ts +0 -19
- package/src/lib/queue/dispatchOrigin.ts +0 -35
- package/src/lib/search/entityAccess.ts +0 -132
- package/src/modules/events/__tests__/factory.test.ts +0 -88
|
@@ -2,14 +2,11 @@ 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'
|
|
6
5
|
|
|
7
6
|
// The token-presence answer is cached process-wide (TTL); without clearing it,
|
|
8
7
|
// probe-count assertions would observe hits from earlier tests in this file.
|
|
9
8
|
beforeEach(() => {
|
|
10
9
|
clearSearchTokenPresenceCache()
|
|
11
|
-
clearEncryptedLikeFieldsCache()
|
|
12
|
-
clearColumnExistsCache()
|
|
13
10
|
})
|
|
14
11
|
|
|
15
12
|
// Mock modules with one entity extension
|
|
@@ -179,13 +176,6 @@ function createFakeKysely(overrides?: FakeData) {
|
|
|
179
176
|
return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
|
|
180
177
|
}
|
|
181
178
|
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
|
-
}
|
|
189
179
|
return { count: String((data[localOps.table] || []).length) }
|
|
190
180
|
}
|
|
191
181
|
const rows = data[localOps.table] || []
|
|
@@ -198,22 +188,6 @@ function createFakeKysely(overrides?: FakeData) {
|
|
|
198
188
|
}
|
|
199
189
|
|
|
200
190
|
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
|
-
}
|
|
217
191
|
const parsed = parseTableSpec(tableArg)
|
|
218
192
|
const ops = {
|
|
219
193
|
table: parsed.table,
|
|
@@ -719,10 +693,10 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
719
693
|
|
|
720
694
|
expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
|
|
721
695
|
const baseCalls = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
722
|
-
expect(baseCalls.length).toBe(
|
|
723
|
-
// qFull ('full' projection) is built first (used for phase 2)
|
|
724
|
-
// '
|
|
725
|
-
const [phase2Call,
|
|
696
|
+
expect(baseCalls.length).toBe(2)
|
|
697
|
+
// qFull ('full' projection) is built first (used for count + phase 2);
|
|
698
|
+
// qSort ('sortKeys' projection) is built second (phase 1).
|
|
699
|
+
const [phase2Call, phase1Call] = baseCalls
|
|
726
700
|
// Phase 1 (slim id+sort-column scan): no SQL order/limit — the full candidate
|
|
727
701
|
// set is fetched, decrypted, and sorted in memory.
|
|
728
702
|
expect(phase1Call._ops.orderBys).toEqual([])
|
|
@@ -904,7 +878,7 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
904
878
|
page: { page: 1, pageSize: 2 },
|
|
905
879
|
})
|
|
906
880
|
expect(result.meta?.encryptedSortRowCapWarning).toBeUndefined()
|
|
907
|
-
const [,
|
|
881
|
+
const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
908
882
|
expect(phase1Call._ops.limits).toBe(0)
|
|
909
883
|
})
|
|
910
884
|
|
|
@@ -938,37 +912,10 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
938
912
|
maxRows: 3,
|
|
939
913
|
totalMatched: 5,
|
|
940
914
|
})
|
|
941
|
-
const [,
|
|
942
|
-
|
|
943
|
-
// not by comparing against a (possibly capped) total.
|
|
944
|
-
expect(phase1Call._ops.limits).toBe(4)
|
|
915
|
+
const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
916
|
+
expect(phase1Call._ops.limits).toBe(3)
|
|
945
917
|
expect(phase1Call._ops.orderBys).toEqual([['customer_entities.id', 'asc']])
|
|
946
918
|
})
|
|
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
|
-
})
|
|
972
919
|
})
|
|
973
920
|
|
|
974
921
|
test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
|
|
@@ -1007,80 +954,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
1007
954
|
expect(baseCall._ops.offsets).toBe(10)
|
|
1008
955
|
})
|
|
1009
956
|
|
|
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
|
-
|
|
1084
957
|
// A tiebreak sort is only worth configuring if the engine actually emits it.
|
|
1085
958
|
// `list.tiebreakSortField` (used by the sales line routes to keep lines with an
|
|
1086
959
|
// equal `line_number` in a repeatable order) is the first caller to pass more
|
|
@@ -1261,314 +1134,3 @@ describe('BasicQueryEngine entity-extension joins', () => {
|
|
|
1261
1134
|
expect(join.aliasObj).toEqual({ ext_example_session_note: 'example_session_notes' })
|
|
1262
1135
|
})
|
|
1263
1136
|
})
|
|
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
|
-
})
|