mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
218 lines
7.8 KiB
JavaScript
218 lines
7.8 KiB
JavaScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
buildRewriteRule,
|
|
findPrimaryWorkerDomain,
|
|
managedRuleRef,
|
|
syncCloudflareWebDav,
|
|
webDavHostname,
|
|
} from './cloudflare-webdav.mjs'
|
|
import { webDavOrigin } from './sync-cloudflare-webdav.mjs'
|
|
|
|
function envelope(result, init = {}) {
|
|
return Response.json({ success: true, errors: [], messages: [], result, ...init })
|
|
}
|
|
|
|
function createCloudflareMock() {
|
|
const state = {
|
|
calls: [],
|
|
rulesets: new Map(),
|
|
domains: [],
|
|
nextRuleId: 1,
|
|
nextDomainId: 1,
|
|
}
|
|
|
|
const apiFetch = async (input, init = {}) => {
|
|
const url = new URL(String(input))
|
|
const method = init.method ?? 'GET'
|
|
const body = init.body ? JSON.parse(String(init.body)) : null
|
|
state.calls.push({ method, path: url.pathname, body })
|
|
|
|
const entrypoint = /^\/client\/v4\/zones\/([^/]+)\/rulesets\/phases\/http_request_transform\/entrypoint$/.exec(
|
|
url.pathname,
|
|
)
|
|
if (entrypoint && method === 'GET') {
|
|
const ruleset = state.rulesets.get(entrypoint[1])
|
|
return ruleset
|
|
? envelope(ruleset)
|
|
: Response.json({ success: false, errors: [{ message: 'not found' }] }, { status: 404 })
|
|
}
|
|
|
|
const rulesetCollection = /^\/client\/v4\/zones\/([^/]+)\/rulesets$/.exec(url.pathname)
|
|
if (rulesetCollection && method === 'POST') {
|
|
const ruleset = { id: `ruleset-${rulesetCollection[1]}`, ...body, rules: [] }
|
|
state.rulesets.set(rulesetCollection[1], ruleset)
|
|
return envelope(ruleset)
|
|
}
|
|
|
|
const ruleCollection = /^\/client\/v4\/zones\/([^/]+)\/rulesets\/([^/]+)\/rules$/.exec(url.pathname)
|
|
if (ruleCollection && method === 'POST') {
|
|
const ruleset = state.rulesets.get(ruleCollection[1])
|
|
const rule = { ...body, id: `rule-${state.nextRuleId++}` }
|
|
ruleset.rules.push(rule)
|
|
return envelope(ruleset)
|
|
}
|
|
|
|
const ruleItem = /^\/client\/v4\/zones\/([^/]+)\/rulesets\/([^/]+)\/rules\/([^/]+)$/.exec(url.pathname)
|
|
if (ruleItem && method === 'PATCH') {
|
|
const ruleset = state.rulesets.get(ruleItem[1])
|
|
const index = ruleset.rules.findIndex((rule) => rule.id === ruleItem[3])
|
|
ruleset.rules[index] = { ...body, id: ruleItem[3] }
|
|
return envelope(ruleset)
|
|
}
|
|
if (ruleItem && method === 'DELETE') {
|
|
const ruleset = state.rulesets.get(ruleItem[1])
|
|
ruleset.rules = ruleset.rules.filter((rule) => rule.id !== ruleItem[3])
|
|
return envelope(ruleset)
|
|
}
|
|
|
|
if (url.pathname === '/client/v4/accounts/account-1/workers/domains' && method === 'GET') {
|
|
return envelope(state.domains)
|
|
}
|
|
if (url.pathname === '/client/v4/accounts/account-1/workers/domains' && method === 'PUT') {
|
|
const domain = { ...body, id: `domain-${state.nextDomainId++}` }
|
|
state.domains.push(domain)
|
|
return envelope(domain)
|
|
}
|
|
|
|
const domainItem = /^\/client\/v4\/accounts\/account-1\/workers\/domains\/([^/]+)$/.exec(url.pathname)
|
|
if (domainItem && method === 'DELETE') {
|
|
state.domains = state.domains.filter((domain) => domain.id !== domainItem[1])
|
|
return envelope(null)
|
|
}
|
|
|
|
throw new Error(`Unexpected Cloudflare request: ${method} ${url.pathname}`)
|
|
}
|
|
|
|
return { state, apiFetch }
|
|
}
|
|
|
|
const readyFetch = async () =>
|
|
new Response('Unauthorized', {
|
|
status: 401,
|
|
headers: { 'WWW-Authenticate': 'Basic realm="ZPan WebDAV"' },
|
|
})
|
|
|
|
describe('Cloudflare WebDAV deployment sync', () => {
|
|
it('derives the fixed hostname and builds a deterministic rewrite', () => {
|
|
expect(webDavHostname('files.example.com')).toBe('dav.files.example.com')
|
|
const rule = buildRewriteRule('dav.files.example.com')
|
|
expect(rule.ref).toBe(managedRuleRef('dav.files.example.com'))
|
|
expect(rule.expression).toBe('http.host eq "dav.files.example.com"')
|
|
expect(rule.action_parameters.uri.path.expression).toBe('concat("/dav", http.request.uri.path)')
|
|
expect(webDavOrigin({ hostname: 'dav.files.example.com' })).toBe('https://dav.files.example.com')
|
|
expect(webDavOrigin({ hostname: null })).toBe('')
|
|
})
|
|
|
|
it('identifies the primary domain and excludes its fixed DAV companion', () => {
|
|
const main = { hostname: 'files.example.com' }
|
|
const dav = { hostname: 'dav.files.example.com' }
|
|
expect(findPrimaryWorkerDomain([main, dav])).toBe(main)
|
|
expect(() => findPrimaryWorkerDomain([main, { hostname: 'other.example.com' }])).toThrow(
|
|
'Multiple primary Worker Custom Domains',
|
|
)
|
|
})
|
|
|
|
it('creates the rule and domain once, then converges without rewriting unrelated state', async () => {
|
|
const { state, apiFetch } = createCloudflareMock()
|
|
state.domains.push({
|
|
id: 'domain-main',
|
|
hostname: 'files.example.com',
|
|
service: 'zpan',
|
|
zone_id: 'zone-child',
|
|
zone_name: 'files.example.com',
|
|
})
|
|
const options = {
|
|
token: 'token',
|
|
accountId: 'account-1',
|
|
apiFetch,
|
|
verifyFetch: readyFetch,
|
|
sleep: async () => {},
|
|
}
|
|
|
|
await syncCloudflareWebDav(options)
|
|
expect(state.domains).toEqual([
|
|
expect.objectContaining({ hostname: 'files.example.com', service: 'zpan', zone_id: 'zone-child' }),
|
|
expect.objectContaining({ hostname: 'dav.files.example.com', service: 'zpan', zone_id: 'zone-child' }),
|
|
])
|
|
expect(state.rulesets.get('zone-child').rules).toEqual([
|
|
expect.objectContaining({ ref: managedRuleRef('dav.files.example.com') }),
|
|
])
|
|
|
|
const mutations = state.calls.filter((call) => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(call.method)).length
|
|
await syncCloudflareWebDav(options)
|
|
expect(state.calls.filter((call) => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(call.method))).toHaveLength(
|
|
mutations,
|
|
)
|
|
})
|
|
|
|
it('verifies a replacement before removing the previous managed domain and rule', async () => {
|
|
const { state, apiFetch } = createCloudflareMock()
|
|
state.domains.push({
|
|
id: 'domain-old-main',
|
|
hostname: 'old.example.com',
|
|
service: 'zpan',
|
|
zone_id: 'zone-parent',
|
|
zone_name: 'example.com',
|
|
})
|
|
const base = {
|
|
token: 'token',
|
|
accountId: 'account-1',
|
|
apiFetch,
|
|
verifyFetch: readyFetch,
|
|
sleep: async () => {},
|
|
}
|
|
await syncCloudflareWebDav(base)
|
|
state.domains = state.domains.filter((domain) => domain.hostname !== 'old.example.com')
|
|
state.domains.push({
|
|
id: 'domain-new-main',
|
|
hostname: 'new.example.com',
|
|
service: 'zpan',
|
|
zone_id: 'zone-parent',
|
|
zone_name: 'example.com',
|
|
})
|
|
await syncCloudflareWebDav(base)
|
|
|
|
expect(state.domains.map((domain) => domain.hostname).sort()).toEqual(['dav.new.example.com', 'new.example.com'])
|
|
expect(state.rulesets.get('zone-parent').rules.map((rule) => rule.ref)).toEqual([
|
|
managedRuleRef('dav.new.example.com'),
|
|
])
|
|
})
|
|
|
|
it('removes managed resources when the primary Worker domain is removed', async () => {
|
|
const { state, apiFetch } = createCloudflareMock()
|
|
state.domains.push({
|
|
id: 'domain-main',
|
|
hostname: 'example.com',
|
|
service: 'zpan',
|
|
zone_id: 'zone-parent',
|
|
zone_name: 'example.com',
|
|
})
|
|
const base = {
|
|
token: 'token',
|
|
accountId: 'account-1',
|
|
apiFetch,
|
|
verifyFetch: readyFetch,
|
|
sleep: async () => {},
|
|
}
|
|
await syncCloudflareWebDav(base)
|
|
state.domains = state.domains.filter((domain) => domain.hostname !== 'example.com')
|
|
const result = await syncCloudflareWebDav(base)
|
|
|
|
expect(result).toMatchObject({ hostname: null, removedRules: 1, removedDomains: 1 })
|
|
expect(state.domains).toEqual([])
|
|
expect(state.rulesets.get('zone-parent').rules).toEqual([])
|
|
})
|
|
|
|
it('fails loudly when Cloudflare rejects a required API call', async () => {
|
|
await expect(
|
|
syncCloudflareWebDav({
|
|
token: 'token',
|
|
accountId: 'account-1',
|
|
apiFetch: async () =>
|
|
Response.json({ success: false, errors: [{ message: 'permission denied' }] }, { status: 403 }),
|
|
verifyFetch: readyFetch,
|
|
}),
|
|
).rejects.toThrow('permission denied')
|
|
})
|
|
})
|