|
| 1 | +import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; |
| 2 | +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; |
| 3 | +import { CookieManager } from '../src/CookieManager'; |
| 4 | +import { createStartupAPI } from '../src/createStartupAPI'; |
| 5 | +import { AccessPolicy } from '../src/policy/accessPolicy'; |
| 6 | +import type { Gate } from '../src/schemas/policy'; |
| 7 | + |
| 8 | +const cookieManager = new CookieManager(env.SESSION_SECRET); |
| 9 | + |
| 10 | +// Reset the global policy so each configured instance re-initializes, and leave it uninitialized |
| 11 | +// afterwards so other test files re-init from their own config. |
| 12 | +beforeEach(() => AccessPolicy.reset()); |
| 13 | +afterAll(() => AccessPolicy.reset()); |
| 14 | + |
| 15 | +// A path gated behind the "benefit-vip" Patreon perk. Denials are served as in-place gate pages. |
| 16 | +function gatePolicy(gate: Gate, on_unauthorized: 'gate' | 'upgrade' | 'login' = 'gate', extra: Record<string, unknown> = {}) { |
| 17 | + return { |
| 18 | + rules: [ |
| 19 | + { |
| 20 | + pattern: '/gated', |
| 21 | + requirement: { mode: 'entitlement' as const, provider: 'patreon', condition: { type: 'benefit' as const, benefit_id: 'benefit-vip' } }, |
| 22 | + on_unauthorized, |
| 23 | + ...(on_unauthorized === 'gate' ? { gate } : {}), |
| 24 | + ...extra, |
| 25 | + }, |
| 26 | + ], |
| 27 | + default: { mode: 'public' as const }, |
| 28 | + }; |
| 29 | +} |
| 30 | + |
| 31 | +function fetchWith(policy: any, path: string, cookie?: string, assetsMock?: (req: Request) => Promise<Response>) { |
| 32 | + const api = createStartupAPI({ accessPolicy: policy }); |
| 33 | + const ctx = createExecutionContext(); |
| 34 | + const headers: Record<string, string> = cookie ? { Cookie: `session_id=${cookie}` } : {}; |
| 35 | + // Override ASSETS only when a mock is provided (asset-sourced gate pages). |
| 36 | + const testEnv = assetsMock ? { ...env, ASSETS: { fetch: vi.fn(assetsMock) } } : env; |
| 37 | + return api.fetch(new Request('http://example.com' + path, { headers }), testEnv as any, ctx).then(async (res) => { |
| 38 | + await waitOnExecutionContext(ctx); |
| 39 | + return res; |
| 40 | + }); |
| 41 | +} |
| 42 | + |
| 43 | +// Logged-in Patreon user with the given benefits, stored entitlements seeded so no network is needed. |
| 44 | +async function createPatreonUser(benefits: string[], active = true, userId = env.USER.newUniqueId()) { |
| 45 | + const userStub = env.USER.get(userId); |
| 46 | + const userIdStr = userId.toString(); |
| 47 | + const subjectId = 'patreon-' + userIdStr.slice(0, 10); |
| 48 | + |
| 49 | + await userStub.addMembership(env.ACCOUNT.newUniqueId().toString(), 1, true); |
| 50 | + await userStub.addCredential('patreon', subjectId); |
| 51 | + const { sessionId } = await userStub.createSession({ provider: 'patreon' }); |
| 52 | + |
| 53 | + const entitlements = { |
| 54 | + provider: 'patreon', |
| 55 | + checked_at: Date.now(), |
| 56 | + source: 'oauth', |
| 57 | + patreon: { |
| 58 | + patron_status: active ? 'active_patron' : 'former_patron', |
| 59 | + is_active_patron: active, |
| 60 | + entitled_tier_ids: ['t1'], |
| 61 | + entitled_benefit_ids: benefits, |
| 62 | + pledge_amount_cents: 500, |
| 63 | + }, |
| 64 | + }; |
| 65 | + await userStub.setEntitlements('patreon', subjectId, entitlements, entitlements.checked_at); |
| 66 | + |
| 67 | + return cookieManager.encrypt(`${sessionId}:${userIdStr}`); |
| 68 | +} |
| 69 | + |
| 70 | +// Spy globalThis.fetch (origin proxy). Returns a distinct body per origin path so we can assert which |
| 71 | +// page was served. |
| 72 | +function spyOrigin() { |
| 73 | + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { |
| 74 | + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; |
| 75 | + const path = new URL(url).pathname; |
| 76 | + return new Response(`origin:${path}`, { status: 200, headers: { 'Content-Type': 'text/html' } }) as any; |
| 77 | + }); |
| 78 | +} |
| 79 | + |
| 80 | +// ASSETS mock that returns a distinct body per asset path. |
| 81 | +async function assetMock(req: Request): Promise<Response> { |
| 82 | + const path = new URL(req.url).pathname; |
| 83 | + return new Response(`asset:${path}`, { status: 200, headers: { 'Content-Type': 'text/html' } }); |
| 84 | +} |
| 85 | + |
| 86 | +describe('gate action — serves an explainer page in place', () => { |
| 87 | + it('anonymous visitor → asset variant', async () => { |
| 88 | + const gate: Gate = { anonymous: { asset: '/early-access' } }; |
| 89 | + const res = await fetchWith(gatePolicy(gate), '/gated', undefined, assetMock); |
| 90 | + expect(res.status).toBe(200); |
| 91 | + expect(await res.text()).toBe('asset:/early-access'); |
| 92 | + }); |
| 93 | + |
| 94 | + it('anonymous visitor → origin variant (no redirect)', async () => { |
| 95 | + const gate: Gate = { anonymous: { origin: '/early-access' } }; |
| 96 | + const fetchSpy = spyOrigin(); |
| 97 | + try { |
| 98 | + const res = await fetchWith(gatePolicy(gate), '/gated'); |
| 99 | + expect(res.status).toBe(200); |
| 100 | + expect(await res.text()).toBe('origin:/early-access'); |
| 101 | + // It is served in place — no redirect. |
| 102 | + expect(res.headers.has('Location')).toBe(false); |
| 103 | + } finally { |
| 104 | + fetchSpy.mockRestore(); |
| 105 | + } |
| 106 | + }); |
| 107 | + |
| 108 | + it('unentitled (logged-in, lacks benefit) → asset variant', async () => { |
| 109 | + const gate: Gate = { anonymous: { asset: '/early-access' }, unentitled: { asset: '/pledge-needed' } }; |
| 110 | + const cookie = await createPatreonUser(['some-other-benefit']); |
| 111 | + const res = await fetchWith(gatePolicy(gate), '/gated', cookie, assetMock); |
| 112 | + expect(res.status).toBe(200); |
| 113 | + expect(await res.text()).toBe('asset:/pledge-needed'); |
| 114 | + }); |
| 115 | + |
| 116 | + it('unentitled (logged-in, lacks benefit) → origin variant', async () => { |
| 117 | + const gate: Gate = { anonymous: { origin: '/early-access' }, unentitled: { origin: '/pledge-needed' } }; |
| 118 | + const cookie = await createPatreonUser(['some-other-benefit']); |
| 119 | + const fetchSpy = spyOrigin(); |
| 120 | + try { |
| 121 | + const res = await fetchWith(gatePolicy(gate), '/gated', cookie); |
| 122 | + expect(res.status).toBe(200); |
| 123 | + expect(await res.text()).toBe('origin:/pledge-needed'); |
| 124 | + } finally { |
| 125 | + fetchSpy.mockRestore(); |
| 126 | + } |
| 127 | + }); |
| 128 | + |
| 129 | + it('unentitled falls back to anonymous when no unentitled variant is configured', async () => { |
| 130 | + const gate: Gate = { anonymous: { asset: '/early-access' } }; |
| 131 | + const cookie = await createPatreonUser(['some-other-benefit']); |
| 132 | + const res = await fetchWith(gatePolicy(gate), '/gated', cookie, assetMock); |
| 133 | + expect(res.status).toBe(200); |
| 134 | + expect(await res.text()).toBe('asset:/early-access'); |
| 135 | + }); |
| 136 | + |
| 137 | + it('re-stamps a custom status onto the served page', async () => { |
| 138 | + const gate: Gate = { anonymous: { asset: '/early-access' }, status: 403 }; |
| 139 | + const res = await fetchWith(gatePolicy(gate), '/gated', undefined, assetMock); |
| 140 | + expect(res.status).toBe(403); |
| 141 | + expect(await res.text()).toBe('asset:/early-access'); |
| 142 | + }); |
| 143 | + |
| 144 | + it('allows the gated path (no gate page) when the user has the required benefit', async () => { |
| 145 | + const gate: Gate = { anonymous: { asset: '/early-access' } }; |
| 146 | + const cookie = await createPatreonUser(['benefit-vip']); |
| 147 | + const fetchSpy = spyOrigin(); |
| 148 | + try { |
| 149 | + const res = await fetchWith(gatePolicy(gate), '/gated', cookie); |
| 150 | + expect(res.status).toBe(200); |
| 151 | + // Served from origin (the real page), not the gate asset. |
| 152 | + expect(await res.text()).toContain('origin:/gated'); |
| 153 | + } finally { |
| 154 | + fetchSpy.mockRestore(); |
| 155 | + } |
| 156 | + }); |
| 157 | +}); |
| 158 | + |
| 159 | +describe('gate action — back-compat: omitting gate leaves upgrade/login unchanged', () => { |
| 160 | + it('upgrade still redirects (302) to upgrade_url', async () => { |
| 161 | + const policy = gatePolicy({ anonymous: { asset: '/x' } }, 'upgrade', { upgrade_url: 'https://patreon.com/join' }); |
| 162 | + const res = await fetchWith(policy, '/gated'); |
| 163 | + expect(res.status).toBe(302); |
| 164 | + expect(res.headers.get('Location')).toBe('https://patreon.com/join'); |
| 165 | + }); |
| 166 | + |
| 167 | + it('login still redirects (302) to authenticate', async () => { |
| 168 | + const policy = gatePolicy({ anonymous: { asset: '/x' } }, 'login'); |
| 169 | + const res = await fetchWith(policy, '/gated'); |
| 170 | + expect(res.status).toBe(302); |
| 171 | + expect(res.headers.get('Location')).toContain('return_url='); |
| 172 | + }); |
| 173 | +}); |
0 commit comments