Skip to content

Commit 84a9b82

Browse files
committed
fix(integrations): require the access hook to be called and read namespace imports in check:integration-access
1 parent 47fc03c commit 84a9b82

2 files changed

Lines changed: 80 additions & 29 deletions

File tree

‎scripts/check-integration-access.test.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, expect, it } from 'vitest'
22
import { findIntegrationAccessViolations } from './check-integration-access'
33

4-
const GATE = "import { useIntegrationAccess } from '@/hooks/use-integration-access'"
4+
const GATE = `import { useIntegrationAccess } from '@/hooks/use-integration-access'
5+
const { getAccess } = useIntegrationAccess(workspaceId)`
56

67
function check(sources: Record<string, string>, exempt: Record<string, string> = {}) {
78
return findIntegrationAccessViolations(
@@ -28,9 +29,28 @@ describe('findIntegrationAccessViolations', () => {
2829
])
2930
})
3031

31-
it('flags a namespace import of a raw catalog module', () => {
32-
const source = "import * as metadata from '@sim/deployment-config/integration-metadata'"
33-
expect(check({ 'ee/tile.tsx': source })).toHaveLength(1)
32+
it('flags a raw catalog read through a namespace import', () => {
33+
const source = `import * as metadata from '@sim/deployment-config/integration-metadata'
34+
export const types = metadata.INTEGRATION_METADATA.map((entry) => entry.type)`
35+
expect(check({ 'ee/tile.tsx': source })).toEqual([
36+
expect.objectContaining({ path: 'ee/tile.tsx', names: ['INTEGRATION_METADATA'] }),
37+
])
38+
})
39+
40+
it('flags a raw listing that imports the gate without calling it', () => {
41+
const source = `import { useIntegrationAccess } from '@/hooks/use-integration-access'
42+
import { listIntegrations } from '@/blocks/integration-matcher'`
43+
expect(check({ 'app/picker.ts': source })).toHaveLength(1)
44+
})
45+
46+
it('accepts the gate called through a namespace or a renamed binding', () => {
47+
const namespaced = `import * as access from '@/hooks/use-integration-access'
48+
import { INTEGRATIONS } from '@/lib/integrations'
49+
const { getAccess } = access.useIntegrationAccess(workspaceId)`
50+
const renamed = `import { useIntegrationAccess as useAccess } from '@/hooks/use-integration-access'
51+
import { INTEGRATIONS } from '@/lib/integrations'
52+
const { getAccess } = useAccess(workspaceId)`
53+
expect(check({ 'app/a.tsx': namespaced, 'app/b.tsx': renamed })).toEqual([])
3454
})
3555

3656
it('accepts a raw catalog read alongside the access gate', () => {
@@ -55,6 +75,14 @@ import { type listIntegrations } from '@/blocks/integration-matcher'`
5575
).toEqual([])
5676
})
5777

78+
it('refuses the fail-open policy through a namespace import', () => {
79+
const source = `import * as access from '@/hooks/use-integration-access'
80+
const { policy } = access.useIntegrationAccessPolicy(workspaceId)`
81+
expect(check({ 'app/picker.tsx': source })).toEqual([
82+
expect.objectContaining({ path: 'app/picker.tsx', names: ['useIntegrationAccessPolicy'] }),
83+
])
84+
})
85+
5886
it('refuses the fail-open access policy outside the permission config hook', () => {
5987
const source = "import { useIntegrationAccessPolicy } from '@/hooks/use-integration-access'"
6088
expect(check({ 'app/picker.tsx': source, 'hooks/use-permission-config.ts': source })).toEqual([

‎scripts/check-integration-access.ts‎

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* one verdict those surfaces share; this audit makes reaching a raw catalog
1111
* without it a build failure.
1212
*
13-
* The rule: a scanned module that imports a raw catalog must also import
13+
* The rule: a scanned module that reads a raw catalog must also call
1414
* `useIntegrationAccess`, or appear in {@link EXEMPT} with the reason it may see
1515
* every integration (a lookup by id, a public page, a builder whose caller
1616
* gates). An exemption that no longer covers a raw import is reported too, so
@@ -25,6 +25,7 @@
2525
import { readdirSync, readFileSync } from 'node:fs'
2626
import { dirname, join, relative, resolve } from 'node:path'
2727
import { fileURLToPath } from 'node:url'
28+
import { escapeRegExp } from '@sim/utils/string'
2829

2930
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../apps/sim')
3031
const SCAN_ROOTS = ['app', 'components', 'hooks', 'stores', 'ee'] as const
@@ -63,36 +64,54 @@ interface SourceFile {
6364

6465
interface IntegrationAccessViolation {
6566
path: string
66-
/** The raw catalog exports, or the fail-open policy, imported without leave; empty for a stale exemption. */
67+
/** The raw catalog exports, or the fail-open policy, read without leave; empty for a stale exemption. */
6768
names: string[]
6869
stale: boolean
6970
}
7071

7172
const NAMED_IMPORT = /import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g
72-
const NAMESPACE_IMPORT = /import\s+\*\s+as\s+\w+\s+from\s*['"]([^'"]+)['"]/g
73+
const NAMESPACE_IMPORT = /import\s+\*\s+as\s+([\w$]+)\s+from\s*['"]([^'"]+)['"]/g
7374

74-
/** The names a module imports as values from `from`, by their exported name. */
75-
function valueImports(source: string, from: string): string[] {
76-
const names: string[] = []
75+
/** One export of a module, and the expression the importing module reaches it through. */
76+
interface ExportReference {
77+
exported: string
78+
/** The local binding, or `namespace.member` for a read through a namespace import. */
79+
local: string
80+
}
81+
82+
/** The exports of `from` a module reaches as values, through named or namespace imports. */
83+
function exportReferences(source: string, from: string): ExportReference[] {
84+
const references: ExportReference[] = []
7785
for (const [, typeOnly, specifiers, module] of source.matchAll(NAMED_IMPORT)) {
7886
if (module !== from || typeOnly) continue
7987
for (const specifier of specifiers.split(',')) {
80-
const [imported] = specifier.trim().split(/\s+as\s+/)
81-
if (imported && !imported.startsWith('type ')) names.push(imported)
88+
const [exported, local = exported] = specifier.trim().split(/\s+as\s+/)
89+
if (exported && !exported.startsWith('type ')) references.push({ exported, local })
90+
}
91+
}
92+
for (const [, namespace, module] of source.matchAll(NAMESPACE_IMPORT)) {
93+
if (module !== from) continue
94+
const memberRead = new RegExp(`(?<![\\w$.])${escapeRegExp(namespace)}\\.([\\w$]+)`, 'g')
95+
for (const [local, exported] of source.matchAll(memberRead)) {
96+
references.push({ exported, local })
8297
}
8398
}
84-
return names
99+
return references
85100
}
86101

87-
/** The raw catalog exports a module imports as values. */
88-
function rawCatalogImports(source: string): string[] {
89-
const names = Object.entries(RAW_CATALOGS).flatMap(([module, catalog]) =>
90-
valueImports(source, module).filter((name) => catalog.includes(name))
91-
)
92-
for (const [, from] of source.matchAll(NAMESPACE_IMPORT)) {
93-
if (RAW_CATALOGS[from]) names.push(...RAW_CATALOGS[from])
102+
function isCalled(source: string, local: string): boolean {
103+
return new RegExp(`(?<![\\w$.])${escapeRegExp(local)}\\s*\\(`).test(source)
104+
}
105+
106+
/** The raw catalog exports a module reads. */
107+
function rawCatalogReads(source: string): string[] {
108+
const names = new Set<string>()
109+
for (const [module, catalog] of Object.entries(RAW_CATALOGS)) {
110+
for (const { exported } of exportReferences(source, module)) {
111+
if (catalog.includes(exported)) names.add(exported)
112+
}
94113
}
95-
return names
114+
return [...names]
96115
}
97116

98117
function exemptionFor(path: string, exempt: Readonly<Record<string, string>>): string | undefined {
@@ -108,18 +127,22 @@ export function findIntegrationAccessViolations(
108127
const violations: IntegrationAccessViolation[] = []
109128
const usedExemptions = new Set<string>()
110129
for (const { path, source } of files) {
111-
const gateImports = valueImports(source, GATE_MODULE)
112-
if (gateImports.includes(FAIL_OPEN_NAME) && path !== FAIL_OPEN_READER) {
130+
const gateReferences = exportReferences(source, GATE_MODULE)
131+
const readsFailOpen = gateReferences.some(({ exported }) => exported === FAIL_OPEN_NAME)
132+
if (readsFailOpen && path !== FAIL_OPEN_READER) {
113133
violations.push({ path, names: [FAIL_OPEN_NAME], stale: false })
114134
}
115-
const names = rawCatalogImports(source)
135+
const names = rawCatalogReads(source)
116136
if (names.length === 0) continue
117137
const exemption = exemptionFor(path, exempt)
118138
if (exemption) {
119139
usedExemptions.add(exemption)
120140
continue
121141
}
122-
if (!gateImports.includes(GATE_NAME)) violations.push({ path, names, stale: false })
142+
const callsGate = gateReferences.some(
143+
({ exported, local }) => exported === GATE_NAME && isCalled(source, local)
144+
)
145+
if (!callsGate) violations.push({ path, names, stale: false })
123146
}
124147
for (const entry of Object.keys(exempt)) {
125148
if (!usedExemptions.has(entry)) violations.push({ path: entry, names: [], stale: true })
@@ -152,21 +175,21 @@ function collectSources(): SourceFile[] {
152175
function main() {
153176
const violations = findIntegrationAccessViolations(collectSources(), EXEMPT)
154177
if (violations.length === 0) {
155-
console.log('✓ every integration listing reads useIntegrationAccess')
178+
console.log('✓ every integration listing calls useIntegrationAccess')
156179
return
157180
}
158181
for (const violation of violations) {
159182
if (violation.stale) {
160183
console.error(
161-
`✗ ${violation.path}: exempt in check-integration-access.ts but imports no raw catalog — remove the entry`
184+
`✗ ${violation.path}: exempt in check-integration-access.ts but reads no raw catalog — remove the entry`
162185
)
163186
} else if (violation.names.includes(FAIL_OPEN_NAME)) {
164187
console.error(
165-
`✗ apps/sim/${violation.path}: imports ${FAIL_OPEN_NAME}, which fails open while loading — only ${FAIL_OPEN_READER} may`
188+
`✗ apps/sim/${violation.path}: reads ${FAIL_OPEN_NAME}, which fails open while loading — only ${FAIL_OPEN_READER} may`
166189
)
167190
} else {
168191
console.error(
169-
`✗ apps/sim/${violation.path}: imports ${violation.names.join(', ')} without ${GATE_NAME}`
192+
`✗ apps/sim/${violation.path}: reads ${violation.names.join(', ')} without calling ${GATE_NAME}`
170193
)
171194
}
172195
}

0 commit comments

Comments
 (0)