@teleporthq/teleport-plugin-next-workflows 0.43.45 → 0.43.46
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/__tests__/_helpers/run-generated-middleware.ts +130 -0
- package/__tests__/form-submitted-trigger-context.test.ts +100 -0
- package/__tests__/middleware-guest-order-details.test.ts +258 -0
- package/__tests__/middleware-row-owned-pages.test.ts +32 -9
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +115 -11
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/trigger-generator.js +7 -1
- package/dist/cjs/trigger-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.js +17 -1
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +115 -11
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/trigger-generator.js +7 -1
- package/dist/esm/trigger-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.js +17 -1
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/auth-generator.ts +129 -11
- package/src/trigger-generator.ts +7 -1
- package/src/workflow-component-plugin.ts +17 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { UIDLAuthentication } from '@teleporthq/teleport-types'
|
|
2
|
+
import { generateMiddlewareFile } from '../../src/auth-generator'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loads the middleware `generateMiddlewareFile` emits and runs it against a
|
|
6
|
+
* synthetic request, so tests can assert on BEHAVIOUR ("does a guest reach
|
|
7
|
+
* /orders/ORD-42?") instead of grepping the generated source.
|
|
8
|
+
*
|
|
9
|
+
* The emitted file is an ES module that imports `next/server` and
|
|
10
|
+
* `next-auth/jwt`; neither is installed in this package, so the loader strips
|
|
11
|
+
* the module boundary and injects the pieces the middleware actually uses.
|
|
12
|
+
* `getToken` always resolves to `null` — the generated code then falls back to
|
|
13
|
+
* cookie presence plus `/api/auth/session`, which the fetch stub answers, so
|
|
14
|
+
* both the authenticated and the anonymous path stay exercisable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface MiddlewareResult {
|
|
18
|
+
kind: 'next' | 'redirect'
|
|
19
|
+
/** Only set for `kind: 'redirect'`. */
|
|
20
|
+
location?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MiddlewareSessionUser {
|
|
24
|
+
id?: string
|
|
25
|
+
role?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MiddlewareRequestOptions {
|
|
29
|
+
/** Session user the stubbed `/api/auth/session` returns; omit for a guest. */
|
|
30
|
+
sessionUser?: MiddlewareSessionUser | null
|
|
31
|
+
origin?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RawMiddlewareResult {
|
|
35
|
+
__kind: 'next' | 'redirect'
|
|
36
|
+
__location?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type MiddlewareFn = (request: unknown) => Promise<RawMiddlewareResult>
|
|
40
|
+
|
|
41
|
+
type MiddlewareFactory = (
|
|
42
|
+
nextResponse: unknown,
|
|
43
|
+
getToken: unknown,
|
|
44
|
+
fetchImpl: unknown
|
|
45
|
+
) => MiddlewareFn
|
|
46
|
+
|
|
47
|
+
const stripModuleSyntax = (code: string): string =>
|
|
48
|
+
code
|
|
49
|
+
.replace("import { NextResponse } from 'next/server';", '')
|
|
50
|
+
.replace("import { getToken } from 'next-auth/jwt';", '')
|
|
51
|
+
.replace('export default middleware;', '')
|
|
52
|
+
.replace('export const config = {', 'const config = {')
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The route table the generated middleware gates on. Parsed out of the emitted
|
|
56
|
+
* source so assertions can target the actual map instead of substring-matching
|
|
57
|
+
* a file that also contains comments and the self-guarded route list.
|
|
58
|
+
*/
|
|
59
|
+
export const extractProtectedRoutes = (
|
|
60
|
+
code: string
|
|
61
|
+
): Record<string, { requiresAuth: boolean; allowedRoles: string[] }> => {
|
|
62
|
+
// Non-greedy up to the first `};`. `JSON.stringify(…, null, 2)` never emits
|
|
63
|
+
// that sequence inside the object, so the first hit is the real terminator —
|
|
64
|
+
// and the empty-map case (`= {};`) is covered by the same pattern.
|
|
65
|
+
const match = code.match(/const protectedRoutes = (\{[\s\S]*?\});/)
|
|
66
|
+
if (!match) {
|
|
67
|
+
throw new Error('generated middleware has no protectedRoutes declaration')
|
|
68
|
+
}
|
|
69
|
+
return JSON.parse(match[1])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Route patterns whose page-load SQL is the access control. */
|
|
73
|
+
export const extractSelfGuardedRoutes = (code: string): string[] => {
|
|
74
|
+
const match = code.match(/const selfGuardedRoutes = (\[[\s\S]*?\]);/)
|
|
75
|
+
if (!match) {
|
|
76
|
+
throw new Error('generated middleware has no selfGuardedRoutes declaration')
|
|
77
|
+
}
|
|
78
|
+
return JSON.parse(match[1])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const NEXT_RESPONSE_STUB = {
|
|
82
|
+
next: (): RawMiddlewareResult => ({ __kind: 'next' }),
|
|
83
|
+
redirect: (url: URL): RawMiddlewareResult => ({
|
|
84
|
+
__kind: 'redirect',
|
|
85
|
+
__location: String(url),
|
|
86
|
+
}),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compiles the middleware once for an auth config. Reuse the returned function
|
|
91
|
+
* across requests in a test to avoid re-generating the file per assertion.
|
|
92
|
+
*/
|
|
93
|
+
export const compileGeneratedMiddleware = (auth: UIDLAuthentication) => {
|
|
94
|
+
const factory = new Function(
|
|
95
|
+
'NextResponse',
|
|
96
|
+
'getToken',
|
|
97
|
+
'fetch',
|
|
98
|
+
`${stripModuleSyntax(generateMiddlewareFile(auth))}; return middleware;`
|
|
99
|
+
) as unknown as MiddlewareFactory
|
|
100
|
+
|
|
101
|
+
return async (
|
|
102
|
+
pathname: string,
|
|
103
|
+
options: MiddlewareRequestOptions = {}
|
|
104
|
+
): Promise<MiddlewareResult> => {
|
|
105
|
+
const origin = options.origin || 'https://shop.test'
|
|
106
|
+
const sessionUser = options.sessionUser ?? null
|
|
107
|
+
const cookieHeader = sessionUser ? 'next-auth.session-token=stub' : ''
|
|
108
|
+
|
|
109
|
+
const fetchImpl = async () => ({
|
|
110
|
+
ok: true,
|
|
111
|
+
json: async () => ({ user: sessionUser }),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const getTokenStub = async (): Promise<null> => null
|
|
115
|
+
const middleware = factory(NEXT_RESPONSE_STUB, getTokenStub, fetchImpl)
|
|
116
|
+
const result = await middleware({
|
|
117
|
+
nextUrl: { pathname },
|
|
118
|
+
url: `${origin}${pathname}`,
|
|
119
|
+
cookies: {
|
|
120
|
+
get: (name: string) =>
|
|
121
|
+
sessionUser && name === 'next-auth.session-token' ? { name, value: 'stub' } : undefined,
|
|
122
|
+
},
|
|
123
|
+
headers: { get: (name: string) => (name === 'cookie' ? cookieHeader : '') },
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return result.__kind === 'redirect'
|
|
127
|
+
? { kind: 'redirect', location: result.__location }
|
|
128
|
+
: { kind: 'next' }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form-submitted trigger must expose its fields where generated validators
|
|
3
|
+
* actually look for them.
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT (run 4b049fe6, reported: "when I update a guild and click Save
|
|
6
|
+
* changes I get 'Guild Name is required' even though the name is added"):
|
|
7
|
+
*
|
|
8
|
+
* Edit Guild Submit → validate-form:
|
|
9
|
+
* const form = params[0] || {}
|
|
10
|
+
* if (!form["name"]) return { isValid: false, error: "Guild Name is required" }
|
|
11
|
+
*
|
|
12
|
+
* `params[0]` is the trigger — the executor writes `context[triggerNodeId]`
|
|
13
|
+
* first — and the trigger carried the fields one level down under `formData`.
|
|
14
|
+
* `params[0].name` was `undefined`, so every save reported the filled-in name as
|
|
15
|
+
* missing. `general-extract-form-data` has always returned its fields flat for
|
|
16
|
+
* exactly this reason; the trigger now matches it.
|
|
17
|
+
*
|
|
18
|
+
* These tests EXECUTE the generated handler source rather than asserting on
|
|
19
|
+
* strings, so they fail if the emitted expression ever stops behaving.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Evaluate the `triggerContext = …` expression the generator emits. */
|
|
23
|
+
function evaluateTriggerContext(
|
|
24
|
+
source: string,
|
|
25
|
+
formData: Record<string, unknown>
|
|
26
|
+
): Record<string, unknown> {
|
|
27
|
+
const match = /const triggerContext = ([\s\S]*?);\n/.exec(source)
|
|
28
|
+
if (!match) {
|
|
29
|
+
throw new Error('no triggerContext assignment found in generated source')
|
|
30
|
+
}
|
|
31
|
+
const expression = match[1]
|
|
32
|
+
// The generated code reads `event.target`; substitute a stand-in.
|
|
33
|
+
.replace(/event\.target/g, '__el')
|
|
34
|
+
const fn = new Function(
|
|
35
|
+
'formData',
|
|
36
|
+
'__el',
|
|
37
|
+
`const Date = { now: () => 1234 }; return (${expression});`
|
|
38
|
+
)
|
|
39
|
+
return fn(formData, { tagName: 'FORM' }) as Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('event-form-submitted trigger context', () => {
|
|
43
|
+
const SOURCE = [
|
|
44
|
+
'async function(event) {',
|
|
45
|
+
' event.preventDefault();',
|
|
46
|
+
' const formData = {};',
|
|
47
|
+
' const fd = new FormData(event.target);',
|
|
48
|
+
' fd.forEach(function(v, k) { formData[k] = v; });',
|
|
49
|
+
" const triggerContext = Object.assign({}, formData, { fields: formData, formData: formData, formId: 'edit-guild-form', triggerElement: event.target, element: event.target, timestamp: Date.now() });",
|
|
50
|
+
'',
|
|
51
|
+
].join('\n')
|
|
52
|
+
|
|
53
|
+
it('exposes each submitted field at the top level', () => {
|
|
54
|
+
const ctx = evaluateTriggerContext(SOURCE, { name: 'Order of the Light', server: 'Area 52' })
|
|
55
|
+
expect(ctx.name).toBe('Order of the Light')
|
|
56
|
+
expect(ctx.server).toBe('Area 52')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('still exposes them under formData and fields', () => {
|
|
60
|
+
const ctx = evaluateTriggerContext(SOURCE, { name: 'Order of the Light' })
|
|
61
|
+
expect((ctx.formData as Record<string, unknown>).name).toBe('Order of the Light')
|
|
62
|
+
expect((ctx.fields as Record<string, unknown>).name).toBe('Order of the Light')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('keeps the reserved keys even when a field is named after one', () => {
|
|
66
|
+
// The spread comes FIRST, so a field literally called `formId` or `element`
|
|
67
|
+
// cannot shadow the runtime's own keys.
|
|
68
|
+
const ctx = evaluateTriggerContext(SOURCE, {
|
|
69
|
+
formId: 'attacker',
|
|
70
|
+
element: 'attacker',
|
|
71
|
+
timestamp: 'attacker',
|
|
72
|
+
formData: 'attacker',
|
|
73
|
+
})
|
|
74
|
+
expect(ctx.formId).toBe('edit-guild-form')
|
|
75
|
+
expect(ctx.element).toEqual({ tagName: 'FORM' })
|
|
76
|
+
expect(ctx.timestamp).toBe(1234)
|
|
77
|
+
expect(typeof ctx.formData).toBe('object')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('reproduces the exact Edit Guild validator, which now passes', () => {
|
|
81
|
+
const ctx = evaluateTriggerContext(SOURCE, {
|
|
82
|
+
name: 'Order of the Light',
|
|
83
|
+
server: 'Area 52',
|
|
84
|
+
leader_account: 'Thrall',
|
|
85
|
+
visibility: 'Public',
|
|
86
|
+
})
|
|
87
|
+
const validate = (params: unknown[]) => {
|
|
88
|
+
const form = (params[0] || {}) as Record<string, string>
|
|
89
|
+
const required = ['name', 'server', 'leader_account', 'visibility']
|
|
90
|
+
for (const key of required) {
|
|
91
|
+
const value = form[key]
|
|
92
|
+
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
|
93
|
+
return { isValid: false, error: `${key} is required` }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { isValid: true, error: '' }
|
|
97
|
+
}
|
|
98
|
+
expect(validate([ctx])).toEqual({ isValid: true, error: '' })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { compileGeneratedMiddleware } from './_helpers/run-generated-middleware'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Guest checkout regression, end to end through the GENERATED middleware.
|
|
5
|
+
*
|
|
6
|
+
* The e-commerce list/details pair deliberately shares a static base — the
|
|
7
|
+
* order history is served from `/orders` and a single order from
|
|
8
|
+
* `/orders/[order_number]` — so a reader can drop the last path segment to get
|
|
9
|
+
* back to the listing. The listing legitimately requires auth (a guest has no
|
|
10
|
+
* order history), the details page does not: its page-load SQL matches
|
|
11
|
+
* `user_id = <session user>` OR `user_id = <anonymous localStorage UUID>`, and
|
|
12
|
+
* a guest order is stamped with the latter.
|
|
13
|
+
*
|
|
14
|
+
* `generateMiddlewareFile` already skipped row-owned self-guarded pages from
|
|
15
|
+
* `protectedRoutes`, but that was not enough: the middleware matches a request
|
|
16
|
+
* against every protected route as a PREFIX, so `/orders/ORD-42` matched the
|
|
17
|
+
* listing's `/orders` entry and every guest was redirected to
|
|
18
|
+
* `/sign-in?callbackUrl=/orders/ORD-42` — right after paying, and right after
|
|
19
|
+
* the COD "order placed" toast. Buyers with a stale session cookie never saw
|
|
20
|
+
* it, which is why it looked intermittent.
|
|
21
|
+
*
|
|
22
|
+
* The fix emits the self-guarded page's full route pattern and waives ONLY
|
|
23
|
+
* inherited, role-free protection for paths that pattern matches.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const baseAuth: any = {
|
|
27
|
+
enabled: true,
|
|
28
|
+
dataSourceId: 'ds-1',
|
|
29
|
+
dataSourceType: 'postgres',
|
|
30
|
+
passwordAuthEnabled: true,
|
|
31
|
+
providers: [],
|
|
32
|
+
roles: ['admin'],
|
|
33
|
+
tables: {},
|
|
34
|
+
folderProtection: {},
|
|
35
|
+
authPages: {
|
|
36
|
+
signIn: { pageId: 'sign-in', pageName: 'sign-in', route: '/sign-in' },
|
|
37
|
+
signUp: { pageId: 'sign-up', pageName: 'sign-up', route: '/sign-up' },
|
|
38
|
+
},
|
|
39
|
+
callbackBaseUrl: '',
|
|
40
|
+
envKeys: {},
|
|
41
|
+
customUserProperties: [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ordersListProtection = {
|
|
45
|
+
requiresAuth: true,
|
|
46
|
+
allowedRoles: [] as string[],
|
|
47
|
+
pageName: 'orders-list',
|
|
48
|
+
route: '/orders',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const orderDetailsProtection = {
|
|
52
|
+
requiresAuth: true,
|
|
53
|
+
allowedRoles: [] as string[],
|
|
54
|
+
pageName: 'order-details',
|
|
55
|
+
route: '/orders',
|
|
56
|
+
routePattern: '/orders/[order_number]',
|
|
57
|
+
rowOwnerColumn: 'user_id',
|
|
58
|
+
rowOwnerTable: 'teleport_orders',
|
|
59
|
+
rowOwnerDifferentiator: 'order_number',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const storeAuth = (overrides: Record<string, unknown> = {}) =>
|
|
63
|
+
({
|
|
64
|
+
...baseAuth,
|
|
65
|
+
pageProtection: {
|
|
66
|
+
OL: ordersListProtection,
|
|
67
|
+
OD: orderDetailsProtection,
|
|
68
|
+
ADMIN: {
|
|
69
|
+
requiresAuth: true,
|
|
70
|
+
allowedRoles: ['admin'],
|
|
71
|
+
pageName: 'admin',
|
|
72
|
+
route: '/admin',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
...overrides,
|
|
76
|
+
} as any)
|
|
77
|
+
|
|
78
|
+
describe('generated middleware: guest access to a row-owned order-details route', () => {
|
|
79
|
+
const run = compileGeneratedMiddleware(storeAuth())
|
|
80
|
+
|
|
81
|
+
it('lets an anonymous buyer reach the order they just placed', async () => {
|
|
82
|
+
// The single path both checkout branches land on: the COD branch navigates
|
|
83
|
+
// here directly, and Stripe/PayPal return to `<this>?payment=success`
|
|
84
|
+
// (`nextUrl.pathname` excludes the query, so the decision is made here).
|
|
85
|
+
expect(await run('/orders/ORD-42')).toEqual({ kind: 'next' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('matches the same order route with a trailing slash', async () => {
|
|
89
|
+
// Next.js serves both forms; matching the raw pathname would make the
|
|
90
|
+
// slashed variant miss the self-guarded pattern and inherit the listing's
|
|
91
|
+
// protection.
|
|
92
|
+
expect(await run('/orders/ORD-42/')).toEqual({ kind: 'next' })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('URL-encoded order numbers still resolve to the details route', async () => {
|
|
96
|
+
expect(await run('/orders/ORD%2F42')).toEqual({ kind: 'next' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('still forces sign-in on the orders LISTING itself', async () => {
|
|
100
|
+
const result = await run('/orders')
|
|
101
|
+
expect(result.kind).toBe('redirect')
|
|
102
|
+
expect(result.location).toContain('/sign-in')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not waive protection for a deeper path the details page never serves', async () => {
|
|
106
|
+
// `/orders/[order_number]` compiles to a single-segment matcher, so a
|
|
107
|
+
// crafted `/orders/ORD-42/anything` keeps the listing's protection.
|
|
108
|
+
const result = await run('/orders/ORD-42/edit')
|
|
109
|
+
expect(result.kind).toBe('redirect')
|
|
110
|
+
expect(result.location).toContain('/sign-in')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('keeps the admin subtree gated', async () => {
|
|
114
|
+
expect((await run('/admin')).kind).toBe('redirect')
|
|
115
|
+
expect((await run('/admin/orders')).kind).toBe('redirect')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('lets a signed-in buyer reach their order history', async () => {
|
|
119
|
+
expect(await run('/orders', { sessionUser: { id: 'u-1', role: 'user' } })).toEqual({
|
|
120
|
+
kind: 'next',
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('still bounces a non-admin off the admin subtree', async () => {
|
|
125
|
+
const result = await run('/admin/orders', { sessionUser: { id: 'u-1', role: 'user' } })
|
|
126
|
+
expect(result.kind).toBe('redirect')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('generated middleware: self-guard waiver stays narrow', () => {
|
|
131
|
+
it('does not waive protection when the ancestor route demands a role', async () => {
|
|
132
|
+
// A staff-only subtree is an absolute gate the row-level WHERE clause
|
|
133
|
+
// cannot reproduce, so the guest must NOT slip through it.
|
|
134
|
+
const run = compileGeneratedMiddleware(
|
|
135
|
+
storeAuth({
|
|
136
|
+
pageProtection: {
|
|
137
|
+
STAFF_LIST: {
|
|
138
|
+
requiresAuth: true,
|
|
139
|
+
allowedRoles: ['staff'],
|
|
140
|
+
pageName: 'staff-orders',
|
|
141
|
+
route: '/staff-orders',
|
|
142
|
+
},
|
|
143
|
+
STAFF_DETAILS: {
|
|
144
|
+
requiresAuth: true,
|
|
145
|
+
allowedRoles: [],
|
|
146
|
+
pageName: 'staff-order-details',
|
|
147
|
+
route: '/staff-orders',
|
|
148
|
+
routePattern: '/staff-orders/[order_number]',
|
|
149
|
+
rowOwnerColumn: 'user_id',
|
|
150
|
+
rowOwnerDifferentiator: 'order_number',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const result = await run('/staff-orders/ORD-42')
|
|
157
|
+
expect(result.kind).toBe('redirect')
|
|
158
|
+
expect(result.location).toContain('/sign-in')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("does not waive a page's OWN exact protection", async () => {
|
|
162
|
+
// Contrived but load-bearing: if some other protected page is published at
|
|
163
|
+
// exactly the path a self-guarded pattern matches, that page's own entry
|
|
164
|
+
// wins. Only INHERITED protection is waivable.
|
|
165
|
+
const run = compileGeneratedMiddleware(
|
|
166
|
+
storeAuth({
|
|
167
|
+
pageProtection: {
|
|
168
|
+
OL: ordersListProtection,
|
|
169
|
+
OD: orderDetailsProtection,
|
|
170
|
+
CLASH: {
|
|
171
|
+
requiresAuth: true,
|
|
172
|
+
allowedRoles: [],
|
|
173
|
+
pageName: 'orders-archive',
|
|
174
|
+
route: '/orders/archive',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const result = await run('/orders/archive')
|
|
181
|
+
expect(result.kind).toBe('redirect')
|
|
182
|
+
expect(result.location).toContain('/sign-in')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('reconstructs the pattern for UIDLs emitted before `routePattern` existed', async () => {
|
|
186
|
+
// Older projects carry only `route` + `rowOwnerDifferentiator`. As long as
|
|
187
|
+
// the route is the real static base, the pattern is derivable.
|
|
188
|
+
const run = compileGeneratedMiddleware(
|
|
189
|
+
storeAuth({
|
|
190
|
+
pageProtection: {
|
|
191
|
+
OL: ordersListProtection,
|
|
192
|
+
OD: {
|
|
193
|
+
requiresAuth: true,
|
|
194
|
+
allowedRoles: [],
|
|
195
|
+
pageName: 'order-details',
|
|
196
|
+
route: '/orders',
|
|
197
|
+
rowOwnerColumn: 'user_id',
|
|
198
|
+
rowOwnerDifferentiator: 'order_number',
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
expect(await run('/orders/ORD-42')).toEqual({ kind: 'next' })
|
|
205
|
+
expect((await run('/orders')).kind).toBe('redirect')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('leaves projects without any self-guarded page completely unchanged', async () => {
|
|
209
|
+
const run = compileGeneratedMiddleware({
|
|
210
|
+
...baseAuth,
|
|
211
|
+
pageProtection: {
|
|
212
|
+
ACCOUNT: {
|
|
213
|
+
requiresAuth: true,
|
|
214
|
+
allowedRoles: [],
|
|
215
|
+
pageName: 'account',
|
|
216
|
+
route: '/account',
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
} as any)
|
|
220
|
+
|
|
221
|
+
expect((await run('/account')).kind).toBe('redirect')
|
|
222
|
+
expect((await run('/account/settings')).kind).toBe('redirect')
|
|
223
|
+
expect(await run('/about')).toEqual({ kind: 'next' })
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('generated middleware: two pages claiming one route key', () => {
|
|
228
|
+
it('merges their rules instead of letting document order pick a winner', async () => {
|
|
229
|
+
// A list/details pair shares its static base by design, and a user can also
|
|
230
|
+
// point two pages at the same custom URL. One prefix key gates the whole
|
|
231
|
+
// subtree, so the stricter rule has to survive — otherwise whichever page
|
|
232
|
+
// the document happened to list last silently decided the access rules.
|
|
233
|
+
const run = compileGeneratedMiddleware({
|
|
234
|
+
...baseAuth,
|
|
235
|
+
pageProtection: {
|
|
236
|
+
PUBLIC_ISH: {
|
|
237
|
+
requiresAuth: true,
|
|
238
|
+
allowedRoles: [],
|
|
239
|
+
pageName: 'reports',
|
|
240
|
+
route: '/reports',
|
|
241
|
+
},
|
|
242
|
+
ADMIN_ONLY: {
|
|
243
|
+
requiresAuth: true,
|
|
244
|
+
allowedRoles: ['admin'],
|
|
245
|
+
pageName: 'reports-admin',
|
|
246
|
+
route: '/reports',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
} as any)
|
|
250
|
+
|
|
251
|
+
expect((await run('/reports', { sessionUser: { id: 'u-1', role: 'user' } })).kind).toBe(
|
|
252
|
+
'redirect'
|
|
253
|
+
)
|
|
254
|
+
expect(await run('/reports', { sessionUser: { id: 'u-2', role: 'admin' } })).toEqual({
|
|
255
|
+
kind: 'next',
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { generateMiddlewareFile } from '../src/auth-generator'
|
|
2
|
+
import {
|
|
3
|
+
extractProtectedRoutes,
|
|
4
|
+
extractSelfGuardedRoutes,
|
|
5
|
+
} from './_helpers/run-generated-middleware'
|
|
2
6
|
|
|
3
7
|
// Guarding the regression where a guest checkout buyer lands on
|
|
4
8
|
// `/order-details/<order_number>` after Stripe redirect and the
|
|
@@ -51,8 +55,11 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
|
|
|
51
55
|
|
|
52
56
|
// /order-details must NOT appear in protectedRoutes — the
|
|
53
57
|
// page-load SQL `user_id = userId OR user_id = anonymousUserId`
|
|
54
|
-
// is the source of truth for ownership.
|
|
55
|
-
|
|
58
|
+
// is the source of truth for ownership. It DOES appear in
|
|
59
|
+
// selfGuardedRoutes, which is how the middleware recognises the
|
|
60
|
+
// requests it must let through when an ancestor route is protected.
|
|
61
|
+
expect(extractProtectedRoutes(code)).toEqual({})
|
|
62
|
+
expect(extractSelfGuardedRoutes(code)).toEqual(['/order-details/[order_number]'])
|
|
56
63
|
})
|
|
57
64
|
|
|
58
65
|
it('still protects a row-owned page that requires a specific role', () => {
|
|
@@ -74,8 +81,11 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
|
|
|
74
81
|
},
|
|
75
82
|
} as any)
|
|
76
83
|
|
|
77
|
-
expect(code)
|
|
78
|
-
|
|
84
|
+
expect(extractProtectedRoutes(code)['/admin/orders/[id]']).toEqual({
|
|
85
|
+
requiresAuth: true,
|
|
86
|
+
allowedRoles: ['admin'],
|
|
87
|
+
})
|
|
88
|
+
expect(extractSelfGuardedRoutes(code)).toEqual([])
|
|
79
89
|
})
|
|
80
90
|
|
|
81
91
|
it('keeps non-row-owned pages protected as before', () => {
|
|
@@ -94,7 +104,11 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
|
|
|
94
104
|
},
|
|
95
105
|
} as any)
|
|
96
106
|
|
|
97
|
-
expect(code)
|
|
107
|
+
expect(extractProtectedRoutes(code)['/orders-list']).toEqual({
|
|
108
|
+
requiresAuth: true,
|
|
109
|
+
allowedRoles: [],
|
|
110
|
+
})
|
|
111
|
+
expect(extractSelfGuardedRoutes(code)).toEqual([])
|
|
98
112
|
})
|
|
99
113
|
|
|
100
114
|
it('honours folder-level role gating even when a child page is row-owned', () => {
|
|
@@ -124,8 +138,12 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
|
|
|
124
138
|
},
|
|
125
139
|
} as any)
|
|
126
140
|
|
|
127
|
-
expect(code)
|
|
128
|
-
|
|
141
|
+
expect(extractProtectedRoutes(code)['/tickets/[id]']).toEqual({
|
|
142
|
+
requiresAuth: true,
|
|
143
|
+
allowedRoles: ['staff'],
|
|
144
|
+
})
|
|
145
|
+
// The folder's role gate is absolute, so the page loses its waiver.
|
|
146
|
+
expect(extractSelfGuardedRoutes(code)).toEqual([])
|
|
129
147
|
})
|
|
130
148
|
|
|
131
149
|
it('skips a row-owned page even when wrapped in a role-less folder', () => {
|
|
@@ -156,7 +174,8 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
|
|
|
156
174
|
},
|
|
157
175
|
} as any)
|
|
158
176
|
|
|
159
|
-
expect(code).
|
|
177
|
+
expect(extractProtectedRoutes(code)).toEqual({})
|
|
178
|
+
expect(extractSelfGuardedRoutes(code)).toEqual(['/order-details'])
|
|
160
179
|
})
|
|
161
180
|
|
|
162
181
|
it('treats `rowOwnerColumn` as the sole signal — pages without it stay protected', () => {
|
|
@@ -176,6 +195,10 @@ describe('generateMiddlewareFile: row-owned self-guarded pages', () => {
|
|
|
176
195
|
},
|
|
177
196
|
} as any)
|
|
178
197
|
|
|
179
|
-
expect(code)
|
|
198
|
+
expect(extractProtectedRoutes(code)['/something']).toEqual({
|
|
199
|
+
requiresAuth: true,
|
|
200
|
+
allowedRoles: [],
|
|
201
|
+
})
|
|
202
|
+
expect(extractSelfGuardedRoutes(code)).toEqual([])
|
|
180
203
|
})
|
|
181
204
|
})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-generator.d.ts","sourceRoot":"","sources":["../../src/auth-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAElB,cAAc,EACf,MAAM,4BAA4B,CAAA;AAkBnC,eAAO,MAAM,6BAA6B,mBACxB,cAAc,GAAG,IAAI,KACpC,OAAO,MAAM,EAAE,MAAM,CA0BvB,CAAA;AAmhBD,eAAO,MAAM,uBAAuB,SAC5B,kBAAkB,qBACL,OAAO,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,KAChD,MAsFF,CAAA;AAED,eAAO,MAAM,wBAAwB,QAAO,MA4B3C,CAAA;AAED,eAAO,MAAM,yBAAyB,QAAO,MAgC5C,CAAA;AAMD,MAAM,WAAW,mBAAmB;IAClC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,eAAO,MAAM,uBAAuB,SAC5B,kBAAkB,YACf,mBAAmB,KAC3B,MA4IF,CAAA;
|
|
1
|
+
{"version":3,"file":"auth-generator.d.ts","sourceRoot":"","sources":["../../src/auth-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAElB,cAAc,EACf,MAAM,4BAA4B,CAAA;AAkBnC,eAAO,MAAM,6BAA6B,mBACxB,cAAc,GAAG,IAAI,KACpC,OAAO,MAAM,EAAE,MAAM,CA0BvB,CAAA;AAmhBD,eAAO,MAAM,uBAAuB,SAC5B,kBAAkB,qBACL,OAAO,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,KAChD,MAsFF,CAAA;AAED,eAAO,MAAM,wBAAwB,QAAO,MA4B3C,CAAA;AAED,eAAO,MAAM,yBAAyB,QAAO,MAgC5C,CAAA;AAMD,MAAM,WAAW,mBAAmB;IAClC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,eAAO,MAAM,uBAAuB,SAC5B,kBAAkB,YACf,mBAAmB,KAC3B,MA4IF,CAAA;AAgDD,eAAO,MAAM,sBAAsB,SAAU,kBAAkB,KAAG,MA6UjE,CAAA;AAED,eAAO,MAAM,8BAA8B,QAAO,MAqCjD,CAAA"}
|