mirror of
https://github.com/nextcloud/all-in-one.git
synced 2026-07-21 21:52:53 +00:00
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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user