diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c75d7..1ac73c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: permissions: contents: read +env: + NICTOOL_VALIDATE_SPEC: https://github.com/NicTool/validate/archive/45037764d5ef420be6536e66894da02f60527e5e.tar.gz + jobs: lint: uses: NicTool/.github/.github/workflows/lint.yml@main @@ -43,6 +46,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-mac: @@ -63,6 +68,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: npm test test-docker: @@ -100,4 +107,6 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install + - name: Install proposed validate source + run: npm install --no-save ${{ env.NICTOOL_VALIDATE_SPEC }} - run: sh test/run.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e03ac1e..f2c5767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased - fix: reject create conflicts and duplicate permission targets +- create: allocate ids internally and reject caller-supplied ids ### [3.0.3] - 2026-07-27 diff --git a/docker/Dockerfile b/docker/Dockerfile index 0fea311..88437ff 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,11 @@ FROM node:22-trixie-slim WORKDIR /app COPY package*.json . # --omit=dev is safe: tests use node:test (stdlib), devDeps are only eslint/prettier -RUN npm install --omit=dev +ARG NICTOOL_VALIDATE_SPEC +RUN npm install --omit=dev \ + && if [ -n "$NICTOOL_VALIDATE_SPEC" ]; then \ + npm install --omit=dev --no-save "$NICTOOL_VALIDATE_SPEC"; \ + fi COPY . . EXPOSE 3000 CMD ["node", "server.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 505c3aa..60e55dc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,6 +24,8 @@ services: build: context: .. dockerfile: docker/Dockerfile + args: + NICTOOL_VALIDATE_SPEC: ${NICTOOL_VALIDATE_SPEC:-} ports: - '${API_PORT:-3000}:3000' depends_on: diff --git a/lib/group/store/file.js b/lib/group/store/file.js index b2680cd..29a6368 100644 --- a/lib/group/store/file.js +++ b/lib/group/store/file.js @@ -1,4 +1,4 @@ -import FileStore from '../../store/file.js' +import FileStore, { nextId } from '../../store/file.js' import { idConflict } from '../../store/error.js' import GroupBase from './base.js' @@ -60,37 +60,35 @@ class GroupRepoFile extends GroupBase { async create(args, options) { args = JSON.parse(JSON.stringify(args)) - - if (args.id !== undefined) { - const existing = [ - ...(await this.get({ id: args.id })), - ...(await this.get({ id: args.id, deleted: true })), - ] - if (existing.length > 0) return idConflict('group', args.id, options) - } - const usable_ns = args.usable_ns ?? [] delete args.usable_ns - const gid = args.id - args.permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: gid, - name: `Group ${args.name} perms`, - user: { id: gid, create: false, write: false, delete: false }, - group: { id: gid, create: false, write: false, delete: false }, - nameserver: { - usable: Array.isArray(usable_ns) ? usable_ns : [], - create: false, - write: false, - delete: false, - }, - } + return this.file.mutate('group', (groups, data) => { + if (args.id !== undefined && groups.some((group) => group.id === args.id)) { + return idConflict('group', args.id, options) + } - const groups = await this._load() - groups.push(args) - await this._save(groups) - return gid + if (args.id === undefined) args.id = nextId(groups, data.last_id) + data.last_id = Math.max(data.last_id ?? 0, args.id) + + const gid = args.id + args.permissions = { + ...JSON.parse(JSON.stringify(defaultPermissions)), + id: gid, + name: `Group ${args.name} perms`, + user: { id: gid, create: false, write: false, delete: false }, + group: { id: gid, create: false, write: false, delete: false }, + nameserver: { + usable: Array.isArray(usable_ns) ? usable_ns : [], + create: false, + write: false, + delete: false, + }, + } + + groups.push(args) + return gid + }) } async get(args_orig) { diff --git a/lib/nameserver/store/file.js b/lib/nameserver/store/file.js index 40c16b0..744d601 100644 --- a/lib/nameserver/store/file.js +++ b/lib/nameserver/store/file.js @@ -1,4 +1,4 @@ -import FileStore from '../../store/file.js' +import FileStore, { nextId } from '../../store/file.js' import { idConflict } from '../../store/error.js' import NameserverBase from './base.js' @@ -40,18 +40,18 @@ class NameserverRepoFile extends NameserverBase { } async create(args, options) { - if (args.id !== undefined) { - const existing = [ - ...(await this.get({ id: args.id })), - ...(await this.get({ id: args.id, deleted: true })), - ] - if (existing.length > 0) return idConflict('nameserver', args.id, options) - } + args = JSON.parse(JSON.stringify(args)) - const nameservers = await this._load() - nameservers.push(JSON.parse(JSON.stringify(args))) - await this._save(nameservers) - return args.id + return this.file.mutate('nameserver', (nameservers, data) => { + if (args.id !== undefined && nameservers.some((nameserver) => nameserver.id === args.id)) { + return idConflict('nameserver', args.id, options) + } + + if (args.id === undefined) args.id = nextId(nameservers, data.last_id, 0xffff) + data.last_id = Math.max(data.last_id ?? 0, args.id) + nameservers.push(args) + return args.id + }) } async get(args) { diff --git a/lib/session/store/file.js b/lib/session/store/file.js index d3d959f..ebd15cc 100644 --- a/lib/session/store/file.js +++ b/lib/session/store/file.js @@ -1,4 +1,4 @@ -import FileStore from '../../store/file.js' +import FileStore, { nextId } from '../../store/file.js' // Map legacy nt_* column names to the friendly API names used throughout. function normalizeArgs(args) { @@ -33,15 +33,17 @@ class SessionRepoFile { async create(args) { args = normalizeArgs(JSON.parse(JSON.stringify(args))) - const existing = await this.get({ uid: args.uid, session: args.session }) - if (existing) return existing.id + return this.file.mutate('session', (sessions, data) => { + const existing = sessions.find( + (session) => session.uid === args.uid && session.session === args.session, + ) + if (existing) return existing.id - const sessions = await this._load() - const nextId = sessions.reduce((max, s) => Math.max(max, s.id ?? 0), 0) + 1 - args.id = nextId - sessions.push(args) - await this._save(sessions) - return nextId + args.id = nextId(sessions, data.last_id) + data.last_id = args.id + sessions.push(args) + return args.id + }) } async get(args) { diff --git a/lib/store/file.js b/lib/store/file.js index 8ec2c9e..ba1ce62 100644 --- a/lib/store/file.js +++ b/lib/store/file.js @@ -19,11 +19,38 @@ const tomlCodec = { } const codecs = { json: jsonCodec, toml: tomlCodec } +const mutations = new Map() + +async function serialize(file, callback) { + const previous = mutations.get(file) ?? Promise.resolve() + const current = previous.catch(() => {}).then(callback) + mutations.set(file, current) + + try { + return await current + } finally { + if (mutations.get(file) === current) mutations.delete(file) + } +} export function resolveCodec(type = storeConfig().type) { return codecs[type] ?? jsonCodec } +export function nextId(rows, lastId = 0, maxId = 0xffffffff) { + if (!Number.isInteger(lastId) || lastId < 0) throw new TypeError('file store last id must be unsigned') + + let highest = lastId + for (const row of rows) { + if (row.id === undefined) continue + if (!Number.isInteger(row.id) || row.id < 0) throw new TypeError('file store row id must be unsigned') + highest = Math.max(highest, row.id) + } + + if (highest >= maxId) throw new RangeError(`file store id space exhausted at ${maxId}`) + return highest + 1 +} + /** * One file per entity, holding an array of rows under a single top-level key. * The codec is chosen by store.type; everything above this layer is identical @@ -53,20 +80,45 @@ export class FileStore { return path.join(base, `${this.basename}.${this.codec.ext}`) } - async load(key) { + async _read(file) { try { - const data = await this.codec.parse(await fs.readFile(this.filePath, 'utf8')) - return Array.isArray(data?.[key]) ? data[key] : [] + const data = await this.codec.parse(await fs.readFile(file, 'utf8')) + return data && typeof data === 'object' && !Array.isArray(data) ? data : {} } catch (err) { - if (err.code === 'ENOENT') return [] + if (err.code === 'ENOENT') return {} throw err } } + async _write(file, data) { + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, await this.codec.stringify(data)) + } + + async load(key) { + const data = await this._read(this.filePath) + return Array.isArray(data[key]) ? data[key] : [] + } + async save(key, rows) { const file = this.filePath - await fs.mkdir(path.dirname(file), { recursive: true }) - await fs.writeFile(file, await this.codec.stringify({ [key]: rows })) + return serialize(file, async () => { + const data = await this._read(file) + data[key] = rows + await this._write(file, data) + }) + } + + async mutate(key, callback) { + const file = this.filePath + return serialize(file, async () => { + const data = await this._read(file) + const rows = Array.isArray(data[key]) ? data[key] : [] + const result = await callback(rows, data) + data[key] = rows + await this._write(file, data) + return result + }) } } diff --git a/lib/store/file.test.js b/lib/store/file.test.js index c76b6e2..e58515e 100644 --- a/lib/store/file.test.js +++ b/lib/store/file.test.js @@ -4,7 +4,8 @@ import os from 'node:os' import path from 'node:path' import { describe, it, before, after, beforeEach } from 'node:test' -import FileStore, { resolveCodec } from './file.js' +import GroupRepoFile from '../group/store/file.js' +import FileStore, { nextId, resolveCodec } from './file.js' const envKeys = ['NICTOOL_DATA_STORE', 'NICTOOL_DATA_STORE_PATH', 'NICTOOL_CONF_DIR'] @@ -12,6 +13,13 @@ describe('file store', () => { const savedEnv = {} let tmp + it('allocates after the highest existing id', () => { + assert.equal(nextId([]), 1) + assert.equal(nextId([{ id: 9 }, { id: 2 }, {}]), 10) + assert.equal(nextId([{ id: 9 }], 12), 13) + assert.throws(() => nextId([{ id: 0xffffffff }]), /id space exhausted/) + }) + before(() => { for (const key of envKeys) { savedEnv[key] = process.env[key] @@ -103,4 +111,21 @@ describe('file store', () => { process.env.NICTOOL_DATA_STORE = 'nonsense' assert.equal(resolveCodec().ext, 'json') }) + + for (const type of ['json', 'toml']) { + it(`serializes ${type} id allocation and does not reuse a destroyed id`, async () => { + process.env.NICTOOL_DATA_STORE = type + const store = new GroupRepoFile() + const ids = await Promise.all( + Array.from({ length: 20 }, (_, index) => store.create({ name: `group-${index}.example` })), + ) + + assert.deepEqual( + [...ids].sort((a, b) => a - b), + Array.from({ length: 20 }, (_, index) => index + 1), + ) + await store.destroy({ id: 20 }) + assert.equal(await store.create({ name: 'after-destroy.example' }), 21) + }) + } }) diff --git a/lib/user/store/file.js b/lib/user/store/file.js index ec31b84..582d6bb 100644 --- a/lib/user/store/file.js +++ b/lib/user/store/file.js @@ -1,4 +1,4 @@ -import FileStore from '../../store/file.js' +import FileStore, { nextId } from '../../store/file.js' import { idConflict } from '../../store/error.js' import Config from '../../config.js' import Credentials from '../credentials.js' @@ -132,14 +132,6 @@ class UserRepoFile extends UserBase { } async create(args, options) { - if (args.id !== undefined) { - const existing = [ - ...(await this.get({ id: args.id })), - ...(await this.get({ id: args.id, deleted: true })), - ] - if (existing.length > 0) return idConflict('user', args.id, options) - } - args = JSON.parse(JSON.stringify(args)) const inherit = args.inherit_group_permissions @@ -149,19 +141,26 @@ class UserRepoFile extends UserBase { Object.assign(args, await Credentials.forStorage(args.password, args.pass_salt)) } - if (inherit === false) { - args.permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: args.id, - user: { id: args.id, create: false, write: false, delete: false }, - group: { id: args.gid, create: false, write: false, delete: false }, + return this.file.mutate('user', (users, data) => { + if (args.id !== undefined && users.some((user) => user.id === args.id)) { + return idConflict('user', args.id, options) } - } - const users = await this._load() - users.push(args) - await this._save(users) - return args.id + if (args.id === undefined) args.id = nextId(users, data.last_id) + data.last_id = Math.max(data.last_id ?? 0, args.id) + + if (inherit === false) { + args.permissions = { + ...JSON.parse(JSON.stringify(defaultPermissions)), + id: args.id, + user: { id: args.id, create: false, write: false, delete: false }, + group: { id: args.gid, create: false, write: false, delete: false }, + } + } + + users.push(args) + return args.id + }) } async put(args) { diff --git a/lib/zone/store/file.js b/lib/zone/store/file.js index 28be839..0a64ac3 100644 --- a/lib/zone/store/file.js +++ b/lib/zone/store/file.js @@ -1,4 +1,4 @@ -import FileStore, { resolveCodec } from '../../store/file.js' +import FileStore, { nextId, resolveCodec } from '../../store/file.js' import { idConflict } from '../../store/error.js' import ZoneBase from './base.js' @@ -36,18 +36,18 @@ class ZoneRepoFile extends ZoneBase { } async create(args, options) { - if (args.id !== undefined) { - const existing = [ - ...(await this.get({ id: args.id })), - ...(await this.get({ id: args.id, deleted: true })), - ] - if (existing.length > 0) return idConflict('zone', args.id, options) - } + args = JSON.parse(JSON.stringify(args)) - const zones = await this._load() - zones.push(JSON.parse(JSON.stringify(args))) - await this._save(zones) - return args.id + return this.file.mutate('zone', (zones, data) => { + if (args.id !== undefined && zones.some((zone) => zone.id === args.id)) { + return idConflict('zone', args.id, options) + } + + if (args.id === undefined) args.id = nextId(zones, data.last_id) + data.last_id = Math.max(data.last_id ?? 0, args.id) + zones.push(args) + return args.id + }) } async get(args) { diff --git a/lib/zone_record/store/file.js b/lib/zone_record/store/file.js index e05d797..d8913ba 100644 --- a/lib/zone_record/store/file.js +++ b/lib/zone_record/store/file.js @@ -1,4 +1,4 @@ -import FileStore from '../../store/file.js' +import FileStore, { nextId } from '../../store/file.js' import { idConflict } from '../../store/error.js' import ZoneRecordBase from './base.js' @@ -20,26 +20,18 @@ class ZoneRecordRepoFile extends ZoneRecordBase { async create(args, options) { args = JSON.parse(JSON.stringify(args)) - if (args.id !== undefined) { - const existing = [ - ...(await this.get({ id: args.id })), - ...(await this.get({ id: args.id, deleted: true })), - ] - if (existing.length > 0) return idConflict('zone record', args.id, options) - } - - const records = await this._load() + return this.file.mutate('zone_record', (records, data) => { + if (args.id !== undefined && records.some((record) => record.id === args.id)) { + return idConflict('zone record', args.id, options) + } - if (!args.id) { - const maxId = records.reduce((max, r) => Math.max(max, r.id ?? 0), 0) - args.id = maxId + 1 - } + if (args.id === undefined) args.id = nextId(records, data.last_id) + data.last_id = Math.max(data.last_id ?? 0, args.id) + if (args.ttl === undefined) args.ttl = 0 - if (args.ttl === undefined) args.ttl = 0 - - records.push(args) - await this._save(records) - return args.id + records.push(args) + return args.id + }) } async count(args = {}) { diff --git a/routes/create_id.test.js b/routes/create_id.test.js new file mode 100644 index 0000000..e00994f --- /dev/null +++ b/routes/create_id.test.js @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { after, before, describe, it } from 'node:test' + +import Group from '../lib/group/index.js' +import Nameserver from '../lib/nameserver/index.js' +import Permission from '../lib/permission/index.js' +import User from '../lib/user/index.js' +import Zone from '../lib/zone/index.js' +import ZoneRecord from '../lib/zone_record/index.js' +import { init } from './index.js' + +import groupCase from './test/group.json' with { type: 'json' } +import nameserverCase from './test/nameserver.json' with { type: 'json' } +import permissionCase from './test/permission.json' with { type: 'json' } +import userCase from './test/user.json' with { type: 'json' } +import zoneCase from './test/zone.json' with { type: 'json' } + +const chosenId = 65001 +let server +const auth = { headers: {} } + +const userPayload = { + ...userCase, + id: chosenId, + gid: groupCase.id, + email: 'caller-id@example.com', + username: 'caller-id-user', +} +delete userPayload.deleted + +const cases = [ + ['group', { ...groupCase, id: chosenId, name: 'caller-id-group' }], + ['nameserver', { ...nameserverCase, id: chosenId, gid: groupCase.id, name: 'caller-id.ns.example.com.' }], + [ + 'permission', + { + ...permissionCase, + id: chosenId, + group: { ...permissionCase.group, id: chosenId }, + user: { ...permissionCase.user, id: chosenId }, + }, + ], + ['user', userPayload], + ['zone', { ...zoneCase, id: chosenId, gid: groupCase.id, zone: 'caller-id.example.com.' }], + [ + 'zone_record', + { + id: chosenId, + zid: zoneCase.id, + owner: 'caller-id.route.example.com.', + ttl: 300, + type: 'A', + address: '203.0.113.111', + }, + ], +] + +before(async () => { + const fixture = { ifExists: 'return' } + await ZoneRecord.destroy({ id: chosenId }) + await Zone.destroy({ id: chosenId }) + await User.destroy({ id: chosenId }) + await Permission.destroy({ id: chosenId }) + await Nameserver.destroy({ id: chosenId }) + await Group.destroy({ id: chosenId }) + await Group.create(groupCase, fixture) + await User.create(userCase, fixture) + await Zone.create(zoneCase, fixture) + server = await init() + + const res = await server.inject({ + method: 'POST', + url: '/session', + payload: { + username: `${userCase.username}@${groupCase.name}`, + password: userCase.password, + }, + }) + assert.equal(res.statusCode, 200) + auth.headers = { Authorization: `Bearer ${res.result.session.token}` } +}) + +after(async () => { + await ZoneRecord.destroy({ id: chosenId }) + await Zone.destroy({ id: chosenId }) + await User.destroy({ id: chosenId }) + await Permission.destroy({ id: chosenId }) + await Nameserver.destroy({ id: chosenId }) + await Group.destroy({ id: chosenId }) + if (server) await server.stop() +}) + +describe('caller-supplied create ids', () => { + for (const [entity, payload] of cases) { + it(`rejects an id on POST /${entity}`, async () => { + const res = await server.inject({ + method: 'POST', + url: `/${entity}`, + headers: auth.headers, + payload, + }) + + assert.equal(res.statusCode, 400) + assert.match(res.result.message, /"id" is not allowed/) + }) + } +}) diff --git a/routes/group.test.js b/routes/group.test.js index 8945093..f27e7a8 100644 --- a/routes/group.test.js +++ b/routes/group.test.js @@ -9,7 +9,7 @@ import groupCase from './test/group.json' with { type: 'json' } import userCase from './test/user.json' with { type: 'json' } let server -const case2Id = 4094 +let case2Id before(async () => { server = await init() @@ -18,7 +18,7 @@ before(async () => { }) after(async () => { - await Group.destroy({ id: case2Id }) + if (case2Id) await Group.destroy({ id: case2Id }) await server.stop() }) @@ -49,9 +49,9 @@ describe('group routes', () => { assert.equal(res.result.group[0].id, groupCase.id) }) - it('POST /group', async () => { + it('POST /group allocates an id', async () => { const testCase = JSON.parse(JSON.stringify(groupCase)) - testCase.id = case2Id // make it unique + delete testCase.id testCase.name = `example2.com` delete testCase.deleted @@ -62,9 +62,24 @@ describe('group routes', () => { payload: testCase, }) assert.equal(res.statusCode, 201) + assert.equal(res.result.group.length, 1) + assert.equal(res.result.group[0].name, 'example2.com') + case2Id = res.result.group[0].id + assert.ok(Number.isInteger(case2Id)) }) - it(`GET /group/${case2Id}`, async () => { + it('PUT /group/{id} keeps using the route id', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/group/${case2Id}`, + headers: auth.headers, + payload: { name: 'example3.com' }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.group[0].name, 'example3.com') + }) + + it('GET /group/{id}', async () => { const res = await server.inject({ method: 'GET', url: `/group/${case2Id}`, @@ -74,7 +89,7 @@ describe('group routes', () => { assert.equal(res.result.group[0].id, case2Id) }) - it(`DELETE /group/${case2Id}`, async () => { + it('DELETE /group/{id}', async () => { const res = await server.inject({ method: 'DELETE', url: `/group/${case2Id}`, @@ -83,7 +98,7 @@ describe('group routes', () => { assert.equal(res.statusCode, 200) }) - it(`GET /group/${case2Id} (soft-deleted → empty array)`, async () => { + it('GET /group/{id} hides a soft-deleted group', async () => { const res = await server.inject({ method: 'GET', url: `/group/${case2Id}`, @@ -93,7 +108,7 @@ describe('group routes', () => { assert.deepEqual(res.result.group, []) }) - it(`GET /group/${case2Id} (deleted)`, async () => { + it('GET /group/{id}?deleted=true returns a soft-deleted group', async () => { const res = await server.inject({ method: 'GET', url: `/group/${case2Id}?deleted=true`, diff --git a/routes/nameserver.test.js b/routes/nameserver.test.js index b9c65d1..d9dc710 100644 --- a/routes/nameserver.test.js +++ b/routes/nameserver.test.js @@ -11,10 +11,9 @@ import userCase from './test/user.json' with { type: 'json' } import nsCase from './test/nameserver.json' with { type: 'json' } let server -let case2Id = 4094 +let case2Id before(async () => { - await Nameserver.destroy({ id: case2Id }) await Group.create(groupCase, { ifExists: 'return' }) await User.create(userCase, { ifExists: 'return' }) await Nameserver.create(nsCase, { ifExists: 'return' }) @@ -22,7 +21,7 @@ before(async () => { }) after(async () => { - await Nameserver.destroy({ id: case2Id }) + if (case2Id) await Nameserver.destroy({ id: case2Id }) await server.stop() }) @@ -53,9 +52,9 @@ describe('nameserver routes', () => { assert.equal(res.result.nameserver[0].name, nsCase.name) }) - it(`POST /nameserver (${case2Id})`, async () => { + it('POST /nameserver allocates an id', async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) - testCase.id = case2Id // make it unique + delete testCase.id testCase.name = 'c.ns.example.com.' const res = await server.inject({ @@ -65,32 +64,35 @@ describe('nameserver routes', () => { payload: testCase, }) assert.equal(res.statusCode, 201) + assert.equal(res.result.nameserver.length, 1) + assert.equal(res.result.nameserver[0].name, 'c.ns.example.com.') assert.ok(res.result.nameserver[0].gid) + case2Id = res.result.nameserver[0].id + assert.ok(Number.isInteger(case2Id)) }) - it(`GET /nameserver/${case2Id}`, async () => { + it('PUT /nameserver/{id} keeps using the route id', async () => { const res = await server.inject({ - method: 'GET', + method: 'PUT', url: `/nameserver/${case2Id}`, headers: auth.headers, + payload: { description: 'updated' }, }) assert.equal(res.statusCode, 200) - assert.ok(res.result.nameserver[0].gid) + assert.equal(res.result.nameserver[0].description, 'updated') }) - it(`PUT /nameserver/${case2Id}`, async () => { + it('GET /nameserver/{id}', async () => { const res = await server.inject({ - method: 'PUT', + method: 'GET', url: `/nameserver/${case2Id}`, headers: auth.headers, - payload: { description: 'edited by the route test' }, }) - assert.equal(res.statusCode, 200) - assert.equal((await Nameserver.get({ id: case2Id }))[0].description, 'edited by the route test') + assert.ok(res.result.nameserver[0].gid) }) - it(`DELETE /nameserver/${case2Id}`, async () => { + it('DELETE /nameserver/{id}', async () => { const res = await server.inject({ method: 'DELETE', url: `/nameserver/${case2Id}`, @@ -99,7 +101,7 @@ describe('nameserver routes', () => { assert.equal(res.statusCode, 200) }) - it(`DELETE /nameserver/${case2Id}`, async () => { + it('DELETE /nameserver/{id} returns 404 when already deleted', async () => { const res = await server.inject({ method: 'DELETE', url: `/nameserver/${case2Id}`, @@ -108,7 +110,7 @@ describe('nameserver routes', () => { assert.equal(res.statusCode, 404) }) - it(`GET /nameserver/${case2Id}`, async () => { + it('GET /nameserver/{id} hides a soft-deleted nameserver', async () => { const res = await server.inject({ method: 'GET', url: `/nameserver/${case2Id}`, @@ -117,7 +119,7 @@ describe('nameserver routes', () => { assert.deepEqual(res.result.nameserver, []) }) - it(`GET /nameserver/${case2Id} (deleted)`, async () => { + it('GET /nameserver/{id}?deleted=true returns a soft-deleted nameserver', async () => { const res = await server.inject({ method: 'GET', url: `/nameserver/${case2Id}?deleted=true`, diff --git a/routes/permission.test.js b/routes/permission.test.js index 39069b8..9c9eddf 100644 --- a/routes/permission.test.js +++ b/routes/permission.test.js @@ -53,7 +53,7 @@ describe('permission routes', () => { assert.equal(res.result.permission.nameserver.create, false) }) - it('POST /permission', async () => { + it('POST /permission allocates an id', async () => { const testCase = JSON.parse(JSON.stringify(permCase)) delete testCase.id testCase.user.id = targetId @@ -68,9 +68,11 @@ describe('permission routes', () => { payload: testCase, }) assert.equal(res.statusCode, 201) - case2Id = res.result.permission.id + assert.equal(res.result.permission.name, 'Route Test Permission 2') assert.equal(res.result.permission.zone.create, true) assert.equal(res.result.permission.nameserver.create, false) + case2Id = res.result.permission.id + assert.ok(Number.isInteger(case2Id)) }) it('POST /permission rejects an existing target', async () => { @@ -94,7 +96,7 @@ describe('permission routes', () => { assert.equal(existing.name, 'Route Test Permission 2') }) - it('GET the created permission', async () => { + it('GET /permission/{id}', async () => { const res = await server.inject({ method: 'GET', url: `/permission/${case2Id}`, @@ -106,7 +108,7 @@ describe('permission routes', () => { assert.equal(res.result.permission.nameserver.create, false) }) - it('DELETE the created permission', async () => { + it('DELETE /permission/{id}', async () => { const res = await server.inject({ method: 'DELETE', url: `/permission/${case2Id}`, @@ -116,7 +118,7 @@ describe('permission routes', () => { assert.equal(res.statusCode, 200) }) - it('DELETE the created permission again', async () => { + it('DELETE /permission/{id} returns 404 when already deleted', async () => { const res = await server.inject({ method: 'DELETE', url: `/permission/${case2Id}`, @@ -126,7 +128,7 @@ describe('permission routes', () => { assert.equal(res.statusCode, 404) }) - it('GET the deleted permission', async () => { + it('GET /permission/{id} hides a soft-deleted permission', async () => { const res = await server.inject({ method: 'GET', url: `/permission/${case2Id}`, @@ -137,7 +139,7 @@ describe('permission routes', () => { assert.equal(res.result.permission, undefined) }) - it('GET the deleted permission with deleted=true', async () => { + it('GET /permission/{id}?deleted=true returns a soft-deleted permission', async () => { const res = await server.inject({ method: 'GET', url: `/permission/${case2Id}?deleted=true`, diff --git a/routes/user.test.js b/routes/user.test.js index fa97de4..b11987f 100644 --- a/routes/user.test.js +++ b/routes/user.test.js @@ -17,10 +17,10 @@ before(async () => { await User.create(userCase, { ifExists: 'return' }) }) -const userId2 = 4094 +let userId2 after(async () => { - User.destroy({ id: userId2 }) + if (userId2) await User.destroy({ id: userId2 }) await server.stop() }) @@ -57,9 +57,9 @@ describe('user routes', () => { assert.equal(res.statusCode, 200) }) - it('POST /user', async () => { + it('POST /user allocates an id', async () => { const testCase = JSON.parse(JSON.stringify(userCase)) - testCase.id = userId2 // make it unique + delete testCase.id testCase.username = `${testCase.username}2` delete testCase.deleted @@ -70,9 +70,24 @@ describe('user routes', () => { payload: testCase, }) assert.equal(res.statusCode, 201) + assert.equal(res.result.user.length, 1) + assert.equal(res.result.user[0].username, `${userCase.username}2`) + userId2 = res.result.user[0].id + assert.ok(Number.isInteger(userId2)) }) - it(`GET /user/${userId2}`, async () => { + it('PUT /user/{id} keeps using the route id', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/user/${userId2}`, + headers: auth.headers, + payload: { first_name: 'Updated' }, + }) + assert.equal(res.statusCode, 200) + assert.equal(res.result.user[0].first_name, 'Updated') + }) + + it('GET /user/{id}', async () => { const res = await server.inject({ method: 'GET', url: `/user/${userId2}`, @@ -81,7 +96,7 @@ describe('user routes', () => { assert.equal(res.statusCode, 200) }) - it(`DELETE /user/${userId2}`, async () => { + it('DELETE /user/{id}', async () => { const res = await server.inject({ method: 'DELETE', url: `/user/${userId2}`, @@ -90,7 +105,7 @@ describe('user routes', () => { assert.equal(res.statusCode, 200) }) - it(`GET /user/${userId2} (deleted)`, async () => { + it('GET /user/{id} hides a soft-deleted user', async () => { const res = await server.inject({ method: 'GET', url: `/user/${userId2}`, @@ -99,7 +114,7 @@ describe('user routes', () => { assert.ok([200, 204].includes(res.statusCode)) }) - it(`GET /user/${userId2}?deleted=true`, async () => { + it('GET /user/{id}?deleted=true returns a soft-deleted user', async () => { const res = await server.inject({ method: 'GET', url: `/user/${userId2}?deleted=true`, diff --git a/routes/zone.test.js b/routes/zone.test.js index 2bf903d..a72852b 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -11,14 +11,13 @@ import userCase from './test/user.json' with { type: 'json' } import nsCase from './test/zone.json' with { type: 'json' } let server -let case2Id = 4094 +let case2Id const subGroup = { id: 4090, parent_gid: groupCase.id, name: 'sub.route.example.com' } const subZone = { ...nsCase, id: 4091, gid: subGroup.id, zone: 'sub.route.example.com.' } before(async () => { await Zone.destroy({ id: nsCase.id }) - await Zone.destroy({ id: case2Id }) await Zone.destroy({ id: subZone.id }) // Destroy the subgroup before recreating it: a lingering row would make // Group.create early-return and skip addToSubgroups, leaving the @@ -33,6 +32,7 @@ before(async () => { }) after(async () => { + if (case2Id) await Zone.destroy({ id: case2Id }) await Zone.destroy({ id: subZone.id }) await Group.destroy({ id: subGroup.id }) await server.stop() @@ -75,10 +75,10 @@ describe('zone routes', () => { assert.ok(res.result.zone.some((z) => z.zone === nsCase.zone)) }) - it(`POST /zone (${case2Id})`, async () => { + it('POST /zone allocates an id', async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) - testCase.id = case2Id // make it unique - testCase.gid = case2Id + delete testCase.id + testCase.gid = groupCase.id testCase.zone = 'route2.example.com.' const res = await server.inject({ @@ -89,33 +89,36 @@ describe('zone routes', () => { }) // console.log(res.result) assert.equal(res.statusCode, 201) + assert.equal(res.result.zone.length, 1) + assert.equal(res.result.zone[0].zone, 'route2.example.com.') assert.ok(res.result.zone[0].gid) + case2Id = res.result.zone[0].id + assert.ok(Number.isInteger(case2Id)) }) - it(`GET /zone/${case2Id}`, async () => { + it('PUT /zone/{id} keeps using the route id', async () => { const res = await server.inject({ - method: 'GET', + method: 'PUT', url: `/zone/${case2Id}`, headers: auth.headers, + payload: { description: 'updated' }, }) - // console.log(res.result) assert.equal(res.statusCode, 200) - assert.ok(res.result.zone[0].gid) + assert.equal(res.result.zone[0].description, 'updated') }) - it(`PUT /zone/${case2Id}`, async () => { + it('GET /zone/{id}', async () => { const res = await server.inject({ - method: 'PUT', + method: 'GET', url: `/zone/${case2Id}`, headers: auth.headers, - payload: { description: 'edited by the route test' }, }) - + // console.log(res.result) assert.equal(res.statusCode, 200) - assert.equal((await Zone.get({ id: case2Id }))[0].description, 'edited by the route test') + assert.ok(res.result.zone[0].gid) }) - it(`DELETE /zone/${case2Id}`, async () => { + it('DELETE /zone/{id}', async () => { const res = await server.inject({ method: 'DELETE', url: `/zone/${case2Id}`, @@ -125,7 +128,7 @@ describe('zone routes', () => { assert.equal(res.statusCode, 200) }) - it(`DELETE /zone/${case2Id}`, async () => { + it('DELETE /zone/{id} returns 404 when already deleted', async () => { const res = await server.inject({ method: 'DELETE', url: `/zone/${case2Id}`, @@ -135,7 +138,7 @@ describe('zone routes', () => { assert.equal(res.statusCode, 404) }) - it(`GET /zone/${case2Id}`, async () => { + it('GET /zone/{id} hides a soft-deleted zone', async () => { const res = await server.inject({ method: 'GET', url: `/zone/${case2Id}`, @@ -146,7 +149,7 @@ describe('zone routes', () => { assert.deepEqual(res.result.zone, []) }) - it(`GET /zone/${case2Id} (deleted)`, async () => { + it('GET /zone/{id}?deleted=true returns a soft-deleted zone', async () => { const res = await server.inject({ method: 'GET', url: `/zone/${case2Id}?deleted=true`, diff --git a/routes/zone_record.test.js b/routes/zone_record.test.js index 848600f..82adb41 100644 --- a/routes/zone_record.test.js +++ b/routes/zone_record.test.js @@ -90,24 +90,6 @@ describe('zone_record routes', () => { auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) - it('POST /zone_record returns 409 for an existing id', async () => { - const res = await server.inject({ - method: 'POST', - url: '/zone_record', - headers: auth.headers, - payload: { - ...testZoneRecord, - owner: 'changed.route-zr-delete.example.com.', - }, - }) - - assert.equal(res.statusCode, 409) - assert.equal(res.result.message, `zone record id ${testZoneRecordId} already exists`) - - const [existing] = await ZoneRecord.get({ id: testZoneRecordId }) - assert.equal(existing.owner, testZoneRecord.owner) - }) - it('POST /zone_record creates and returns array payload', async () => { const res = await server.inject({ method: 'POST', @@ -131,6 +113,19 @@ describe('zone_record routes', () => { createdZoneRecordIds.push(res.result.zone_record[0].id) }) + it('PUT /zone_record/{id} keeps using the route id', async () => { + const id = createdZoneRecordIds[0] + const res = await server.inject({ + method: 'PUT', + url: `/zone_record/${id}`, + headers: auth.headers, + payload: { description: 'updated' }, + }) + + assert.equal(res.statusCode, 200) + assert.equal(res.result.zone_record[0].description, 'updated') + }) + it('POST /zone_record accepts omitted ttl and stores 0', async () => { const res = await server.inject({ method: 'POST',