mirror of
https://github.com/nextcloud/all-in-one.git
synced 2026-07-21 21:52:53 +00:00
9cfd5b41f6
Logging in with an existing deSEC account and entering a slug you already
own failed with "Domain limit exceeded": registerDomain() always tried to
POST /domains/, which deSEC rejects (403 when the account is at its quota,
400/409 when the name is taken) even though the domain belongs to you.
Now, for a user-specified slug, a 400/403/409 triggers an ownership check
(GET /domains/{name}/); if the account already owns the domain it is reused
and registration completes. Otherwise a clear message is shown (over-limit
vs. taken by someone else). Random-slug registration is unchanged.
Update the deSEC mock to mirror the real status codes (per-account ownership,
domain limit -> 403, taken name -> 400, GET /domains/{name}/) and add a spec
that exercises the over-limit ownership-recovery path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Simon L. <szaimen@e.mail.de>
169 lines
7.4 KiB
JavaScript
169 lines
7.4 KiB
JavaScript
// Minimal mock of the deSEC API, implementing exactly the endpoints that
|
|
// php/src/Desec/DesecManager.php calls, with the same HTTP status-code semantics
|
|
// as the real service (see https://desec.readthedocs.io). It exists so the deSEC
|
|
// register -> verify -> domain flow can be exercised end-to-end in CI without
|
|
// touching the real deSEC service (which would create real accounts, be flaky and
|
|
// rate-limited, and require a human to click an email verification link).
|
|
//
|
|
// We do not probe deSEC's live API (their ToS discourages automated requests, and the
|
|
// /api/v1/ interface is contractually stable). Instead, the scheduled workflow
|
|
// .github/workflows/desec-api-version-watch.yml watches deSEC's published API-version
|
|
// table for changes; if it goes red, review the change and update this mock (plus
|
|
// DesecManager) to match.
|
|
//
|
|
// Usage: node desec-mock.mjs [port] (default port 8090)
|
|
// Routes:
|
|
// POST /api/v1/auth/ -> 202 (account requested; verify email)
|
|
// POST /api/v1/auth/login/ -> 403 until verified, then 200 + {token}
|
|
// POST /api/v1/domains/ -> 201 (new) | 400 (taken by another zone) | 403 (account limit)
|
|
// GET /api/v1/domains/{name}/ -> 200 (owned by this account) | 404 (not owned)
|
|
// POST /api/v1/domains/{name}/rrsets/ -> 201 (wildcard CNAME)
|
|
// GET /update -> 200 "good" (dyndns update endpoint)
|
|
// POST /__control/verify -> test hook: mark accounts email-verified
|
|
// POST /__control/reset -> test hook: wipe in-memory state
|
|
|
|
import { createServer } from 'node:http'
|
|
|
|
const port = Number(process.argv[2] ?? process.env.DESEC_MOCK_PORT ?? 8090)
|
|
|
|
// In-memory state. Reset between independent test scenarios via /__control/reset.
|
|
const state = {
|
|
accounts: new Map(), // email -> { password }
|
|
domains: new Map(), // fully-qualified domain name -> owner email (mirrors deSEC: a name is globally unique)
|
|
verified: false, // simulates whether the (most recent) account's email was verified
|
|
}
|
|
|
|
// Per-account domain limit. The real deSEC service caps the number of domains an
|
|
// (unverified-payment) account may hold and answers 403 once it is reached; we model
|
|
// that so the "user already owns this slug but is over quota" recovery path is testable.
|
|
const DOMAIN_LIMIT = Number(process.env.DESEC_MOCK_DOMAIN_LIMIT ?? 15)
|
|
|
|
// The mock issues tokens of the form `mock-token-<hex(email)[:16]>` (see /auth/login/).
|
|
// Recover the owner email from the Authorization header so domain ops are per-account.
|
|
function emailFromToken(req) {
|
|
const auth = req.headers['authorization'] ?? ''
|
|
const token = auth.replace(/^Token\s+/i, '')
|
|
const hex = token.replace(/^mock-token-/, '')
|
|
for (const email of state.accounts.keys()) {
|
|
if (Buffer.from(email).toString('hex').slice(0, 16) === hex) return email
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve) => {
|
|
let data = ''
|
|
req.on('data', (chunk) => { data += chunk })
|
|
req.on('end', () => {
|
|
if (data === '') return resolve({})
|
|
try { resolve(JSON.parse(data)) } catch { resolve({}) }
|
|
})
|
|
})
|
|
}
|
|
|
|
function send(res, code, body) {
|
|
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
res.writeHead(code, { 'Content-Type': typeof body === 'string' ? 'text/plain' : 'application/json' })
|
|
res.end(payload)
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://localhost:${port}`)
|
|
const path = url.pathname
|
|
const method = req.method ?? 'GET'
|
|
|
|
// --- Test control hooks (not part of the real deSEC API) ---
|
|
if (method === 'POST' && path === '/__control/verify') {
|
|
state.verified = true
|
|
return send(res, 200, { verified: true })
|
|
}
|
|
if (method === 'POST' && path === '/__control/reset') {
|
|
state.accounts.clear()
|
|
state.domains.clear()
|
|
state.verified = false
|
|
return send(res, 200, { reset: true })
|
|
}
|
|
// Test hook: pre-assign a domain to an account, simulating one the user registered
|
|
// earlier (optionally filling the account up to its domain limit).
|
|
if (method === 'POST' && path === '/__control/seed-domain') {
|
|
const body = await readBody(req)
|
|
if (body.name && body.email) state.domains.set(String(body.name), String(body.email))
|
|
return send(res, 200, { seeded: true })
|
|
}
|
|
|
|
// --- dyndns update endpoint (DesecManager::updateIpIfDesecDomain) ---
|
|
if (method === 'GET' && path === '/update') {
|
|
return send(res, 200, 'good')
|
|
}
|
|
|
|
// --- Account registration: deSEC always answers 202 and emails a link; ---
|
|
// --- it returns 202 even for an already-registered email (anti-enumeration). ---
|
|
if (method === 'POST' && path === '/api/v1/auth/') {
|
|
const body = await readBody(req)
|
|
if (body.email && !state.accounts.has(body.email)) {
|
|
state.accounts.set(body.email, { password: body.password ?? '' })
|
|
// A freshly created account starts unverified; the test flips this via /__control/verify.
|
|
state.verified = false
|
|
}
|
|
return send(res, 202, '')
|
|
}
|
|
|
|
// --- Login: 403 until the email is verified, then 200 + a token object. ---
|
|
if (method === 'POST' && path === '/api/v1/auth/login/') {
|
|
const body = await readBody(req)
|
|
const account = body.email ? state.accounts.get(body.email) : undefined
|
|
const passwordMatches = account && account.password === body.password
|
|
if (!state.verified || !passwordMatches) {
|
|
return send(res, 403, { detail: 'Invalid email or password, or email not verified.' })
|
|
}
|
|
return send(res, 200, {
|
|
token: 'mock-token-' + Buffer.from(body.email).toString('hex').slice(0, 16),
|
|
id: 'mock-token-id',
|
|
created: '2024-01-01T00:00:00.000000Z',
|
|
max_age: '7 00:00:00',
|
|
})
|
|
}
|
|
|
|
// --- Domain creation. Mirrors deSEC's status codes: ---
|
|
// 400 if the name is already registered (to this or another account);
|
|
// 403 once the account has reached its domain limit;
|
|
// 201 otherwise.
|
|
if (method === 'POST' && path === '/api/v1/domains/') {
|
|
const body = await readBody(req)
|
|
const name = String(body.name ?? '')
|
|
const email = emailFromToken(req)
|
|
if (state.domains.has(name)) {
|
|
return send(res, 400, { detail: 'This domain name is unavailable.' })
|
|
}
|
|
const owned = [...state.domains.values()].filter((owner) => owner === email).length
|
|
if (owned >= DOMAIN_LIMIT) {
|
|
return send(res, 403, { detail: 'Domain limit exceeded. Please contact support to create additional domains.' })
|
|
}
|
|
state.domains.set(name, email)
|
|
return send(res, 201, { name })
|
|
}
|
|
|
|
// --- Domain detail: 200 if the token's account owns the name, 404 otherwise. ---
|
|
const domainDetail = path.match(/^\/api\/v1\/domains\/([^/]+)\/$/)
|
|
if (method === 'GET' && domainDetail) {
|
|
const name = decodeURIComponent(domainDetail[1])
|
|
const email = emailFromToken(req)
|
|
if (state.domains.get(name) === email && email !== undefined) {
|
|
return send(res, 200, { name })
|
|
}
|
|
return send(res, 404, { detail: 'Not found.' })
|
|
}
|
|
|
|
// --- RRset creation (wildcard CNAME): 201. ---
|
|
if (method === 'POST' && /^\/api\/v1\/domains\/[^/]+\/rrsets\/$/.test(path)) {
|
|
const body = await readBody(req)
|
|
return send(res, 201, { subname: body.subname ?? '', type: body.type ?? '', records: body.records ?? [] })
|
|
}
|
|
|
|
return send(res, 404, { detail: 'Not found in mock: ' + method + ' ' + path })
|
|
})
|
|
|
|
server.listen(port, '0.0.0.0', () => {
|
|
console.log(`deSEC mock listening on http://0.0.0.0:${port}`)
|
|
})
|