@things-factory/headless-twin 10.1.3 → 10.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/canonical-ingest.js +48 -3
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/service/reference/twin-reference.d.ts +24 -0
- package/dist-server/service/reference/twin-reference.js +4 -3
- package/dist-server/service/reference/twin-reference.js.map +1 -1
- package/package.json +7 -7
- package/server/engine/canonical-ingest.ts +52 -3
- package/server/service/reference/twin-reference.ts +29 -4
- package/test/control-capability.test.ts +6 -11
- package/test/dispatch-after-approval-source.test.ts +81 -0
- package/test/guard-roots.ts +76 -0
- package/test/ingest-health-wiring.test.ts +3 -4
- package/test/journal-read-discipline.test.ts +3 -4
- package/test/kernel-kind-guard.test.ts +3 -1
- package/test/rejection-carries-event-id.test.ts +101 -0
- package/test/secrets-at-rest.test.ts +135 -0
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Two properties of the dispatch that follows an approval, guarded by reading its source.
|
|
3
|
+
*
|
|
4
|
+
* ── Why source and not behaviour ───────────────────────────────────────────
|
|
5
|
+
* `dispatchAfterApproval` runs outside the request, touches the database and reaches an
|
|
6
|
+
* adapter that talks to a factory. It cannot be exercised here. What can be guarded is the
|
|
7
|
+
* shape of the two decisions that make it safe, and both are visible in the text.
|
|
8
|
+
*
|
|
9
|
+
* ── Why the guard lives here now (2026-09-08) ──────────────────────────────
|
|
10
|
+
* These two assertions were in `operato-twin/test/actuation-gate-walk.test.ts`, reading
|
|
11
|
+
* `../../headless-twin/server/…`. The applications then moved to the operato-application
|
|
12
|
+
* repository and that relative path left this repository — three checks failed with ENOENT.
|
|
13
|
+
*
|
|
14
|
+
* The subject of both is a file in *this* package, so this is where they belong. The half that
|
|
15
|
+
* guards the app's own callback (that approval, and only approval, calls dispatch) stays with
|
|
16
|
+
* the app. A guard reads its own repository; when it has to read across one, the seam is in
|
|
17
|
+
* the wrong place.
|
|
18
|
+
*/
|
|
19
|
+
import { test } from 'node:test'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
|
|
23
|
+
import { guardFile } from './guard-roots.js'
|
|
24
|
+
|
|
25
|
+
const PLACE = '../server/service/actuation/dispatch-after-approval.ts'
|
|
26
|
+
|
|
27
|
+
/** The source with comments removed — a term inside a comment is not a use of it. */
|
|
28
|
+
function sourceOf(): string {
|
|
29
|
+
return readFileSync(guardFile(import.meta.url, PLACE), 'utf8')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withoutComments(src: string): string {
|
|
33
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('dispatch does not run inside the approval transaction', () => {
|
|
37
|
+
/*
|
|
38
|
+
* Approval happens inside the worklist's transaction. Sending a request to the factory from
|
|
39
|
+
* inside it means a rollback cannot take the request back: the plant keeps the work order
|
|
40
|
+
* and our ledger has the approval undone. What went out over the network was never in the
|
|
41
|
+
* transaction's care.
|
|
42
|
+
*
|
|
43
|
+
* A transaction-bound store is passed the transaction — `databaseCommandStore(tx)`. An
|
|
44
|
+
* unbound one takes no argument. So the argument list is the property.
|
|
45
|
+
*/
|
|
46
|
+
const src = withoutComments(sourceOf())
|
|
47
|
+
|
|
48
|
+
assert.match(src, /databaseCommandStore\(\)/, 'dispatch uses a transaction-bound store')
|
|
49
|
+
assert.doesNotMatch(src, /databaseCommandStore\(\s*[a-zA-Z]/, 'dispatch is being handed a transaction store')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('a failed dispatch does not undo the approval, and does not take the process down', () => {
|
|
53
|
+
/*
|
|
54
|
+
* This runs outside the request, in `setImmediate`. A throw there is an unhandled rejection
|
|
55
|
+
* and the server exits — one undelivered actuation would stop everything else.
|
|
56
|
+
*
|
|
57
|
+
* The approval also has to survive an adapter that is down. That is the whole reason
|
|
58
|
+
* approving and dispatching are two steps, and it is a measured property, not a wish:
|
|
59
|
+
* command `67f8765a` was dispatched successfully later, on the same approval.
|
|
60
|
+
*/
|
|
61
|
+
const src = sourceOf()
|
|
62
|
+
|
|
63
|
+
assert.match(src, /catch\s*\(/, 'nothing catches a failed dispatch')
|
|
64
|
+
assert.doesNotMatch(
|
|
65
|
+
withoutComments(src),
|
|
66
|
+
/\bthrow\b/,
|
|
67
|
+
'the dispatch path throws — outside a request that ends the process'
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('this guard reads the real file, so a wrong path is red and not green', () => {
|
|
72
|
+
/*
|
|
73
|
+
* The two checks above fail when a pattern stops matching. They would also "pass" against an
|
|
74
|
+
* empty string, so the path itself has to be guarded — that is exactly how these assertions
|
|
75
|
+
* left this repository unnoticed in the first place.
|
|
76
|
+
*/
|
|
77
|
+
const src = sourceOf()
|
|
78
|
+
|
|
79
|
+
assert.ok(src.length > 1000, `the dispatch source is ${src.length} chars — the path is wrong`)
|
|
80
|
+
assert.match(src, /dispatchAfterApproval/, 'the file read is not the one this guard names')
|
|
81
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* Where the source-reading guards look, and what happens when a place is gone.
|
|
7
|
+
*
|
|
8
|
+
* ── Why this exists (2026-09-08) ───────────────────────────────────────────
|
|
9
|
+
* Four guards in this package read the *consumer's* source as well as their own, because the
|
|
10
|
+
* defects they catch are committed by callers: a caller that fills a kernel kind with a
|
|
11
|
+
* literal, a caller that reads the journal without saying it is folding, a caller that
|
|
12
|
+
* ingests without writing to the ledger.
|
|
13
|
+
*
|
|
14
|
+
* The applications then moved to their own repository, and `../../operato-twin/server` stopped
|
|
15
|
+
* existing. Two of the four failed with ENOENT. **The other two passed** — their directory
|
|
16
|
+
* walker swallows a missing root:
|
|
17
|
+
*
|
|
18
|
+
* try { entries = readdirSync(dir) } catch { … }
|
|
19
|
+
*
|
|
20
|
+
* So half of each of those guards was scanning nothing and reporting green. A guard that
|
|
21
|
+
* quietly stops guarding is worse than one that fails, because the failure is the only signal
|
|
22
|
+
* that it was ever load-bearing.
|
|
23
|
+
*
|
|
24
|
+
* ── So a missing root is an error, not a skip ──────────────────────────────
|
|
25
|
+
* `guardRoots` refuses to hand back a root that is not there. The message says which place is
|
|
26
|
+
* gone and what to do about it, because the answer is not "delete the root" — the code being
|
|
27
|
+
* guarded still exists, it is just in another repository now, and the guard has to follow it
|
|
28
|
+
* there.
|
|
29
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the places a guard scans, and fail loudly if any of them is missing.
|
|
34
|
+
*
|
|
35
|
+
* @param base `import.meta.url` of the calling test.
|
|
36
|
+
* @param places Paths relative to that test file.
|
|
37
|
+
*/
|
|
38
|
+
export function guardRoots(base: string, places: readonly string[]): string[] {
|
|
39
|
+
const missing: string[] = []
|
|
40
|
+
const roots = places.map(place => {
|
|
41
|
+
const path = fileURLToPath(new URL(place, base))
|
|
42
|
+
if (!existsSync(path)) missing.push(place)
|
|
43
|
+
return path
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (missing.length) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`guard root missing: ${missing.join(' · ')}\n` +
|
|
49
|
+
'This guard reads a consumer\'s source, and that source is no longer at this path.\n' +
|
|
50
|
+
'The applications moved to the operato-application repository (ADR-0041 era). Move the\n' +
|
|
51
|
+
'consumer half of this guard there rather than deleting the root — a root removed here\n' +
|
|
52
|
+
'is a rule that stops being checked, and nothing will say so.'
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return roots
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve one file a guard reads, and fail loudly if it is gone.
|
|
61
|
+
*
|
|
62
|
+
* Same reason as above, for the guards that read a single named file rather than walking a
|
|
63
|
+
* tree. `readFileSync` would already throw here — this only replaces `ENOENT: no such file`
|
|
64
|
+
* with a sentence that says what to do.
|
|
65
|
+
*/
|
|
66
|
+
export function guardFile(base: string, place: string): string {
|
|
67
|
+
const path = fileURLToPath(new URL(place, base))
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`guard file missing: ${place}\n` +
|
|
71
|
+
'This guard reads a consumer\'s source. That file moved to the operato-application\n' +
|
|
72
|
+
'repository — move this assertion there rather than deleting it.'
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return path
|
|
76
|
+
}
|
|
@@ -14,11 +14,10 @@ import assert from 'node:assert/strict'
|
|
|
14
14
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
16
|
|
|
17
|
+
import { guardRoots } from './guard-roots.ts'
|
|
18
|
+
|
|
17
19
|
/** 훑는 곳 — 이 저장소에서 인제스트를 부를 수 있는 층 둘. */
|
|
18
|
-
const ROOTS = [
|
|
19
|
-
new URL('../server', import.meta.url).pathname,
|
|
20
|
-
new URL('../../operato-twin/server', import.meta.url).pathname
|
|
21
|
-
]
|
|
20
|
+
const ROOTS = guardRoots(import.meta.url, ['../server'])
|
|
22
21
|
|
|
23
22
|
/** 인제스트를 여는 함수 이름. 이 이름이 나오는 자리가 곧 유입 경로다. */
|
|
24
23
|
const INGEST_CALL = 'ingestCanonicalRecords('
|
|
@@ -24,11 +24,10 @@ import assert from 'node:assert/strict'
|
|
|
24
24
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
26
|
|
|
27
|
+
import { guardRoots } from './guard-roots.ts'
|
|
28
|
+
|
|
27
29
|
/** 훑는 곳 — 저널을 읽을 수 있는 층 둘. */
|
|
28
|
-
const ROOTS = [
|
|
29
|
-
new URL('../server', import.meta.url).pathname,
|
|
30
|
-
new URL('../../operato-twin/server', import.meta.url).pathname
|
|
31
|
-
]
|
|
30
|
+
const ROOTS = guardRoots(import.meta.url, ['../server'])
|
|
32
31
|
|
|
33
32
|
const READ = 'getRepository(TwinEvent)'
|
|
34
33
|
/** 폴드라고 밝히는 표식. 이유를 함께 적는 것이 이 표식의 값이다. */
|
|
@@ -25,6 +25,8 @@ import assert from 'node:assert/strict'
|
|
|
25
25
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
|
|
28
|
+
import { guardRoots } from './guard-roots.ts'
|
|
29
|
+
|
|
28
30
|
const ENGINE = new URL('../server/engine/twin-engine.ts', import.meta.url).pathname
|
|
29
31
|
const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutation.ts', import.meta.url).pathname
|
|
30
32
|
|
|
@@ -34,7 +36,7 @@ const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutati
|
|
|
34
36
|
* `det.kind ?? 'wms'` 로 **엔진이 오류를 내기 전에 먼저 값을 정하고** 있었다. 커넥터 spec 도 마찬가지였다.
|
|
35
37
|
* 그래서 두 패키지의 서버 코드를 함께 훑는다.
|
|
36
38
|
*/
|
|
37
|
-
const ROOTS =
|
|
39
|
+
const ROOTS = guardRoots(import.meta.url, ['../server'])
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* 코드만 본다 — 주석은 **과거를 설명하느라 옛 코드를 그대로 인용한다**(바로 위 머리말이 그렇다).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A refusal has to name which envelope was refused.
|
|
3
|
+
*
|
|
4
|
+
* ── What this catches, and why the existing test did not (2026-09-08) ──────
|
|
5
|
+
* `rejectedForCaller` pairs a refused record with its envelope by **object identity** — value
|
|
6
|
+
* comparison would let two identically shaped records take each other's id. Its own test
|
|
7
|
+
* hands the same object to both sides, so it passes and proves the pairing works.
|
|
8
|
+
*
|
|
9
|
+
* It does not prove anything about the real path, because in between
|
|
10
|
+
* `ingestCanonicalRecords` builds `{ ...r, eventTime, sourceType }` — **a new object**. The
|
|
11
|
+
* refusal then carries that copy, the copy is not in the identity map, and the response comes
|
|
12
|
+
* back as `{ record, errors }` with no `eventId`:
|
|
13
|
+
*
|
|
14
|
+
* sent {"eventId":"probe-eventid-check-001","record":{"kind":"transformation"}}
|
|
15
|
+
* back rejected: [{ record: {kind:"transformation", sourceType:"twin"}, errors:[…] }]
|
|
16
|
+
*
|
|
17
|
+
* The sending side cannot mark the row that was refused, so it cannot settle the batch and
|
|
18
|
+
* its queue stops moving — measured on the plant side as 719 envelopes pending with nothing
|
|
19
|
+
* lost and nothing advancing.
|
|
20
|
+
*
|
|
21
|
+
* So this test walks the seam the way a real envelope does: through the ingest that makes the
|
|
22
|
+
* copy, then through the pairing. That is the step a pure test of either half cannot take.
|
|
23
|
+
*/
|
|
24
|
+
import { test } from 'node:test'
|
|
25
|
+
import assert from 'node:assert/strict'
|
|
26
|
+
|
|
27
|
+
import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.js'
|
|
28
|
+
import { rejectedForCaller } from '../server/service/reference/hook-rejected.js'
|
|
29
|
+
|
|
30
|
+
const AT = '2026-09-08T02:00:00.000Z'
|
|
31
|
+
|
|
32
|
+
/** A transformation the kernel refuses — no inputs, no outputs, no bizStep. */
|
|
33
|
+
const REFUSED = { kind: 'transformation' } as any
|
|
34
|
+
|
|
35
|
+
test('a refusal names the envelope it came from, after ingest has copied the record', () => {
|
|
36
|
+
/* The envelope as the hook holds it: the record object, and the id that identifies its row. */
|
|
37
|
+
const items = [{ record: REFUSED, eventId: 'evt-refused-1' }]
|
|
38
|
+
|
|
39
|
+
const result = ingestCanonicalRecords(
|
|
40
|
+
items.map(i => i.record),
|
|
41
|
+
'test-domain',
|
|
42
|
+
AT
|
|
43
|
+
) as any
|
|
44
|
+
|
|
45
|
+
assert.equal(result.rejected.length, 1, 'the kernel refuses this record')
|
|
46
|
+
|
|
47
|
+
const forCaller = rejectedForCaller(result.rejected, items)
|
|
48
|
+
|
|
49
|
+
assert.deepEqual(
|
|
50
|
+
forCaller.map((r: any) => r.eventId),
|
|
51
|
+
['evt-refused-1'],
|
|
52
|
+
'the refusal carries the envelope id — without it the sender cannot mark the row'
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('two refused records of the same shape keep their own ids', () => {
|
|
57
|
+
/*
|
|
58
|
+
* This is why the pairing is by identity and not by value. Both records look the same, and
|
|
59
|
+
* value comparison would hand the first id to both — the sender would mark one row twice
|
|
60
|
+
* and leave the other pending forever.
|
|
61
|
+
*/
|
|
62
|
+
const a = { kind: 'transformation' } as any
|
|
63
|
+
const b = { kind: 'transformation' } as any
|
|
64
|
+
const items = [
|
|
65
|
+
{ record: a, eventId: 'evt-a' },
|
|
66
|
+
{ record: b, eventId: 'evt-b' }
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const result = ingestCanonicalRecords(
|
|
70
|
+
items.map(i => i.record),
|
|
71
|
+
'test-domain',
|
|
72
|
+
AT
|
|
73
|
+
) as any
|
|
74
|
+
const forCaller = rejectedForCaller(result.rejected, items)
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
forCaller.map((r: any) => r.eventId).sort(),
|
|
78
|
+
['evt-a', 'evt-b'],
|
|
79
|
+
'each refusal keeps its own id'
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('an envelope with no id still says what was refused', () => {
|
|
84
|
+
/*
|
|
85
|
+
* A connector that does not number its envelopes gets the record back instead. An empty id
|
|
86
|
+
* would be worse than none: the sender would look for a row, find nothing, and treat the
|
|
87
|
+
* batch as understood.
|
|
88
|
+
*/
|
|
89
|
+
const items = [{ record: REFUSED }]
|
|
90
|
+
|
|
91
|
+
const result = ingestCanonicalRecords(
|
|
92
|
+
items.map(i => i.record),
|
|
93
|
+
'test-domain',
|
|
94
|
+
AT
|
|
95
|
+
) as any
|
|
96
|
+
const forCaller = rejectedForCaller(result.rejected, items) as any[]
|
|
97
|
+
|
|
98
|
+
assert.equal(forCaller.length, 1)
|
|
99
|
+
assert.equal('eventId' in forCaller[0], false, 'no id is sent rather than an empty one')
|
|
100
|
+
assert.ok('record' in forCaller[0], 'the record is sent so a person can see what was dropped')
|
|
101
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A column that can hold a credential has to be encrypted at rest.
|
|
3
|
+
*
|
|
4
|
+
* ── What this catches, and what it cost to find by hand (2026-09-09) ──────
|
|
5
|
+
* `TwinReference.connectionConfig` was a plain `simple-json` column holding a signing secret
|
|
6
|
+
* and a live access token. Two shell commands read both:
|
|
7
|
+
*
|
|
8
|
+
* sqlite3 -readonly db.sqlite "select connection_config from twin_references"
|
|
9
|
+
* {"endpoint":"…","hookSecret":"dev-only-localhost-hook-2026","token":"eyJhbGciOi…"}
|
|
10
|
+
*
|
|
11
|
+
* The read path was already guarded — the detail resolver masks fields an adapter declares
|
|
12
|
+
* `secret: true`. So the leak was not on the way out, it was the stored bytes: the database
|
|
13
|
+
* file, its backups, its replicas. **Two different protections, and having one reads exactly
|
|
14
|
+
* like having both.**
|
|
15
|
+
*
|
|
16
|
+
* ── Why a guard and not a review habit ────────────────────────────────────
|
|
17
|
+
* The framework has had the pieces for a while — `encryptJsonTransformer` and
|
|
18
|
+
* `longTextColumn` — and nothing used them together. Two packages imported the transformer and
|
|
19
|
+
* left it commented out. A rule that depends on someone remembering two exports is a rule that
|
|
20
|
+
* holds until the next new column.
|
|
21
|
+
*
|
|
22
|
+
* So the rule is measured instead: any column whose name says it can carry a credential must
|
|
23
|
+
* carry an encrypting transformer. New entities are covered the day they are written.
|
|
24
|
+
*
|
|
25
|
+
* ── What this does not claim ──────────────────────────────────────────────
|
|
26
|
+
* Encryption at rest does not protect a value from anyone who can run this process — that
|
|
27
|
+
* person holds the key. It protects the stored copy, which is a different set of people.
|
|
28
|
+
*/
|
|
29
|
+
import { test } from 'node:test'
|
|
30
|
+
import assert from 'node:assert/strict'
|
|
31
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
|
|
35
|
+
const SERVER = fileURLToPath(new URL('../server', import.meta.url))
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 이름이 「자격 증명이 들 수 있다」고 말하는 칸.
|
|
39
|
+
*
|
|
40
|
+
* 이름으로 판정하는 것의 한계를 적어 둔다 — `connectionConfig` 처럼 **이름이 비밀을 말하지 않는**
|
|
41
|
+
* 칸이 실제로 비밀을 담고 있었다. 그래서 그 이름을 목록에 넣는다. 새 칸이 같은 부류이면 여기 더한다.
|
|
42
|
+
*/
|
|
43
|
+
const CREDENTIAL_NAMES = /^(secret|token|password|passwd|apiKey|credential|credentials|privateKey|connectionConfig)\??$/i
|
|
44
|
+
|
|
45
|
+
/** 암호화를 붙이는 방법 — 둘 중 하나면 된다(shell 이 내놓는 것). */
|
|
46
|
+
const ENCRYPTED = /encryptedJsonColumn\(\)|encryptTransformer|encryptJsonTransformer/
|
|
47
|
+
|
|
48
|
+
function entityFiles(dir: string, out: string[] = []): string[] {
|
|
49
|
+
for (const name of readdirSync(dir)) {
|
|
50
|
+
const full = join(dir, name)
|
|
51
|
+
if (statSync(full).isDirectory()) {
|
|
52
|
+
entityFiles(full, out)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (!full.endsWith('.ts') || full.endsWith('.d.ts')) continue
|
|
56
|
+
const src = readFileSync(full, 'utf-8')
|
|
57
|
+
if (src.includes('@Entity(')) out.push(full)
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 한 파일에서 `@Column` 선언과 그것이 붙은 속성 이름의 짝. */
|
|
63
|
+
function columnsOf(src: string): { property: string; declaration: string; line: number }[] {
|
|
64
|
+
const lines = src.split('\n')
|
|
65
|
+
const out: { property: string; declaration: string; line: number }[] = []
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
if (!/^\s*@Column\(/.test(lines[i])) continue
|
|
69
|
+
|
|
70
|
+
/* 선언이 여러 줄일 수 있다 — 괄호가 닫힐 때까지 모은다. */
|
|
71
|
+
let declaration = ''
|
|
72
|
+
let depth = 0
|
|
73
|
+
let j = i
|
|
74
|
+
do {
|
|
75
|
+
declaration += lines[j]
|
|
76
|
+
for (const ch of lines[j]) {
|
|
77
|
+
if (ch === '(') depth++
|
|
78
|
+
else if (ch === ')') depth--
|
|
79
|
+
}
|
|
80
|
+
j++
|
|
81
|
+
} while (depth > 0 && j < lines.length && j - i < 12)
|
|
82
|
+
|
|
83
|
+
/* 데코레이터 다음의 첫 속성 줄 — 사이에 @Field 등이 끼어든다. */
|
|
84
|
+
for (let k = j; k < Math.min(j + 6, lines.length); k++) {
|
|
85
|
+
const m = /^\s{2}(?:readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)\??\s*[:!]/.exec(lines[k])
|
|
86
|
+
if (m) {
|
|
87
|
+
out.push({ property: m[1], declaration, line: i + 1 })
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('★ a column that can hold a credential is encrypted at rest', () => {
|
|
96
|
+
const offenders: string[] = []
|
|
97
|
+
let examined = 0
|
|
98
|
+
|
|
99
|
+
for (const file of entityFiles(SERVER)) {
|
|
100
|
+
const src = readFileSync(file, 'utf-8')
|
|
101
|
+
for (const column of columnsOf(src)) {
|
|
102
|
+
examined++
|
|
103
|
+
if (!CREDENTIAL_NAMES.test(column.property)) continue
|
|
104
|
+
if (ENCRYPTED.test(column.declaration)) continue
|
|
105
|
+
offenders.push(`${file.slice(SERVER.length + 1)}:${column.line} ${column.property}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
assert.ok(examined > 40, `본 칸이 ${examined}개다 — 찾는 방법이 깨졌다`)
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
offenders,
|
|
112
|
+
[],
|
|
113
|
+
'these columns can hold a credential and are stored in the clear:\n ' +
|
|
114
|
+
offenders.join('\n ') +
|
|
115
|
+
'\nAdd ...encryptedJsonColumn() (json) or transformer: encryptTransformer (string) from @things-factory/shell.\n' +
|
|
116
|
+
'Note the column type has to change with it — encryptedJsonColumn() carries both, which is why it exists.'
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('this guard reads real entities, so a broken scan is red and not green', () => {
|
|
121
|
+
/*
|
|
122
|
+
* 위 검사는 위반이 없으면 통과한다. **아무것도 못 읽어도** 통과하므로, 읽었다는 사실을 따로 잰다 —
|
|
123
|
+
* 오늘 이 저장소의 다른 검사가 견준 것이 0인데 초록이던 부류가 넷이었다.
|
|
124
|
+
*/
|
|
125
|
+
const files = entityFiles(SERVER)
|
|
126
|
+
assert.ok(files.length > 10, `엔티티 파일이 ${files.length}개다 — 순회가 깨졌다`)
|
|
127
|
+
|
|
128
|
+
const reference = files.find(f => f.endsWith('twin-reference.ts'))
|
|
129
|
+
assert.ok(reference, 'twin-reference.ts 를 못 찾았다 — 이 검사가 생긴 이유가 그 파일이다')
|
|
130
|
+
|
|
131
|
+
const found = columnsOf(readFileSync(reference!, 'utf-8'))
|
|
132
|
+
const config = found.find(c => c.property === 'connectionConfig')
|
|
133
|
+
assert.ok(config, 'connectionConfig 칸을 못 읽었다 — 짝짓기가 깨졌다')
|
|
134
|
+
assert.match(config!.declaration, ENCRYPTED, 'connectionConfig 가 암호화되어 있지 않다')
|
|
135
|
+
})
|