@rubytech/create-maxy-code 0.1.478 → 0.1.479
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/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +22 -0
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
- package/payload/platform/lib/graph-style/src/index.ts +20 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +13 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
- package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
- package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +122 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/server/public/activity.html +8 -6
- package/payload/server/public/assets/{AdminLoginScreens-DR5eLF-a.js → AdminLoginScreens-DkxQZnvB.js} +1 -1
- package/payload/server/public/assets/AdminShell-Cv_nIFGe.js +2 -0
- package/payload/server/public/assets/Checkbox-CsTWK8mm.js +1 -0
- package/payload/server/public/assets/activity-BbnTiVBT.js +1 -0
- package/payload/server/public/assets/{admin-D2KLZnQW.js → admin-BZY3i_Ff.js} +1 -1
- package/payload/server/public/assets/{browser-CDWqEiCD.js → browser-BbIiSFKr.js} +1 -1
- package/payload/server/public/assets/{calendar-BG7fOpbB.js → calendar-BvxeTHvQ.js} +1 -1
- package/payload/server/public/assets/chat-DUKxufRk.js +1 -0
- package/payload/server/public/assets/chevron-left-BtfJD6LL.js +1 -0
- package/payload/server/public/assets/data-hl1wa-Ax.js +1 -0
- package/payload/server/public/assets/{graph-DRloNic4.js → graph-CDSGRoeb.js} +1 -1
- package/payload/server/public/assets/graph-labels-C5qLwnWA.js +1 -0
- package/payload/server/public/assets/{maximize-2-CzDaBugs.js → maximize-2-CAzH8dED.js} +1 -1
- package/payload/server/public/assets/{operator-BZeeyait.js → operator-BK7r8DAB.js} +1 -1
- package/payload/server/public/assets/{page-ef-6rRPL.js → page-B5YngOrK.js} +1 -1
- package/payload/server/public/assets/{page-B0obcg3b.js → page-BsCS0Dmc.js} +1 -1
- package/payload/server/public/assets/{public-9D3MMOkC.js → public-C1Pz-IBs.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-DXEKUhXP.js → rotate-ccw-UDln4Tou.js} +1 -1
- package/payload/server/public/assets/{tasks-BB0L7e5a.js → tasks-B-cLsn3p.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-CsYAjplS.js → time-entry-format-DPOBIE18.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-0lSBIUg-.js → triangle-alert-Dz-CZKo5.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-jTBQ2h0V.js → useCopyFeedback-wtUHGohk.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-BooArySZ.js → useSelectionMode-sph7AQT5.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-BRIluIKf.css +1 -0
- package/payload/server/public/assets/{useVoiceRecorder-PUT8q_sm.js → useVoiceRecorder-DN4jYQ0A.js} +1 -1
- package/payload/server/public/brand-defaults.css +1 -0
- package/payload/server/public/browser.html +5 -5
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +12 -12
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +11 -11
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/privacy.html +1 -0
- package/payload/server/public/public.html +12 -12
- package/payload/server/public/tasks.html +6 -5
- package/payload/server/public/vnc-popout.html +1 -0
- package/payload/server/server.js +741 -366
- package/payload/server/public/assets/AdminShell-D9zaUxPd.js +0 -2
- package/payload/server/public/assets/Checkbox-DcbbAIe5.js +0 -1
- package/payload/server/public/assets/activity-C9P5NjEZ.js +0 -1
- package/payload/server/public/assets/chat-RyH-WYQC.js +0 -1
- package/payload/server/public/assets/chevron-left-DmXCr6nB.js +0 -1
- package/payload/server/public/assets/data-BaY_LkLR.js +0 -1
- package/payload/server/public/assets/graph-labels-DD-GqS4v.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +0 -1
- /package/payload/server/public/assets/{useSubAccountSwitcher-BZnzc5C3.js → useSubAccountSwitcher-v8LyBzZN.js} +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { processFiles, entriesUnder } from '../../skills/data-portal/template/functions/api/files'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
const DIRECTORY = [
|
|
6
|
+
{ accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z' },
|
|
7
|
+
{ accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
|
|
8
|
+
{ accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z' },
|
|
9
|
+
{ accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
function makeEnv(sessions: Record<string, { ownerId: string; accountId: string }>) {
|
|
13
|
+
return {
|
|
14
|
+
DB: {
|
|
15
|
+
prepare(query: string) {
|
|
16
|
+
let bound: unknown[] = []
|
|
17
|
+
const stmt = {
|
|
18
|
+
bind(...v: unknown[]) {
|
|
19
|
+
bound = v
|
|
20
|
+
return stmt
|
|
21
|
+
},
|
|
22
|
+
async run() {
|
|
23
|
+
return { meta: { changes: 0 } }
|
|
24
|
+
},
|
|
25
|
+
async all<T>() {
|
|
26
|
+
if (/FROM directory/i.test(query)) {
|
|
27
|
+
return { results: DIRECTORY.filter((d) => d.accountId === bound[0]) as T[] }
|
|
28
|
+
}
|
|
29
|
+
return { results: [] as T[] }
|
|
30
|
+
},
|
|
31
|
+
async first<T>() {
|
|
32
|
+
if (/FROM sessions/i.test(query)) return (sessions[bound[0] as string] ?? null) as T | null
|
|
33
|
+
return null as T | null
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
return stmt
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
} as unknown as PortalEnv
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a' } }
|
|
43
|
+
type Entry = { name: string; kind: string; relPath: string; sizeBytes: number | null; modifiedAt: string | null }
|
|
44
|
+
|
|
45
|
+
describe('entriesUnder', () => {
|
|
46
|
+
const rows = DIRECTORY.filter((d) => d.accountId === 'acc-a')
|
|
47
|
+
|
|
48
|
+
it('synthesises top-level folders from row paths', () => {
|
|
49
|
+
const e = entriesUnder(rows, '')
|
|
50
|
+
expect(e.map((x) => x.name)).toEqual(['output', 'quotes'])
|
|
51
|
+
expect(e.every((x) => x.kind === 'directory')).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('lists folders inside a prefix', () => {
|
|
55
|
+
expect(entriesUnder(rows, 'quotes').map((x) => x.name)).toEqual(['CR2969', 'CR2974'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('lists files inside a leaf folder with size and time', () => {
|
|
59
|
+
expect(entriesUnder(rows, 'quotes/CR2969')).toEqual([
|
|
60
|
+
{
|
|
61
|
+
name: 'quote.pdf',
|
|
62
|
+
kind: 'file',
|
|
63
|
+
relPath: 'quotes/CR2969/quote.pdf',
|
|
64
|
+
sizeBytes: 20,
|
|
65
|
+
modifiedAt: '2026-07-20T11:00:00.000Z',
|
|
66
|
+
},
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('puts folders before files and files newest first', () => {
|
|
71
|
+
const mixed = [
|
|
72
|
+
{ relPath: 'output/old.pdf', sizeBytes: 1, modifiedAt: '2026-01-01T00:00:00.000Z' },
|
|
73
|
+
{ relPath: 'output/new.pdf', sizeBytes: 1, modifiedAt: '2026-09-01T00:00:00.000Z' },
|
|
74
|
+
{ relPath: 'output/sub/x.pdf', sizeBytes: 1, modifiedAt: '2026-05-01T00:00:00.000Z' },
|
|
75
|
+
]
|
|
76
|
+
expect(entriesUnder(mixed, 'output').map((x) => x.name)).toEqual(['sub', 'new.pdf', 'old.pdf'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// A prefix must match a whole segment: `quote` must not reach `quotes/`.
|
|
80
|
+
it('does not match a partial segment as a prefix', () => {
|
|
81
|
+
expect(entriesUnder(rows, 'quote')).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('processFiles directory listing', () => {
|
|
86
|
+
it('returns synthesised folders for the session account', async () => {
|
|
87
|
+
const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
|
|
88
|
+
expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('never leaks another account rows', async () => {
|
|
92
|
+
const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
|
|
93
|
+
expect(JSON.stringify(r.payload)).not.toContain('secret.pdf')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('returns an empty entries list when the person has no account bound', async () => {
|
|
97
|
+
const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: '' } })
|
|
98
|
+
const r = await processFiles('sess-a', env, () => {}, 0, '')
|
|
99
|
+
expect(r.status).toBe(200)
|
|
100
|
+
expect(r.payload.entries).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('still denies without a session', async () => {
|
|
104
|
+
const r = await processFiles('nope', makeEnv(SESSIONS), () => {}, 0, '')
|
|
105
|
+
expect(r.status).toBe(401)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('logs the account and the prefix', async () => {
|
|
109
|
+
const lines: string[] = []
|
|
110
|
+
await processFiles('sess-a', makeEnv(SESSIONS), (l) => lines.push(l), 0, 'quotes')
|
|
111
|
+
// Free-text log fields are quoted by `q()` — these values come off the wire.
|
|
112
|
+
expect(lines.some((l) => l.includes('account="acc-a"') && l.includes('prefix="quotes"'))).toBe(true)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('keeps the uploads list alongside the folder entries', async () => {
|
|
116
|
+
const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
|
|
117
|
+
expect(r.payload).toHaveProperty('files')
|
|
118
|
+
expect(r.payload).toHaveProperty('entries')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
const run = promisify(execFile)
|
|
13
13
|
const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '../../bin/portal-enrol.mjs')
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** `--account` is required (it binds the person to the sub-account whose folders
|
|
16
|
+
* they may read), but it is irrelevant to the passcode behaviour most of these
|
|
17
|
+
* tests cover, so the helper supplies one unless the caller names its own. Tests
|
|
18
|
+
* that assert the requirement itself call `run` directly. */
|
|
19
|
+
const enrol = (args: string[]) =>
|
|
20
|
+
run('node', [SCRIPT, ...(args.includes('--account') ? args : [...args, '--account', 'acc-1'])])
|
|
16
21
|
|
|
17
22
|
/** The script's contract: stdout is SQL and nothing else, so it pipes straight
|
|
18
23
|
* into `wrangler d1 execute --file=-`; the passcode goes to stderr, where the
|
|
@@ -26,7 +31,9 @@ function parse(r: { stdout: string; stderr: string }) {
|
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const valuesOf = (sql: string) =>
|
|
29
|
-
/VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)'\)/.exec(
|
|
34
|
+
/VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)'\)/.exec(
|
|
35
|
+
sql,
|
|
36
|
+
)!
|
|
30
37
|
|
|
31
38
|
/** Apply the emitted SQL to a real SQLite database built from the template's own
|
|
32
39
|
* schema.sql, the way `wrangler d1 execute` applies it to D1. A fake would model
|
|
@@ -120,13 +127,26 @@ describe('portal-enrol', () => {
|
|
|
120
127
|
it('writes the row the schema declares, in order', async () => {
|
|
121
128
|
const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
|
|
122
129
|
const { sql } = parse(r)
|
|
123
|
-
expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt)')
|
|
124
|
-
const [, owner, name, salt, hash, createdAt] = valuesOf(sql!)
|
|
130
|
+
expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId)')
|
|
131
|
+
const [, owner, name, salt, hash, createdAt, accountId] = valuesOf(sql!)
|
|
125
132
|
expect(owner).toBe('alice')
|
|
126
133
|
expect(name).toBe('Alice')
|
|
127
134
|
expect(salt).toMatch(/^[0-9a-f]{32}$/)
|
|
128
135
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
129
136
|
expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
|
|
137
|
+
expect(accountId).toBe('acc-1')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('refuses enrolment with no --account', async () => {
|
|
141
|
+
// A person with no account bound resolves to an empty folder list, which
|
|
142
|
+
// looks exactly like a sub-account that has no deliverables. Refuse here
|
|
143
|
+
// rather than mint a row that fails silently at read time.
|
|
144
|
+
await expect(run('node', [SCRIPT, '--owner', 'alice', '--name', 'Alice'])).rejects.toThrow()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('refuses an accountId that is not an account id', async () => {
|
|
148
|
+
await expect(enrol(['--owner', 'alice', '--name', 'Alice', '--account', '../etc'])).rejects.toThrow()
|
|
149
|
+
await expect(enrol(['--owner', 'alice', '--name', 'Alice', '--account', "a'b"])).rejects.toThrow()
|
|
130
150
|
})
|
|
131
151
|
|
|
132
152
|
it('puts SQL and only SQL on stdout, so the documented pipe works', async () => {
|
|
@@ -195,6 +215,41 @@ describe('portal-enrol', () => {
|
|
|
195
215
|
expect(await db.query("SELECT createdAt FROM people WHERE ownerId='alice';")).toBe(createdAt)
|
|
196
216
|
})
|
|
197
217
|
|
|
218
|
+
it('binds the person to the account, and rotation keeps the binding current', async () => {
|
|
219
|
+
const db = await d1('enrol-account-')
|
|
220
|
+
await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])).sql!)
|
|
221
|
+
expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-a')
|
|
222
|
+
|
|
223
|
+
// Re-enrolling to a different account moves the binding rather than
|
|
224
|
+
// leaving the person pointed at folders they no longer belong to.
|
|
225
|
+
await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-b'])).sql!)
|
|
226
|
+
expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-b')
|
|
227
|
+
expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('declares a directory table keyed uniquely on account and path', async () => {
|
|
231
|
+
const db = await d1('enrol-directory-')
|
|
232
|
+
await db.exec(
|
|
233
|
+
"INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES ('a','output/x.pdf',1,'t','t');",
|
|
234
|
+
)
|
|
235
|
+
// The full-replace push relies on this: a second insert of the same path
|
|
236
|
+
// must not silently double the row.
|
|
237
|
+
await expect(
|
|
238
|
+
db.exec(
|
|
239
|
+
"INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES ('a','output/x.pdf',1,'t','t');",
|
|
240
|
+
),
|
|
241
|
+
).rejects.toThrow()
|
|
242
|
+
expect(await db.query('SELECT COUNT(*) FROM directory;')).toBe('1')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('scopes directory rows by account', async () => {
|
|
246
|
+
const db = await d1('enrol-directory-scope-')
|
|
247
|
+
await db.exec(
|
|
248
|
+
"INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES ('a','output/x.pdf',1,'t','t'),('b','output/y.pdf',1,'t','t');",
|
|
249
|
+
)
|
|
250
|
+
expect(await db.query("SELECT relPath FROM directory WHERE accountId='a';")).toBe('output/x.pdf')
|
|
251
|
+
})
|
|
252
|
+
|
|
198
253
|
it('stores a crafted name as data rather than executing it', async () => {
|
|
199
254
|
const db = await d1('enrol-inject-')
|
|
200
255
|
const r = await enrol(['--owner', 'alice', '--name', "x'); DROP TABLE people; --"])
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { walkExposed, pushAccount } from '../../bin/portal-index-push.mjs'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
|
|
9
|
+
function file(rel: string, body = 'x') {
|
|
10
|
+
const abs = join(dir, rel)
|
|
11
|
+
mkdirSync(join(abs, '..'), { recursive: true })
|
|
12
|
+
writeFileSync(abs, body)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SCHEMA = [
|
|
16
|
+
'```allowed-top-level',
|
|
17
|
+
'output',
|
|
18
|
+
'quotes',
|
|
19
|
+
'documents',
|
|
20
|
+
'uploads',
|
|
21
|
+
'```',
|
|
22
|
+
'',
|
|
23
|
+
'<!-- ontology-buckets:start -->',
|
|
24
|
+
'- `quotes/` - one folder per Quotation record.',
|
|
25
|
+
'<!-- ontology-buckets:end -->',
|
|
26
|
+
].join('\n')
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), 'portal-push-'))
|
|
30
|
+
writeFileSync(join(dir, 'SCHEMA.md'), SCHEMA)
|
|
31
|
+
file('output/report.pdf')
|
|
32
|
+
file('quotes/CR2969/quote.pdf')
|
|
33
|
+
file('quotes/CR2974/quote.pdf')
|
|
34
|
+
file('documents/private.pdf')
|
|
35
|
+
file('uploads/client-sent.pdf')
|
|
36
|
+
file('.claude/settings.json')
|
|
37
|
+
file('output/.uploads-tmp/partial.bin')
|
|
38
|
+
})
|
|
39
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
40
|
+
|
|
41
|
+
function fakeClient() {
|
|
42
|
+
const calls: { sql: string; params: unknown[] }[] = []
|
|
43
|
+
const rows = new Set<string>()
|
|
44
|
+
return {
|
|
45
|
+
calls,
|
|
46
|
+
async query(sql: string, params: unknown[] = []) {
|
|
47
|
+
calls.push({ sql, params })
|
|
48
|
+
if (/DELETE FROM directory/i.test(sql)) {
|
|
49
|
+
rows.clear()
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
if (/INSERT INTO directory/i.test(sql)) {
|
|
53
|
+
rows.add(String(params[1]))
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
if (/SELECT COUNT/i.test(sql)) return [{ n: rows.size }]
|
|
57
|
+
return []
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const push = (client: unknown, overrides: Record<string, unknown> = {}) =>
|
|
63
|
+
pushAccount({
|
|
64
|
+
accountDir: dir,
|
|
65
|
+
accountId: 'acc-1',
|
|
66
|
+
dbName: 'db',
|
|
67
|
+
client: client as never,
|
|
68
|
+
log: () => {},
|
|
69
|
+
nowIso: '2026-07-20T00:00:00.000Z',
|
|
70
|
+
...overrides,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('walkExposed', () => {
|
|
74
|
+
it('returns files only from exposed dirs, at any depth', async () => {
|
|
75
|
+
const paths = (await walkExposed(dir, ['output', 'quotes'])).map((r) => r.relPath).sort()
|
|
76
|
+
expect(paths).toEqual(['output/report.pdf', 'quotes/CR2969/quote.pdf', 'quotes/CR2974/quote.pdf'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('never returns operator data, uploads, dot dirs or upload temps', async () => {
|
|
80
|
+
const paths = (await walkExposed(dir, ['output', 'quotes'])).map((r) => r.relPath)
|
|
81
|
+
expect(paths.some((p) => p.startsWith('documents/'))).toBe(false)
|
|
82
|
+
expect(paths.some((p) => p.startsWith('uploads/'))).toBe(false)
|
|
83
|
+
expect(paths.some((p) => p.includes('.uploads-tmp'))).toBe(false)
|
|
84
|
+
expect(paths.some((p) => p.includes('.claude'))).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns nothing for a dir that does not exist', async () => {
|
|
88
|
+
expect(await walkExposed(dir, ['nope'])).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('carries size and an iso modified time', async () => {
|
|
92
|
+
const row = (await walkExposed(dir, ['output']))[0]
|
|
93
|
+
expect(row.sizeBytes).toBe(1)
|
|
94
|
+
expect(row.modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('pushAccount', () => {
|
|
99
|
+
it('deletes the account rows then inserts the current walk', async () => {
|
|
100
|
+
const c = fakeClient()
|
|
101
|
+
const r = await push(c)
|
|
102
|
+
expect(r.exposed).toEqual(['output', 'quotes'])
|
|
103
|
+
expect(r.rows).toBe(3)
|
|
104
|
+
expect(/DELETE FROM directory/i.test(c.calls[0].sql)).toBe(true)
|
|
105
|
+
expect(c.calls[0].params).toEqual(['acc-1'])
|
|
106
|
+
expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(3)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('converges on a delete: a removed file leaves no stale row', async () => {
|
|
110
|
+
const c = fakeClient()
|
|
111
|
+
await push(c)
|
|
112
|
+
unlinkSync(join(dir, 'quotes/CR2974/quote.pdf'))
|
|
113
|
+
const second = await push(c)
|
|
114
|
+
expect(second.rows).toBe(2)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('binds every value as a param rather than interpolating it', async () => {
|
|
118
|
+
const c = fakeClient()
|
|
119
|
+
await push(c)
|
|
120
|
+
const insert = c.calls.find((x) => /INSERT INTO directory/i.test(x.sql))!
|
|
121
|
+
expect(insert.sql).not.toContain('output/')
|
|
122
|
+
expect(insert.params[0]).toBe('acc-1')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Fail closed, but do NOT delete: an empty publish and a missing schema must
|
|
126
|
+
// not look the same in the table.
|
|
127
|
+
it('exposes nothing and writes nothing when the schema is missing', async () => {
|
|
128
|
+
rmSync(join(dir, 'SCHEMA.md'))
|
|
129
|
+
const c = fakeClient()
|
|
130
|
+
const r = await push(c)
|
|
131
|
+
expect(r.schemaPresent).toBe(false)
|
|
132
|
+
expect(r.exposed).toEqual([])
|
|
133
|
+
expect(c.calls.length).toBe(0)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('logs resolve, replace and verify lines', async () => {
|
|
137
|
+
const lines: string[] = []
|
|
138
|
+
const c = fakeClient()
|
|
139
|
+
await push(c, { log: (l: string) => lines.push(l) })
|
|
140
|
+
expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
|
|
141
|
+
expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=3'))).toBe(true)
|
|
142
|
+
expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=3') && l.includes('expected=3'))).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('reports an exposed-set change only when it actually changes', async () => {
|
|
146
|
+
const lines: string[] = []
|
|
147
|
+
// Same set as the fixture resolves to: silent.
|
|
148
|
+
await push(fakeClient(), { log: (l: string) => lines.push(l), previousExposed: ['output', 'quotes'] })
|
|
149
|
+
expect(lines.some((l) => l.includes('op=exposed-changed'))).toBe(false)
|
|
150
|
+
|
|
151
|
+
// A bucket the client used to see is gone — the client-visible regression.
|
|
152
|
+
lines.length = 0
|
|
153
|
+
await push(fakeClient(), { log: (l: string) => lines.push(l), previousExposed: ['output', 'quotes', 'invoices'] })
|
|
154
|
+
const line = lines.find((l) => l.includes('op=exposed-changed'))!
|
|
155
|
+
expect(line).toContain('removed=invoices')
|
|
156
|
+
expect(line).toContain('added=none')
|
|
157
|
+
|
|
158
|
+
// A newly projected bucket.
|
|
159
|
+
lines.length = 0
|
|
160
|
+
await push(fakeClient(), { log: (l: string) => lines.push(l), previousExposed: ['output'] })
|
|
161
|
+
expect(lines.find((l) => l.includes('op=exposed-changed'))).toContain('added=quotes')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('stays silent about changes on a first run, when there is no previous set', async () => {
|
|
165
|
+
const lines: string[] = []
|
|
166
|
+
await push(fakeClient(), { log: (l: string) => lines.push(l) })
|
|
167
|
+
expect(lines.some((l) => l.includes('op=exposed-changed'))).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('logs schemaPresent=false so a fail-closed account is distinguishable', async () => {
|
|
171
|
+
rmSync(join(dir, 'SCHEMA.md'))
|
|
172
|
+
const lines: string[] = []
|
|
173
|
+
await push(fakeClient(), { log: (l: string) => lines.push(l) })
|
|
174
|
+
expect(lines.some((l) => l.includes('schemaPresent=false') && l.includes('exposed=none'))).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { stateFilePath, readState, recordSuccess } from '../../bin/portal-index-push.mjs'
|
|
6
|
+
// Test-only cross-tree import. The two modules are in different plugins and
|
|
7
|
+
// never import each other in production; this file is the contract between
|
|
8
|
+
// them, and it exists because the last thing that went wrong on this feature
|
|
9
|
+
// was two halves that each passed their own tests and did not connect.
|
|
10
|
+
import {
|
|
11
|
+
auditPortalIndex,
|
|
12
|
+
formatPortalIndexAudit,
|
|
13
|
+
} from '../../../scheduling/mcp/src/lib/portal-index-audit.js'
|
|
14
|
+
|
|
15
|
+
let root: string
|
|
16
|
+
let platformRoot: string
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
root = mkdtempSync(join(tmpdir(), 'portal-state-'))
|
|
20
|
+
platformRoot = join(root, 'platform')
|
|
21
|
+
mkdirSync(platformRoot, { recursive: true })
|
|
22
|
+
mkdirSync(join(root, 'data'), { recursive: true })
|
|
23
|
+
})
|
|
24
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
|
25
|
+
|
|
26
|
+
describe('the push writes exactly what the audit reads', () => {
|
|
27
|
+
it('puts the state where the heartbeat looks for it', () => {
|
|
28
|
+
// The audit resolves <platformRoot>/../data/portal-index-state.json
|
|
29
|
+
// independently. If these two ever disagree the audit reports every healthy
|
|
30
|
+
// account as never-ran, which is a false alarm pointing at the wrong thing.
|
|
31
|
+
expect(stateFilePath(platformRoot)).toBe(join(root, 'data', 'portal-index-state.json'))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('round-trips an entry the audit reads as healthy', () => {
|
|
35
|
+
recordSuccess(platformRoot, 'acc-a', {
|
|
36
|
+
lastPushMs: Date.now(),
|
|
37
|
+
rows: 41,
|
|
38
|
+
exposed: ['output', 'quotes'],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const onDisk = JSON.parse(readFileSync(stateFilePath(platformRoot), 'utf8'))
|
|
42
|
+
const result = auditPortalIndex(['acc-a'], onDisk, Date.now(), 3_600_000)
|
|
43
|
+
|
|
44
|
+
expect(result.neverRan).toBe(0)
|
|
45
|
+
expect(result.stale).toBe(0)
|
|
46
|
+
expect(result.accounts[0].rows).toBe(41)
|
|
47
|
+
expect(result.accounts[0].exposed).toEqual(['output', 'quotes'])
|
|
48
|
+
expect(formatPortalIndexAudit(result)).toEqual([
|
|
49
|
+
'[portal-index] op=audit accounts=1 stale=0 never-ran=0',
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('an account the push never recorded reads as never-ran', () => {
|
|
54
|
+
recordSuccess(platformRoot, 'acc-a', { lastPushMs: Date.now(), rows: 1, exposed: ['output'] })
|
|
55
|
+
const onDisk = JSON.parse(readFileSync(stateFilePath(platformRoot), 'utf8'))
|
|
56
|
+
const result = auditPortalIndex(['acc-a', 'acc-b'], onDisk, Date.now(), 3_600_000)
|
|
57
|
+
expect(result.neverRan).toBe(1)
|
|
58
|
+
expect(formatPortalIndexAudit(result)[0]).toContain('account=acc-b')
|
|
59
|
+
expect(formatPortalIndexAudit(result)[0]).toContain('result=never-ran')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('recording one account does not erase another', () => {
|
|
63
|
+
recordSuccess(platformRoot, 'acc-a', { lastPushMs: 1, rows: 1, exposed: ['output'] })
|
|
64
|
+
recordSuccess(platformRoot, 'acc-b', { lastPushMs: 2, rows: 2, exposed: ['quotes'] })
|
|
65
|
+
const state = readState(platformRoot)
|
|
66
|
+
expect(Object.keys(state).sort()).toEqual(['acc-a', 'acc-b'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('an absent state file reads as an empty object, not a throw', () => {
|
|
70
|
+
expect(readState(platformRoot)).toEqual({})
|
|
71
|
+
// …and an absent file with portals enrolled is the never-ran signal, which
|
|
72
|
+
// is the state a device sits in before the very first push.
|
|
73
|
+
expect(auditPortalIndex(['acc-a'], readState(platformRoot), Date.now(), 3_600_000).neverRan).toBe(1)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { processIndexedDownload } from '../../skills/data-portal/template/functions/api/download'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
const PATH = 'quotes/CR2969/quote.pdf'
|
|
6
|
+
|
|
7
|
+
function makeEnv(
|
|
8
|
+
opts: { accountId?: string; secret?: string; origin?: string; paths?: string[] } = {},
|
|
9
|
+
) {
|
|
10
|
+
const { accountId = 'acc-a', paths = [PATH] } = opts
|
|
11
|
+
return {
|
|
12
|
+
DB: {
|
|
13
|
+
prepare(query: string) {
|
|
14
|
+
let bound: unknown[] = []
|
|
15
|
+
const stmt = {
|
|
16
|
+
bind(...v: unknown[]) {
|
|
17
|
+
bound = v
|
|
18
|
+
return stmt
|
|
19
|
+
},
|
|
20
|
+
async run() {
|
|
21
|
+
return { meta: { changes: 0 } }
|
|
22
|
+
},
|
|
23
|
+
async all<T>() {
|
|
24
|
+
return { results: [] as T[] }
|
|
25
|
+
},
|
|
26
|
+
async first<T>() {
|
|
27
|
+
if (/FROM sessions/i.test(query)) {
|
|
28
|
+
return (bound[0] === 'sess-a' ? { ownerId: 'alice', accountId } : null) as T | null
|
|
29
|
+
}
|
|
30
|
+
if (/FROM directory/i.test(query)) {
|
|
31
|
+
return (paths.includes(bound[1] as string) ? { relPath: bound[1] } : null) as T | null
|
|
32
|
+
}
|
|
33
|
+
return null as T | null
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
return stmt
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
PORTAL_FETCH_SECRET: 'secret' in opts ? opts.secret : 'a-secret',
|
|
40
|
+
PORTAL_INSTALL_ORIGIN: 'origin' in opts ? opts.origin : 'https://install.example',
|
|
41
|
+
} as unknown as PortalEnv
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const okHead = async () => ({ ok: true, status: 200 })
|
|
45
|
+
const deadHead = async () => {
|
|
46
|
+
throw new Error('ECONNREFUSED')
|
|
47
|
+
}
|
|
48
|
+
const errHead = async () => ({ ok: false, status: 502 })
|
|
49
|
+
|
|
50
|
+
describe('processIndexedDownload', () => {
|
|
51
|
+
it('returns a signed url for an indexed file when the device answers', async () => {
|
|
52
|
+
const r = await processIndexedDownload('sess-a', PATH, makeEnv(), () => {}, 1000, okHead as never)
|
|
53
|
+
expect(r.status).toBe(200)
|
|
54
|
+
const url = new URL(r.url as string)
|
|
55
|
+
expect(url.origin).toBe('https://install.example')
|
|
56
|
+
expect(url.pathname).toBe('/api/portal/fetch')
|
|
57
|
+
expect(url.searchParams.get('path')).toBe(PATH)
|
|
58
|
+
expect(url.searchParams.get('accountId')).toBe('acc-a')
|
|
59
|
+
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
60
|
+
expect(Number(url.searchParams.get('exp'))).toBeGreaterThan(1000)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reports device-offline distinguishably when the probe throws', async () => {
|
|
64
|
+
const r = await processIndexedDownload('sess-a', PATH, makeEnv(), () => {}, 1000, deadHead as never)
|
|
65
|
+
expect(r.status).toBe(503)
|
|
66
|
+
expect(r.payload.error).toBe('device-offline')
|
|
67
|
+
expect(r.url).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('reports device-offline when the probe answers non-ok', async () => {
|
|
71
|
+
const r = await processIndexedDownload('sess-a', PATH, makeEnv(), () => {}, 1000, errHead as never)
|
|
72
|
+
expect(r.status).toBe(503)
|
|
73
|
+
expect(r.payload.error).toBe('device-offline')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('404s a path that is not in the index, without contacting the device', async () => {
|
|
77
|
+
let touched = false
|
|
78
|
+
const spy = async () => {
|
|
79
|
+
touched = true
|
|
80
|
+
return { ok: true, status: 200 }
|
|
81
|
+
}
|
|
82
|
+
const r = await processIndexedDownload(
|
|
83
|
+
'sess-a',
|
|
84
|
+
'documents/private.pdf',
|
|
85
|
+
makeEnv(),
|
|
86
|
+
() => {},
|
|
87
|
+
1000,
|
|
88
|
+
spy as never,
|
|
89
|
+
)
|
|
90
|
+
expect(r.status).toBe(404)
|
|
91
|
+
expect(touched).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('401s without a session', async () => {
|
|
95
|
+
const r = await processIndexedDownload('nope', PATH, makeEnv(), () => {}, 1000, okHead as never)
|
|
96
|
+
expect(r.status).toBe(401)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('404s when the person has no account bound', async () => {
|
|
100
|
+
const r = await processIndexedDownload(
|
|
101
|
+
'sess-a',
|
|
102
|
+
PATH,
|
|
103
|
+
makeEnv({ accountId: '' }),
|
|
104
|
+
() => {},
|
|
105
|
+
1000,
|
|
106
|
+
okHead as never,
|
|
107
|
+
)
|
|
108
|
+
expect(r.status).toBe(404)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('never mints a link when the secret or origin is unconfigured', async () => {
|
|
112
|
+
for (const env of [makeEnv({ secret: undefined }), makeEnv({ origin: undefined })]) {
|
|
113
|
+
const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
|
|
114
|
+
expect(r.status).toBe(503)
|
|
115
|
+
expect(r.url).toBeUndefined()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('never puts the secret in the url or the log', async () => {
|
|
120
|
+
const lines: string[] = []
|
|
121
|
+
const r = await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, okHead as never)
|
|
122
|
+
expect(r.url).not.toContain('a-secret')
|
|
123
|
+
expect(lines.join('\n')).not.toContain('a-secret')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('logs the outcome with the path', async () => {
|
|
127
|
+
const lines: string[] = []
|
|
128
|
+
await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, okHead as never)
|
|
129
|
+
expect(lines.some((l) => l.includes('op=fetch-through') && l.includes('result=ok'))).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
SIGNED_TTL_MS,
|
|
4
|
+
signPath,
|
|
5
|
+
verifyPath,
|
|
6
|
+
buildSignedUrl,
|
|
7
|
+
} from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
|
|
8
|
+
|
|
9
|
+
const SECRET = 'test-secret-value'
|
|
10
|
+
const ACC = '098a18a3-1741-447f-9778-65470645d57d'
|
|
11
|
+
const NOW = 1_800_000_000_000
|
|
12
|
+
|
|
13
|
+
describe('portal-sign', () => {
|
|
14
|
+
it('round-trips a signature', async () => {
|
|
15
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
16
|
+
const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp)
|
|
17
|
+
expect(await verifyPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp, sig, NOW)).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('refuses an expired signature', async () => {
|
|
21
|
+
const exp = NOW - 1
|
|
22
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
23
|
+
expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('refuses a tampered path', async () => {
|
|
27
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
28
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
29
|
+
expect(await verifyPath(SECRET, ACC, 'documents/private.pdf', exp, sig, NOW)).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('refuses a tampered account', async () => {
|
|
33
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
34
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
35
|
+
expect(await verifyPath(SECRET, 'other-account', 'output/a.pdf', exp, sig, NOW)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('refuses a tampered expiry', async () => {
|
|
39
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
40
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
41
|
+
expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp + 60_000, sig, NOW)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('refuses a signature made with a different secret', async () => {
|
|
45
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
46
|
+
const sig = await signPath('other-secret', ACC, 'output/a.pdf', exp)
|
|
47
|
+
expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// The field separator must be load-bearing: without it, an accountId that
|
|
51
|
+
// absorbs the start of a path could forge a different (account, path) pair.
|
|
52
|
+
it('does not confuse account and path across the separator', async () => {
|
|
53
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
54
|
+
const sig = await signPath(SECRET, 'acc', 'a/b.pdf', exp)
|
|
55
|
+
expect(await verifyPath(SECRET, 'acc\na', '/b.pdf', exp, sig, NOW)).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('builds a url carrying account, path, expiry and signature', async () => {
|
|
59
|
+
const url = new URL(
|
|
60
|
+
await buildSignedUrl('https://install.example', SECRET, ACC, 'output/a b.pdf', NOW + 1000),
|
|
61
|
+
)
|
|
62
|
+
expect(url.pathname).toBe('/api/portal/fetch')
|
|
63
|
+
expect(url.searchParams.get('accountId')).toBe(ACC)
|
|
64
|
+
expect(url.searchParams.get('path')).toBe('output/a b.pdf')
|
|
65
|
+
expect(url.searchParams.get('exp')).toBe(String(NOW + 1000))
|
|
66
|
+
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
67
|
+
})
|
|
68
|
+
})
|