@things-factory/menu-base 10.1.79 → 10.1.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/service/menu/menu-mutation.js +46 -12
- package/dist-server/service/menu/menu-mutation.js.map +1 -1
- package/dist-server/service/menu-button/menu-button-mutation.js +5 -0
- package/dist-server/service/menu-button/menu-button-mutation.js.map +1 -1
- package/dist-server/service/menu-column/menu-column-mutation.js +5 -0
- package/dist-server/service/menu-column/menu-column-mutation.js.map +1 -1
- package/dist-server/service/menu-detail/menu-detail-mutation.js +5 -0
- package/dist-server/service/menu-detail/menu-detail-mutation.js.map +1 -1
- package/dist-server/service/menu-detail-button/menu-detail-button-mutation.js +5 -0
- package/dist-server/service/menu-detail-button/menu-detail-button-mutation.js.map +1 -1
- package/dist-server/service/menu-detail-column/menu-detail-column-mutation.js +5 -0
- package/dist-server/service/menu-detail-column/menu-detail-column-mutation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/tests/domain-reference.test.ts +140 -0
- package/tests/open-doors.test.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/menu-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.105",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "client/index.ts",
|
|
6
6
|
"things-factory": true,
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@things-factory/auth-base": "^10.1.79"
|
|
28
28
|
},
|
|
29
|
-
"gitHead": "
|
|
29
|
+
"gitHead": "c8ad9b40d57709beefd5e3d6167fd9cab6efea3a"
|
|
30
30
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A menu's parent and children, and the menu a save edits, are looked up inside the caller's domain.
|
|
3
|
+
*
|
|
4
|
+
* `updateMenu` and the grid save (`updateMultipleMenu`, cuFlag 'M') took the parent and children by id
|
|
5
|
+
* alone, so a menu of one domain could be put under another domain's menu, or pull another domain's menus
|
|
6
|
+
* under itself. A target the domain does not have fell through to a save by its id, which edited the row of
|
|
7
|
+
* the other domain. This runs the real mutations on sqlite and reads the rows back.
|
|
8
|
+
*/
|
|
9
|
+
import 'reflect-metadata'
|
|
10
|
+
|
|
11
|
+
const { existsSync } = require('fs')
|
|
12
|
+
const { join } = require('path')
|
|
13
|
+
const { DataSource } = require('typeorm')
|
|
14
|
+
|
|
15
|
+
const shell = require('@things-factory/shell')
|
|
16
|
+
const authBase = require('@things-factory/auth-base')
|
|
17
|
+
|
|
18
|
+
function loadCompiled(modulePath: string): any {
|
|
19
|
+
const resolved = join(__dirname, '..', 'dist-server', `${modulePath}.js`)
|
|
20
|
+
if (!existsSync(resolved)) {
|
|
21
|
+
throw new Error(`menu-base build output is missing: ${resolved}\nBuild it first: npx tsc`)
|
|
22
|
+
}
|
|
23
|
+
return require(resolved)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let dataSource: any
|
|
27
|
+
let Menu: any
|
|
28
|
+
let MenuMutation: any
|
|
29
|
+
|
|
30
|
+
let domain: any
|
|
31
|
+
let other: any
|
|
32
|
+
let user: any
|
|
33
|
+
let ourTop: any
|
|
34
|
+
let ourMenu: any
|
|
35
|
+
let theirTop: any
|
|
36
|
+
let theirMenu: any
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
const menuBase = loadCompiled('service/index')
|
|
40
|
+
Menu = menuBase.Menu
|
|
41
|
+
MenuMutation = loadCompiled('service/menu/menu-mutation').MenuMutation
|
|
42
|
+
|
|
43
|
+
dataSource = new DataSource({
|
|
44
|
+
type: 'sqlite',
|
|
45
|
+
database: ':memory:',
|
|
46
|
+
synchronize: true,
|
|
47
|
+
dropSchema: true,
|
|
48
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
49
|
+
entities: [...shell.entities, ...authBase.entities, ...menuBase.entities],
|
|
50
|
+
logging: false
|
|
51
|
+
})
|
|
52
|
+
await dataSource.initialize()
|
|
53
|
+
shell.addDataSource('default', dataSource)
|
|
54
|
+
shell.addDataSource('tx', dataSource)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
await dataSource.synchronize(true)
|
|
59
|
+
const domains = dataSource.getRepository(shell.Domain)
|
|
60
|
+
domain = await domains.save({ name: 'ours', subdomain: 'ours' })
|
|
61
|
+
other = await domains.save({ name: 'theirs', subdomain: 'theirs' })
|
|
62
|
+
user = await dataSource.getRepository(authBase.User).save({ name: 'tester', email: 'tester@menu-base.test' })
|
|
63
|
+
|
|
64
|
+
const menus = dataSource.getRepository(Menu)
|
|
65
|
+
ourTop = await menus.save({ domain, name: 'our top' })
|
|
66
|
+
ourMenu = await menus.save({ domain, name: 'our menu', description: 'ours' })
|
|
67
|
+
theirTop = await menus.save({ domain: other, name: 'their top' })
|
|
68
|
+
theirMenu = await menus.save({ domain: other, name: 'their menu', description: 'theirs', parent: theirTop })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
if (!dataSource) return
|
|
73
|
+
shell.removeDataSource('default')
|
|
74
|
+
shell.removeDataSource('tx')
|
|
75
|
+
await dataSource.destroy()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const context = () => ({ state: { domain, user, tx: dataSource.manager } })
|
|
79
|
+
|
|
80
|
+
async function row(id: string) {
|
|
81
|
+
return await dataSource.getRepository(Menu).findOne({ where: { id }, relations: ['parent'] })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("a menu's parent and children come from the caller's domain", () => {
|
|
85
|
+
it('a parent of this domain is accepted', async () => {
|
|
86
|
+
await new MenuMutation().updateMenu(ourMenu.id, { parent: { id: ourTop.id } }, context())
|
|
87
|
+
expect((await row(ourMenu.id)).parent?.id).toBe(ourTop.id)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('createMenu refuses a parent of another domain', async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
new MenuMutation().createMenu({ name: 'new', parent: { id: theirTop.id } }, context())
|
|
93
|
+
).rejects.toThrow(/parent menu not found in this domain/)
|
|
94
|
+
expect(await dataSource.getRepository(Menu).countBy({ name: 'new' })).toBe(0)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('updateMenu refuses a parent of another domain', async () => {
|
|
98
|
+
await expect(new MenuMutation().updateMenu(ourMenu.id, { parent: { id: theirTop.id } }, context())).rejects.toThrow(
|
|
99
|
+
/parent menu not found in this domain/
|
|
100
|
+
)
|
|
101
|
+
expect((await row(ourMenu.id)).parent).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('updateMenu refuses a child of another domain', async () => {
|
|
105
|
+
await expect(
|
|
106
|
+
new MenuMutation().updateMenu(ourTop.id, { children: [{ id: theirMenu.id }] }, context())
|
|
107
|
+
).rejects.toThrow(/child menu not found in this domain/)
|
|
108
|
+
expect((await row(theirMenu.id)).parent?.id).toBe(theirTop.id)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('updateMultipleMenu refuses them on a new row and on an edited one', async () => {
|
|
112
|
+
const mutation = new MenuMutation()
|
|
113
|
+
await expect(
|
|
114
|
+
mutation.updateMultipleMenu([{ cuFlag: '+', name: 'new', parent: { id: theirTop.id } }], context())
|
|
115
|
+
).rejects.toThrow(/parent menu not found in this domain/)
|
|
116
|
+
await expect(
|
|
117
|
+
mutation.updateMultipleMenu([{ cuFlag: 'M', id: ourMenu.id, parent: { id: theirTop.id } }], context())
|
|
118
|
+
).rejects.toThrow(/parent menu not found in this domain/)
|
|
119
|
+
await expect(
|
|
120
|
+
mutation.updateMultipleMenu([{ cuFlag: 'M', id: ourTop.id, children: [{ id: theirMenu.id }] }], context())
|
|
121
|
+
).rejects.toThrow(/child menu not found in this domain/)
|
|
122
|
+
expect((await row(theirMenu.id)).parent?.id).toBe(theirTop.id)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe("a save does not edit another domain's menu by its id", () => {
|
|
127
|
+
it('updateMenu', async () => {
|
|
128
|
+
await expect(
|
|
129
|
+
new MenuMutation().updateMenu(theirMenu.id, { description: 'overwritten' }, context())
|
|
130
|
+
).rejects.toThrow(/menu not found in this domain/)
|
|
131
|
+
expect((await row(theirMenu.id)).description).toBe('theirs')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('updateMultipleMenu', async () => {
|
|
135
|
+
await expect(
|
|
136
|
+
new MenuMutation().updateMultipleMenu([{ cuFlag: 'M', id: theirMenu.id, description: 'overwritten' }], context())
|
|
137
|
+
).rejects.toThrow(/menu not found in this domain/)
|
|
138
|
+
expect((await row(theirMenu.id)).description).toBe('theirs')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Every mutation of menu-base declares a privilege, and the domain owner may call it.
|
|
3
|
+
*
|
|
4
|
+
* Until 2026-10-01 no mutation of menu-base declared anything: any member of a domain could call them. Menus and their columns, buttons and details ask for `menu:mutation`
|
|
5
|
+
* (the category lite-menu already uses); which menus a role sees is role administration and asks for
|
|
6
|
+
* `user:mutation`, as the role mutations of auth-base do.
|
|
7
|
+
* The queries are not gated yet and are not measured here.
|
|
8
|
+
*/
|
|
9
|
+
import { gatesOf, openFields } from '../../../test/helpers/open-doors'
|
|
10
|
+
|
|
11
|
+
/** The category each mutation asks for; anything not named here asks for `menu`. */
|
|
12
|
+
const CATEGORY: Record<string, string> = {
|
|
13
|
+
updateRoleMenu: 'user',
|
|
14
|
+
updateRoleMenus: 'user'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Open on purpose or not decided yet, each with its reason. */
|
|
18
|
+
const OPEN: Record<string, string> = {}
|
|
19
|
+
|
|
20
|
+
let gates: Awaited<ReturnType<typeof gatesOf>>
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
gates = await gatesOf('menu-base')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('every open mutation has a written reason', () => {
|
|
27
|
+
expect(openFields(gates.Mutation)).toEqual(Object.keys(OPEN).sort())
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('every declared mutation asks for its category and admits the domain owner', () => {
|
|
31
|
+
for (const [field, gate] of Object.entries(gates.Mutation)) {
|
|
32
|
+
if (field in OPEN) continue
|
|
33
|
+
expect({ field, ...gate }).toEqual({
|
|
34
|
+
field,
|
|
35
|
+
category: CATEGORY[field] ?? 'menu',
|
|
36
|
+
privilege: 'mutation',
|
|
37
|
+
owner: true,
|
|
38
|
+
super: false
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('the walk reaches every mutation', () => {
|
|
44
|
+
/* If the resolver list shrank, the checks above would pass by having nothing to look at. */
|
|
45
|
+
expect(Object.keys(gates.Mutation)).toHaveLength(33)
|
|
46
|
+
})
|