@fabrika/app 0.8.2 → 0.9.0-cloudflare.2
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 +40 -24
- package/skill/SKILL.md +29 -3
- package/skill/references/app-setup.md +29 -11
- package/skill/references/dev.md +2 -1
- package/skill/references/endpoint.md +12 -3
- package/skill/references/errors.md +39 -35
- package/skill/references/files.md +4 -2
- package/skill/references/jobs.md +9 -0
- package/skill/references/table-and-migration.md +19 -2
- package/skill/references/tests.md +33 -1
- package/skill/references/webhooks-links-connectors.md +1 -1
- package/src/build-recipe.ts +198 -0
- package/src/build.ts +60 -4
- package/src/bun.ts +33 -8
- package/src/cloudflare/application.ts +352 -0
- package/src/cloudflare/bindings.ts +159 -0
- package/src/cloudflare/names.ts +16 -0
- package/src/cloudflare/ops.ts +246 -0
- package/src/cloudflare/r2-files.ts +172 -0
- package/src/cloudflare.ts +99 -146
- package/src/contract/app-id.ts +5 -0
- package/src/contract/artifact-schema.ts +8 -1
- package/src/contract/build.ts +68 -11
- package/src/contract/compile.ts +1 -0
- package/src/contract/context.ts +9 -0
- package/src/contract/declaration.ts +2 -0
- package/src/contract/define.ts +28 -2
- package/src/contract/group.ts +10 -1
- package/src/contract/kit.ts +6 -2
- package/src/contract/platform.ts +3 -2
- package/src/contract/vocabulary.ts +26 -8
- package/src/dev/changes.ts +35 -0
- package/src/dev/contract-digest.ts +17 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +713 -0
- package/src/dev/output.ts +72 -0
- package/src/dev/wire.ts +136 -0
- package/src/disk-assets.ts +8 -3
- package/src/files/archive.ts +2 -2
- package/src/files/job-names.ts +12 -0
- package/src/files/kinds.ts +19 -7
- package/src/files/scan.ts +3 -4
- package/src/files/streams.ts +3 -1
- package/src/implement/anonymous.ts +11 -1
- package/src/implement/boot.ts +70 -7
- package/src/implement/bounded.ts +21 -2
- package/src/implement/capability.ts +18 -3
- package/src/implement/dispatch.ts +251 -147
- package/src/implement/egress.ts +341 -8
- package/src/implement/failures.ts +17 -1
- package/src/implement/faults.ts +1 -0
- package/src/implement/index.ts +1 -0
- package/src/implement/invocation.ts +18 -9
- package/src/implement/principal.ts +25 -2
- package/src/implement/terminals.ts +93 -0
- package/src/implement/workload.ts +24 -13
- package/src/jobs/report.ts +5 -1
- package/src/jobs/runner.ts +40 -19
- package/src/jobs/schedule.ts +7 -1
- package/src/jobs/scrub.ts +14 -1
- package/src/spa-fallback.ts +12 -0
- package/src/testing/endpoint-access.ts +307 -152
- package/src/testing/isolation.ts +8 -1
- package/src/testing/kit.ts +20 -2
- package/src/testing/proxy.ts +5 -5
- package/types/src/agent-skill.d.ts +29 -0
- package/types/src/build-recipe.d.ts +70 -0
- package/types/src/build.d.ts +29 -0
- package/types/src/bun.d.ts +66 -0
- package/types/src/client/caller.d.ts +19 -0
- package/types/src/client/contract.d.ts +131 -0
- package/types/src/client/error-reporting.d.ts +66 -0
- package/types/src/client/index.d.ts +4 -0
- package/types/src/client/rpc.d.ts +14 -0
- package/types/src/client/share.d.ts +2 -0
- package/types/src/client/transport.d.ts +42 -0
- package/types/src/cloudflare/application.d.ts +42 -0
- package/types/src/cloudflare/bindings.d.ts +33 -0
- package/types/src/cloudflare/names.d.ts +11 -0
- package/types/src/cloudflare/ops.d.ts +46 -0
- package/types/src/cloudflare/r2-files.d.ts +9 -0
- package/types/src/cloudflare.d.ts +42 -0
- package/types/src/contract/app-id.d.ts +2 -0
- package/types/src/contract/artifact-schema.d.ts +8 -0
- package/types/src/contract/attachment.d.ts +41 -0
- package/types/src/contract/build.d.ts +12 -0
- package/types/src/contract/compile.d.ts +11 -0
- package/types/src/contract/connect.d.ts +51 -0
- package/types/src/contract/context.d.ts +510 -0
- package/types/src/contract/declaration.d.ts +307 -0
- package/types/src/contract/defaults.d.ts +208 -0
- package/types/src/contract/define.d.ts +376 -0
- package/types/src/contract/file.d.ts +44 -0
- package/types/src/contract/group.d.ts +282 -0
- package/types/src/contract/handled.d.ts +18 -0
- package/types/src/contract/index.d.ts +19 -0
- package/types/src/contract/kit.d.ts +211 -0
- package/types/src/contract/platform.d.ts +9 -0
- package/types/src/contract/problems.d.ts +14 -0
- package/types/src/contract/redirect.d.ts +25 -0
- package/types/src/contract/schema-from-shape.d.ts +10 -0
- package/types/src/contract/schema-shape.d.ts +5 -0
- package/types/src/contract/snapshot-source.d.ts +15 -0
- package/types/src/contract/token.d.ts +58 -0
- package/types/src/contract/vocabulary.d.ts +32 -0
- package/types/src/contract/webhook-binding.d.ts +24 -0
- package/types/src/contract/webhook-scheme.d.ts +23 -0
- package/types/src/dev/changes.d.ts +7 -0
- package/types/src/dev/contract-digest.d.ts +1 -0
- package/types/src/dev/index.d.ts +2 -0
- package/types/src/dev/launcher.d.ts +32 -0
- package/types/src/dev/output.d.ts +28 -0
- package/types/src/dev/wire.d.ts +126 -0
- package/types/src/disk-assets.d.ts +6 -0
- package/types/src/errors.d.ts +46 -0
- package/types/src/fetch.d.ts +7 -0
- package/types/src/files/archive.d.ts +26 -0
- package/types/src/files/clamscan.d.ts +32 -0
- package/types/src/files/handle.d.ts +106 -0
- package/types/src/files/job-names.d.ts +9 -0
- package/types/src/files/kinds.d.ts +101 -0
- package/types/src/files/scan.d.ts +78 -0
- package/types/src/files/sniff.d.ts +7 -0
- package/types/src/files/streams.d.ts +17 -0
- package/types/src/files/treatment.d.ts +18 -0
- package/types/src/from-row.d.ts +20 -0
- package/types/src/honeypot.d.ts +2 -0
- package/types/src/implement/anonymous.d.ts +28 -0
- package/types/src/implement/attachment.d.ts +14 -0
- package/types/src/implement/boot.d.ts +120 -0
- package/types/src/implement/bounded.d.ts +32 -0
- package/types/src/implement/browser-config.d.ts +19 -0
- package/types/src/implement/capability.d.ts +144 -0
- package/types/src/implement/challenge.d.ts +75 -0
- package/types/src/implement/client-address.d.ts +11 -0
- package/types/src/implement/connect.d.ts +67 -0
- package/types/src/implement/crashes.d.ts +31 -0
- package/types/src/implement/dispatch.d.ts +221 -0
- package/types/src/implement/egress.d.ts +49 -0
- package/types/src/implement/environment.d.ts +14 -0
- package/types/src/implement/failures.d.ts +39 -0
- package/types/src/implement/faults.d.ts +45 -0
- package/types/src/implement/headers.d.ts +7 -0
- package/types/src/implement/implement.d.ts +58 -0
- package/types/src/implement/index.d.ts +25 -0
- package/types/src/implement/invocation.d.ts +125 -0
- package/types/src/implement/membership.d.ts +201 -0
- package/types/src/implement/multipart.d.ts +36 -0
- package/types/src/implement/params.d.ts +7 -0
- package/types/src/implement/principal.d.ts +57 -0
- package/types/src/implement/project.d.ts +11 -0
- package/types/src/implement/public.d.ts +61 -0
- package/types/src/implement/serve.d.ts +25 -0
- package/types/src/implement/terminals.d.ts +33 -0
- package/types/src/implement/tokens.d.ts +49 -0
- package/types/src/implement/types.d.ts +243 -0
- package/types/src/implement/urls.d.ts +15 -0
- package/types/src/implement/verifiers.d.ts +19 -0
- package/types/src/implement/webhook.d.ts +55 -0
- package/types/src/implement/workload.d.ts +56 -0
- package/types/src/implement/xlsx.d.ts +6 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/jobs/exception.d.ts +40 -0
- package/types/src/jobs/mail.d.ts +67 -0
- package/types/src/jobs/report.d.ts +56 -0
- package/types/src/jobs/runner.d.ts +154 -0
- package/types/src/jobs/schedule.d.ts +17 -0
- package/types/src/jobs/scrub.d.ts +12 -0
- package/types/src/jobs/types.d.ts +98 -0
- package/types/src/react/caller.d.ts +30 -0
- package/types/src/react/errors.d.ts +40 -0
- package/types/src/react/hooks.d.ts +110 -0
- package/types/src/react/index.d.ts +5 -0
- package/types/src/react/provider.d.ts +58 -0
- package/types/src/react/store.d.ts +68 -0
- package/types/src/react/testing.d.ts +28 -0
- package/types/src/react/types.d.ts +33 -0
- package/types/src/rpc/builder.d.ts +52 -0
- package/types/src/rpc/contract.d.ts +23 -0
- package/types/src/rpc/dispatcher.d.ts +11 -0
- package/types/src/rpc/types.d.ts +62 -0
- package/types/src/service/define.d.ts +42 -0
- package/types/src/service/index.d.ts +3 -0
- package/types/src/service/serve.d.ts +28 -0
- package/types/src/spa-fallback.d.ts +2 -0
- package/types/src/standard-schema.d.ts +55 -0
- package/types/src/testing/caller.d.ts +95 -0
- package/types/src/testing/challenge.d.ts +11 -0
- package/types/src/testing/dev.d.ts +97 -0
- package/types/src/testing/endpoint-access.d.ts +131 -0
- package/types/src/testing/index.d.ts +12 -0
- package/types/src/testing/isolation.d.ts +3 -0
- package/types/src/testing/kit.d.ts +187 -0
- package/types/src/testing/membership.d.ts +132 -0
- package/types/src/testing/policy-isolation.d.ts +113 -0
- package/types/src/testing/preload.d.ts +1 -0
- package/types/src/testing/proxy.d.ts +40 -0
- package/types/src/testing/runner.d.ts +8 -0
- package/types/src/testing/sample-file.d.ts +5 -0
- package/types/src/testing/status.d.ts +4 -0
- package/types/src/undumpable.d.ts +5 -0
|
@@ -20,6 +20,9 @@ import type { Tables } from '@fabrika/db'
|
|
|
20
20
|
import { tablePolicies } from '@fabrika/db/tables'
|
|
21
21
|
import { type IsolationWorlds, type SampleValue, SEEDED_OBJECT_VERSION, type TestDatabase, type WorldRow } from '@fabrika/db/testing'
|
|
22
22
|
import { MemoryFileObjects } from '@fabrika/platform/testing'
|
|
23
|
+
import { readFile } from 'node:fs/promises'
|
|
24
|
+
import { basename } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
23
26
|
import { z } from 'zod/v4'
|
|
24
27
|
import type { RpcFetch } from '../client/transport'
|
|
25
28
|
import { compileContract } from '../contract/compile'
|
|
@@ -33,6 +36,7 @@ import {
|
|
|
33
36
|
} from '../contract/defaults'
|
|
34
37
|
import type { AnyContract } from '../contract/define'
|
|
35
38
|
import type { FileScanner, ScanVerdict } from '../files/scan'
|
|
39
|
+
import { bytesAreOfType, SNIFF_BYTES } from '../files/sniff'
|
|
36
40
|
import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
|
|
37
41
|
import type { TestCaller, TestGrant } from './caller'
|
|
38
42
|
import { standInIamKit } from './kit'
|
|
@@ -58,6 +62,13 @@ export type ReachOutcome = { readonly answered: true } | { readonly answered: fa
|
|
|
58
62
|
/** Column → value of the row of the table that owns the endpoint's dimension, in both scopes: the author's seed. */
|
|
59
63
|
export type Seed = Readonly<Record<string, SampleValue>>
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Table → column → value: one row of each application table the handler reads for the caller's own scope, seeded
|
|
67
|
+
* before a leak or an own case asks and removed once it is over, with every row that refers to it, so no other case
|
|
68
|
+
* finds it. Both scopes may see it, so its values are never counted as the other scope's.
|
|
69
|
+
*/
|
|
70
|
+
export type AppRows = Readonly<Record<string, Seed>>
|
|
71
|
+
|
|
61
72
|
export interface EndpointAccessKit {
|
|
62
73
|
/**
|
|
63
74
|
* A caller holding the endpoint's action in one scope names an object (`on`) or a scope value (`in`) of another;
|
|
@@ -66,16 +77,18 @@ export interface EndpointAccessKit {
|
|
|
66
77
|
denied(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
|
|
67
78
|
/**
|
|
68
79
|
* A caller of the other scope asks first, then a caller of one scope asks for its own object (`on`), its list
|
|
69
|
-
* (`over`) or, for every table that lies in the dimension, its scope value (`in`).
|
|
80
|
+
* (`over`) or, for every table that lies in the dimension, its scope value (`in`). `appRows` are seeded first, as
|
|
81
|
+
* for `own`.
|
|
70
82
|
*/
|
|
71
|
-
leaked(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<LeakOutcome>
|
|
83
|
+
leaked(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed, appRows?: AppRows): Promise<LeakOutcome>
|
|
72
84
|
/**
|
|
73
85
|
* A caller that holds only the endpoint's action asks for its own object: a person or a script in its scope, or, on
|
|
74
86
|
* a link mount, the holder of a link bound to the object. A write action reads its own target. Where IAM requires more
|
|
75
87
|
* of the caller, it holds that too: a member manager an assigning role and the assigned role's actions, a link's
|
|
76
|
-
* issuer the actions the link grants.
|
|
88
|
+
* issuer the actions the link grants. `appRows` are the rows of application tables its handler reads (the guide a link
|
|
89
|
+
* holder downloads), each seeded first.
|
|
77
90
|
*/
|
|
78
|
-
own(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
|
|
91
|
+
own(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed, appRows?: AppRows): Promise<DeniedOutcome>
|
|
79
92
|
/**
|
|
80
93
|
* An `app` or a `creates` endpoint asked by a caller who holds its action in one value of a dimension only, never
|
|
81
94
|
* globally: refused (403), by the proxy for `app` and by IAM for `creates`, since both need a global grant.
|
|
@@ -101,6 +114,54 @@ export interface EndpointAccessKit {
|
|
|
101
114
|
anyone(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>
|
|
102
115
|
}
|
|
103
116
|
|
|
117
|
+
export interface EndpointAccessOptions {
|
|
118
|
+
/**
|
|
119
|
+
* The file every case of an endpoint sends in a file field instead of the kit's sample, by endpoint id and input
|
|
120
|
+
* field: a file of the application's repository that a handler inspecting the content accepts (a valid package). Its
|
|
121
|
+
* type is the first of the field's kind that its bytes are; a file of none of them throws. `files` of the fixtures.
|
|
122
|
+
*/
|
|
123
|
+
readonly files?: Readonly<Record<string, Readonly<Record<string, URL>>>>
|
|
124
|
+
/**
|
|
125
|
+
* The error types of the refusals (400) an endpoint's handler answers its own object with when the case's input meets
|
|
126
|
+
* no domain rule the kit can satisfy, by endpoint id: such a 400 is the handler's own answer, so the request passed
|
|
127
|
+
* the access form, and the endpoint's own case gets it as `{ status: 400 }` instead of failing; so does the other
|
|
128
|
+
* scope's request a leak case makes first. The own scope's answer of a leak case, and a global or capability case,
|
|
129
|
+
* still throw on it (an answer that holds nothing proves no isolation), so does any other 400, and an input the schema
|
|
130
|
+
* refuses (`validation`) always does. `refusals` of the fixtures.
|
|
131
|
+
*/
|
|
132
|
+
readonly refusals?: Readonly<Record<string, readonly string[]>>
|
|
133
|
+
/**
|
|
134
|
+
* Known values for text columns of the row the endpoint addresses (the row of the table that owns its dimension), by
|
|
135
|
+
* endpoint id: the value in the own scope's row, and `<value>-other` in the other scope's, which counts among the
|
|
136
|
+
* other scope's values a leak shows. For a handler whose rule ties a column to the input or a file fixture (a package
|
|
137
|
+
* that names the skill's slug). `pins` of the fixtures.
|
|
138
|
+
*/
|
|
139
|
+
readonly pins?: Readonly<Record<string, Readonly<Record<string, string>>>>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a request takes a refusal the fixtures declare for its endpoint as the handler's answer: an own case's, and the
|
|
144
|
+
* other scope's request a leak case makes first, whose answer is not the one checked.
|
|
145
|
+
*/
|
|
146
|
+
type Refusals = 'declared' | 'none'
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The pins of a case's endpoint (column → the own scope's value of a text column), and the removals of the case: rows
|
|
150
|
+
* seeded with pins are removed once the case is over, so the next case can pin the same values under a unique index.
|
|
151
|
+
*/
|
|
152
|
+
interface Pinning {
|
|
153
|
+
readonly pins: Readonly<Record<string, string>>
|
|
154
|
+
readonly removals: (() => Promise<void>)[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const NO_PINNING: Pinning = { pins: {}, removals: [] }
|
|
158
|
+
|
|
159
|
+
/** Seeded worlds, their removal taken by the case when they hold pinned values. */
|
|
160
|
+
const pinned = <W extends { readonly remove: () => Promise<void> }>(worlds: W, pinning: Pinning): W => {
|
|
161
|
+
if (Object.keys(pinning.pins).length > 0) pinning.removals.push(worlds.remove)
|
|
162
|
+
return worlds
|
|
163
|
+
}
|
|
164
|
+
|
|
104
165
|
interface ActionEndpoint {
|
|
105
166
|
readonly id: string
|
|
106
167
|
/** The link mount of a link procedure; `null` for every other endpoint. */
|
|
@@ -276,7 +337,12 @@ export const managerGrants = (
|
|
|
276
337
|
* The endpoint access kit of `app`, whose data layer is `scoped(tables)`, over `postgres`. It boots `app` once, so the
|
|
277
338
|
* application's layer is bound here and nowhere else in the process.
|
|
278
339
|
*/
|
|
279
|
-
export function endpointAccessKit<DB, P>(
|
|
340
|
+
export function endpointAccessKit<DB, P>(
|
|
341
|
+
app: ContractImplementation<AnyContract>,
|
|
342
|
+
tables: Tables<DB, P>,
|
|
343
|
+
postgres: TestDatabase,
|
|
344
|
+
options: EndpointAccessOptions = {},
|
|
345
|
+
): EndpointAccessKit {
|
|
280
346
|
// The objects of the files the cases seed live in memory; their rows are the application's own tables.
|
|
281
347
|
const objects = new MemoryFileObjects()
|
|
282
348
|
const { kit, standIn } = standInIamKit(app, {
|
|
@@ -395,16 +461,20 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
395
461
|
throw new Error(`${row.id} admits neither a human nor a machine caller`)
|
|
396
462
|
}
|
|
397
463
|
|
|
398
|
-
/**
|
|
464
|
+
/**
|
|
465
|
+
* A case's request by a new caller holding `grants`, its generated fields filled for `chain` and that caller. Only an
|
|
466
|
+
* own case passes `refusals: 'declared'`.
|
|
467
|
+
*/
|
|
399
468
|
const ask = async (
|
|
400
469
|
row: ActionEndpoint,
|
|
401
470
|
grants: readonly Grant[],
|
|
402
471
|
input: Readonly<Record<string, unknown>>,
|
|
403
472
|
chain: Chain,
|
|
473
|
+
refusals: Refusals = 'none',
|
|
404
474
|
): Promise<{ readonly status: number; readonly text: string; readonly marks: readonly string[] }> => {
|
|
405
475
|
const asker = callerFor(row, grants)
|
|
406
476
|
const asked = withGenerated(row, input, chain, asker.caller)
|
|
407
|
-
return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
|
|
477
|
+
return { ...(await askAs(row, asker.fetch, asked.input, refusals)), marks: asked.marks }
|
|
408
478
|
}
|
|
409
479
|
|
|
410
480
|
// A seeded file column references an empty object, which a handler that serves or opens the file reads here.
|
|
@@ -418,7 +488,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
418
488
|
}
|
|
419
489
|
|
|
420
490
|
/** The request of a case: a GET's input in its URL, a file field's marker as a file of its kind in a multipart body. */
|
|
421
|
-
const requestOf = (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): readonly [string, RequestInit] => {
|
|
491
|
+
const requestOf = async (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): Promise<readonly [string, RequestInit]> => {
|
|
422
492
|
if (row.method === 'GET') {
|
|
423
493
|
const params = new URLSearchParams(Object.entries(body).map(([name, value]) => [name, value === null ? 'null' : String(value)]))
|
|
424
494
|
return [params.size === 0 ? row.path : `${row.path}?${params}`, { method: 'GET' }]
|
|
@@ -437,7 +507,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
437
507
|
for (const item of Array.isArray(value) ? value : [value]) {
|
|
438
508
|
const kind = fileKindOf(item)
|
|
439
509
|
if (kind === undefined) throw new Error(`${row.id}: the file field ${name} holds ${JSON.stringify(item)}, not a generated file`)
|
|
440
|
-
files.push([name, generatedFile(kind)])
|
|
510
|
+
files.push([name, await generatedFile(row.id, name, kind)])
|
|
441
511
|
}
|
|
442
512
|
}
|
|
443
513
|
const form = new FormData()
|
|
@@ -446,30 +516,69 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
446
516
|
return [row.path, { method: 'POST', body: form }]
|
|
447
517
|
}
|
|
448
518
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
519
|
+
/** The file a case sends in `field`: the author's file fixture of the endpoint's field, else a sample of `kind`. */
|
|
520
|
+
const generatedFile = async (endpoint: string, field: string, kind: string): Promise<File> => {
|
|
521
|
+
const types = kit.artifact.fileKinds?.[kind]?.types ?? []
|
|
522
|
+
const fixture = options.files?.[endpoint]?.[field]
|
|
523
|
+
if (fixture === undefined) {
|
|
524
|
+
const sample = sampleFile(kind, types)
|
|
525
|
+
return new File([sample.bytes], `generated-${kind}`, { type: sample.type })
|
|
526
|
+
}
|
|
527
|
+
const path = fileURLToPath(fixture)
|
|
528
|
+
const bytes = await fixtureBytes(endpoint, field, path)
|
|
529
|
+
const type = types.find((candidate) => bytesAreOfType(candidate, bytes.subarray(0, SNIFF_BYTES)))
|
|
530
|
+
if (type === undefined) {
|
|
531
|
+
throw new Error(`${endpoint}: the file of ${field}, ${path}, is none of the types of files.${kind} (${types.join(', ')})`)
|
|
532
|
+
}
|
|
533
|
+
return new File([bytes], basename(path), { type })
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// A file fixture is read once; every case sends the same bytes.
|
|
537
|
+
const fixtureFiles = new Map<string, Uint8Array<ArrayBuffer>>()
|
|
538
|
+
const fixtureBytes = async (endpoint: string, field: string, path: string): Promise<Uint8Array<ArrayBuffer>> => {
|
|
539
|
+
const known = fixtureFiles.get(path)
|
|
540
|
+
if (known !== undefined) return known
|
|
541
|
+
let bytes: Uint8Array<ArrayBuffer>
|
|
542
|
+
try {
|
|
543
|
+
bytes = new Uint8Array(await readFile(path))
|
|
544
|
+
} catch {
|
|
545
|
+
throw new Error(`${endpoint}: the file of ${field} in the fixtures, ${path}, cannot be read; add it to the repository or correct its path`)
|
|
546
|
+
}
|
|
547
|
+
fixtureFiles.set(path, bytes)
|
|
548
|
+
return bytes
|
|
452
549
|
}
|
|
453
550
|
|
|
454
551
|
const askAs = async (
|
|
455
552
|
row: ActionEndpoint,
|
|
456
553
|
fetch: RpcFetch,
|
|
457
554
|
body: Readonly<Record<string, unknown>>,
|
|
555
|
+
refusals: Refusals = 'none',
|
|
458
556
|
): Promise<{ readonly status: number; readonly text: string }> => {
|
|
459
557
|
stockSeededObjects()
|
|
460
|
-
const [url, init] = requestOf(row, body)
|
|
558
|
+
const [url, init] = await requestOf(row, body)
|
|
461
559
|
const response = await fetch(url, init)
|
|
462
560
|
const text = await response.text()
|
|
463
561
|
// A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
|
|
464
562
|
// the input it was given is no answer to another scope. Either is what the generator could not build.
|
|
465
563
|
if (response.status === 400) {
|
|
466
564
|
const type = errorType(text)
|
|
565
|
+
const declared = type !== undefined && type !== 'validation' && options.refusals?.[row.id]?.includes(type) === true
|
|
566
|
+
// The own case may take it: it shows the access form let the caller through. The answer a leak case checks, or a
|
|
567
|
+
// global case's, would pass holding nothing, which proves nothing.
|
|
568
|
+
if (declared && refusals === 'declared') {
|
|
569
|
+
return { status: response.status, text }
|
|
570
|
+
}
|
|
571
|
+
if (declared) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`${row.id}: the handler answered its declared refusal (400 ${type}), which only the own case takes; this case proves nothing on it until the fixtures give what the handler needs (pins, files, fixtures) (${text})`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
467
576
|
throw new Error(
|
|
468
577
|
type === 'validation'
|
|
469
578
|
? `${row.id}: the case's input does not match the endpoint's input schema; correct its fixture (${text})`
|
|
470
579
|
: `${row.id}: the handler refused the case's input (400 ${
|
|
471
580
|
type ?? 'without a type'
|
|
472
|
-
}); the case cannot reach what it tests until its fixtures give what the handler needs: a picked person in members, a row of its object in rows, the state in seeds
|
|
581
|
+
}); the case cannot reach what it tests until its fixtures give what the handler needs: a picked person in members, a row of its object in rows, the state in seeds, a file it accepts in files, the whole input in fixtures, or the refusal it answers in refusals (${text})`,
|
|
473
582
|
)
|
|
474
583
|
}
|
|
475
584
|
return { status: response.status, text }
|
|
@@ -485,6 +594,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
485
594
|
field: SchemaShape | undefined,
|
|
486
595
|
seed: Seed,
|
|
487
596
|
input: Readonly<Record<string, unknown>>,
|
|
597
|
+
pinning: Pinning = NO_PINNING,
|
|
488
598
|
): Promise<Scopes> => {
|
|
489
599
|
const named = rowFields(input)
|
|
490
600
|
const owner = ownerOf(dimension)
|
|
@@ -505,7 +615,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
505
615
|
},
|
|
506
616
|
}
|
|
507
617
|
}
|
|
508
|
-
const worlds = await postgres.seedWorlds(tables, owner, seed)
|
|
618
|
+
const worlds = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
|
|
509
619
|
const own = await rowsIn(worlds.own, named)
|
|
510
620
|
const other = await rowsIn(worlds.other, named)
|
|
511
621
|
return {
|
|
@@ -560,14 +670,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
560
670
|
* For a report `in` `dimension`: one row in each scope of every table that lies in it, the table that owns it with
|
|
561
671
|
* `seed`. Each side's targets are its rows' values of `dimension`, and each caller holds all of them.
|
|
562
672
|
*/
|
|
563
|
-
const inScopesOf = async (action: string, dimension: string, field: SchemaShape | undefined, seed: Seed) => {
|
|
673
|
+
const inScopesOf = async (action: string, dimension: string, field: SchemaShape | undefined, seed: Seed, pinning: Pinning = NO_PINNING) => {
|
|
564
674
|
const own: InTarget[] = []
|
|
565
675
|
const other: InTarget[] = []
|
|
566
676
|
const values: string[] = []
|
|
567
677
|
for (const name of tableNames) {
|
|
568
678
|
const mode = policies[name]?.scope.mode
|
|
569
679
|
if (mode !== 'dimension' && mode !== 'child') continue
|
|
570
|
-
const
|
|
680
|
+
const owned = name === ownerOf(dimension)
|
|
681
|
+
const worlds = owned
|
|
682
|
+
? pinned(await postgres.seedWorlds(tables, name, seed, pinning.pins), pinning)
|
|
683
|
+
: await postgres.seedWorlds(tables, name)
|
|
571
684
|
const ownAt = worlds.own.chain.findIndex((link) => link.type === dimension)
|
|
572
685
|
const otherAt = worlds.other.chain.findIndex((link) => link.type === dimension)
|
|
573
686
|
const ownValue = worlds.own.chain[ownAt]?.value
|
|
@@ -579,7 +692,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
579
692
|
}
|
|
580
693
|
}
|
|
581
694
|
if (own.length === 0) {
|
|
582
|
-
const scopes = await scopesOf(action, dimension, field, seed, {})
|
|
695
|
+
const scopes = await scopesOf(action, dimension, field, seed, {}, pinning)
|
|
583
696
|
return { own: [scopes.own], other: [scopes.other], values: scopes.other.values }
|
|
584
697
|
}
|
|
585
698
|
return { own, other, values }
|
|
@@ -702,6 +815,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
702
815
|
mount: string,
|
|
703
816
|
seed: Seed,
|
|
704
817
|
input: Readonly<Record<string, unknown>>,
|
|
818
|
+
pinning: Pinning,
|
|
705
819
|
): Promise<{ readonly fetch: RpcFetch; readonly rows: Readonly<Record<string, string>> }> => {
|
|
706
820
|
const access = row.access
|
|
707
821
|
if (access.form !== 'on') {
|
|
@@ -711,7 +825,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
711
825
|
if (owner === undefined) {
|
|
712
826
|
throw new Error(`${row.id}: no table owns ${access.dimension}, so no link can be bound to one`)
|
|
713
827
|
}
|
|
714
|
-
const { own } = await postgres.seedWorlds(tables, owner, seed)
|
|
828
|
+
const { own } = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
|
|
715
829
|
const holder = kit.testCaller([{ action: row.action, scope: { type: access.dimension, value: own.key } }], {
|
|
716
830
|
kind: 'link',
|
|
717
831
|
link: { mount, chain: own.chain },
|
|
@@ -724,147 +838,188 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
|
|
|
724
838
|
return { fetch, rows: (await rowsIn(own, rowFields(input))).rows }
|
|
725
839
|
}
|
|
726
840
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const marks: string[] = []
|
|
747
|
-
for (const side of scopes.other) {
|
|
748
|
-
const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain)
|
|
749
|
-
marks.push(...answer.marks)
|
|
841
|
+
// The cases of one call: `removals` takes the removal of the application rows it seeds, which `drained` runs once
|
|
842
|
+
// the call and the runs it kicked are over. A list per call, so a concurrent case never removes the rows another
|
|
843
|
+
// was given; the generated cases run one after another, since a row a required foreign key needed is shared.
|
|
844
|
+
const accessKitOf = (removals: (() => Promise<void>)[]): EndpointAccessKit => {
|
|
845
|
+
/** The pins of `endpoint`, whose seeded rows this call removes once it is over. */
|
|
846
|
+
const pinningOf = (endpoint: string): Pinning => ({ pins: options.pins?.[endpoint] ?? {}, removals })
|
|
847
|
+
|
|
848
|
+
/** One row of each application table `appRows` names, for this call only. */
|
|
849
|
+
const seedAppRows = async (appRows: AppRows): Promise<void> => {
|
|
850
|
+
if (Object.keys(appRows).length === 0) return
|
|
851
|
+
removals.push((await postgres.seedApplicationRows(tables, appRows)).remove)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
async denied(endpoint, input, seed = {}) {
|
|
856
|
+
const row = endpointOf(endpoint)
|
|
857
|
+
const { action, access } = row
|
|
858
|
+
if (access.form !== 'on' && access.form !== 'in') {
|
|
859
|
+
throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
|
|
750
860
|
}
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
861
|
+
const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
|
|
862
|
+
const asked = addressing(input, access.from, scopes.other)
|
|
863
|
+
const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
|
|
864
|
+
return { status }
|
|
865
|
+
},
|
|
866
|
+
async leaked(endpoint, input, seed = {}, appRows = {}) {
|
|
867
|
+
const row = endpointOf(endpoint)
|
|
868
|
+
const { action, access } = row
|
|
869
|
+
if (access.form === 'in') {
|
|
870
|
+
await seedAppRows(appRows)
|
|
871
|
+
const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, pinningOf(endpoint))
|
|
872
|
+
const grants = (targets: readonly InTarget[]): Grant[] =>
|
|
873
|
+
targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
|
|
874
|
+
const marks: string[] = []
|
|
875
|
+
for (const side of scopes.other) {
|
|
876
|
+
const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain, 'declared')
|
|
877
|
+
marks.push(...answer.marks)
|
|
878
|
+
}
|
|
879
|
+
const answers = []
|
|
880
|
+
for (const side of scopes.own) {
|
|
881
|
+
answers.push(await ask(row, grants(scopes.own), addressing(input, access.from, side), side.chain))
|
|
882
|
+
}
|
|
883
|
+
return leakOutcome(answers, [...scopes.values, ...marks])
|
|
754
884
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
885
|
+
if (access.form !== 'on' && access.form !== 'over') {
|
|
886
|
+
throw new Error(`${endpoint} has the access form ${access.form}; only on, in and over answer with rows of a scope`)
|
|
887
|
+
}
|
|
888
|
+
await seedAppRows(appRows)
|
|
889
|
+
const scopes = access.form === 'on'
|
|
890
|
+
? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
|
|
891
|
+
: await listScopesOf(action, access.dimension)
|
|
892
|
+
const from = access.form === 'on' ? access.from : null
|
|
893
|
+
// The other scope asks first, to fill whatever a handler keeps between requests; its pins differ from the own
|
|
894
|
+
// scope's, so a refusal the fixtures declare is its answer too. The own scope's answer is the one checked.
|
|
895
|
+
const other = await ask(
|
|
896
|
+
row,
|
|
897
|
+
holding(row, input, scopes.other.grants, scopes.other.chain),
|
|
898
|
+
addressing(input, from, scopes.other),
|
|
899
|
+
scopes.other.chain,
|
|
900
|
+
'declared',
|
|
901
|
+
)
|
|
902
|
+
const answer = await ask(
|
|
903
|
+
row,
|
|
904
|
+
holding(row, input, scopes.own.grants, scopes.own.chain),
|
|
905
|
+
addressing(input, from, scopes.own),
|
|
906
|
+
scopes.own.chain,
|
|
907
|
+
)
|
|
908
|
+
return leakOutcome([answer], [...scopes.other.values, ...other.marks])
|
|
909
|
+
},
|
|
910
|
+
async own(endpoint, input, seed = {}, appRows = {}) {
|
|
911
|
+
const row = endpointOf(endpoint)
|
|
912
|
+
const { action, access } = row
|
|
913
|
+
if (access.form !== 'on') {
|
|
914
|
+
throw new Error(`${endpoint} has the access form ${access.form}; only on addresses an object of its own`)
|
|
915
|
+
}
|
|
916
|
+
await seedAppRows(appRows)
|
|
917
|
+
if (row.link !== null) {
|
|
918
|
+
const holder = await linkHolder(row, row.link, seed, input, pinningOf(endpoint))
|
|
919
|
+
const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows }, 'declared')
|
|
920
|
+
return { status }
|
|
921
|
+
}
|
|
922
|
+
const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
|
|
923
|
+
const { status } = await ask(
|
|
924
|
+
row,
|
|
925
|
+
holding(row, input, scopes.own.grants, scopes.own.chain),
|
|
926
|
+
addressing(input, access.from, scopes.own),
|
|
927
|
+
scopes.own.chain,
|
|
928
|
+
'declared',
|
|
929
|
+
)
|
|
787
930
|
return { status }
|
|
931
|
+
},
|
|
932
|
+
async scoped(endpoint, input) {
|
|
933
|
+
const row = globalEndpointOf(endpoint)
|
|
934
|
+
const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
|
|
935
|
+
const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
|
|
936
|
+
return { status }
|
|
937
|
+
},
|
|
938
|
+
async anyone(endpoint, input) {
|
|
939
|
+
const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
|
|
940
|
+
if (row?.kind !== 'public') {
|
|
941
|
+
throw new Error(`${endpoint} is not a public procedure of the contract`)
|
|
942
|
+
}
|
|
943
|
+
const response = await kit.fetch(row.path, {
|
|
944
|
+
method: row.method,
|
|
945
|
+
headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
|
|
946
|
+
body: JSON.stringify(input),
|
|
947
|
+
})
|
|
948
|
+
const status = response.status
|
|
949
|
+
const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
|
|
950
|
+
return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
|
|
951
|
+
},
|
|
952
|
+
async global(endpoint, input) {
|
|
953
|
+
const row = globalEndpointOf(endpoint)
|
|
954
|
+
const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
|
|
955
|
+
// Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
|
|
956
|
+
return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
|
|
957
|
+
},
|
|
958
|
+
async capability(endpoint, capability, input, seed = {}) {
|
|
959
|
+
const capabilities = capabilitiesEndpointOf(endpoint)
|
|
960
|
+
const named = capabilities.get(capability)
|
|
961
|
+
if (named === undefined) {
|
|
962
|
+
throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
|
|
963
|
+
}
|
|
964
|
+
const grants: Grant[] = []
|
|
965
|
+
const global: { readonly action: string }[] = []
|
|
966
|
+
let asked = input
|
|
967
|
+
for (const [name, other] of capabilities) {
|
|
968
|
+
if (name === capability) continue
|
|
969
|
+
if (other.access.form === 'over') {
|
|
970
|
+
global.push({ action: other.action })
|
|
971
|
+
} else if (other.access.form === 'on' || other.access.form === 'in') {
|
|
972
|
+
const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
|
|
973
|
+
asked = addressing(asked, other.access.from, own)
|
|
974
|
+
grants.push(...own.grants)
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
const { access } = named
|
|
978
|
+
if (access.form === 'on' || access.form === 'in') {
|
|
979
|
+
const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input, pinningOf(endpoint))
|
|
980
|
+
asked = addressing(asked, access.from, scopes.other)
|
|
981
|
+
grants.push(...scopes.own.grants)
|
|
982
|
+
}
|
|
983
|
+
const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
|
|
984
|
+
return { status }
|
|
985
|
+
},
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
// Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one, and
|
|
989
|
+
// only then removes its application rows, which those runs may still read. Every step runs whatever failed before
|
|
990
|
+
// it, and the case's own error is thrown first.
|
|
991
|
+
const drained =
|
|
992
|
+
<A extends readonly unknown[], R>(method: (kit: EndpointAccessKit) => (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
|
|
993
|
+
const removals: (() => Promise<void>)[] = []
|
|
994
|
+
let outcome: { readonly ok: true; readonly value: R } | { readonly ok: false; readonly error: unknown }
|
|
995
|
+
try {
|
|
996
|
+
outcome = { ok: true, value: await method(accessKitOf(removals))(...args) }
|
|
997
|
+
} catch (error) {
|
|
998
|
+
outcome = { ok: false, error }
|
|
788
999
|
}
|
|
789
|
-
const
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
)
|
|
796
|
-
return { status }
|
|
797
|
-
},
|
|
798
|
-
async scoped(endpoint, input) {
|
|
799
|
-
const row = globalEndpointOf(endpoint)
|
|
800
|
-
const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
|
|
801
|
-
const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
|
|
802
|
-
return { status }
|
|
803
|
-
},
|
|
804
|
-
async anyone(endpoint, input) {
|
|
805
|
-
const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
|
|
806
|
-
if (row?.kind !== 'public') {
|
|
807
|
-
throw new Error(`${endpoint} is not a public procedure of the contract`)
|
|
808
|
-
}
|
|
809
|
-
const response = await kit.fetch(row.path, {
|
|
810
|
-
method: row.method,
|
|
811
|
-
headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
|
|
812
|
-
body: JSON.stringify(input),
|
|
813
|
-
})
|
|
814
|
-
const status = response.status
|
|
815
|
-
const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
|
|
816
|
-
return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
|
|
817
|
-
},
|
|
818
|
-
async global(endpoint, input) {
|
|
819
|
-
const row = globalEndpointOf(endpoint)
|
|
820
|
-
const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
|
|
821
|
-
// Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
|
|
822
|
-
return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
|
|
823
|
-
},
|
|
824
|
-
async capability(endpoint, capability, input, seed = {}) {
|
|
825
|
-
const capabilities = capabilitiesEndpointOf(endpoint)
|
|
826
|
-
const named = capabilities.get(capability)
|
|
827
|
-
if (named === undefined) {
|
|
828
|
-
throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
|
|
829
|
-
}
|
|
830
|
-
const grants: Grant[] = []
|
|
831
|
-
const global: { readonly action: string }[] = []
|
|
832
|
-
let asked = input
|
|
833
|
-
for (const [name, other] of capabilities) {
|
|
834
|
-
if (name === capability) continue
|
|
835
|
-
if (other.access.form === 'over') {
|
|
836
|
-
global.push({ action: other.action })
|
|
837
|
-
} else if (other.access.form === 'on' || other.access.form === 'in') {
|
|
838
|
-
const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
|
|
839
|
-
asked = addressing(asked, other.access.from, own)
|
|
840
|
-
grants.push(...own.grants)
|
|
1000
|
+
const failures: unknown[] = []
|
|
1001
|
+
for (const step of [() => kit.drain(), ...removals]) {
|
|
1002
|
+
try {
|
|
1003
|
+
await step()
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
failures.push(error)
|
|
841
1006
|
}
|
|
842
1007
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
asked = addressing(asked, access.from, scopes.other)
|
|
847
|
-
grants.push(...scopes.own.grants)
|
|
1008
|
+
if (!outcome.ok) {
|
|
1009
|
+
if (failures.length === 0) throw outcome.error
|
|
1010
|
+
throw new AggregateError([outcome.error, ...failures], 'the case failed, and so did its cleanup')
|
|
848
1011
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
}
|
|
853
|
-
// Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one.
|
|
854
|
-
const drained = <A extends readonly unknown[], R>(method: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
|
|
855
|
-
try {
|
|
856
|
-
return await method(...args)
|
|
857
|
-
} finally {
|
|
858
|
-
await kit.drain()
|
|
1012
|
+
if (failures.length === 1) throw failures[0]
|
|
1013
|
+
if (failures.length > 1) throw new AggregateError(failures, 'the cleanup of the case failed')
|
|
1014
|
+
return outcome.value
|
|
859
1015
|
}
|
|
860
|
-
}
|
|
861
1016
|
return Object.freeze({
|
|
862
|
-
denied: drained(
|
|
863
|
-
leaked: drained(
|
|
864
|
-
own: drained(
|
|
865
|
-
scoped: drained(
|
|
866
|
-
global: drained(
|
|
867
|
-
anyone: drained(
|
|
868
|
-
capability: drained(
|
|
1017
|
+
denied: drained((cases) => cases.denied),
|
|
1018
|
+
leaked: drained((cases) => cases.leaked),
|
|
1019
|
+
own: drained((cases) => cases.own),
|
|
1020
|
+
scoped: drained((cases) => cases.scoped),
|
|
1021
|
+
global: drained((cases) => cases.global),
|
|
1022
|
+
anyone: drained((cases) => cases.anyone),
|
|
1023
|
+
capability: drained((cases) => cases.capability),
|
|
869
1024
|
})
|
|
870
1025
|
}
|
package/src/testing/isolation.ts
CHANGED
|
@@ -6,7 +6,14 @@ import { refuseInProduction } from '@fabrika/db/production'
|
|
|
6
6
|
|
|
7
7
|
refuseInProduction('@fabrika/app/testing/isolation')
|
|
8
8
|
|
|
9
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
type DeniedOutcome,
|
|
11
|
+
type EndpointAccessKit,
|
|
12
|
+
endpointAccessKit,
|
|
13
|
+
type EndpointAccessOptions,
|
|
14
|
+
type LeakOutcome,
|
|
15
|
+
type ReachOutcome,
|
|
16
|
+
} from './endpoint-access'
|
|
10
17
|
export {
|
|
11
18
|
type AppCaller,
|
|
12
19
|
type AppCountOutcome,
|