@things-factory/auth-base 10.1.37 → 10.1.38
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-client/auth.d.ts +8 -0
- package/dist-client/auth.js +26 -0
- package/dist-client/auth.js.map +1 -1
- package/dist-client/domain-required.d.ts +54 -0
- package/dist-client/domain-required.js +91 -0
- package/dist-client/domain-required.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/invitation.d.ts +26 -0
- package/dist-server/controllers/invitation.js +12 -1
- package/dist-server/controllers/invitation.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-query.d.ts +17 -0
- package/dist-server/service/app-binding/app-binding-query.js +32 -2
- package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
- package/dist-server/service/index.d.ts +7 -2
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.d.ts +9 -0
- package/dist-server/service/open-fields.js +24 -0
- package/dist-server/service/open-fields.js.map +1 -0
- package/dist-server/service/privilege/open-field-rule.d.ts +93 -0
- package/dist-server/service/privilege/open-field-rule.js +81 -0
- package/dist-server/service/privilege/open-field-rule.js.map +1 -0
- package/dist-server/service/privilege/privilege-directive.d.ts +9 -1
- package/dist-server/service/privilege/privilege-directive.js +177 -95
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/tests/app-binding-db.test.ts +48 -0
- package/tests/domain-required.test.ts +127 -0
- package/tests/invitation-route.test.ts +97 -0
- package/tests/open-doors.test.ts +13 -21
- package/tests/open-fields-enforcement.test.ts +215 -0
- package/translations/en.json +0 -1
- package/translations/ja.json +0 -1
- package/translations/ko.json +0 -1
- package/translations/ms.json +0 -1
- package/translations/zh.json +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.38",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"@reduxjs/toolkit": "^2.2.5",
|
|
35
35
|
"@simplewebauthn/browser": "^13.0.0",
|
|
36
36
|
"@simplewebauthn/server": "^13.0.0",
|
|
37
|
-
"@things-factory/email-base": "^10.1.
|
|
37
|
+
"@things-factory/email-base": "^10.1.38",
|
|
38
38
|
"@things-factory/env": "^10.1.20",
|
|
39
|
-
"@things-factory/shell": "^10.1.
|
|
39
|
+
"@things-factory/shell": "^10.1.38",
|
|
40
40
|
"@things-factory/utils": "^10.1.20",
|
|
41
41
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
42
42
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "b947349fe1c27d11038b592ceadaba336f14785b"
|
|
52
52
|
}
|
|
@@ -160,3 +160,51 @@ describe('the list', () => {
|
|
|
160
160
|
expect(items).toHaveLength(1)
|
|
161
161
|
})
|
|
162
162
|
})
|
|
163
|
+
|
|
164
|
+
describe('the free-text term', () => {
|
|
165
|
+
/*
|
|
166
|
+
* The screen now sends the term itself (`ListParam.search`) and lets the server say where it
|
|
167
|
+
* looks. This door builds with the older `buildQuery`, which only understood search *filters* -
|
|
168
|
+
* so a term that arrived here was dropped, and the whole list came back as if it were the answer.
|
|
169
|
+
* A person searching for a binding's name read that as "not here".
|
|
170
|
+
*/
|
|
171
|
+
it('narrows the list to the bindings that match', async () => {
|
|
172
|
+
await makeUser(ours, 'erp-bridge', 'application')
|
|
173
|
+
await makeUser(ours, 'label-printer', 'application')
|
|
174
|
+
await makeUser(ours, 'erp-archive', 'application')
|
|
175
|
+
|
|
176
|
+
const { items, total } = await query.appBindings({ search: 'erp' } as any, context(ours))
|
|
177
|
+
|
|
178
|
+
expect(items.map((one: any) => one.username).sort()).toEqual(['erp-archive', 'erp-bridge'])
|
|
179
|
+
expect(total).toBe(2)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('looks in the email too', async () => {
|
|
183
|
+
await makeUser(ours, 'scanner-1', 'application')
|
|
184
|
+
await makeUser(ours, 'scanner-2', 'application')
|
|
185
|
+
|
|
186
|
+
const { items } = await query.appBindings({ search: 'scanner-2@example' } as any, context(ours))
|
|
187
|
+
|
|
188
|
+
expect(items.map((one: any) => one.username)).toEqual(['scanner-2'])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('still answers only for this domain when a term is given', async () => {
|
|
192
|
+
/* A term must not widen the tenant: the OR of the search sits inside the domain condition. */
|
|
193
|
+
await makeUser(ours, 'erp-ours', 'application')
|
|
194
|
+
await makeUser(theirs, 'erp-theirs', 'application')
|
|
195
|
+
|
|
196
|
+
const { items } = await query.appBindings({ search: 'erp' } as any, context(ours))
|
|
197
|
+
|
|
198
|
+
expect(items.map((one: any) => one.username)).toEqual(['erp-ours'])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('an empty term is no term', async () => {
|
|
202
|
+
await makeUser(ours, 'app-one', 'application')
|
|
203
|
+
await makeUser(ours, 'app-two', 'application')
|
|
204
|
+
|
|
205
|
+
const { total } = await query.appBindings({ search: ' ' } as any, context(ours))
|
|
206
|
+
|
|
207
|
+
expect(total).toBe(2)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { decideDomainRedirect, DOMAIN_REDIRECT_WINDOW_MS, domainRequiredTarget } from '../client/domain-required'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a screen that came up before the shell settled on a domain is sent.
|
|
5
|
+
*
|
|
6
|
+
* Every query on such a screen answers 403, and "try again" answers 403 again, forever. The shell
|
|
7
|
+
* sends the reader to check-in - the same place the server already sends an HTML request - and
|
|
8
|
+
* never from check-in or sign-in themselves, where it would go round in a circle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe('domainRequiredTarget', () => {
|
|
12
|
+
test('a screen without a domain segment goes to check-in, carrying where it was', () => {
|
|
13
|
+
const target = new URL(domainRequiredTarget('http://localhost:4000/activity-catalog')!)
|
|
14
|
+
|
|
15
|
+
expect(target.pathname).toBe('/auth/checkin')
|
|
16
|
+
expect(target.searchParams.get('redirect_to')).toBe('http://localhost:4000/activity-catalog')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('a screen on a domain the reader may not enter also goes to check-in - the server names why', () => {
|
|
20
|
+
const target = new URL(domainRequiredTarget('http://localhost:4000/domain/other/activity-catalog')!)
|
|
21
|
+
|
|
22
|
+
expect(target.pathname).toBe('/auth/checkin')
|
|
23
|
+
expect(target.searchParams.get('redirect_to')).toBe('http://localhost:4000/domain/other/activity-catalog')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('the query string and hash of the screen survive the round trip', () => {
|
|
27
|
+
const from = 'http://localhost:4000/activity-detail?name=MES%20Disposition%20Approval#line'
|
|
28
|
+
const target = new URL(domainRequiredTarget(from)!)
|
|
29
|
+
|
|
30
|
+
expect(target.searchParams.get('redirect_to')).toBe(from)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('it stays on the same origin', () => {
|
|
34
|
+
expect(new URL(domainRequiredTarget('https://plant.example.com/x')!).origin).toBe('https://plant.example.com')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('check-in itself is never sent to check-in', () => {
|
|
38
|
+
expect(domainRequiredTarget('http://localhost:4000/auth/checkin?redirect_to=%2F')).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('sign-in and the other auth screens are left alone', () => {
|
|
42
|
+
expect(domainRequiredTarget('http://localhost:4000/auth/signin')).toBeNull()
|
|
43
|
+
expect(domainRequiredTarget('http://localhost:4000/auth')).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('a path that only starts with the letters auth is not an auth screen', () => {
|
|
47
|
+
expect(domainRequiredTarget('http://localhost:4000/authority-list')).not.toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('something that is not an address is not guessed at', () => {
|
|
51
|
+
expect(domainRequiredTarget('')).toBeNull()
|
|
52
|
+
expect(domainRequiredTarget('not a url')).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* A 403 is not only "no domain". The shell also answers 403 to an address on the site's IP block list
|
|
57
|
+
* (shell/server/middlewares/domain-middleware.ts). That reader has a domain; check-in sends them
|
|
58
|
+
* straight back and the screen gets 403 again. Without this guard the tab goes round forever.
|
|
59
|
+
*/
|
|
60
|
+
describe('after one round trip it stops', () => {
|
|
61
|
+
const now = 1_000_000_000
|
|
62
|
+
const here = 'http://localhost:4000/domain/miratek/activity-catalog'
|
|
63
|
+
|
|
64
|
+
test('sent a moment ago: not sent again, whatever the reason for the 403', () => {
|
|
65
|
+
expect(domainRequiredTarget(here, now - 2_000, now)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('it is the time that counts, not the address - the server changed the address on the way back', () => {
|
|
69
|
+
expect(domainRequiredTarget('http://localhost:4000/activity-catalog', now - 2_000, now)).toBeNull()
|
|
70
|
+
expect(domainRequiredTarget(here, now - 2_000, now)).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('once the window has passed, a new 403 is a new case and is sent', () => {
|
|
74
|
+
expect(domainRequiredTarget(here, now - DOMAIN_REDIRECT_WINDOW_MS, now)).not.toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('never sent before: sent', () => {
|
|
78
|
+
expect(domainRequiredTarget(here, null, now)).not.toBeNull()
|
|
79
|
+
expect(domainRequiredTarget(here, undefined, now)).not.toBeNull()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('a stamp from the future (a clock that moved back) does not block for ever', () => {
|
|
83
|
+
expect(domainRequiredTarget(here, now + 60_000, now)).not.toBeNull()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
* The stamp is the only thing that knows "we just came back". A browser that blocks storage would lose it,
|
|
89
|
+
* and a reader on the IP block list would go round again. So storage that does not work means: do not send.
|
|
90
|
+
*/
|
|
91
|
+
describe('decideDomainRedirect - the stamp must work or nothing is sent', () => {
|
|
92
|
+
const now = 1_000_000_000
|
|
93
|
+
const here = 'http://localhost:4000/activity-catalog'
|
|
94
|
+
const store = (saved: string | null = null) => {
|
|
95
|
+
const box = { value: saved, written: [] as string[] }
|
|
96
|
+
return { box, stamp: { read: () => box.value, write: (v: string) => { box.written.push(v); box.value = v } } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('first time: sent, and the stamp is written before leaving', () => {
|
|
100
|
+
const { box, stamp } = store()
|
|
101
|
+
expect(decideDomainRedirect(here, stamp, now)).not.toBeNull()
|
|
102
|
+
expect(box.written).toEqual([String(now)])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('just came back: not sent, and the stamp is left as it was', () => {
|
|
106
|
+
const { box, stamp } = store(String(now - 2_000))
|
|
107
|
+
expect(decideDomainRedirect(here, stamp, now)).toBeNull()
|
|
108
|
+
expect(box.written).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('storage that cannot be read: not sent', () => {
|
|
112
|
+
const stamp = { read: () => { throw new Error('blocked') }, write: () => {} }
|
|
113
|
+
expect(decideDomainRedirect(here, stamp, now)).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('storage that cannot be written: not sent - a trip we cannot remember could loop', () => {
|
|
117
|
+
const stamp = { read: () => null, write: () => { throw new Error('quota') } }
|
|
118
|
+
expect(decideDomainRedirect(here, stamp, now)).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a stamp that is not a number is treated as none, and the trip goes ahead', () => {
|
|
122
|
+
const { stamp } = store('garbage')
|
|
123
|
+
expect(decideDomainRedirect(here, stamp, now)).not.toBeNull()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
import { loadCompiled } from './compiled'
|
|
5
|
+
|
|
6
|
+
const { authPublicProcessRouter } = loadCompiled('router/auth-public-process-router')
|
|
7
|
+
const { ACCEPT_INVITATION_PATH } = loadCompiled('controllers/invitation')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The link in each mail lands on a route that exists.
|
|
11
|
+
*
|
|
12
|
+
* ── Why this file exists ────────────────────────────────────────────────────
|
|
13
|
+
* The invitation mail pointed at `/auth/accept/<token>` — a path with no route anywhere — so the
|
|
14
|
+
* invitee fell through to the private history fallback and the domain gate turned them away for
|
|
15
|
+
* not being a member yet. That was found and fixed on 2026-09-20, and the fix made the same
|
|
16
|
+
* mistake again: the new route was declared as `/accept-invitation` on a router whose prefix is
|
|
17
|
+
* `/auth`, and the mail and the page both used `/accept-invitation`. It shipped that way.
|
|
18
|
+
*
|
|
19
|
+
* Every test of the invitation called the controller directly, so none of them ever went through
|
|
20
|
+
* a route. This one reads the router's own table — the paths koa-router resolved, prefix included
|
|
21
|
+
* — and holds each mail's link against it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Layer = { path: string; methods: string[] }
|
|
25
|
+
|
|
26
|
+
const layers: Layer[] = authPublicProcessRouter.stack.map((layer: any) => ({
|
|
27
|
+
path: layer.path,
|
|
28
|
+
methods: layer.methods
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
function answers(method: string, path: string): boolean {
|
|
32
|
+
return layers.some(layer => layer.methods.includes(method) && matches(layer.path, path))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A declared path against a concrete one: `:param` matches one segment. */
|
|
36
|
+
function matches(declared: string, actual: string): boolean {
|
|
37
|
+
const a = declared.split('/')
|
|
38
|
+
const b = actual.split('/')
|
|
39
|
+
|
|
40
|
+
return a.length === b.length && a.every((part, i) => part.startsWith(':') || part === b[i])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('the invitation link', () => {
|
|
44
|
+
it('the page answers where the mail points', () => {
|
|
45
|
+
/* ⚠ The case that shipped broken. */
|
|
46
|
+
expect(answers('GET', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('the page posts where the router listens', () => {
|
|
50
|
+
expect(answers('POST', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('carries the prefix the router adds', () => {
|
|
54
|
+
/*
|
|
55
|
+
* Declaring `/accept-invitation` on this router makes it `/auth/accept-invitation`. The spelling
|
|
56
|
+
* without the prefix is exactly the one that went out, so it is named here as the wrong one.
|
|
57
|
+
*/
|
|
58
|
+
expect(ACCEPT_INVITATION_PATH.startsWith('/auth/')).toBe(true)
|
|
59
|
+
expect(answers('GET', '/accept-invitation')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('is the path the client page uses too', () => {
|
|
63
|
+
const page = readFileSync(
|
|
64
|
+
join(__dirname, '..', '..', 'auth-ui', 'client', 'components', 'accept-invitation.ts'),
|
|
65
|
+
'utf-8'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
expect(page).toContain(`return '${ACCEPT_INVITATION_PATH}'`)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('the other links sent by mail', () => {
|
|
73
|
+
/*
|
|
74
|
+
* Same class, older links. Each controller builds its URL from a literal; every literal has to be
|
|
75
|
+
* a route this router answers. Read from the source because they are not exported — the point is
|
|
76
|
+
* that the link and the route are two spellings of one thing and nothing else ties them.
|
|
77
|
+
*/
|
|
78
|
+
const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
|
|
79
|
+
|
|
80
|
+
const linked = readdirSync(CONTROLLERS)
|
|
81
|
+
.filter(file => file.endsWith('.ts'))
|
|
82
|
+
.flatMap(file =>
|
|
83
|
+
[...readFileSync(join(CONTROLLERS, file), 'utf-8').matchAll(/new URL\(`(\/auth\/[^`?$]*)/g)].map(m => ({
|
|
84
|
+
file,
|
|
85
|
+
path: m[1].replace(/\/$/, '/:token')
|
|
86
|
+
}))
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
it('finds the links it means to check', () => {
|
|
90
|
+
/* A scan that matches nothing passes whatever the links say. */
|
|
91
|
+
expect(linked.map(l => l.file).sort()).toEqual(['reset-password.ts', 'unlock-user.ts', 'verification.ts'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it.each(linked.map(l => [l.file, l.path]))('%s → %s', (_file, path) => {
|
|
95
|
+
expect(answers('GET', path)).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/tests/open-doors.test.ts
CHANGED
|
@@ -30,27 +30,18 @@ import { loadCompiled } from './compiled'
|
|
|
30
30
|
const { schema: declared } = loadCompiled('service/index')
|
|
31
31
|
const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
* Open on purpose
|
|
35
|
-
*
|
|
33
|
+
/*
|
|
34
|
+
* Open on purpose — read from the module's own declaration (`server/service/open-fields.ts`), which is
|
|
35
|
+
* also what the directive enforces against. It used to be a copy kept here, and two copies of one
|
|
36
|
+
* list are two lists.
|
|
36
37
|
*/
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
checkUserBelongsDomain: 'whether the caller belongs to this domain',
|
|
45
|
-
checkResettablePasswordToDefault: 'about the caller\'s own password',
|
|
46
|
-
checkDefaultPassword: 'whether the caller is still on the default password',
|
|
47
|
-
|
|
48
|
-
/* Neither tenant data nor about a person. */
|
|
49
|
-
passwordRule:
|
|
50
|
-
'the installation\'s password policy. The change-password screen needs it before the caller has any privilege — and after a forced reset they may have none',
|
|
51
|
-
authProviderTypes: 'which provider types this build supports. A catalogue, not a tenant\'s configuration'
|
|
52
|
-
},
|
|
53
|
-
Mutation: {}
|
|
38
|
+
const { openFields: declaration } = loadCompiled('service/open-fields')
|
|
39
|
+
|
|
40
|
+
const OPEN_BY_DECISION: Record<string, Record<string, string>> = { Query: {}, Mutation: {} }
|
|
41
|
+
|
|
42
|
+
for (const [where, reason] of Object.entries(declaration.byDesign as Record<string, string>)) {
|
|
43
|
+
const [typeName, field] = where.split('.')
|
|
44
|
+
OPEN_BY_DECISION[typeName][field] = reason
|
|
54
45
|
}
|
|
55
46
|
|
|
56
47
|
let schema: GraphQLSchema
|
|
@@ -58,7 +49,8 @@ let schema: GraphQLSchema
|
|
|
58
49
|
beforeAll(async () => {
|
|
59
50
|
schema = await buildSchema({ resolvers: declared.resolverClasses, validate: false })
|
|
60
51
|
|
|
61
|
-
|
|
52
|
+
/* No modules: this file measures what is open, not what would be refused. */
|
|
53
|
+
privilegeDirectiveResolver(schema, { modules: [] })
|
|
62
54
|
})
|
|
63
55
|
|
|
64
56
|
function openFields(typeName: 'Query' | 'Mutation'): string[] {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { buildSchema } from 'type-graphql'
|
|
2
|
+
import { graphql, GraphQLSchema } from 'graphql'
|
|
3
|
+
import { makeExecutableSchema } from '@graphql-tools/schema'
|
|
4
|
+
|
|
5
|
+
import { loadCompiled } from './compiled'
|
|
6
|
+
|
|
7
|
+
const { schema: declared } = loadCompiled('service/index')
|
|
8
|
+
const { privilegeDirectiveResolver, privilegeDirectiveTypeDefs } = loadCompiled('service/privilege/privilege-directive')
|
|
9
|
+
const { coverageOf, judgeUndeclared } = loadCompiled('service/privilege/open-field-rule')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A field that declares no `@privilege` and that its module does not list — what happens to it
|
|
13
|
+
* (`docs/design/auth-rebuild.md` §8 step 1; question sheet Q1 · Q2 · Q5).
|
|
14
|
+
*
|
|
15
|
+
* ── The four answers ────────────────────────────────────────────────────────
|
|
16
|
+
* byDesign open, and the module says why
|
|
17
|
+
* notYetDeclared open, and on the module's list to close
|
|
18
|
+
* unlisted the module opted in and says nothing: boot refuses in development, the field
|
|
19
|
+
* refuses in production
|
|
20
|
+
* not opted in the module declared no lists at all: exactly as before
|
|
21
|
+
*
|
|
22
|
+
* The last one is deliberate. The four products serve resolvers of their own from another
|
|
23
|
+
* repository, and enforcing on them the day this lands would stop all four from booting.
|
|
24
|
+
*
|
|
25
|
+
* These build auth-base's real schema from the `resolverClasses` the server boots from, and vary
|
|
26
|
+
* only the module's declaration — so what is tested is the directive against real fields, not
|
|
27
|
+
* against a model of them.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const MODULE = 'auth-base'
|
|
31
|
+
const REAL = declared.openFields
|
|
32
|
+
|
|
33
|
+
function modules(openFields: any) {
|
|
34
|
+
return [{ name: MODULE, schema: { resolverClasses: declared.resolverClasses, openFields } }]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** auth-base's declaration with one reason taken out — the shape of a field somebody forgot to list. */
|
|
38
|
+
function without(where: string) {
|
|
39
|
+
const byDesign = { ...REAL.byDesign }
|
|
40
|
+
delete byDesign[where]
|
|
41
|
+
return { ...REAL, byDesign }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function built(): Promise<GraphQLSchema> {
|
|
45
|
+
return await buildSchema({ resolvers: declared.resolverClasses, validate: false })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function refusalCodeOf(promise: Promise<any>): Promise<string> {
|
|
49
|
+
const result = await promise
|
|
50
|
+
return result?.errors?.[0]?.extensions?.refusal?.code || result?.errors?.[0]?.extensions?.code || 'no error'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('who is covered', () => {
|
|
54
|
+
it('attributes each root field to the module whose class declared it', async () => {
|
|
55
|
+
const coverage = coverageOf(modules(REAL))
|
|
56
|
+
|
|
57
|
+
expect(coverage.get('Query.myRoles')).toEqual({ module: MODULE, listed: 'by-design' })
|
|
58
|
+
expect(coverage.get('Mutation.createRole')).toEqual({ module: MODULE, listed: null })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('covers nothing for a module that declared no lists', () => {
|
|
62
|
+
/* ⚠ The line that keeps the four products booting. */
|
|
63
|
+
expect(coverageOf(modules(undefined)).size).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not cover a field of a class the module did not name', () => {
|
|
67
|
+
const coverage = coverageOf([{ name: 'other', schema: { resolverClasses: [], openFields: {} } }])
|
|
68
|
+
|
|
69
|
+
expect(judgeUndeclared('Query.myRoles', coverage)).toEqual('not-opted-in')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('the verdicts', () => {
|
|
74
|
+
const coverage = () =>
|
|
75
|
+
coverageOf(modules({ byDesign: { 'Query.myRoles': 'mine' }, notYetDeclared: ['Query.passwordRule'] }))
|
|
76
|
+
|
|
77
|
+
it('open by design', () => {
|
|
78
|
+
expect(judgeUndeclared('Query.myRoles', coverage())).toEqual('open-by-design')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('open, not yet declared', () => {
|
|
82
|
+
expect(judgeUndeclared('Query.passwordRule', coverage())).toEqual('open-not-yet-declared')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('unlisted', () => {
|
|
86
|
+
expect(judgeUndeclared('Query.hasPrivilege', coverage())).toEqual('unlisted')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('not opted in', () => {
|
|
90
|
+
expect(judgeUndeclared('Query.somethingElse', coverage())).toEqual('not-opted-in')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('in development — the schema refuses to build', () => {
|
|
95
|
+
it('builds with the declaration auth-base ships', async () => {
|
|
96
|
+
/* Every undeclared field of auth-base is in `byDesign`; nothing is unlisted today. */
|
|
97
|
+
const schema = await built()
|
|
98
|
+
|
|
99
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(REAL), mode: 'boot' })).not.toThrow()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('names the field that is unlisted', async () => {
|
|
103
|
+
/* ⚠ The case the step exists for: a field arrived and nobody said who may call it. */
|
|
104
|
+
const schema = await built()
|
|
105
|
+
|
|
106
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(without('Query.myRoles')), mode: 'boot' })).toThrow(
|
|
107
|
+
/Query\.myRoles/
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('names every unlisted field at once, not the first', async () => {
|
|
112
|
+
const schema = await built()
|
|
113
|
+
const declaration = without('Query.myRoles')
|
|
114
|
+
delete declaration.byDesign['Query.passwordRule']
|
|
115
|
+
|
|
116
|
+
let message = ''
|
|
117
|
+
try {
|
|
118
|
+
privilegeDirectiveResolver(schema, { modules: modules(declaration), mode: 'boot' })
|
|
119
|
+
} catch (e: any) {
|
|
120
|
+
message = e.message
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
expect(message).toMatch(/2 field\(s\)/)
|
|
124
|
+
expect(message).toMatch(/Query\.myRoles/)
|
|
125
|
+
expect(message).toMatch(/Query\.passwordRule/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('accepts the field once it is listed as not yet declared', async () => {
|
|
129
|
+
const schema = await built()
|
|
130
|
+
const declaration = { ...without('Query.myRoles'), notYetDeclared: ['Query.myRoles'] }
|
|
131
|
+
|
|
132
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(declaration), mode: 'boot' })).not.toThrow()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('leaves a module that declared no lists exactly as it was', async () => {
|
|
136
|
+
const schema = await built()
|
|
137
|
+
|
|
138
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(undefined), mode: 'boot' })).not.toThrow()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('in production — only that field refuses', () => {
|
|
143
|
+
it('still builds', async () => {
|
|
144
|
+
const schema = await built()
|
|
145
|
+
|
|
146
|
+
expect(() =>
|
|
147
|
+
privilegeDirectiveResolver(schema, { modules: modules(without('Query.passwordRule')), mode: 'request' })
|
|
148
|
+
).not.toThrow()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('refuses the unlisted field, with a name the screen can read', async () => {
|
|
152
|
+
/*
|
|
153
|
+
* ⚠ Never the other way. An unlisted field in production answers nobody — wrong in the worst case
|
|
154
|
+
* means one field refuses, never one field opens.
|
|
155
|
+
*/
|
|
156
|
+
const schema = privilegeDirectiveResolver(await built(), {
|
|
157
|
+
modules: modules(without('Query.passwordRule')),
|
|
158
|
+
mode: 'request'
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(await refusalCodeOf(graphql({ schema, source: '{ passwordRule { lowerCase } }', contextValue: { state: {} } }))).toEqual(
|
|
162
|
+
'field-declares-no-privilege'
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves a listed field answering', async () => {
|
|
167
|
+
const schema = privilegeDirectiveResolver(await built(), {
|
|
168
|
+
modules: modules(without('Query.myRoles')),
|
|
169
|
+
mode: 'request'
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const result = await graphql({ schema, source: '{ passwordRule { lowerCase } }', contextValue: { state: {} } })
|
|
173
|
+
|
|
174
|
+
expect(result.errors?.[0]?.extensions?.refusal).toBeUndefined()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('a subscription is stopped where it opens', () => {
|
|
179
|
+
/*
|
|
180
|
+
* ⚠ The directive used to wrap `resolve` only. A declared subscription then connected for anyone
|
|
181
|
+
* and failed on every event — it leaked nothing and refused nothing. Built from SDL because
|
|
182
|
+
* auth-base has no subscription of its own; the directive sees the same field configuration.
|
|
183
|
+
*/
|
|
184
|
+
const typeDefs = `
|
|
185
|
+
type Query { ok: Boolean }
|
|
186
|
+
type Subscription {
|
|
187
|
+
guarded: String @privilege(category: "board", privilege: "query")
|
|
188
|
+
}
|
|
189
|
+
`
|
|
190
|
+
|
|
191
|
+
function schemaWith(subscribe: () => any) {
|
|
192
|
+
return privilegeDirectiveResolver(
|
|
193
|
+
makeExecutableSchema({
|
|
194
|
+
typeDefs: [privilegeDirectiveTypeDefs, typeDefs],
|
|
195
|
+
resolvers: { Query: { ok: () => true }, Subscription: { guarded: { subscribe, resolve: (v: any) => v } } }
|
|
196
|
+
}),
|
|
197
|
+
{ modules: [] }
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it('refuses at subscribe for somebody without the privilege', async () => {
|
|
202
|
+
let opened = false
|
|
203
|
+
const schema = schemaWith(() => {
|
|
204
|
+
opened = true
|
|
205
|
+
return (async function* () {})()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const field = schema.getSubscriptionType()!.getFields().guarded as any
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
field.subscribe({}, {}, { state: { user: { id: 'u', domains: [] }, domain: { id: 'd' } } }, {} as any)
|
|
212
|
+
).rejects.toBeTruthy()
|
|
213
|
+
expect(opened).toBe(false)
|
|
214
|
+
})
|
|
215
|
+
})
|
package/translations/en.json
CHANGED
package/translations/ja.json
CHANGED
package/translations/ko.json
CHANGED
package/translations/ms.json
CHANGED