@kernhq/module-inventory 0.1.2 → 0.2.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 +55 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +52 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +33 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +22 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +146 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +107 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +22 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +26 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +42 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +18 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +29 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +4 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +9 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
- package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
- package/dist/server/router.js +83 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +25 -11
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +28 -10
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +73 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +261 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/index.d.ts +10 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +15 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/notify.d.ts +63 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +105 -0
- package/dist/server/services/notify.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/_journal.json +2 -2
- package/package.json +5 -4
- package/src/client/api.ts +1 -1
- package/src/client/components/AssetFormDialog.svelte +130 -47
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/messages.test.ts +169 -0
- package/src/client/messages.ts +399 -0
- package/src/client/mock.test.ts +161 -0
- package/src/client/mock.ts +267 -45
- package/src/client/module.ts +22 -2
- package/src/client/pages/AssetsPage.svelte +358 -137
- package/src/client/permissions.ts +1 -1
- package/src/client/price.test.ts +106 -0
- package/src/client/price.ts +135 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +15 -2
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/settings/core-api.ts +32 -0
- package/src/client/widgets/OverviewWidget.svelte +16 -3
- package/src/contract/capabilities.ts +55 -0
- package/src/contract/events.ts +34 -0
- package/src/contract/index.ts +15 -0
- package/src/contract/models.ts +123 -0
- package/src/contract/permissions.ts +26 -0
- package/src/contract/router.ts +46 -0
- package/src/contract/settings.ts +30 -0
- package/src/module.test.ts +140 -7
- package/src/server/index.ts +16 -13
- package/src/server/inventory.int.test.ts +819 -0
- package/src/server/migrations.test.ts +138 -0
- package/src/server/router.ts +118 -0
- package/src/server/schema.ts +27 -10
- package/src/server/services/assets.ts +368 -0
- package/src/server/services/index.ts +23 -0
- package/src/server/services/notify.ts +151 -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.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.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
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { Cursor, type Principal, type WorkspaceId } from '@kernhq/contracts'
|
|
3
|
+
import { createKernel, type Kernel, type RequestContext, type Tx } from '@kernhq/kernel'
|
|
4
|
+
import { call } from '@orpc/server'
|
|
5
|
+
import { and, asc, eq } from 'drizzle-orm'
|
|
6
|
+
import pg from 'pg'
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
8
|
+
import type { Asset } from '../contract/models.js'
|
|
9
|
+
import { inventoryModule } from './index.js'
|
|
10
|
+
import { inventoryRouter } from './router.js'
|
|
11
|
+
import { assetHistory, assets, custodyPeriods, TENANT_TABLES } from './schema.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Inventory against a real Postgres.
|
|
15
|
+
*
|
|
16
|
+
* The unit tests walk the contract and the router as data; this proves the things only a database
|
|
17
|
+
* can answer — that both migrations apply on a database created from nothing, that the exclusion
|
|
18
|
+
* constraint refuses the write it claims to, that row-level security holds for a role that cannot
|
|
19
|
+
* bypass it, and that the keyset cursor pages through a list without dropping or repeating a row.
|
|
20
|
+
*
|
|
21
|
+
* A scratch database per run, dropped afterwards, so it never touches development data.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const BASE_URL = process.env.DATABASE_URL ?? 'postgres://kern:kern@localhost:5432/kern'
|
|
25
|
+
const DB_NAME = `kern_inventory_test_${Date.now().toString(36)}`
|
|
26
|
+
const RLS_ROLE = `kern_inv_rls_${Date.now().toString(36)}`
|
|
27
|
+
|
|
28
|
+
let kernel: Kernel
|
|
29
|
+
let inv: ReturnType<typeof inventoryRouter>
|
|
30
|
+
let admin: pg.Client
|
|
31
|
+
let databaseUrl: string
|
|
32
|
+
|
|
33
|
+
/** Branded in the contract, plain uuids here — the brand is a compile-time claim, not a value. */
|
|
34
|
+
const workspace = () => randomUUID() as WorkspaceId
|
|
35
|
+
|
|
36
|
+
const WS_A = workspace()
|
|
37
|
+
const WS_B = workspace()
|
|
38
|
+
/** Its own workspace, so the counter starts at 1 and the codes are predictable. */
|
|
39
|
+
const WS_CODES = workspace()
|
|
40
|
+
const WS_PAGE = workspace()
|
|
41
|
+
const WS_FILTER = workspace()
|
|
42
|
+
|
|
43
|
+
const ALICE = randomUUID()
|
|
44
|
+
const BOB = randomUUID()
|
|
45
|
+
|
|
46
|
+
const principal = (userId: string, workspaceId: string): Principal =>
|
|
47
|
+
// `unknown` first: `userId` is branded on Principal and a plain string does not overlap it, which
|
|
48
|
+
// is a real difference the test does not need to model.
|
|
49
|
+
({
|
|
50
|
+
kind: 'user',
|
|
51
|
+
userId,
|
|
52
|
+
email: `${userId}@example.test`,
|
|
53
|
+
name: userId.slice(0, 8),
|
|
54
|
+
locale: 'en',
|
|
55
|
+
instanceAdmin: false,
|
|
56
|
+
service: null,
|
|
57
|
+
memberships: [{ workspaceId, role: 'admin', roleIds: [], groupIds: [], status: 'active' }],
|
|
58
|
+
permissionVersion: 0,
|
|
59
|
+
}) as unknown as Principal
|
|
60
|
+
|
|
61
|
+
const inWs =
|
|
62
|
+
(workspaceId: string) =>
|
|
63
|
+
<T>(fn: (tx: Tx) => Promise<T>): Promise<T> =>
|
|
64
|
+
kernel.database.withWorkspace(workspaceId, fn, { userId: ALICE })
|
|
65
|
+
|
|
66
|
+
const run = inWs(WS_A)
|
|
67
|
+
|
|
68
|
+
function registerCoreStubs(k: Kernel) {
|
|
69
|
+
k.broker.register('core', {
|
|
70
|
+
'activity.record': { handler: async () => ({ ok: true }) },
|
|
71
|
+
'notifications.create': { handler: async () => ({ ok: true }) },
|
|
72
|
+
'search.index': { handler: async () => ({ ok: true }) },
|
|
73
|
+
'search.remove': { handler: async () => ({ ok: true }) },
|
|
74
|
+
'modules.isEnabled': { handler: async () => true },
|
|
75
|
+
'authz.customRolePermissions': { handler: async () => [] },
|
|
76
|
+
'authz.bindings': { handler: async () => [] },
|
|
77
|
+
// Inventory declares one capability, `core`, and it is `required` — so nothing here has to
|
|
78
|
+
// switch anything on. Empty settings is what a workspace that never opened the module has, and
|
|
79
|
+
// `InventorySettings` fills in `INV-`/4 from its own defaults.
|
|
80
|
+
'settings.getModule': { handler: async () => ({}) },
|
|
81
|
+
'settings.setModule': { handler: async () => ({ ok: true }) },
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A request context, as the HTTP layer would build one.
|
|
87
|
+
*
|
|
88
|
+
* Every procedure is called through `call()` rather than through its service, because the router is
|
|
89
|
+
* the only place `workspaceScoped` and `requires` live — calling `AssetService` directly would test
|
|
90
|
+
* the query and skip the two gates that decide whether anybody may reach it.
|
|
91
|
+
*/
|
|
92
|
+
const asUser = (userId: string, workspaceId: string = WS_A): RequestContext => ({
|
|
93
|
+
kernel,
|
|
94
|
+
principal: principal(userId, workspaceId),
|
|
95
|
+
requestId: randomUUID(),
|
|
96
|
+
ip: '127.0.0.1',
|
|
97
|
+
headers: {},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
admin = new pg.Client({ connectionString: BASE_URL })
|
|
102
|
+
await admin.connect()
|
|
103
|
+
await admin.query(`create database "${DB_NAME}"`)
|
|
104
|
+
const url = new URL(BASE_URL)
|
|
105
|
+
url.pathname = `/${DB_NAME}`
|
|
106
|
+
databaseUrl = url.toString()
|
|
107
|
+
|
|
108
|
+
kernel = await createKernel({
|
|
109
|
+
service: 'inventory-test',
|
|
110
|
+
modules: [inventoryModule],
|
|
111
|
+
role: 'api',
|
|
112
|
+
env: {
|
|
113
|
+
DATABASE_URL: databaseUrl,
|
|
114
|
+
KERN_SECRET: 'test-secret-that-is-long-enough-for-kern',
|
|
115
|
+
NODE_ENV: 'test',
|
|
116
|
+
NATS_URL: undefined,
|
|
117
|
+
VALKEY_URL: undefined,
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
registerCoreStubs(kernel)
|
|
121
|
+
await kernel.start()
|
|
122
|
+
inv = inventoryRouter(kernel)
|
|
123
|
+
}, 180_000)
|
|
124
|
+
|
|
125
|
+
afterAll(async () => {
|
|
126
|
+
await kernel?.stop().catch(() => undefined)
|
|
127
|
+
await admin.query(`drop database if exists "${DB_NAME}" with (force)`).catch(() => undefined)
|
|
128
|
+
await admin.query(`drop role if exists "${RLS_ROLE}"`).catch(() => undefined)
|
|
129
|
+
await admin.end().catch(() => undefined)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------------------------
|
|
133
|
+
// helpers
|
|
134
|
+
|
|
135
|
+
interface NewAsset {
|
|
136
|
+
name: string
|
|
137
|
+
description?: string
|
|
138
|
+
serialNumber?: string | null
|
|
139
|
+
location?: string | null
|
|
140
|
+
purchasedOn?: string | null
|
|
141
|
+
priceMinor?: number | null
|
|
142
|
+
currency?: string | null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const createAsset = (workspaceId: WorkspaceId, input: NewAsset, userId = ALICE): Promise<Asset> =>
|
|
146
|
+
call(inv.assets.create, { workspaceId, ...input }, { context: asUser(userId, workspaceId) })
|
|
147
|
+
|
|
148
|
+
const listAssets = (
|
|
149
|
+
workspaceId: WorkspaceId,
|
|
150
|
+
input: Omit<Parameters<typeof inv.assets.list>[0], 'workspaceId'> = {},
|
|
151
|
+
userId = ALICE,
|
|
152
|
+
) => call(inv.assets.list, { workspaceId, ...input }, { context: asUser(userId, workspaceId) })
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The error a rejected call actually carried.
|
|
156
|
+
*
|
|
157
|
+
* `KernError` travels up through `call()` unwrapped, but oRPC is free to wrap it, so this reaches
|
|
158
|
+
* through `cause` rather than trusting the top frame. Asserting on the message would pass for any
|
|
159
|
+
* failure — a typo in the input included — which is the whole thing these tests are trying to rule
|
|
160
|
+
* out.
|
|
161
|
+
*/
|
|
162
|
+
async function refusedWith(fn: () => Promise<unknown>): Promise<string> {
|
|
163
|
+
try {
|
|
164
|
+
await fn()
|
|
165
|
+
} catch (err) {
|
|
166
|
+
let cursor: unknown = err
|
|
167
|
+
for (let depth = 0; depth < 5 && cursor; depth++) {
|
|
168
|
+
const code = (cursor as { code?: unknown }).code
|
|
169
|
+
if (typeof code === 'string') return code
|
|
170
|
+
cursor = (cursor as { cause?: unknown }).cause
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`Rejected, but with no error code: ${String(err)}`)
|
|
173
|
+
}
|
|
174
|
+
throw new Error('Expected the call to be refused, but it succeeded')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Postgres reports which constraint refused a write; drizzle wraps that in a "Failed query" error
|
|
179
|
+
* whose message does not carry the name. Asserting on the message alone would pass for *any*
|
|
180
|
+
* failure, so this reaches through to the driver's own field.
|
|
181
|
+
*/
|
|
182
|
+
async function constraintViolated(fn: () => Promise<unknown>): Promise<string> {
|
|
183
|
+
try {
|
|
184
|
+
await fn()
|
|
185
|
+
} catch (err) {
|
|
186
|
+
let cursor: unknown = err
|
|
187
|
+
for (let depth = 0; depth < 5 && cursor; depth++) {
|
|
188
|
+
const name = (cursor as { constraint?: string }).constraint
|
|
189
|
+
if (name) return name
|
|
190
|
+
cursor = (cursor as { cause?: unknown }).cause
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`Rejected, but not by a named constraint: ${String(err)}`)
|
|
193
|
+
}
|
|
194
|
+
throw new Error('Expected the write to be refused, but it succeeded')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Booting the module is itself the first assertion: `kernel.start()` creates `mod_inventory` and
|
|
201
|
+
* runs both migrations, so a broken one fails here rather than on somebody's instance during an
|
|
202
|
+
* upgrade. Two of them have already shipped broken — a `gist` exclusion constraint with no
|
|
203
|
+
* `btree_gist`, and two column-level primary keys — and each one stops the *host service* booting,
|
|
204
|
+
* not merely this module.
|
|
205
|
+
*/
|
|
206
|
+
describe('the module boots', () => {
|
|
207
|
+
it('created its schema and every table it declares', async () => {
|
|
208
|
+
const { rows } = await kernel.database.pool.query<{ table_name: string }>(
|
|
209
|
+
`select table_name from information_schema.tables where table_schema = 'mod_inventory' order by 1`,
|
|
210
|
+
)
|
|
211
|
+
const names = rows.map((r) => r.table_name)
|
|
212
|
+
for (const t of TENANT_TABLES) expect(names, `mod_inventory.${t}`).toContain(t)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('put row-level security on every tenant table', async () => {
|
|
216
|
+
const { rows } = await kernel.database.pool.query<{ tablename: string; rowsecurity: boolean }>(
|
|
217
|
+
`select tablename, rowsecurity from pg_tables where schemaname = 'mod_inventory'`,
|
|
218
|
+
)
|
|
219
|
+
const secured = new Map(rows.map((r) => [r.tablename, r.rowsecurity]))
|
|
220
|
+
// Checked against TENANT_TABLES rather than "every table in the schema": drizzle's own
|
|
221
|
+
// `__migrations` bookkeeping lives here too and is not tenant data. Asserting over the declared
|
|
222
|
+
// list is also what makes a new table added without a policy fail — the whole reason the list
|
|
223
|
+
// exists next to the schema.
|
|
224
|
+
for (const t of TENANT_TABLES) expect(secured.get(t), `mod_inventory.${t} has RLS`).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('leaves no table carrying workspace_id out of TENANT_TABLES', async () => {
|
|
228
|
+
/**
|
|
229
|
+
* Guards the other direction, and does it against the **database** rather than a count.
|
|
230
|
+
*
|
|
231
|
+
* A table added to `schema.ts` but left out of `TENANT_TABLES` passes the test above by simply
|
|
232
|
+
* never being asked about — which is exactly how a table ships without a policy. Comparing what
|
|
233
|
+
* actually exists to what is declared cannot go stale, where a hardcoded number goes stale the
|
|
234
|
+
* first time anybody adds a table.
|
|
235
|
+
*
|
|
236
|
+
* "Carries `workspace_id`" is the rule, not "is in the schema", because that is the rule
|
|
237
|
+
* `schema.ts` states at the top of the file: a column named `workspace_id` is the definition of
|
|
238
|
+
* a tenant table, and a tenant table without a policy is simply readable by anything that
|
|
239
|
+
* reaches the database another way.
|
|
240
|
+
*/
|
|
241
|
+
const { rows } = await kernel.database.pool.query<{ table_name: string }>(
|
|
242
|
+
`select c.relname as table_name
|
|
243
|
+
from pg_class c
|
|
244
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
245
|
+
join pg_attribute a
|
|
246
|
+
on a.attrelid = c.oid
|
|
247
|
+
and a.attname = 'workspace_id'
|
|
248
|
+
and a.attnum > 0
|
|
249
|
+
and not a.attisdropped
|
|
250
|
+
where n.nspname = 'mod_inventory'
|
|
251
|
+
and c.relkind in ('r', 'p')
|
|
252
|
+
and not c.relispartition`,
|
|
253
|
+
)
|
|
254
|
+
const declared = new Set<string>(TENANT_TABLES)
|
|
255
|
+
const undeclared = rows
|
|
256
|
+
.map((r) => r.table_name)
|
|
257
|
+
// drizzle's own bookkeeping is not tenant data and correctly has no policy.
|
|
258
|
+
.filter((t) => !t.startsWith('__'))
|
|
259
|
+
.filter((t) => !declared.has(t))
|
|
260
|
+
expect(undeclared, 'tables carrying workspace_id but missing from TENANT_TABLES').toEqual([])
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The two constraints the schema leans on, checked as facts about the database rather than as
|
|
266
|
+
* intentions in TypeScript.
|
|
267
|
+
*/
|
|
268
|
+
describe('the schema the migrations actually produced', () => {
|
|
269
|
+
it('gives counters one composite primary key, not two', async () => {
|
|
270
|
+
// The regression test for SQLSTATE 42P16. Two column-level `.primaryKey()` calls read like a
|
|
271
|
+
// compound key and are not one — Postgres refuses the table, and because a module's migration
|
|
272
|
+
// is the first thing the kernel runs the symptom is a host service that never binds its port.
|
|
273
|
+
// By the time the suite gets here the table exists, so what is left to prove is that the key is
|
|
274
|
+
// the pair rather than one column with the other silently dropped.
|
|
275
|
+
const { rows } = await kernel.database.pool.query<{ attname: string }>(
|
|
276
|
+
`select a.attname
|
|
277
|
+
from pg_constraint c
|
|
278
|
+
join unnest(c.conkey) with ordinality as k(attnum, ord) on true
|
|
279
|
+
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum
|
|
280
|
+
where c.conrelid = 'mod_inventory.counters'::regclass
|
|
281
|
+
and c.contype = 'p'
|
|
282
|
+
order by k.ord`,
|
|
283
|
+
)
|
|
284
|
+
expect(rows.map((r) => r.attname)).toEqual(['workspace_id', 'key'])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('installed btree_gist, without which the custody constraint cannot exist', async () => {
|
|
288
|
+
// Core creates pg_trgm, pgcrypto, ltree and vector; btree_gist is not among them. A module
|
|
289
|
+
// reaching for `uuid with =` inside a gist exclusion constraint declares it itself, or its
|
|
290
|
+
// migration dies on a clean database with "data type uuid has no default operator class for
|
|
291
|
+
// access method gist" — invisible on any machine whose database already had the extension.
|
|
292
|
+
const { rows } = await kernel.database.pool.query<{ extname: string }>(
|
|
293
|
+
`select extname from pg_extension where extname = 'btree_gist'`,
|
|
294
|
+
)
|
|
295
|
+
expect(rows.map((r) => r.extname)).toEqual(['btree_gist'])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('carries the custody exclusion constraint', async () => {
|
|
299
|
+
const { rows } = await kernel.database.pool.query<{ contype: string }>(
|
|
300
|
+
`select contype from pg_constraint
|
|
301
|
+
where conrelid = 'mod_inventory.custody_periods'::regclass
|
|
302
|
+
and conname = 'inventory_custody_no_overlap'`,
|
|
303
|
+
)
|
|
304
|
+
expect(rows[0]?.contype, "'x' means exclusion").toBe('x')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('refuses a second open custody period for the same asset', async () => {
|
|
308
|
+
// The constraint's whole purpose, and the half a schema dump cannot show: two concurrent
|
|
309
|
+
// transfers must not both win. `effective_to` null means "still open", which tstzrange treats
|
|
310
|
+
// as unbounded, so an open period overlaps everything that would follow it.
|
|
311
|
+
const asset = await createAsset(WS_A, { name: 'Contested laptop' })
|
|
312
|
+
await run((tx) =>
|
|
313
|
+
tx.insert(custodyPeriods).values({ workspaceId: WS_A, assetId: asset.id, userId: ALICE }),
|
|
314
|
+
)
|
|
315
|
+
const name = await constraintViolated(() =>
|
|
316
|
+
run((tx) => tx.insert(custodyPeriods).values({ workspaceId: WS_A, assetId: asset.id, userId: BOB })),
|
|
317
|
+
)
|
|
318
|
+
expect(name).toBe('inventory_custody_no_overlap')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('still allows an open period once the previous one is closed', async () => {
|
|
322
|
+
// The other half: a constraint that refused this would make a transfer impossible, and a test
|
|
323
|
+
// that only proves rejection cannot tell the two apart.
|
|
324
|
+
const asset = await createAsset(WS_A, { name: 'Handed on' })
|
|
325
|
+
await run((tx) =>
|
|
326
|
+
tx.insert(custodyPeriods).values({
|
|
327
|
+
workspaceId: WS_A,
|
|
328
|
+
assetId: asset.id,
|
|
329
|
+
userId: ALICE,
|
|
330
|
+
effectiveFrom: new Date('2026-01-01T00:00:00Z'),
|
|
331
|
+
effectiveTo: new Date('2026-06-01T00:00:00Z'),
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
334
|
+
await run((tx) =>
|
|
335
|
+
tx.insert(custodyPeriods).values({
|
|
336
|
+
workspaceId: WS_A,
|
|
337
|
+
assetId: asset.id,
|
|
338
|
+
userId: BOB,
|
|
339
|
+
effectiveFrom: new Date('2026-06-01T00:00:00Z'),
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
const rows = await run((tx) =>
|
|
343
|
+
tx.select().from(custodyPeriods).where(eq(custodyPeriods.assetId, asset.id)),
|
|
344
|
+
)
|
|
345
|
+
expect(rows).toHaveLength(2)
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Row-level security, proven as a role that cannot bypass it.
|
|
351
|
+
*
|
|
352
|
+
* The development user is a superuser, and superusers bypass RLS entirely — so the same assertions
|
|
353
|
+
* run as `kern` would pass against a table with no policy at all. This is the only version of the
|
|
354
|
+
* test that proves anything.
|
|
355
|
+
*/
|
|
356
|
+
describe('row-level security, as a role that cannot bypass it', () => {
|
|
357
|
+
let plain: pg.Client
|
|
358
|
+
|
|
359
|
+
beforeAll(async () => {
|
|
360
|
+
await createAsset(WS_A, { name: 'Visible to A' })
|
|
361
|
+
await createAsset(WS_B, { name: 'Visible to B' }, BOB)
|
|
362
|
+
|
|
363
|
+
const scratch = new pg.Client({ connectionString: databaseUrl })
|
|
364
|
+
await scratch.connect()
|
|
365
|
+
await scratch.query(`create role "${RLS_ROLE}" login password 'probe'`)
|
|
366
|
+
await scratch.query(`grant usage on schema mod_inventory to "${RLS_ROLE}"`)
|
|
367
|
+
await scratch.query(`grant select on all tables in schema mod_inventory to "${RLS_ROLE}"`)
|
|
368
|
+
await scratch.end()
|
|
369
|
+
|
|
370
|
+
const url = new URL(databaseUrl)
|
|
371
|
+
url.username = RLS_ROLE
|
|
372
|
+
url.password = 'probe'
|
|
373
|
+
plain = new pg.Client({ connectionString: url.toString() })
|
|
374
|
+
await plain.connect()
|
|
375
|
+
}, 60_000)
|
|
376
|
+
|
|
377
|
+
afterAll(async () => {
|
|
378
|
+
await plain?.end().catch(() => undefined)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const count = async (sqlText: string) => {
|
|
382
|
+
const { rows } = await plain.query<{ n: string }>(sqlText)
|
|
383
|
+
return Number(rows[0]?.n ?? -1)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
it('shows nothing at all when no workspace is set', async () => {
|
|
387
|
+
await plain.query('reset app.workspace_id')
|
|
388
|
+
expect(await count('select count(*) as n from mod_inventory.assets')).toBe(0)
|
|
389
|
+
expect(await count('select count(*) as n from mod_inventory.asset_history')).toBe(0)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('shows one workspace its own rows', async () => {
|
|
393
|
+
await plain.query(`set app.workspace_id = '${WS_A}'`)
|
|
394
|
+
expect(await count('select count(*) as n from mod_inventory.assets')).toBeGreaterThan(0)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('shows one workspace nothing of another', async () => {
|
|
398
|
+
await plain.query(`set app.workspace_id = '${WS_B}'`)
|
|
399
|
+
expect(await count(`select count(*) as n from mod_inventory.assets where workspace_id = '${WS_A}'`)).toBe(
|
|
400
|
+
0,
|
|
401
|
+
)
|
|
402
|
+
// And the reverse, so a policy that simply hides everything cannot pass this.
|
|
403
|
+
expect(await count(`select count(*) as n from mod_inventory.assets where workspace_id = '${WS_B}'`)).toBe(
|
|
404
|
+
1,
|
|
405
|
+
)
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The counter row's conflict lock, under the only condition that can break it.
|
|
411
|
+
*
|
|
412
|
+
* `insert … on conflict do update … returning` increments under the row lock the insert already
|
|
413
|
+
* takes, so two concurrent creates each read the value their own statement returned. Run one at a
|
|
414
|
+
* time this is indistinguishable from a read-then-write, which is the version that hands two people
|
|
415
|
+
* the same asset tag.
|
|
416
|
+
*/
|
|
417
|
+
describe('asset codes under concurrency', () => {
|
|
418
|
+
it('gives twenty simultaneous creates twenty different codes', async () => {
|
|
419
|
+
const created = await Promise.all(
|
|
420
|
+
Array.from({ length: 20 }, (_, i) => createAsset(WS_CODES, { name: `Laptop ${i}` })),
|
|
421
|
+
)
|
|
422
|
+
const codes = created.map((a) => a.code)
|
|
423
|
+
expect(new Set(codes).size, 'every code is distinct').toBe(20)
|
|
424
|
+
for (const code of codes) expect(code).toMatch(/^INV-\d{4}$/)
|
|
425
|
+
// A fresh workspace, so the counter started at 1 and the run is exactly 1..20 — which also
|
|
426
|
+
// catches a lock that let two transactions read the same value and then skip a number.
|
|
427
|
+
expect([...codes].sort()).toEqual(
|
|
428
|
+
Array.from({ length: 20 }, (_, i) => `INV-${String(i + 1).padStart(4, '0')}`),
|
|
429
|
+
)
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Keyset pagination, checked as the property that actually matters: paging through a list must
|
|
435
|
+
* yield every row exactly once. An off-by-one in the cursor comparison repeats the boundary row; a
|
|
436
|
+
* cursor on a non-unique column drops the rows that share its value. Both look like a working list
|
|
437
|
+
* on page one, which is why the page used to filter in the browser and nobody noticed.
|
|
438
|
+
*/
|
|
439
|
+
describe('paging through a list', () => {
|
|
440
|
+
const TOTAL = 25
|
|
441
|
+
/** Deliberately repeated: `name` is not unique, which is why the cursor carries the id too. */
|
|
442
|
+
const NAMES = ['Chair', 'Desk', 'Laptop', 'Monitor', 'Phone']
|
|
443
|
+
let everyId: string[] = []
|
|
444
|
+
|
|
445
|
+
beforeAll(async () => {
|
|
446
|
+
const created: Asset[] = []
|
|
447
|
+
/**
|
|
448
|
+
* Serially **and a clear millisecond apart**, so `recent` (uuidv7 order) really is creation
|
|
449
|
+
* order and a repeated row is visible.
|
|
450
|
+
*
|
|
451
|
+
* The wait is not padding. `uuidv7()` carries the clock in its first six bytes — to the
|
|
452
|
+
* millisecond — and fills the remaining ten from `randomUUID()`, with no counter: two rows
|
|
453
|
+
* created inside one millisecond sort in *random* order relative to each other. Serial creates
|
|
454
|
+
* alone do not buy the ordering this block's assertions rest on, they only make it likely, and
|
|
455
|
+
* anything that makes `create` quicker (taking the module-settings read out of the
|
|
456
|
+
* transaction did) raises the collision rate until the suite fails intermittently on an
|
|
457
|
+
* ordering the module never promised.
|
|
458
|
+
*/
|
|
459
|
+
for (let i = 0; i < TOTAL; i++) {
|
|
460
|
+
created.push(await createAsset(WS_PAGE, { name: NAMES[i % NAMES.length]! }))
|
|
461
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
462
|
+
}
|
|
463
|
+
everyId = created.map((a) => a.id)
|
|
464
|
+
}, 120_000)
|
|
465
|
+
|
|
466
|
+
async function pageThrough(sort: 'recent' | 'name' | 'code') {
|
|
467
|
+
const seen: Asset[] = []
|
|
468
|
+
let cursor: string | undefined
|
|
469
|
+
let pages = 0
|
|
470
|
+
do {
|
|
471
|
+
const page = await listAssets(WS_PAGE, { sort, limit: 10, ...(cursor ? { cursor } : {}) })
|
|
472
|
+
seen.push(...page.items)
|
|
473
|
+
cursor = page.nextCursor ?? undefined
|
|
474
|
+
pages += 1
|
|
475
|
+
if (pages > 10) throw new Error('paging did not terminate')
|
|
476
|
+
} while (cursor)
|
|
477
|
+
return { seen, pages }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
it('returns every row exactly once, newest first', async () => {
|
|
481
|
+
const { seen, pages } = await pageThrough('recent')
|
|
482
|
+
expect(pages, '25 rows at 10 a page').toBe(3)
|
|
483
|
+
expect(seen).toHaveLength(TOTAL)
|
|
484
|
+
expect(new Set(seen.map((a) => a.id)).size, 'no row appears twice').toBe(TOTAL)
|
|
485
|
+
expect([...seen.map((a) => a.id)].sort()).toEqual([...everyId].sort())
|
|
486
|
+
expect(seen.map((a) => a.id)).toEqual([...everyId].reverse())
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('returns every row exactly once when the sort column is not unique', async () => {
|
|
490
|
+
const { seen, pages } = await pageThrough('name')
|
|
491
|
+
expect(pages).toBe(3)
|
|
492
|
+
expect(seen).toHaveLength(TOTAL)
|
|
493
|
+
expect(new Set(seen.map((a) => a.id)).size, 'no row appears twice').toBe(TOTAL)
|
|
494
|
+
expect([...seen.map((a) => a.id)].sort()).toEqual([...everyId].sort())
|
|
495
|
+
// Five rows share each name, so a cursor that carried only the name would skip four of them at
|
|
496
|
+
// every page boundary. The order must still be globally non-decreasing.
|
|
497
|
+
const names = seen.map((a) => a.name)
|
|
498
|
+
expect(names).toEqual([...names].sort())
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('refuses a page marker it did not issue rather than silently returning page one', async () => {
|
|
502
|
+
// The failure this rules out is the quiet one: a cursor that cannot be decoded, swallowed, and
|
|
503
|
+
// an infinite list that serves page one for ever.
|
|
504
|
+
expect(
|
|
505
|
+
await refusedWith(() => listAssets(WS_PAGE, { sort: 'name', limit: 10, cursor: 'nonsense' })),
|
|
506
|
+
).toBe('BAD_REQUEST')
|
|
507
|
+
const wrongShape = Buffer.from(JSON.stringify({ nope: 1 }), 'utf8').toString('base64url')
|
|
508
|
+
expect(
|
|
509
|
+
await refusedWith(() => listAssets(WS_PAGE, { sort: 'name', limit: 10, cursor: wrongShape })),
|
|
510
|
+
).toBe('BAD_REQUEST')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('refuses a page marker whose row id is not a uuid, rather than handing it to Postgres', async () => {
|
|
514
|
+
// The cursor's id is interpolated into a `::uuid` cast. `decode` used to check only that it was
|
|
515
|
+
// a *string*, so this exact value reached the database as a 22P02 nobody caught: an unhandled
|
|
516
|
+
// 500 and an error-level log line per request, from anyone who could type in the address bar.
|
|
517
|
+
const notAUuid = Buffer.from(JSON.stringify({ i: 'not-a-uuid', s: 'name' }), 'utf8').toString('base64url')
|
|
518
|
+
expect(await refusedWith(() => listAssets(WS_PAGE, { sort: 'name', limit: 10, cursor: notAUuid }))).toBe(
|
|
519
|
+
'BAD_REQUEST',
|
|
520
|
+
)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('refuses a page marker issued under a different sort', async () => {
|
|
524
|
+
// Replayed under another ordering, a bookmark cannot be read at all: page two came back equal
|
|
525
|
+
// to page one, so "Load more" served the same ten rows for ever. A cursor names its sort now,
|
|
526
|
+
// and a list that is not that sort refuses it.
|
|
527
|
+
const first = await listAssets(WS_PAGE, { sort: 'recent', limit: 10 })
|
|
528
|
+
expect(first.nextCursor, 'a 25-row list at 10 a page has a second page').not.toBeNull()
|
|
529
|
+
expect(
|
|
530
|
+
await refusedWith(() => listAssets(WS_PAGE, { sort: 'code', limit: 10, cursor: first.nextCursor! })),
|
|
531
|
+
).toBe('BAD_REQUEST')
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('issues a cursor the contract can carry, however long the name is', async () => {
|
|
535
|
+
// `Cursor` is `max(512)`. The bookmark used to carry the sort key itself, so a 200-character
|
|
536
|
+
// Persian name — the longest `name` the contract allows — encoded to 602 characters and broke
|
|
537
|
+
// "Load more" with a validation error, in the locales least likely to be tested. The bookmark
|
|
538
|
+
// carries the row id and the sort now, so its size does not depend on the data at all.
|
|
539
|
+
const ws = workspace()
|
|
540
|
+
const long = 'صندلی اداری چرخدار با پشتی مشبک و تنظیم ارتفاع '.repeat(5).slice(0, 200)
|
|
541
|
+
expect(long, 'the longest name the contract allows').toHaveLength(200)
|
|
542
|
+
await createAsset(ws, { name: long })
|
|
543
|
+
await createAsset(ws, { name: `${long.slice(0, 199)}ی` })
|
|
544
|
+
|
|
545
|
+
const page = await listAssets(ws, { sort: 'name', limit: 1 })
|
|
546
|
+
expect(page.nextCursor).not.toBeNull()
|
|
547
|
+
expect(
|
|
548
|
+
Cursor.safeParse(page.nextCursor).success,
|
|
549
|
+
'the server must not issue a cursor its own contract will reject on the way back',
|
|
550
|
+
).toBe(true)
|
|
551
|
+
|
|
552
|
+
// And it still pages: a cursor that validates but does not work is the same bug wearing a hat.
|
|
553
|
+
const second = await listAssets(ws, { sort: 'name', limit: 1, cursor: page.nextCursor! })
|
|
554
|
+
expect(second.items.map((a) => a.id)).not.toEqual(page.items.map((a) => a.id))
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('ends the list when the row a cursor points at has since been deleted', async () => {
|
|
558
|
+
/**
|
|
559
|
+
* Deliberately an empty page rather than a 400.
|
|
560
|
+
*
|
|
561
|
+
* Somebody archiving or deleting a row while another person reads the list is an ordinary race,
|
|
562
|
+
* not a malformed request — an error on "Load more" would blame the reader for it. The row
|
|
563
|
+
* comparison is against a subquery, so a missing anchor makes it NULL and the page comes back
|
|
564
|
+
* empty with no cursor: the list ends where it was, and the next refresh is correct.
|
|
565
|
+
*/
|
|
566
|
+
const ws = workspace()
|
|
567
|
+
for (let i = 0; i < 3; i++) await createAsset(ws, { name: `Vanishing ${i}` })
|
|
568
|
+
|
|
569
|
+
const first = await listAssets(ws, { sort: 'recent', limit: 1 })
|
|
570
|
+
expect(first.nextCursor).not.toBeNull()
|
|
571
|
+
const anchor = first.items[0]!.id
|
|
572
|
+
await kernel.database.withWorkspace(ws, (tx) =>
|
|
573
|
+
tx.delete(assets).where(and(eq(assets.workspaceId, ws), eq(assets.id, anchor))),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
const second = await listAssets(ws, { sort: 'recent', limit: 1, cursor: first.nextCursor! })
|
|
577
|
+
expect(second.items, 'the page the deleted row bookmarked').toEqual([])
|
|
578
|
+
expect(second.nextCursor, 'and nothing to keep asking for').toBeNull()
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* A search box takes text, not a pattern.
|
|
584
|
+
*
|
|
585
|
+
* `%`, `_` and `\` are `ilike` syntax, and interpolated straight into the pattern they turned a
|
|
586
|
+
* search into a wildcard the person did not ask for: `50%` matched every row in the workspace and
|
|
587
|
+
* `_hair` matched "Chair". Both read as a broken search rather than as a clever one.
|
|
588
|
+
*/
|
|
589
|
+
describe('searching for text that looks like a pattern', () => {
|
|
590
|
+
const WS_LIKE = workspace()
|
|
591
|
+
let chair: Asset
|
|
592
|
+
let throw_: Asset
|
|
593
|
+
|
|
594
|
+
beforeAll(async () => {
|
|
595
|
+
chair = await createAsset(WS_LIKE, { name: 'Chair' })
|
|
596
|
+
throw_ = await createAsset(WS_LIKE, { name: '50% cotton throw' })
|
|
597
|
+
}, 60_000)
|
|
598
|
+
|
|
599
|
+
it('treats _ as a character, not as "any character"', async () => {
|
|
600
|
+
const page = await listAssets(WS_LIKE, { limit: 50, q: '_hair' })
|
|
601
|
+
expect(
|
|
602
|
+
page.items.map((a) => a.id),
|
|
603
|
+
'unescaped, this matches Chair',
|
|
604
|
+
).toEqual([])
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
it('treats % as a character, not as "anything at all"', async () => {
|
|
608
|
+
const wild = await listAssets(WS_LIKE, { limit: 50, q: '%' })
|
|
609
|
+
expect(
|
|
610
|
+
wild.items.map((a) => a.id),
|
|
611
|
+
'unescaped, this matches every row',
|
|
612
|
+
).toEqual([throw_.id])
|
|
613
|
+
|
|
614
|
+
const literal = await listAssets(WS_LIKE, { limit: 50, q: '50%' })
|
|
615
|
+
expect(literal.items.map((a) => a.id)).toEqual([throw_.id])
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('still finds the plain text beside them', async () => {
|
|
619
|
+
// The other half: escaping that stripped the characters would break an honest search too.
|
|
620
|
+
const page = await listAssets(WS_LIKE, { limit: 50, q: 'chair' })
|
|
621
|
+
expect(page.items.map((a) => a.id)).toEqual([chair.id])
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Filters, applied by the server.
|
|
627
|
+
*
|
|
628
|
+
* Every one of these used to be — or could plausibly be — a filter the browser applies to the page
|
|
629
|
+
* it was handed, which is wrong the moment there is more than one page: the first twenty rows come
|
|
630
|
+
* back, half are dropped, and the list looks short rather than paged.
|
|
631
|
+
*/
|
|
632
|
+
describe('narrowing a list', () => {
|
|
633
|
+
let thinkpad: Asset
|
|
634
|
+
let archived: Asset
|
|
635
|
+
let broken: Asset
|
|
636
|
+
|
|
637
|
+
beforeAll(async () => {
|
|
638
|
+
thinkpad = await createAsset(WS_FILTER, { name: 'Thinkpad X1', serialNumber: 'SN-QQQ-1' })
|
|
639
|
+
archived = await createAsset(WS_FILTER, { name: 'Retired projector' })
|
|
640
|
+
broken = await createAsset(WS_FILTER, { name: 'Cracked monitor' })
|
|
641
|
+
await call(
|
|
642
|
+
inv.assets.archive,
|
|
643
|
+
{ workspaceId: WS_FILTER, assetId: archived.id, archived: true },
|
|
644
|
+
{ context: asUser(ALICE, WS_FILTER) },
|
|
645
|
+
)
|
|
646
|
+
// `status` and `custodian_user_id` follow custody and repairs, neither of which has procedures
|
|
647
|
+
// yet, so they are set the way those procedures will set them — directly, in the module's own
|
|
648
|
+
// transaction. The filter is what is under test, not the writer.
|
|
649
|
+
await kernel.database.withWorkspace(WS_FILTER, (tx) =>
|
|
650
|
+
tx
|
|
651
|
+
.update(assets)
|
|
652
|
+
.set({ status: 'under_repair', custodianUserId: BOB })
|
|
653
|
+
.where(and(eq(assets.workspaceId, WS_FILTER), eq(assets.id, broken.id))),
|
|
654
|
+
)
|
|
655
|
+
}, 60_000)
|
|
656
|
+
|
|
657
|
+
const ids = (page: { items: Asset[] }) => page.items.map((a) => a.id).sort()
|
|
658
|
+
|
|
659
|
+
it('leaves archived rows out unless they are asked for', async () => {
|
|
660
|
+
const live = await listAssets(WS_FILTER, { limit: 50 })
|
|
661
|
+
expect(live.items.map((a) => a.id)).not.toContain(archived.id)
|
|
662
|
+
expect(ids(live)).toEqual([thinkpad.id, broken.id].sort())
|
|
663
|
+
|
|
664
|
+
const all = await listAssets(WS_FILTER, { limit: 50, archived: true })
|
|
665
|
+
expect(all.items.map((a) => a.id)).toContain(archived.id)
|
|
666
|
+
expect(ids(all)).toEqual([thinkpad.id, archived.id, broken.id].sort())
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('narrows by status', async () => {
|
|
670
|
+
const page = await listAssets(WS_FILTER, { limit: 50, status: 'under_repair' })
|
|
671
|
+
expect(ids(page)).toEqual([broken.id])
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('narrows by custodian', async () => {
|
|
675
|
+
const page = await listAssets(WS_FILTER, { limit: 50, custodianUserId: BOB })
|
|
676
|
+
expect(ids(page)).toEqual([broken.id])
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('matches a query against the name, the code and the serial', async () => {
|
|
680
|
+
// Three separate reads, because a search that covers only the name is the one somebody notices
|
|
681
|
+
// while standing in front of the machine reading its sticker out loud.
|
|
682
|
+
const byName = await listAssets(WS_FILTER, { limit: 50, q: 'thinkpad' })
|
|
683
|
+
expect(ids(byName)).toEqual([thinkpad.id])
|
|
684
|
+
|
|
685
|
+
const byCode = await listAssets(WS_FILTER, { limit: 50, q: thinkpad.code })
|
|
686
|
+
expect(ids(byCode)).toEqual([thinkpad.id])
|
|
687
|
+
|
|
688
|
+
const bySerial = await listAssets(WS_FILTER, { limit: 50, q: 'qqq' })
|
|
689
|
+
expect(ids(bySerial)).toEqual([thinkpad.id])
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Tenant isolation through the API, not through the policy.
|
|
695
|
+
*
|
|
696
|
+
* The database is reached as a superuser here, exactly as `core` reaches it in production, so RLS
|
|
697
|
+
* is bypassed and the `workspace_id` predicate in the service is the only thing standing between
|
|
698
|
+
* two customers. That is the predicate this asserts.
|
|
699
|
+
*/
|
|
700
|
+
describe('two workspaces', () => {
|
|
701
|
+
it('does not let one read the other’s asset', async () => {
|
|
702
|
+
const mine = await createAsset(WS_A, { name: 'Only ours' })
|
|
703
|
+
expect(
|
|
704
|
+
await refusedWith(() =>
|
|
705
|
+
call(inv.assets.get, { workspaceId: WS_B, assetId: mine.id }, { context: asUser(BOB, WS_B) }),
|
|
706
|
+
),
|
|
707
|
+
'a leak and a 403 are both wrong here: from B, the row does not exist',
|
|
708
|
+
).toBe('NOT_FOUND')
|
|
709
|
+
})
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The history trail — the module's own authoritative record of what happened, written inside the
|
|
714
|
+
* caller's transaction. Core activity is a best-effort mirror of it and may be missing; this may
|
|
715
|
+
* not be.
|
|
716
|
+
*/
|
|
717
|
+
describe('the history trail', () => {
|
|
718
|
+
const historyOf = (workspaceId: string, assetId: string) =>
|
|
719
|
+
kernel.database.withWorkspace(workspaceId, (tx) =>
|
|
720
|
+
tx
|
|
721
|
+
.select()
|
|
722
|
+
.from(assetHistory)
|
|
723
|
+
.where(and(eq(assetHistory.workspaceId, workspaceId), eq(assetHistory.assetId, assetId)))
|
|
724
|
+
.orderBy(asc(assetHistory.id)),
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
it('records a creation, then an update carrying only the field that moved', async () => {
|
|
728
|
+
// No description on purpose. With one set, this diff also names `description` on a patch that
|
|
729
|
+
// never mentioned it — see "patching one field" below, which is the same defect seen from the
|
|
730
|
+
// other side.
|
|
731
|
+
const asset = await createAsset(WS_A, { name: 'Timeline' })
|
|
732
|
+
await call(
|
|
733
|
+
inv.assets.update,
|
|
734
|
+
{ workspaceId: WS_A, assetId: asset.id, name: 'Timeline mk2' },
|
|
735
|
+
{ context: asUser(ALICE) },
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
const rows = await historyOf(WS_A, asset.id)
|
|
739
|
+
expect(rows.map((r) => r.action)).toEqual(['created', 'updated'])
|
|
740
|
+
expect(rows[1]?.changes).toEqual([{ field: 'name', from: 'Timeline', to: 'Timeline mk2' }])
|
|
741
|
+
expect(rows[1]?.actorId).toBe(ALICE)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('writes nothing for an update that changes nothing', async () => {
|
|
745
|
+
// A timeline that logs a no-op is a timeline nobody reads, and "somebody touched this" is not
|
|
746
|
+
// what the page promises.
|
|
747
|
+
const asset = await createAsset(WS_A, { name: 'Unmoved' })
|
|
748
|
+
await call(
|
|
749
|
+
inv.assets.update,
|
|
750
|
+
{ workspaceId: WS_A, assetId: asset.id, name: 'Unmoved' },
|
|
751
|
+
{ context: asUser(ALICE) },
|
|
752
|
+
)
|
|
753
|
+
const rows = await historyOf(WS_A, asset.id)
|
|
754
|
+
expect(rows.map((r) => r.action)).toEqual(['created'])
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it('records archiving and restoring under their own actions', async () => {
|
|
758
|
+
const asset = await createAsset(WS_A, { name: 'Round trip' })
|
|
759
|
+
const ctx = { context: asUser(ALICE) }
|
|
760
|
+
await call(inv.assets.archive, { workspaceId: WS_A, assetId: asset.id, archived: true }, ctx)
|
|
761
|
+
const back = await call(
|
|
762
|
+
inv.assets.archive,
|
|
763
|
+
{ workspaceId: WS_A, assetId: asset.id, archived: false },
|
|
764
|
+
ctx,
|
|
765
|
+
)
|
|
766
|
+
expect(back.archivedAt).toBeNull()
|
|
767
|
+
const rows = await historyOf(WS_A, asset.id)
|
|
768
|
+
expect(rows.map((r) => r.action)).toEqual(['created', 'retired', 'restored'])
|
|
769
|
+
})
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* `undefined` means "not mentioned"; `null` means "clear it".
|
|
774
|
+
*
|
|
775
|
+
* Collapsing the two is how an edit of one field quietly wipes the others, and the wipe is silent —
|
|
776
|
+
* the request succeeds, the screen redraws, and the location the person spent a morning filling in
|
|
777
|
+
* is gone.
|
|
778
|
+
*/
|
|
779
|
+
describe('patching one field', () => {
|
|
780
|
+
it('leaves a field the patch never mentioned alone', async () => {
|
|
781
|
+
const asset = await createAsset(WS_A, { name: 'Desk lamp', location: 'Desk 4' })
|
|
782
|
+
const patched = await call(
|
|
783
|
+
inv.assets.update,
|
|
784
|
+
{ workspaceId: WS_A, assetId: asset.id, name: 'Desk lamp (tall)' },
|
|
785
|
+
{ context: asUser(ALICE) },
|
|
786
|
+
)
|
|
787
|
+
expect(patched.name).toBe('Desk lamp (tall)')
|
|
788
|
+
expect(patched.location, 'a patch that never named location must not clear it').toBe('Desk 4')
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('clears a field the patch explicitly passes as null', async () => {
|
|
792
|
+
const asset = await createAsset(WS_A, { name: 'Loose cable', location: 'Drawer' })
|
|
793
|
+
const patched = await call(
|
|
794
|
+
inv.assets.update,
|
|
795
|
+
{ workspaceId: WS_A, assetId: asset.id, location: null },
|
|
796
|
+
{ context: asUser(ALICE) },
|
|
797
|
+
)
|
|
798
|
+
expect(patched.location).toBeNull()
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('leaves a description the patch never mentioned alone', async () => {
|
|
802
|
+
/**
|
|
803
|
+
* The same rule as the two above, and the one field that breaks it.
|
|
804
|
+
*
|
|
805
|
+
* `AssetInput.description` carries `.default('')`, and `.partial()` does not remove a default —
|
|
806
|
+
* it wraps the field in `optional`, and zod still substitutes the default for a missing key. So
|
|
807
|
+
* a PATCH of `{ name }` arrives at the handler as `{ name, description: '' }`, `AssetService`
|
|
808
|
+
* correctly reads that as "clear it", and the description is destroyed by a rename. The
|
|
809
|
+
* `undefined`-versus-`null` care in the service is defeated one layer above it, in the contract.
|
|
810
|
+
*/
|
|
811
|
+
const asset = await createAsset(WS_A, { name: 'Server rack', description: 'Second floor, bolted' })
|
|
812
|
+
const patched = await call(
|
|
813
|
+
inv.assets.update,
|
|
814
|
+
{ workspaceId: WS_A, assetId: asset.id, name: 'Server rack B' },
|
|
815
|
+
{ context: asUser(ALICE) },
|
|
816
|
+
)
|
|
817
|
+
expect(patched.description, 'a rename must not wipe the description').toBe('Second floor, bolted')
|
|
818
|
+
})
|
|
819
|
+
})
|