@kernhq/module-inventory 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts +0 -387
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.d.ts +0 -427
- package/dist/server/_impl.d.ts.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
package/src/module.test.ts
CHANGED
|
@@ -11,11 +11,21 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Add your module's real tests next to it; this one keeps working as the contract grows.
|
|
13
13
|
*/
|
|
14
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
14
17
|
import type { Kernel } from '@kernhq/kernel'
|
|
15
18
|
import { describe, expect, it } from 'vitest'
|
|
16
|
-
import {
|
|
17
|
-
|
|
19
|
+
import {
|
|
20
|
+
inventoryCapabilities,
|
|
21
|
+
inventoryCapabilityProcedures,
|
|
22
|
+
inventoryContract,
|
|
23
|
+
inventoryEvents,
|
|
24
|
+
inventoryPermissions,
|
|
25
|
+
MODULE_ID,
|
|
26
|
+
} from './contract/index.js'
|
|
18
27
|
import { inventoryModule } from './server/index.js'
|
|
28
|
+
import { inventoryRouter } from './server/router.js'
|
|
19
29
|
|
|
20
30
|
/** An oRPC procedure (contract or implementation) carries `~orpc`; a router group does not. */
|
|
21
31
|
interface Leaf {
|
|
@@ -38,7 +48,7 @@ function leaves(node: unknown, path: string[] = []): Record<string, Leaf> {
|
|
|
38
48
|
|
|
39
49
|
// The router is only inspected, never called, so it needs no real kernel behind it.
|
|
40
50
|
const declared = leaves(inventoryContract)
|
|
41
|
-
const implemented = leaves(
|
|
51
|
+
const implemented = leaves(inventoryRouter({} as Kernel))
|
|
42
52
|
|
|
43
53
|
describe('the contract and the router agree', () => {
|
|
44
54
|
it('implements every declared procedure, and nothing that was never declared', () => {
|
|
@@ -54,11 +64,123 @@ describe('the contract and the router agree', () => {
|
|
|
54
64
|
})
|
|
55
65
|
})
|
|
56
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Which middleware is which — established by what each one *does*, because there is nothing else to
|
|
69
|
+
* go on.
|
|
70
|
+
*
|
|
71
|
+
* `workspaceScoped`, `requiresCapability` and `requires` all come back from oRPC as a function
|
|
72
|
+
* named `decorated` carrying identical own properties: no name, no tag, nothing to compare. So each
|
|
73
|
+
* middleware is called here against a kernel stub that records what it reached for, and what it
|
|
74
|
+
* reached for is its identity.
|
|
75
|
+
*
|
|
76
|
+
* This is what replaced `middlewares.length >= 2`, which was satisfied by *any* two middlewares —
|
|
77
|
+
* a procedure that had lost `workspaceScoped` and kept two permission checks passed it, and that is
|
|
78
|
+
* precisely the failure this file's docblock claims to prevent. A count is not an assertion about
|
|
79
|
+
* authorisation; it is an assertion about arithmetic.
|
|
80
|
+
*/
|
|
81
|
+
interface Reached {
|
|
82
|
+
/** the module id `workspaceScoped` asked `isModuleEnabled` about */
|
|
83
|
+
module?: string
|
|
84
|
+
/** the permission `requires` asked `authz.require` for */
|
|
85
|
+
permission?: string
|
|
86
|
+
/** the `<module>.<capability>` `requiresCapability` looked up */
|
|
87
|
+
capability?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type MiddlewareFn = (
|
|
91
|
+
options: { context: unknown; next: (options?: unknown) => Promise<unknown> },
|
|
92
|
+
input: unknown,
|
|
93
|
+
) => Promise<unknown>
|
|
94
|
+
|
|
95
|
+
const WORKSPACE = '00000000-0000-4000-8000-000000000000'
|
|
96
|
+
|
|
97
|
+
async function reachedFor(middleware: unknown): Promise<Reached> {
|
|
98
|
+
const seen: Reached = {}
|
|
99
|
+
const kernel = {
|
|
100
|
+
authz: {
|
|
101
|
+
requireMember: () => undefined,
|
|
102
|
+
require: async (_principal: unknown, permission: string) => {
|
|
103
|
+
seen.permission = permission
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
isModuleEnabled: async (_workspaceId: string, moduleId: string) => {
|
|
107
|
+
seen.module = moduleId
|
|
108
|
+
return true
|
|
109
|
+
},
|
|
110
|
+
// The real one answers a Set; a stub only has to record which capability was asked about.
|
|
111
|
+
capabilities: async (_workspaceId: string, moduleId: string) => ({
|
|
112
|
+
has: (capability: string) => {
|
|
113
|
+
seen.capability = `${moduleId}.${capability}`
|
|
114
|
+
return true
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
}
|
|
118
|
+
const principal = { kind: 'user', userId: WORKSPACE, instanceAdmin: false, memberships: [] }
|
|
119
|
+
await (middleware as MiddlewareFn)(
|
|
120
|
+
{ context: { kernel, principal }, next: async () => ({}) },
|
|
121
|
+
{ workspaceId: WORKSPACE },
|
|
122
|
+
)
|
|
123
|
+
return seen
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const chainOf = (name: string): unknown[] => implemented[name]?.['~orpc'].middlewares ?? []
|
|
127
|
+
|
|
128
|
+
/** What every implemented procedure's middleware chain reached for, resolved once. */
|
|
129
|
+
async function resolveChains(): Promise<Record<string, Reached[]>> {
|
|
130
|
+
const entries = await Promise.all(
|
|
131
|
+
Object.keys(implemented).map(
|
|
132
|
+
async (name) => [name, await Promise.all(chainOf(name).map(reachedFor))] as const,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
return Object.fromEntries(entries)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Which of `names` the router does **not** put behind `capability`, in the right place.
|
|
140
|
+
*
|
|
141
|
+
* A pure function of the chains rather than an assertion inside a loop, so the check can be aimed at
|
|
142
|
+
* a chain this file builds by hand — which is the only way to show that it rejects what it claims
|
|
143
|
+
* to. An assertion that has never been seen to fail is a comment with a green tick beside it.
|
|
144
|
+
*
|
|
145
|
+
* Two ways to fail, and both matter: no capability gate at all, and a gate that sits *after* the
|
|
146
|
+
* permission check. The order is the whole point of `workspaceScoped` → `requiresCapability` →
|
|
147
|
+
* `requires`: a workspace with the module off must be refused before anything reveals which
|
|
148
|
+
* capabilities it would have had, and a workspace with the capability off must get 404 rather than
|
|
149
|
+
* the 403 a permission check would produce first.
|
|
150
|
+
*/
|
|
151
|
+
function ungated(capability: string, names: readonly string[], chains: Record<string, Reached[]>): string[] {
|
|
152
|
+
return names.filter((name) => {
|
|
153
|
+
const chain = chains[name] ?? []
|
|
154
|
+
const gate = chain.findIndex((r) => r.capability === `${MODULE_ID}.${capability}`)
|
|
155
|
+
const permission = chain.findIndex((r) => r.permission !== undefined)
|
|
156
|
+
// Index 0 is `workspaceScoped`'s place, so a gate there is a gate that displaced it.
|
|
157
|
+
if (gate < 1) return true
|
|
158
|
+
return permission !== -1 && gate > permission
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
57
162
|
describe('every procedure is authorised', () => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
163
|
+
const declaredKeys = new Set(inventoryPermissions.map((p) => p.key))
|
|
164
|
+
|
|
165
|
+
it('puts the workspace and module gate first, on every procedure', async () => {
|
|
166
|
+
for (const name of Object.keys(implemented)) {
|
|
167
|
+
const [first] = chainOf(name)
|
|
168
|
+
expect(
|
|
169
|
+
first === undefined ? undefined : (await reachedFor(first)).module,
|
|
170
|
+
`${name}: the first middleware has to be workspaceScoped('${MODULE_ID}') — a real membership, and the module switched on for that workspace`,
|
|
171
|
+
).toBe(MODULE_ID)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('puts a permission check the module declares after it', async () => {
|
|
176
|
+
for (const name of Object.keys(implemented)) {
|
|
177
|
+
const asked = (await Promise.all(chainOf(name).slice(1).map(reachedFor)))
|
|
178
|
+
.map((r) => r.permission)
|
|
179
|
+
.filter((p) => p !== undefined)
|
|
180
|
+
expect(
|
|
181
|
+
asked.filter((p) => declaredKeys.has(p)),
|
|
182
|
+
`${name}: needs requires(<a permission this module declares>) after the workspace gate; it asked for ${JSON.stringify(asked)}`,
|
|
183
|
+
).not.toHaveLength(0)
|
|
62
184
|
}
|
|
63
185
|
})
|
|
64
186
|
})
|
|
@@ -74,6 +196,207 @@ describe('the module declares what it uses', () => {
|
|
|
74
196
|
expect(inventoryModule.definition.id).toBe(MODULE_ID)
|
|
75
197
|
expect(inventoryModule.definition.permissions).toBe(inventoryPermissions)
|
|
76
198
|
expect(inventoryModule.definition.events).toBe(inventoryEvents)
|
|
199
|
+
expect(inventoryModule.definition.capabilities).toBe(inventoryCapabilities)
|
|
77
200
|
expect(inventoryModule.router, 'a module with a contract has to mount a router').toBeTypeOf('function')
|
|
78
201
|
})
|
|
79
202
|
})
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Capabilities, which are the one thing here that cannot be seen by reading a handler.
|
|
206
|
+
*
|
|
207
|
+
* A missing `requiresCapability` is invisible: the procedure compiles, every other test passes, and
|
|
208
|
+
* the only symptom is a workspace successfully calling a feature it switched off.
|
|
209
|
+
*
|
|
210
|
+
* **The expectation is derived from the contract, not opted into.** This used to check only the
|
|
211
|
+
* procedures named in `inventoryCapabilityProcedures` — so the map was both the claim and the
|
|
212
|
+
* evidence for it, and the regression it exists to catch walked straight past: add
|
|
213
|
+
* `repairs.cancel` to the contract, forget the middleware, forget the map, and every test here is
|
|
214
|
+
* green while a workspace with repairs switched off can call it. A list that has to be updated by
|
|
215
|
+
* the same person who forgot the thing it is guarding is not a guard.
|
|
216
|
+
*
|
|
217
|
+
* So the rule is read off the module's own declarations instead: **a switchable capability owns the
|
|
218
|
+
* router group named after it**, and every procedure in that group is gated on it. `repairs.*`
|
|
219
|
+
* belongs to `repairs`; `attachments.*` belongs to `attachments`. Adding a procedure to either group
|
|
220
|
+
* fails this file until it carries the middleware, whatever the map says. The map is still checked —
|
|
221
|
+
* it is what the client reads — but now against the group rather than against itself.
|
|
222
|
+
*/
|
|
223
|
+
describe('capabilities are enforced where they are declared', () => {
|
|
224
|
+
const gated = new Set(Object.values(inventoryCapabilityProcedures).flat())
|
|
225
|
+
|
|
226
|
+
/** The capabilities a workspace can actually switch: `core` is `required` and owns nothing. */
|
|
227
|
+
const switchable = inventoryCapabilities.filter((c) => !c.required).map((c) => c.id)
|
|
228
|
+
|
|
229
|
+
/** The contract's procedures under a capability's own name — the derived expectation. */
|
|
230
|
+
const groupOf = (capability: string) =>
|
|
231
|
+
Object.keys(declared)
|
|
232
|
+
.filter((name) => name.startsWith(`${capability}.`))
|
|
233
|
+
.sort()
|
|
234
|
+
|
|
235
|
+
it('gates every procedure in a switchable capability’s own group, named in the map or not', async () => {
|
|
236
|
+
const chains = await resolveChains()
|
|
237
|
+
for (const capability of switchable) {
|
|
238
|
+
expect(
|
|
239
|
+
groupOf(capability).length,
|
|
240
|
+
`${capability}: declared as a switch with no procedures behind it — a switch that changes nothing teaches an administrator that the switchboard does not mean anything`,
|
|
241
|
+
).toBeGreaterThan(0)
|
|
242
|
+
expect(
|
|
243
|
+
ungated(capability, groupOf(capability), chains),
|
|
244
|
+
`these need requiresCapability('${MODULE_ID}', '${capability}') between the workspace gate and the permission check`,
|
|
245
|
+
).toEqual([])
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('names that whole group in the map, so the client and the router cannot drift', () => {
|
|
250
|
+
// The client reads this map to decide what to hide; the router decides what to answer. A
|
|
251
|
+
// procedure gated in one and not the other is a tab that is there and 404s, or hidden and works.
|
|
252
|
+
for (const capability of switchable)
|
|
253
|
+
expect([...(inventoryCapabilityProcedures[capability] ?? [])].sort()).toEqual(groupOf(capability))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('rejects an ungated procedure, and one gated in the wrong place', () => {
|
|
257
|
+
/**
|
|
258
|
+
* The assertion aimed at chains built here, because a check nobody has watched fail is a check
|
|
259
|
+
* nobody knows the shape of. Four procedures, one correct and three not, and `ungated` has to
|
|
260
|
+
* name exactly the three.
|
|
261
|
+
*/
|
|
262
|
+
const chains: Record<string, Reached[]> = {
|
|
263
|
+
'repairs.list': [
|
|
264
|
+
{ module: MODULE_ID },
|
|
265
|
+
{ capability: `${MODULE_ID}.repairs` },
|
|
266
|
+
{ permission: 'inventory.asset.view' },
|
|
267
|
+
],
|
|
268
|
+
// The regression this file exists for: somebody added a procedure and no middleware.
|
|
269
|
+
'repairs.create': [{ module: MODULE_ID }, { permission: 'inventory.repair.manage' }],
|
|
270
|
+
// Gated, but after the permission — so a workspace with the capability off gets 403 from the
|
|
271
|
+
// permission check before the 404 that is the honest answer.
|
|
272
|
+
'repairs.update': [
|
|
273
|
+
{ module: MODULE_ID },
|
|
274
|
+
{ permission: 'inventory.repair.manage' },
|
|
275
|
+
{ capability: `${MODULE_ID}.repairs` },
|
|
276
|
+
],
|
|
277
|
+
// Gated on a different capability, which the identity check is what catches.
|
|
278
|
+
'repairs.complete': [
|
|
279
|
+
{ module: MODULE_ID },
|
|
280
|
+
{ capability: `${MODULE_ID}.attachments` },
|
|
281
|
+
{ permission: 'inventory.repair.manage' },
|
|
282
|
+
],
|
|
283
|
+
}
|
|
284
|
+
expect(ungated('repairs', Object.keys(chains), chains)).toEqual([
|
|
285
|
+
'repairs.create',
|
|
286
|
+
'repairs.update',
|
|
287
|
+
'repairs.complete',
|
|
288
|
+
])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('names only capabilities the module actually declares', () => {
|
|
292
|
+
const declaredIds = new Set(inventoryCapabilities.map((c) => c.id))
|
|
293
|
+
for (const id of Object.keys(inventoryCapabilityProcedures))
|
|
294
|
+
expect({ id, declared: declaredIds.has(id) }).toEqual({ id, declared: true })
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('names only procedures the contract actually has', () => {
|
|
298
|
+
for (const name of gated) expect({ name, exists: name in declared }).toEqual({ name, exists: true })
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('gates every procedure that belongs to a capability on that capability', async () => {
|
|
302
|
+
// Identity again, not a count: a third middleware proves nothing about which capability — or
|
|
303
|
+
// whether a capability — is being checked. `workspaceScoped` -> `requiresCapability` ->
|
|
304
|
+
// `requires`, in that order, so a workspace with the whole module off is refused before
|
|
305
|
+
// anything reveals which capabilities it would have had.
|
|
306
|
+
for (const [capability, names] of Object.entries(inventoryCapabilityProcedures)) {
|
|
307
|
+
for (const name of names) {
|
|
308
|
+
const chain = await Promise.all(chainOf(name).map(reachedFor))
|
|
309
|
+
const gate = chain.findIndex((r) => r.capability === `${MODULE_ID}.${capability}`)
|
|
310
|
+
const permission = chain.findIndex((r) => r.permission !== undefined)
|
|
311
|
+
expect(gate, `${name}: needs requiresCapability('${MODULE_ID}', '${capability}')`).toBeGreaterThan(0)
|
|
312
|
+
expect(gate, `${name}: the capability gate belongs before the permission check`).toBeLessThan(
|
|
313
|
+
permission,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('leaves `core` off the map, because a required capability is not a switch', () => {
|
|
320
|
+
expect(inventoryCapabilityProcedures.core).toBeUndefined()
|
|
321
|
+
expect(inventoryCapabilities.find((c) => c.id === 'core')?.required).toBe(true)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The manifest's other declarations — the ones that are handlers rather than middleware.
|
|
327
|
+
*
|
|
328
|
+
* Every entry here is a promise to the rest of the product, and each has exactly one shape of lie
|
|
329
|
+
* available to it: an object type with no resolver renders a link to nothing, a search indexer for a
|
|
330
|
+
* type nobody declared indexes documents nothing can open, a notification type nothing sends is a
|
|
331
|
+
* row in everybody's preferences that changes nothing, and a job with no cron never runs. All four
|
|
332
|
+
* compile. `objectTypes` was removed from this module in 0.2.0 for precisely the first of them.
|
|
333
|
+
*/
|
|
334
|
+
describe('the platform surfaces the module declares', () => {
|
|
335
|
+
const objectTypes = inventoryModule.definition.objectTypes ?? []
|
|
336
|
+
|
|
337
|
+
it('backs every object type with a resolver and an indexer', () => {
|
|
338
|
+
const resolved = new Set((inventoryModule.resolvers ?? []).map((r) => r.type))
|
|
339
|
+
const indexed = new Set((inventoryModule.search ?? []).flatMap((s) => s.types))
|
|
340
|
+
for (const { type } of objectTypes)
|
|
341
|
+
expect({ type, resolved: resolved.has(type), indexed: indexed.has(type) }).toEqual({
|
|
342
|
+
type,
|
|
343
|
+
resolved: true,
|
|
344
|
+
indexed: true,
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('declares every type it resolves or indexes, so nothing points at an undeclared noun', () => {
|
|
349
|
+
const declared = new Set(objectTypes.map((o) => o.type))
|
|
350
|
+
for (const resolver of inventoryModule.resolvers ?? [])
|
|
351
|
+
expect({ type: resolver.type, declared: declared.has(resolver.type) }).toEqual({
|
|
352
|
+
type: resolver.type,
|
|
353
|
+
declared: true,
|
|
354
|
+
})
|
|
355
|
+
for (const type of (inventoryModule.search ?? []).flatMap((s) => s.types))
|
|
356
|
+
expect({ type, declared: declared.has(type) }).toEqual({ type, declared: true })
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('offers a full reindex as well as a single load', () => {
|
|
360
|
+
// Without `scan`, `core.search.reindex` walks this module and finds nothing to do — silently,
|
|
361
|
+
// which is the worst way for a repair mechanism to be missing.
|
|
362
|
+
for (const indexer of inventoryModule.search ?? [])
|
|
363
|
+
expect({ types: indexer.types, scan: typeof indexer.scan }).toEqual({
|
|
364
|
+
types: indexer.types,
|
|
365
|
+
scan: 'function',
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('gives every scheduled job a name of its own and a schedule', () => {
|
|
370
|
+
const jobs = inventoryModule.jobs ?? []
|
|
371
|
+
expect(jobs.length, 'a module with sweeps declares them').toBeGreaterThan(0)
|
|
372
|
+
expect(new Set(jobs.map((j) => j.name)).size, 'two jobs sharing a name share a queue').toBe(jobs.length)
|
|
373
|
+
// Every job this module has is a sweep; one without a cron would be a queue nothing enqueues.
|
|
374
|
+
for (const job of jobs)
|
|
375
|
+
expect({ job: job.name, cron: typeof job.cron }).toEqual({ job: job.name, cron: 'string' })
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('names every notification type under its own module id', () => {
|
|
379
|
+
for (const type of inventoryModule.definition.notificationTypes ?? [])
|
|
380
|
+
expect(type.type.startsWith(`${MODULE_ID}.`), type.type).toBe(true)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A notification type nothing sends is the same lie as a permission nothing checks — and unlike a
|
|
385
|
+
* permission there is no middleware to inspect, so this reads the server for the string.
|
|
386
|
+
*
|
|
387
|
+
* Crude on purpose, and honest about it: it proves the *literal* appears in a file that sends
|
|
388
|
+
* notifications, not that the branch is reachable. A type whose key was built by concatenation
|
|
389
|
+
* would slip past it. Every one of this module's four is a literal at its call site, and keeping
|
|
390
|
+
* them that way is the point — a notification type assembled at runtime is one nobody can grep
|
|
391
|
+
* for either.
|
|
392
|
+
*/
|
|
393
|
+
it('has a sender in the server for every notification type it declares', () => {
|
|
394
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
395
|
+
const sources = readdirSync(join(here, 'server'), { recursive: true, encoding: 'utf8' })
|
|
396
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
|
|
397
|
+
.map((f) => readFileSync(join(here, 'server', f), 'utf8'))
|
|
398
|
+
.join('\n')
|
|
399
|
+
for (const { type } of inventoryModule.definition.notificationTypes ?? [])
|
|
400
|
+
expect({ type, sent: sources.includes(`'${type}'`) }).toEqual({ type, sent: true })
|
|
401
|
+
})
|
|
402
|
+
})
|