fix(desec): reuse an existing slug the user already owns

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>
This commit is contained in:
Simon L.
2026-06-22 15:19:42 +02:00
parent b5e35c90d8
commit 9cfd5b41f6
5 changed files with 174 additions and 9 deletions
+49 -5
View File
@@ -15,7 +15,8 @@
// 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 name) | 409 (name already taken)
// 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
@@ -28,10 +29,27 @@ 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 Set(), // fully-qualified domain names that have been created
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 = ''
@@ -65,6 +83,13 @@ const server = createServer(async (req, res) => {
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') {
@@ -99,17 +124,36 @@ const server = createServer(async (req, res) => {
})
}
// --- Domain creation: 201 for a new name, 409 if the same name already exists. ---
// --- 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, 409, { detail: 'This domain name is unavailable.' })
return send(res, 400, { detail: 'This domain name is unavailable.' })
}
state.domains.add(name)
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)
@@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test';
import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js';
// Exercises re-using a slug the user already owns on an existing deSEC account. This is the
// case that previously failed: deSEC answers POST /domains/ with 403 "Domain limit exceeded"
// (the account is at its domain quota) even though the user owns the very slug they typed.
// DesecManager now recovers by checking GET /domains/{name}/ and reusing the owned domain.
//
// We seed the account up to the mock's domain limit, then pre-assign the target slug to that
// same account, so creation is rejected with 403 and the ownership check is what lets it through.
//
// Like the other domain-registering flows, this runs as its own CI step against freshly
// seeded deSEC + AIO state.
test('deSEC reuses a slug the user already owns when over the domain limit', async ({ page: setupPage }) => {
test.setTimeout(5 * 60 * 1000);
await fetch(`${DESEC_MOCK_URL}/__control/reset`, { method: 'POST' });
const email = `owner-${Math.floor(Math.random() * 2147483647)}@example.com`;
const password = 'correct horse battery staple';
await fetch(`${DESEC_MOCK_URL}/api/v1/auth/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' });
const slug = `aio-owned-${Math.floor(Math.random() * 2147483647)}`;
const domain = `${slug}.dedyn.io`;
// Fill the account up to the limit (15) with throwaway domains, then assign the target
// slug too — so a fresh POST /domains/ for it is rejected with 403, exactly as deSEC does.
for (let i = 0; i < 15; i++) {
await fetch(`${DESEC_MOCK_URL}/__control/seed-domain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `filler-${i}-${slug}.dedyn.io`, email }),
});
}
await fetch(`${DESEC_MOCK_URL}/__control/seed-domain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: domain, email }),
});
const containersPage = await logInToContainersPage(setupPage);
await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click();
await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click();
const frame = containersPage.frameLocator('#desec-frame');
await frame.locator('input[name="desec_email"]').fill(email);
await frame.locator('input[name="desec_password"]').fill(password);
await frame.locator('input[name="desec_slug"]').first().fill(slug);
await frame.getByRole('button', { name: 'Register free domain via deSEC' }).click();
// Despite the 403 on creation, the domain the user already owns is reused and registration
// completes; the modal view then reloads the parent containers page.
await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 });
await expect(
containersPage.getByText("Don't have a domain? Get a free one from deSEC"),
).toHaveCount(0);
});