diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c2af38..f8c75d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: min: ${{ steps.get.outputs.min }} test: - needs: [ get-lts ] + needs: [get-lts] runs-on: ${{ matrix.os }} strategy: matrix: @@ -46,7 +46,7 @@ jobs: - run: npm test test-mac: - needs: [ get-lts ] + needs: [get-lts] runs-on: macos-latest strategy: matrix: @@ -83,7 +83,7 @@ jobs: run: docker compose -f docker/docker-compose.yml down -v test-win: - needs: [ get-lts ] + needs: [get-lts] runs-on: windows-latest strategy: matrix: diff --git a/.gitignore b/.gitignore index d5e7fe8..d81f377 100644 --- a/.gitignore +++ b/.gitignore @@ -131,5 +131,5 @@ dist package-lock.json .release/ -conf.d/*.pem +conf.d/ CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index afb7289..3151a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased -### [3.0.0] - 2026-07-25 +### [3.0.3] - 2026-07-27 + +- many updates for data stores and NS backends +- feat(mysql): new NS columns: engine, listen, publisher, transport, dnssec +- change: */store/toml -> */store/file +- change(sql/*.sql): made setup non-destructive +- feat: self describing password hashes (#56) + +### [3.0.2] - 2026-07-25 -- release 3.0.0 - dep(rr): bump version +- fix: generate conf.d/http.toml on 1st start -### [3.0.0-alpha.13] - 2026-07-24 +### 3.0.0-alpha.13 - 2026-07-24 - fix(sql): quote mysql 8 keyword rank - fix: subgroup searching - feat: paginate zone records (#53) -### [3.0.0-alpha.12] - 2026-04-14 +### 3.0.0-alpha.12 - 2026-04-14 - toml backend (#49) - add: TOML stores for group, nameserver, permission, session (#47) @@ -29,7 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - ci: update permissions to be explicit (#42) - docker compose support + env var config overrides (#40) -### [3.0.0-alpha.11] - 2026-04-07 +### 3.0.0-alpha.11 - 2026-04-07 - decorate user & group with permissions - add some missing PUT routes @@ -39,24 +47,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - zone factory & subclasses - user factory, toml, mysql, mongodb, elastic classes -### [3.0.0-alpha.10] - 2026-03-25 +### 3.0.0-alpha.10 - 2026-03-25 - config: replace .yaml with .toml - zone_record can be empty, default 0 - feat(zone records): create and delete -### [3.0.0-alpha.9] - 2026-03-15 +### 3.0.0-alpha.9 - 2026-03-15 - feat(zone): use DataTable for list, added search/limit options - routes/zr: add extra data about ZR parse failures -### [3.0.0-alpha.8] - 2026-03-14 +### 3.0.0-alpha.8 - 2026-03-14 - lib/zone: add limit option - lib/nameserver.js: handle null fields from DB - routes/zone: report zone name on validation failure -### [3.0.0-alpha.6] - 2025-04-08 +### 3.0.0-alpha.6 - 2025-04-08 - dep(eslint): upgraded to v9 - dep(\*): bump versions to latest @@ -68,7 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - change(routes/ns): GET id is optional, result is always array - change(routes/zone): GET id is optional, result is always array -### [3.0.0-alpha.5] - 2024-03-06 +### 3.0.0-alpha.5 - 2024-03-06 - feat(lib/zone): added, with tests, fixes #22 - feat(lib/zone_record): added, with tests, fixes #23 @@ -77,12 +85,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - sql: return indicative boolean for delete - test(zr): added maps from NT SQL 2 to dns-rr std formats -### [3.0.0-alpha.4] - 2024-03-05 +### 3.0.0-alpha.4 - 2024-03-05 - feat(lib/nameserver): added, with tests - feat(routes/nameserver): added, with tests -### [3.0.0-alpha.3] +### 3.0.0-alpha.3 - routes/permission: added GET, POST, DELETE - permission.get: default search with deleted=0 @@ -92,6 +100,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - lib/group.get: convert booleans - lib/user.get: convert booleans +[3.0.2]: https://github.com/NicTool/api/releases/tag/v3.0.2 +[3.0.3]: https://github.com/NicTool/api/releases/tag/v3.0.3 [3.0.0-alpha.0]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.0 [3.0.0-alpha.1]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.1 [3.0.0-alpha.2]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.2 @@ -99,11 +109,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [3.0.0-alpha.4]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.4 [3.0.0-alpha.5]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.5 [3.0.0-alpha.6]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.6 -[3.0.0-alpha.7]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.7 -[3.0.0-alpha.8]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8 -[3.0.0-alpha.9]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.9 +[3.0.0-alpha.9]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.9 +[3.0.0]: https://github.com/NicTool/api/releases/tag/v3.0.0 [3.0.0-alpha.10]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.10 [3.0.0-alpha.11]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.11 [3.0.0-alpha.12]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.12 [3.0.0-alpha.13]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.13 -[3.0.0]: https://github.com/NicTool/api/releases/tag/v3.0.0 +[3.0.0-alpha.7]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.7 +[3.0.0-alpha.8]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1eee3b3..0800b32 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,7 +2,7 @@ This handcrafted artisanal software is brought to you by: -|
msimerson (30) |
aberoham (1) | +|
msimerson (32) |
aberoham (1) | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | this file is generated by [.release](https://github.com/msimerson/.release). diff --git a/README.md b/README.md index 8d0ef53..95a8335 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ A RESTful JSON web service that exposes DNS management functions to users. 1. Install [Node.js](https://nodejs.org/en/download/) 2. Download the NicTool v3 API - ``` mkdir nictool && cd nictool git clone https://github.com/NicTool/api.git @@ -36,7 +35,6 @@ or will start up the HTTP service on the port specified in `conf.d/http.yml`. The default URL for the service is [http://localhost:3000](http://localhost:3000) and the API methods have documentation at [http://localhost:3000/documentation#/](http://localhost:3000/documentation#/). - ## Using the API service Until the NicTool 3.0 HTTP client is written, using a web browser (in Developer mode) or a CLI HTTP utility like curl can be used. Here's a quick tutorial: @@ -46,7 +44,7 @@ Until the NicTool 3.0 HTTP client is written, using a web browser (in Developer `curl -X POST http://localhost:3000/session` ```json -{"statusCode":400,"error":"Bad Request","message":"Invalid request payload input"} +{ "statusCode": 400, "error": "Bad Request", "message": "Invalid request payload input" } ``` The request was rejected because it's missing the required parameters, as shown in the documentation. Create a file called nt-auth.json and store the credentials of a NicTool user therein. Then try the auth request again: @@ -63,26 +61,26 @@ That's not the easiest to read so lets pipe it through `json_pp`: ```json { - "group" : { - "id" : 4096, - "name" : "example.com" - }, - "meta" : { - "api" : { - "version" : "3.0.0" - }, - "msg" : "you are logged in" - }, - "session" : { - "id" : 162 - }, - "user" : { - "email" : "unit-test@example.com", - "first_name" : "Unit", - "id" : 4096, - "last_name" : "Test", - "username" : "unit-test" - } + "group": { + "id": 4096, + "name": "example.com" + }, + "meta": { + "api": { + "version": "3.0.0" + }, + "msg": "you are logged in" + }, + "session": { + "id": 162 + }, + "user": { + "email": "unit-test@example.com", + "first_name": "Unit", + "id": 4096, + "last_name": "Test", + "username": "unit-test" + } } ``` @@ -143,4 +141,3 @@ curl -b nt-session -X GET http://localhost:3000/user/4096 --header "Content-Type } } ``` - diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2ab35d1..505c3aa 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,9 +13,9 @@ services: - ../sql:/sql:ro - ../sql/init-mysql.sh:/docker-entrypoint-initdb.d/init-mysql.sh:ro ports: - - "${DB_PORT:-3307}:3306" + - '${DB_PORT:-3307}:3306' healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] interval: 5s timeout: 5s retries: 10 @@ -25,7 +25,7 @@ services: context: .. dockerfile: docker/Dockerfile ports: - - "${API_PORT:-3000}:3000" + - '${API_PORT:-3000}:3000' depends_on: db: condition: service_healthy @@ -35,9 +35,15 @@ services: NICTOOL_DB_USER: ${NICTOOL_DB_USER:-nictool} NICTOOL_DB_USER_PASSWORD: ${NICTOOL_DB_USER_PASSWORD} NICTOOL_DB_NAME: ${NICTOOL_DB_NAME:-nictool} - NICTOOL_HTTP_HOST: "0.0.0.0" + NICTOOL_HTTP_HOST: '0.0.0.0' healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3000/documentation').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://localhost:3000/documentation').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + ] interval: 10s timeout: 5s retries: 10 @@ -50,7 +56,7 @@ services: dockerfile: docker/Dockerfile hostname: localhost ports: - - "${SERVER_PORT:-8080}:${SERVER_CONTAINER_PORT:-8080}" + - '${SERVER_PORT:-8080}:${SERVER_CONTAINER_PORT:-8080}' depends_on: api: condition: service_healthy @@ -66,7 +72,7 @@ services: NICTOOL_DB_NAME: ${NICTOOL_DB_NAME:-nictool} NICTOOL_API_HOST: api NICTOOL_API_PORT: 3000 - NICTOOL_BIND_HOST: "0.0.0.0" + NICTOOL_BIND_HOST: '0.0.0.0' volumes: db-data: diff --git a/eslint.config.mjs b/eslint.config.mjs index b5b5ee3..81aca84 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,7 +13,7 @@ export default [ ecmaVersion: 'latest', sourceType: 'module', globals: { - ...globals.node + ...globals.node, }, }, rules: { diff --git a/lib/config.apijson.test.js b/lib/config.apijson.test.js new file mode 100644 index 0000000..261dafd --- /dev/null +++ b/lib/config.apijson.test.js @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, it, before, after, beforeEach } from 'node:test' + +import Config, { storeConfig } from './config.js' +import { clearConfigEnv, OVERRIDE_KEYS } from '../test/env.js' + +describe('api.json config', () => { + let tmp + let restoreEnv + + before(() => { + // These assert what api.json holds, so the environment must not override it. + restoreEnv = clearConfigEnv() + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-apijson-')) + process.env.NICTOOL_CONF_DIR = tmp + }) + + after(() => { + restoreEnv() + fs.rmSync(tmp, { recursive: true, force: true }) + Config.cfg = {} + }) + + beforeEach(() => { + Config.cfg = {} + for (const key of OVERRIDE_KEYS) { + if (key !== 'NICTOOL_CONF_DIR') delete process.env[key] + } + }) + + const writeApiJson = (obj) => fs.writeFileSync(path.join(tmp, 'api.json'), JSON.stringify(obj, null, 2)) + + it(`reads sections from NICTOOL_CONF_DIR/api.json`, async () => { + writeApiJson({ http: { host: '10.0.0.1', port: 8080, group: 'Ops' } }) + + const cfg = await Config.get('http') + + assert.equal(cfg.host, '10.0.0.1') + assert.equal(cfg.port, 8080) + assert.equal(cfg.group, 'Ops') + }) + + it(`does not seed over an existing http section`, async () => { + writeApiJson({ http: { host: 'kept', port: 1234 } }) + + await Config.get('http') + + const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'api.json'), 'utf8')) + assert.equal(onDisk.http.host, 'kept') + assert.equal(onDisk.http.jwt, undefined) + }) + + it(`mints http secrets into a drop-in api.json that carries only a store`, async () => { + writeApiJson({ store: { type: 'json', path: '/var/lib/nictool' } }) + + const cfg = await Config.get('http') + + assert.match(cfg.jwt.key, /^[0-9a-f]{32}$/) + + const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'api.json'), 'utf8')) + assert.deepEqual(onDisk.store, { type: 'json', path: '/var/lib/nictool' }) + assert.ok(onDisk.http.cookie.password.length >= 32) + }) + + it(`derives the mysql section from a mysql store`, async () => { + writeApiJson({ + store: { type: 'mysql', host: 'db.example.com', port: 3307, user: 'nt', database: 'ntdb' }, + }) + + const cfg = await Config.get('mysql') + + assert.equal(cfg.host, 'db.example.com') + assert.equal(cfg.port, 3307) + assert.equal(cfg.database, 'ntdb') + assert.equal(cfg.timezone, '+00:00', 'mysql2 defaults are merged in') + assert.equal(cfg.type, undefined, 'store-only keys are not leaked to mysql2') + }) + + it(`prefers an explicit mysql section over the derived one`, async () => { + writeApiJson({ + store: { type: 'mysql', host: 'derived' }, + mysql: { host: 'explicit', database: 'nictool' }, + }) + + assert.equal((await Config.get('mysql')).host, 'explicit') + }) + + it(`does not derive mysql from a file store`, async () => { + writeApiJson({ store: { type: 'json', path: '/var/lib/nictool' } }) + + await assert.rejects(() => Config.get('mysql'), { code: 'ENOENT' }) + }) + + it(`storeConfig reads the store section`, () => { + writeApiJson({ store: { type: 'json', path: '/var/lib/nictool' } }) + + assert.deepEqual(storeConfig(), { type: 'json', path: '/var/lib/nictool' }) + }) + + it(`storeConfig lets env override api.json`, () => { + writeApiJson({ store: { type: 'json', path: '/from/file' } }) + process.env.NICTOOL_DATA_STORE = 'toml' + process.env.NICTOOL_DATA_STORE_PATH = '/from/env' + + assert.deepEqual(storeConfig(), { type: 'toml', path: '/from/env' }) + }) + + it(`lets the environment override the http section`, async () => { + // docker-compose.yml sets these; a container's api.json is generic and the + // environment is what points it at the right host. + writeApiJson({ http: { host: '10.0.0.1', port: 8080 } }) + process.env.NICTOOL_HTTP_HOST = '0.0.0.0' + process.env.NICTOOL_HTTP_PORT = '3000' + + const cfg = await Config.get('http') + + assert.equal(cfg.host, '0.0.0.0') + assert.equal(cfg.port, 3000, 'and is coerced to a number') + }) + + it(`lets the environment override the mysql section`, async () => { + writeApiJson({ mysql: { host: 'db.example.com', database: 'fromfile' } }) + process.env.NICTOOL_DB_HOST = 'db' + process.env.NICTOOL_DB_NAME = 'fromenv' + + const cfg = await Config.get('mysql') + + assert.equal(cfg.host, 'db') + assert.equal(cfg.database, 'fromenv') + }) + + it(`storeConfig returns empty when nothing is configured`, () => { + fs.rmSync(path.join(tmp, 'api.json'), { force: true }) + + assert.deepEqual(storeConfig(), {}) + }) +}) diff --git a/lib/config.js b/lib/config.js index bf1a76a..25655ca 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,9 +1,20 @@ +import crypto from 'node:crypto' import fs from 'node:fs/promises' import fsSync from 'node:fs' import path from 'node:path' import { parse } from 'smol-toml' +import { toJson } from './util.js' + +// Resolved per call rather than at module load: the server passes the config +// directory in after argv is parsed, and the tests chdir to a temp dir. +function confDir() { + return process.env.NICTOOL_CONF_DIR ?? './conf.d' +} + +const apiJsonPath = () => path.join(confDir(), 'api.json') + class Config { constructor() { this.cfg = {} @@ -15,13 +26,15 @@ class Config { if (this.cfg[name]) return this.cfg[name] - const str = await fs.readFile(`./conf.d/${name}.toml`, 'utf8') - const cfg = parse(str) + seedConfDir() + + const dir = confDir() + let cfg = sectionFromApiJson(name, readApiJsonSync()) + if (!cfg) cfg = parse(await fs.readFile(path.join(dir, `${name}.toml`), 'utf8')) applyEnvOverrides(name, cfg) - // if (this.debug) console.debug(cfg) if (name === 'http') { - const tls = await loadPEM('./conf.d') + const tls = await loadPEM(dir) if (tls) cfg.tls = tls } @@ -34,13 +47,15 @@ class Config { if (this.cfg[name]) return this.cfg[name] - const str = fsSync.readFileSync(`./conf.d/${name}.toml`, 'utf8') - const cfg = parse(str) + seedConfDir() + + const dir = confDir() + let cfg = sectionFromApiJson(name, readApiJsonSync()) + if (!cfg) cfg = parse(fsSync.readFileSync(path.join(dir, `${name}.toml`), 'utf8')) applyEnvOverrides(name, cfg) - // if (this.debug) console.debug(cfg) if (name === 'http') { - const tls = loadPEMSync('./conf.d') + const tls = loadPEMSync(dir) if (tls) cfg.tls = tls } @@ -49,6 +64,87 @@ class Config { } } +function readApiJsonSync() { + try { + return JSON.parse(fsSync.readFileSync(apiJsonPath(), 'utf8')) + } catch (err) { + if (err.code === 'ENOENT') return null + throw err + } +} + +const mysqlDefaults = { + host: '127.0.0.1', + port: 3306, + socketPath: '', + database: 'nictool', + timezone: '+00:00', + dateStrings: ['DATETIME', 'TIMESTAMP'], + decimalNumbers: true, +} + +/** + * Resolve a named section out of api.json. The mysql section is derived from + * [store] when the store is mysql, so database credentials live in exactly one + * place rather than being duplicated across two sections. + */ +function sectionFromApiJson(name, apiJson) { + if (!apiJson) return null + if (apiJson[name]) return structuredClone(apiJson[name]) + + if (name === 'mysql' && apiJson.store?.type === 'mysql') { + const { type: _type, path: _path, dsn: _dsn, ...conn } = apiJson.store + return { ...mysqlDefaults, ...conn } + } + + return null +} + +/** + * Ensure the API has an http section with its own secrets. Seeding is + * per-section rather than per-file: a drop-in api.json carrying only [store] + * is the documented way to point a remote API at its database, and that host + * still has to mint its own jwt/cookie secrets. + */ +function seedConfDir() { + const dir = confDir() + // An existing conf.d predating api.json keeps its *.toml files. + if (fsSync.existsSync(path.join(dir, 'http.toml'))) return + + const existing = readApiJsonSync() + if (existing?.http) return + + fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 }) + const seeded = { ...(existing ?? {}), ...apiSeed() } + fsSync.writeFileSync(apiJsonPath(), toJson(seeded), { mode: 0o600 }) + console.log(`seeded ${path.resolve(apiJsonPath())} with generated secrets`) +} + +function apiSeed() { + const secret = (bytes) => crypto.randomBytes(bytes).toString('hex') + + return { + http: { + host: 'localhost', + port: 3000, + keepAlive: false, + group: 'NicTool', + jwt: { key: secret(16) }, + // https://hapi.dev/module/cookie/api/?v=12.0.1 + cookie: { + name: 'sid-nictool', + ttl: 3600000, + path: '/', + clearInvalid: true, + isSameSite: 'Strict', + isSecure: true, + isHttpOnly: false, + password: secret(32), + }, + }, + } +} + function parsePort(envVar) { const raw = process.env[envVar] if (!raw) return undefined @@ -71,6 +167,27 @@ function applyEnvOverrides(name, cfg) { if (process.env.NICTOOL_HTTP_HOST) cfg.host = process.env.NICTOOL_HTTP_HOST if (process.env.NICTOOL_HTTP_PORT) cfg.port = parsePort('NICTOOL_HTTP_PORT') } + if (name === 'store') { + if (process.env.NICTOOL_DATA_STORE) cfg.type = process.env.NICTOOL_DATA_STORE + if (process.env.NICTOOL_DATA_STORE_PATH) cfg.path = process.env.NICTOOL_DATA_STORE_PATH + if (process.env.NICTOOL_DATA_STORE_DSN) cfg.dsn = process.env.NICTOOL_DATA_STORE_DSN + } +} + +/** + * Store selection, resolved synchronously so the per-entity modules can pick a + * backend at import time. Env wins over api.json; neither is required. + */ +export function storeConfig() { + const cfg = sectionFromApiJson('store', readApiJsonSync()) ?? {} + applyEnvOverrides('store', cfg) + return cfg +} + +/** "directory" was the original name for a file store, before JSON was an option. */ +export function storeType() { + const type = storeConfig().type ?? 'mysql' + return type === 'directory' ? 'toml' : type } async function loadPEM(dir) { diff --git a/lib/config.seed.test.js b/lib/config.seed.test.js new file mode 100644 index 0000000..2bf333c --- /dev/null +++ b/lib/config.seed.test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, it, before, after } from 'node:test' + +import Config from './config.js' +import { clearConfigEnv } from '../test/env.js' + +describe('config seeding', () => { + const cwd = process.cwd() + let tmp + let restoreEnv + + before(() => { + // Seeding writes ./conf.d and the defaults are asserted below, so neither + // NICTOOL_CONF_DIR nor the host/port overrides may be in play. + restoreEnv = clearConfigEnv() + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-conf-')) + process.chdir(tmp) + Config.cfg = {} + }) + + after(() => { + restoreEnv() + process.chdir(cwd) + fs.rmSync(tmp, { recursive: true, force: true }) + Config.cfg = {} + }) + + it(`creates conf.d and seeds api.json when absent`, async () => { + assert.equal(fs.existsSync(path.join(tmp, 'conf.d')), false) + + const cfg = await Config.get('http') + + assert.equal(fs.existsSync(path.join(tmp, 'conf.d', 'api.json')), true) + assert.equal(cfg.host, 'localhost') + assert.equal(cfg.port, 3000) + }) + + it(`mints secrets rather than shipping a shared default`, () => { + const seeded = JSON.parse(fs.readFileSync(path.join(tmp, 'conf.d', 'api.json'), 'utf8')) + + assert.match(seeded.http.jwt.key, /^[0-9a-f]{32}$/) + assert.notEqual(seeded.http.jwt.key, 'af1b926a5e21f535c4f5b6c42941c4cf') + assert.ok(seeded.http.cookie.password.length >= 32) + }) + + it(`leaves an existing conf.d alone`, async () => { + const file = path.join(tmp, 'conf.d', 'api.json') + const before = fs.readFileSync(file, 'utf8') + + Config.cfg = {} + await Config.get('http') + + assert.equal(fs.readFileSync(file, 'utf8'), before) + }) + + it(`seeds no store, so a missing mysql config still throws`, async () => { + Config.cfg = {} + await assert.rejects(() => Config.get('mysql'), { code: 'ENOENT' }) + }) +}) diff --git a/lib/config.test.js b/lib/config.test.js index a1a4c28..e08ada6 100644 --- a/lib/config.test.js +++ b/lib/config.test.js @@ -59,6 +59,7 @@ describe('config', () => { const cfg = await Config.get('http') const { tls, ...rest } = cfg delete rest.password + delete rest.jwt.key assert.deepEqual(rest, httpCfg) }) @@ -66,12 +67,12 @@ describe('config', () => { const cfg = Config.getSync('http') const { tls, ...rest } = cfg delete rest.password + delete rest.jwt.key assert.deepEqual(rest, httpCfg) }) it(`loads tls from conf.d/*.pem when present`, async () => { const cfg = await Config.get('http') - delete cfg.password if (!cfg.tls) return // no PEM on this host — skip assert.match(cfg.tls.key, /-----BEGIN.*PRIVATE KEY-----/) assert.match(cfg.tls.cert, /-----BEGIN CERTIFICATE-----/) @@ -104,9 +105,7 @@ const httpCfg = { port: 3000, keepAlive: false, group: 'NicTool', - jwt: { - key: 'af1b926a5e21f535c4f5b6c42941c4cf', - }, + jwt: {}, cookie: { name: 'sid-nictool', ttl: 3600000, diff --git a/lib/group/index.js b/lib/group/index.js index 5338462..47d9769 100644 --- a/lib/group/index.js +++ b/lib/group/index.js @@ -1,18 +1,21 @@ -const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' +import { storeType } from '../config.js' + +const type = storeType() let RepoClass -switch (storeType) { +switch (type) { + case 'json': case 'toml': - RepoClass = (await import('./store/toml.js')).default + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default break case 'mongodb': RepoClass = (await import('./store/mongodb.js')).default break - case 'elasticsearch': - RepoClass = (await import('./store/elasticsearch.js')).default - break default: - RepoClass = (await import('./store/mysql.js')).default + throw new Error(`group: no store implementation for type "${type}"`) } export default new RepoClass() diff --git a/lib/group/store/toml.js b/lib/group/store/file.js similarity index 82% rename from lib/group/store/toml.js rename to lib/group/store/file.js index 3d5873f..a5044c4 100644 --- a/lib/group/store/toml.js +++ b/lib/group/store/file.js @@ -1,13 +1,7 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { parse, stringify } from 'smol-toml' +import FileStore from '../../store/file.js' import GroupBase from './base.js' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const defaultPermissions = { inherit: false, self_write: false, @@ -18,32 +12,18 @@ const defaultPermissions = { user: { create: false, write: false, delete: false }, } -function resolveStorePath(filename) { - const base = process.env.NICTOOL_DATA_STORE_PATH - if (base) return path.join(base, filename) - return path.resolve(__dirname, '../../../conf.d', filename) -} - -class GroupRepoTOML extends GroupBase { +class GroupRepoFile extends GroupBase { constructor(args = {}) { super(args) - this._filePath = resolveStorePath('group.toml') + this.file = new FileStore('group') } async _load() { - try { - const str = await fs.readFile(this._filePath, 'utf8') - const data = parse(str) - return Array.isArray(data.group) ? data.group : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.file.load('group') } async _save(groups) { - await fs.mkdir(path.dirname(this._filePath), { recursive: true }) - await fs.writeFile(this._filePath, stringify({ group: groups })) + return this.file.save('group', groups) } _postProcess(row, deletedArg) { @@ -184,4 +164,4 @@ class GroupRepoTOML extends GroupBase { } } -export default GroupRepoTOML +export default GroupRepoFile diff --git a/lib/nameserver/index.js b/lib/nameserver/index.js index 5338462..03c2b2e 100644 --- a/lib/nameserver/index.js +++ b/lib/nameserver/index.js @@ -1,18 +1,18 @@ -const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' +import { storeType } from '../config.js' + +const type = storeType() let RepoClass -switch (storeType) { +switch (type) { + case 'json': case 'toml': - RepoClass = (await import('./store/toml.js')).default - break - case 'mongodb': - RepoClass = (await import('./store/mongodb.js')).default + RepoClass = (await import('./store/file.js')).default break - case 'elasticsearch': - RepoClass = (await import('./store/elasticsearch.js')).default + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default break default: - RepoClass = (await import('./store/mysql.js')).default + throw new Error(`nameserver: no store implementation for type "${type}"`) } export default new RepoClass() diff --git a/lib/nameserver/store/toml.js b/lib/nameserver/store/file.js similarity index 76% rename from lib/nameserver/store/toml.js rename to lib/nameserver/store/file.js index 6e90e1a..7088a0a 100644 --- a/lib/nameserver/store/toml.js +++ b/lib/nameserver/store/file.js @@ -1,44 +1,24 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { parse, stringify } from 'smol-toml' +import FileStore from '../../store/file.js' import NameserverBase from './base.js' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const boolFields = ['deleted'] // Fields that default to empty string when absent or null const emptyStringFields = ['description', 'address6', 'remote_login', 'logdir', 'datadir'] -function resolveStorePath(filename) { - const base = process.env.NICTOOL_DATA_STORE_PATH - if (base) return path.join(base, filename) - return path.resolve(__dirname, '../../../conf.d', filename) -} - -class NameserverRepoTOML extends NameserverBase { +class NameserverRepoFile extends NameserverBase { constructor(args = {}) { super(args) - this._filePath = resolveStorePath('nameserver.toml') + this.file = new FileStore('nameserver') } async _load() { - try { - const str = await fs.readFile(this._filePath, 'utf8') - const data = parse(str) - return Array.isArray(data.nameserver) ? data.nameserver : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.file.load('nameserver') } async _save(nameservers) { - await fs.mkdir(path.dirname(this._filePath), { recursive: true }) - await fs.writeFile(this._filePath, stringify({ nameserver: nameservers })) + return this.file.save('nameserver', nameservers) } _postProcess(row, deletedArg) { @@ -121,4 +101,4 @@ class NameserverRepoTOML extends NameserverBase { } } -export default NameserverRepoTOML +export default NameserverRepoFile diff --git a/lib/nameserver/store/mysql.js b/lib/nameserver/store/mysql.js index 3702636..911cba5 100644 --- a/lib/nameserver/store/mysql.js +++ b/lib/nameserver/store/mysql.js @@ -5,6 +5,10 @@ import { mapToDbColumn } from '../../util.js' const nsDbMap = { id: 'nt_nameserver_id', gid: 'nt_group_id' } const boolFields = ['deleted', 'export_serials'] +// Runtime configuration the supervisor consumes. Structured, so it round-trips +// through JSON columns rather than getting a column per leaf field. +const jsonFields = ['listen', 'publisher', 'transport', 'dnssec'] + class Nameserver extends NameserverBase { constructor() { super() @@ -54,6 +58,11 @@ class Nameserver extends NameserverBase { , ns.export_interval , ns.export_serials , ns.export_status + , ns.engine + , ns.listen + , ns.publisher + , ns.transport + , ns.dnssec , ns.deleted , t.name AS export_type FROM nt_nameserver ns @@ -76,7 +85,7 @@ class Nameserver extends NameserverBase { delete args.id // Mysql.debug(1) const r = await Mysql.execute( - ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, mapToDbColumn(args, nsDbMap)), + ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, mapToDbColumn(objectToDb(args), nsDbMap)), ) return r.changedRows === 1 } @@ -107,6 +116,19 @@ function dbToObject(rows) { for (const f of ['description', 'address6', 'remote_login', 'datadir', 'logdir', 'export_status']) { if ([undefined, null].includes(row[f])) row[f] = '' } + for (const f of jsonFields) { + // mysql2 already decodes JSON columns; a string means an older driver or + // a TEXT column left over from a hand-run migration. + if (typeof row[f] === 'string') { + try { + row[f] = JSON.parse(row[f]) + } catch { + row[f] = null + } + } + if (row[f] === null || row[f] === undefined) delete row[f] + } + if ([null, undefined, ''].includes(row.engine)) delete row.engine for (const f of ['export']) { for (const p of ['type', 'interval', 'serials', 'status']) { if (![null, undefined].includes(row[`${f}_${p}`])) { @@ -123,6 +145,11 @@ function dbToObject(rows) { function objectToDb(row) { row = JSON.parse(JSON.stringify(row)) // don't mutate the original + for (const f of jsonFields) { + if (row[f] === undefined) continue + row[f] = row[f] === null ? null : JSON.stringify(row[f]) + } + for (const f of ['export']) { for (const p of ['interval', 'serials', 'status']) { if (row[f] === undefined) continue diff --git a/lib/nameserver/test/runtime.js b/lib/nameserver/test/runtime.js new file mode 100644 index 0000000..c2aef40 --- /dev/null +++ b/lib/nameserver/test/runtime.js @@ -0,0 +1,75 @@ +// The nameserver record carries the v3 runtime configuration the supervisor +// consumes (engine, listen, publisher, transport, dnssec). It has to survive a +// round-trip through whichever store is active: nested structures in the file +// stores, JSON columns under MySQL. +import assert from 'node:assert/strict' +import { describe, it, after, before } from 'node:test' + +import Nameserver from '../index.js' + +const runtimeCase = { + id: 4242, + gid: 4096, + name: 'rt.ns.example.com.', + address: '1.2.3.9', + ttl: 3600, + export: { type: 'nsd', interval: 0, serials: true, status: '' }, + engine: 'native', + listen: [ + { address: '127.0.0.1', port: 5353, proto: 'udp' }, + { address: '127.0.0.1', port: 5353, proto: 'tcp' }, + ], + publisher: { type: 'memory' }, + transport: { type: 'noop', interval: 0, cooldown: 5 }, + dnssec: { enabled: true, algorithm: 'ED25519', nsec3: false }, +} + +before(async () => { + await Nameserver.destroy({ id: runtimeCase.id }) + await Nameserver.create(runtimeCase) +}) + +after(async () => { + await Nameserver.destroy({ id: runtimeCase.id }) + await Nameserver.disconnect() +}) + +describe('nameserver runtime config', () => { + it('round-trips listen sockets', async () => { + const [ns] = await Nameserver.get({ id: runtimeCase.id }) + + assert.deepEqual(ns.listen, runtimeCase.listen) + }) + + it('round-trips engine, publisher, transport and dnssec', async () => { + const [ns] = await Nameserver.get({ id: runtimeCase.id }) + + assert.equal(ns.engine, 'native') + assert.deepEqual(ns.publisher, runtimeCase.publisher) + assert.deepEqual(ns.transport, runtimeCase.transport) + assert.deepEqual(ns.dnssec, runtimeCase.dnssec) + }) + + it('updates runtime config in place', async () => { + const listen = [{ address: '127.0.0.1', port: 5354, proto: 'udp' }] + assert.ok(await Nameserver.put({ id: runtimeCase.id, listen, engine: 'bind' })) + + const [ns] = await Nameserver.get({ id: runtimeCase.id }) + assert.deepEqual(ns.listen, listen) + assert.equal(ns.engine, 'bind') + }) + + it('omits runtime keys entirely for a legacy record that has none', async () => { + const legacy = { ...runtimeCase, id: 4243, name: 'legacy.ns.example.com.' } + for (const k of ['engine', 'listen', 'publisher', 'transport', 'dnssec']) delete legacy[k] + + await Nameserver.destroy({ id: legacy.id }) + await Nameserver.create(legacy) + const [ns] = await Nameserver.get({ id: legacy.id }) + + for (const k of ['engine', 'listen', 'publisher', 'transport', 'dnssec']) { + assert.equal(ns[k], undefined, `${k} should be absent, not null`) + } + await Nameserver.destroy({ id: legacy.id }) + }) +}) diff --git a/lib/permission/index.js b/lib/permission/index.js index 05433fb..f301ceb 100644 --- a/lib/permission/index.js +++ b/lib/permission/index.js @@ -1,12 +1,18 @@ -const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' +import { storeType } from '../config.js' + +const type = storeType() let RepoClass -switch (storeType) { +switch (type) { + case 'json': case 'toml': - RepoClass = (await import('./store/toml.js')).default + RepoClass = (await import('./store/file.js')).default break - default: + case 'mysql': RepoClass = (await import('./store/mysql.js')).default + break + default: + throw new Error(`permission: no store implementation for type "${type}"`) } export default new RepoClass() diff --git a/lib/permission/store/toml.js b/lib/permission/store/file.js similarity index 75% rename from lib/permission/store/toml.js rename to lib/permission/store/file.js index e28ddaa..15e7cdd 100644 --- a/lib/permission/store/toml.js +++ b/lib/permission/store/file.js @@ -1,104 +1,56 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { parse, stringify } from 'smol-toml' +import FileStore from '../../store/file.js' import PermissionBase from './base.js' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -function resolveStorePath(filename) { - const base = process.env.NICTOOL_DATA_STORE_PATH - if (base) return path.join(base, filename) - return path.resolve(__dirname, '../../../conf.d', filename) -} - /** - * TOML permission store. + * File-backed permission store (JSON or TOML, per store.type). * * Permissions are stored in one of three places: * - * 1. Inline in user.toml — for permissions tied to an existing user record. - * Looked up via users[i].permissions.id === N. + * 1. Inline in the user file — for permissions tied to an existing user + * record. Looked up via users[i].permissions.id === N. * - * 2. Inline in group.toml — group-level permissions created by GroupRepoTOML. - * Looked up via groups[i].permissions.id === N. + * 2. Inline in the group file — group-level permissions created by the group + * store. Looked up via groups[i].permissions.id === N. * - * 3. Standalone permission.toml — fallback for permission IDs that reference - * users/groups not present in user.toml / group.toml. + * 3. Standalone permission file — fallback for permission IDs that reference + * users/groups not present in the user / group files. * * get({ uid }) → inline permissions of that user * get({ gid }) → inline permissions of that group (uid absent) * get({ id }) → search user → group → standalone by permissions.id * getGroup({ uid }) → permissions of the group the user belongs to */ -class PermissionRepoTOML extends PermissionBase { +class PermissionRepoFile extends PermissionBase { constructor(args = {}) { super(args) - this._userPath = resolveStorePath('user.toml') - this._groupPath = resolveStorePath('group.toml') - this._standaloneFilePath = resolveStorePath('permission.toml') + this.userFile = new FileStore('user') + this.groupFile = new FileStore('group') + this.standaloneFile = new FileStore('permission') } - // --------------------------------------------------------------------------- - // Raw file I/O — users - // --------------------------------------------------------------------------- - async _loadUsers() { - try { - const str = await fs.readFile(this._userPath, 'utf8') - const data = parse(str) - return Array.isArray(data.user) ? data.user : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.userFile.load('user') } async _saveUsers(users) { - await fs.mkdir(path.dirname(this._userPath), { recursive: true }) - await fs.writeFile(this._userPath, stringify({ user: users })) + return this.userFile.save('user', users) } - // --------------------------------------------------------------------------- - // Raw file I/O — groups - // --------------------------------------------------------------------------- - async _loadGroups() { - try { - const str = await fs.readFile(this._groupPath, 'utf8') - const data = parse(str) - return Array.isArray(data.group) ? data.group : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.groupFile.load('group') } async _saveGroups(groups) { - await fs.mkdir(path.dirname(this._groupPath), { recursive: true }) - await fs.writeFile(this._groupPath, stringify({ group: groups })) + return this.groupFile.save('group', groups) } - // --------------------------------------------------------------------------- - // Raw file I/O — standalone permission.toml - // --------------------------------------------------------------------------- - async _loadStandalone() { - try { - const str = await fs.readFile(this._standaloneFilePath, 'utf8') - const data = parse(str) - return Array.isArray(data.permission) ? data.permission : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.standaloneFile.load('permission') } async _saveStandalone(permissions) { - await fs.mkdir(path.dirname(this._standaloneFilePath), { recursive: true }) - await fs.writeFile(this._standaloneFilePath, stringify({ permission: permissions })) + return this.standaloneFile.save('permission', permissions) } // --------------------------------------------------------------------------- @@ -370,4 +322,4 @@ function deepMerge(target, source) { return result } -export default PermissionRepoTOML +export default PermissionRepoFile diff --git a/lib/session/index.js b/lib/session/index.js index 05433fb..7ddc26c 100644 --- a/lib/session/index.js +++ b/lib/session/index.js @@ -1,12 +1,18 @@ -const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' +import { storeType } from '../config.js' + +const type = storeType() let RepoClass -switch (storeType) { +switch (type) { + case 'json': case 'toml': - RepoClass = (await import('./store/toml.js')).default + RepoClass = (await import('./store/file.js')).default break - default: + case 'mysql': RepoClass = (await import('./store/mysql.js')).default + break + default: + throw new Error(`session: no store implementation for type "${type}"`) } export default new RepoClass() diff --git a/lib/session/store/toml.js b/lib/session/store/file.js similarity index 77% rename from lib/session/store/toml.js rename to lib/session/store/file.js index 96bc2f1..d3d959f 100644 --- a/lib/session/store/toml.js +++ b/lib/session/store/file.js @@ -1,16 +1,4 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { parse, stringify } from 'smol-toml' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -function resolveStorePath(filename) { - const base = process.env.NICTOOL_DATA_STORE_PATH - if (base) return path.join(base, filename) - return path.resolve(__dirname, '../../../conf.d', filename) -} +import FileStore from '../../store/file.js' // Map legacy nt_* column names to the friendly API names used throughout. function normalizeArgs(args) { @@ -29,25 +17,17 @@ function normalizeArgs(args) { return args } -class SessionRepoTOML { +class SessionRepoFile { constructor() { - this._filePath = resolveStorePath('session.toml') + this.file = new FileStore('session') } async _load() { - try { - const str = await fs.readFile(this._filePath, 'utf8') - const data = parse(str) - return Array.isArray(data.session) ? data.session : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.file.load('session') } async _save(sessions) { - await fs.mkdir(path.dirname(this._filePath), { recursive: true }) - await fs.writeFile(this._filePath, stringify({ session: sessions })) + return this.file.save('session', sessions) } async create(args) { @@ -134,4 +114,4 @@ class SessionRepoTOML { } } -export default SessionRepoTOML +export default SessionRepoFile diff --git a/lib/store/file.js b/lib/store/file.js new file mode 100644 index 0000000..8ec2c9e --- /dev/null +++ b/lib/store/file.js @@ -0,0 +1,73 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { storeConfig } from '../config.js' +import { toJson } from '../util.js' + +const jsonCodec = { + ext: 'json', + parse: async (str) => JSON.parse(str), + stringify: async (data) => toJson(data), +} + +// smol-toml is loaded only when a TOML store is actually selected, so a +// JSON-only deployment never pays for it. +const tomlCodec = { + ext: 'toml', + parse: async (str) => (await import('smol-toml')).parse(str), + stringify: async (data) => (await import('smol-toml')).stringify(data), +} + +const codecs = { json: jsonCodec, toml: tomlCodec } + +export function resolveCodec(type = storeConfig().type) { + return codecs[type] ?? jsonCodec +} + +/** + * 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 + * for JSON and TOML. + */ +export class FileStore { + constructor(basename) { + this.basename = basename + } + + get codec() { + return resolveCodec() + } + + /** + * Resolved per call: the store path is configuration, not a build-time + * constant, and an unset path is a misconfiguration rather than a reason to + * fall back to somewhere inside the package. + */ + get filePath() { + const base = storeConfig().path + if (!base) { + throw new Error( + 'file store selected but no path is configured — set store.path in api.json or NICTOOL_DATA_STORE_PATH', + ) + } + return path.join(base, `${this.basename}.${this.codec.ext}`) + } + + async load(key) { + try { + const data = await this.codec.parse(await fs.readFile(this.filePath, 'utf8')) + return Array.isArray(data?.[key]) ? data[key] : [] + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } + } + + 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 })) + } +} + +export default FileStore diff --git a/lib/store/file.test.js b/lib/store/file.test.js new file mode 100644 index 0000000..c76b6e2 --- /dev/null +++ b/lib/store/file.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +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' + +const envKeys = ['NICTOOL_DATA_STORE', 'NICTOOL_DATA_STORE_PATH', 'NICTOOL_CONF_DIR'] + +describe('file store', () => { + const savedEnv = {} + let tmp + + before(() => { + for (const key of envKeys) { + savedEnv[key] = process.env[key] + delete process.env[key] + } + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-filestore-')) + }) + + after(() => { + for (const [key, val] of Object.entries(savedEnv)) { + if (val !== undefined) process.env[key] = val + else delete process.env[key] + } + fs.rmSync(tmp, { recursive: true, force: true }) + }) + + beforeEach(() => { + for (const key of envKeys) delete process.env[key] + // NICTOOL_CONF_DIR points somewhere without an api.json, so storeConfig + // resolves purely from the env vars each test sets. + process.env.NICTOOL_CONF_DIR = path.join(tmp, 'no-such-conf') + process.env.NICTOOL_DATA_STORE_PATH = tmp + }) + + it(`writes .json for a json store and .toml for a toml store`, async () => { + process.env.NICTOOL_DATA_STORE = 'json' + await new FileStore('zone').save('zone', [{ id: 1 }]) + assert.equal(fs.existsSync(path.join(tmp, 'zone.json')), true) + + process.env.NICTOOL_DATA_STORE = 'toml' + await new FileStore('zone').save('zone', [{ id: 1 }]) + assert.equal(fs.existsSync(path.join(tmp, 'zone.toml')), true) + }) + + it(`round-trips rows through the json codec`, async () => { + process.env.NICTOOL_DATA_STORE = 'json' + const store = new FileStore('rt') + + await store.save('rt', [{ id: 1, zone: 'example.com', deleted: false }]) + + assert.deepEqual(await store.load('rt'), [{ id: 1, zone: 'example.com', deleted: false }]) + }) + + it(`preserves null through json, which TOML cannot`, async () => { + process.env.NICTOOL_DATA_STORE = 'json' + const jsonStore = new FileStore('nulls') + await jsonStore.save('nulls', [{ id: 1, last_publish: null }]) + + const [jsonRow] = await jsonStore.load('nulls') + assert.equal(jsonRow.last_publish, null, 'json keeps the null') + + process.env.NICTOOL_DATA_STORE = 'toml' + const tomlStore = new FileStore('nulls') + await tomlStore.save('nulls', [{ id: 1, last_publish: null }]) + + const [tomlRow] = await tomlStore.load('nulls') + assert.equal(tomlRow.last_publish, undefined, 'toml drops it — hence the repair in the zone store') + }) + + it(`returns an empty list when the file does not exist`, async () => { + process.env.NICTOOL_DATA_STORE = 'json' + assert.deepEqual(await new FileStore('absent').load('absent'), []) + }) + + it(`returns an empty list when the key is missing or not an array`, async () => { + process.env.NICTOOL_DATA_STORE = 'json' + fs.writeFileSync(path.join(tmp, 'odd.json'), JSON.stringify({ odd: 'not-an-array' })) + + assert.deepEqual(await new FileStore('odd').load('odd'), []) + }) + + it(`creates missing parent directories on save`, async () => { + process.env.NICTOOL_DATA_STORE = 'json' + process.env.NICTOOL_DATA_STORE_PATH = path.join(tmp, 'deep', 'nested') + + await new FileStore('zone').save('zone', [{ id: 1 }]) + + assert.equal(fs.existsSync(path.join(tmp, 'deep', 'nested', 'zone.json')), true) + }) + + it(`fails loudly rather than writing inside the package when no path is set`, async () => { + process.env.NICTOOL_DATA_STORE = 'json' + delete process.env.NICTOOL_DATA_STORE_PATH + + await assert.rejects(() => new FileStore('zone').load('zone'), /no path is configured/) + }) + + it(`defaults to the json codec for an unrecognized type`, () => { + process.env.NICTOOL_DATA_STORE = 'nonsense' + assert.equal(resolveCodec().ext, 'json') + }) +}) diff --git a/lib/user/index.js b/lib/user/index.js index 5338462..2676bd6 100644 --- a/lib/user/index.js +++ b/lib/user/index.js @@ -1,9 +1,15 @@ -const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' +import { storeType } from '../config.js' + +const type = storeType() let RepoClass -switch (storeType) { +switch (type) { + case 'json': case 'toml': - RepoClass = (await import('./store/toml.js')).default + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default break case 'mongodb': RepoClass = (await import('./store/mongodb.js')).default @@ -12,7 +18,7 @@ switch (storeType) { RepoClass = (await import('./store/elasticsearch.js')).default break default: - RepoClass = (await import('./store/mysql.js')).default + throw new Error(`user: no store implementation for type "${type}"`) } export default new RepoClass() diff --git a/lib/user/store/toml.js b/lib/user/store/file.js similarity index 81% rename from lib/user/store/toml.js rename to lib/user/store/file.js index 3ec4715..4ee232e 100644 --- a/lib/user/store/toml.js +++ b/lib/user/store/file.js @@ -1,27 +1,14 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { parse, stringify } from 'smol-toml' - +import FileStore from '../../store/file.js' import Config from '../../config.js' import Credentials from '../credentials.js' import UserBase from './base.js' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) const boolFields = ['is_admin', 'deleted'] -// Read group.toml directly (never via the Group module) to avoid circular imports. -async function loadGroupPerm(groupPath, gid) { - try { - const str = await fs.readFile(groupPath, 'utf8') - const data = parse(str) - const groups = Array.isArray(data.group) ? data.group : [] - return groups.find((g) => g.id === gid)?.permissions ?? null - } catch (err) { - if (err.code === 'ENOENT') return null - throw err - } +// Read the group file directly (never via the Group module) to avoid circular imports. +async function loadGroupPerm(groupFile, gid) { + const groups = await groupFile.load('group') + return groups.find((g) => g.id === gid)?.permissions ?? null } const defaultPermissions = { @@ -34,34 +21,20 @@ const defaultPermissions = { user: { create: false, write: false, delete: false }, } -function resolveStorePath(filename) { - const base = process.env.NICTOOL_DATA_STORE_PATH - if (base) return path.join(base, filename) - return path.resolve(__dirname, '../../../conf.d', filename) -} - -class UserRepoTOML extends UserBase { +class UserRepoFile extends UserBase { constructor(args = {}) { super(args) this.cfg = Config.getSync('http') - this._filePath = resolveStorePath('user.toml') - this._groupPath = resolveStorePath('group.toml') + this.file = new FileStore('user') + this.groupFile = new FileStore('group') } async _load() { - try { - const str = await fs.readFile(this._filePath, 'utf8') - const data = parse(str) - return Array.isArray(data.user) ? data.user : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.file.load('user') } async _save(users) { - await fs.mkdir(path.dirname(this._filePath), { recursive: true }) - await fs.writeFile(this._filePath, stringify({ user: users })) + return this.file.save('user', users) } _postProcess(u, deletedArg) { @@ -131,7 +104,7 @@ class UserRepoTOML extends UserBase { const r = this._postProcess(u, deletedArg) if (!r.permissions) { // Inheriting user: attach the group's inline permissions - const groupPerm = await loadGroupPerm(this._groupPath, u.gid) + const groupPerm = await loadGroupPerm(this.groupFile, u.gid) if (groupPerm) { r.permissions = JSON.parse(JSON.stringify(groupPerm)) r.inherit_group_permissions = true @@ -238,4 +211,4 @@ class UserRepoTOML extends UserBase { } } -export default UserRepoTOML +export default UserRepoFile diff --git a/lib/util.js b/lib/util.js index 4d28525..98b69d7 100644 --- a/lib/util.js +++ b/lib/util.js @@ -24,6 +24,28 @@ const meta = { }, } +/** + * Recursively order object keys so serialized output is stable — an unsorted + * dump reshuffles on unrelated edits and makes diffs unreadable. + */ +function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys) + if (value === null || typeof value !== 'object') return value + // Date and friends would be flattened to {} by the key walk below. + if (Object.getPrototypeOf(value) !== Object.prototype) return value + + return Object.fromEntries( + Object.keys(value) + .sort() + .map((k) => [k, sortKeys(value[k])]), + ) +} + +/** Human-friendly JSON: sorted, shallow-indented, newline-terminated. */ +function toJson(value) { + return `${JSON.stringify(sortKeys(value), null, 1)}\n` +} + function mapToDbColumn(args, maps) { args = JSON.parse(JSON.stringify(args)) // don't mutate the original @@ -36,4 +58,4 @@ function mapToDbColumn(args, maps) { return args } -export { setEnv, meta, mapToDbColumn } +export { setEnv, meta, mapToDbColumn, sortKeys, toJson } diff --git a/lib/util.test.js b/lib/util.test.js index 40ad143..c54a349 100644 --- a/lib/util.test.js +++ b/lib/util.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { setEnv, mapToDbColumn, meta } from './util.js' +import { setEnv, mapToDbColumn, meta, sortKeys, toJson } from './util.js' describe('util', function () { if (process.env.NODE_ENV === undefined) { @@ -15,7 +15,45 @@ describe('util', function () { describe('meta', () => { it('returns the package version', () => { - assert.ok(/3.0.0/.test(meta.api.version)) + assert.ok(/3.0/.test(meta.api.version)) + }) + }) + + describe('sortKeys', () => { + it('orders keys at every depth', () => { + const sorted = sortKeys({ b: 1, a: { d: 2, c: 3 } }) + + assert.deepEqual(Object.keys(sorted), ['a', 'b']) + assert.deepEqual(Object.keys(sorted.a), ['c', 'd']) + }) + + it('keeps array order while sorting the objects inside', () => { + const sorted = sortKeys([{ z: 1, a: 2 }, { y: 3 }]) + + assert.deepEqual(Object.keys(sorted[0]), ['a', 'z']) + assert.equal(sorted[1].y, 3) + }) + + it('passes scalars and null through', () => { + assert.equal(sortKeys(null), null) + assert.equal(sortKeys(5), 5) + assert.equal(sortKeys('x'), 'x') + }) + + it('leaves non-plain objects intact', () => { + const d = new Date('2020-01-01T00:00:00Z') + assert.equal(sortKeys(d), d) + }) + }) + + describe('toJson', () => { + it('sorts, indents by one space, and ends with a newline', () => { + assert.equal(toJson({ b: 1, a: 2 }), '{\n "a": 2,\n "b": 1\n}\n') + }) + + it('round-trips through JSON.parse', () => { + const value = { zone: [{ id: 1, last_publish: null }] } + assert.deepEqual(JSON.parse(toJson(value)), value) }) }) diff --git a/lib/zone/index.js b/lib/zone/index.js index 5338462..cb09043 100644 --- a/lib/zone/index.js +++ b/lib/zone/index.js @@ -1,9 +1,15 @@ -const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' +import { storeType } from '../config.js' + +const type = storeType() let RepoClass -switch (storeType) { +switch (type) { + case 'json': case 'toml': - RepoClass = (await import('./store/toml.js')).default + RepoClass = (await import('./store/file.js')).default + break + case 'mysql': + RepoClass = (await import('./store/mysql.js')).default break case 'mongodb': RepoClass = (await import('./store/mongodb.js')).default @@ -12,7 +18,7 @@ switch (storeType) { RepoClass = (await import('./store/elasticsearch.js')).default break default: - RepoClass = (await import('./store/mysql.js')).default + throw new Error(`zone: no store implementation for type "${type}"`) } export default new RepoClass() diff --git a/lib/zone/store/toml.js b/lib/zone/store/file.js similarity index 82% rename from lib/zone/store/toml.js rename to lib/zone/store/file.js index f9d941c..2db3eed 100644 --- a/lib/zone/store/toml.js +++ b/lib/zone/store/file.js @@ -1,41 +1,21 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { parse, stringify } from 'smol-toml' +import FileStore, { resolveCodec } from '../../store/file.js' import ZoneBase from './base.js' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const zoneDefaults = { minimum: 3600, ttl: 3600, refresh: 86400, retry: 7200, expire: 1209600 } -function resolveStorePath(filename) { - const base = process.env.NICTOOL_DATA_STORE_PATH - if (base) return path.join(base, filename) - return path.resolve(__dirname, '../../../conf.d', filename) -} - -class ZoneRepoTOML extends ZoneBase { +class ZoneRepoFile extends ZoneBase { constructor(args = {}) { super(args) - this._filePath = resolveStorePath('zone.toml') + this.file = new FileStore('zone') } async _load() { - try { - const str = await fs.readFile(this._filePath, 'utf8') - const data = parse(str) - return Array.isArray(data.zone) ? data.zone : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.file.load('zone') } async _save(zones) { - await fs.mkdir(path.dirname(this._filePath), { recursive: true }) - await fs.writeFile(this._filePath, stringify({ zone: zones })) + return this.file.save('zone', zones) } _postProcess(row, deletedArg) { @@ -46,8 +26,9 @@ class ZoneRepoTOML extends ZoneBase { if ([null, undefined].includes(r[f])) r[f] = val } if ([null, undefined].includes(r.serial)) r.serial = 0 - // TOML drops null on stringify; restore it on read-back - if (r.last_publish === undefined) r.last_publish = null + // TOML drops null on stringify; restore it on read-back. JSON round-trips + // null faithfully and needs no such repair. + if (resolveCodec().ext === 'toml' && r.last_publish === undefined) r.last_publish = null if (/00:00:00/.test(r.last_publish)) r.last_publish = null if (deletedArg === false) delete r.deleted return r @@ -185,4 +166,4 @@ class ZoneRepoTOML extends ZoneBase { } } -export default ZoneRepoTOML +export default ZoneRepoFile diff --git a/lib/zone_record/index.js b/lib/zone_record/index.js index 05433fb..dd17289 100644 --- a/lib/zone_record/index.js +++ b/lib/zone_record/index.js @@ -1,12 +1,18 @@ -const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' +import { storeType } from '../config.js' + +const type = storeType() let RepoClass -switch (storeType) { +switch (type) { + case 'json': case 'toml': - RepoClass = (await import('./store/toml.js')).default + RepoClass = (await import('./store/file.js')).default break - default: + case 'mysql': RepoClass = (await import('./store/mysql.js')).default + break + default: + throw new Error(`zone_record: no store implementation for type "${type}"`) } export default new RepoClass() diff --git a/lib/zone_record/store/toml.js b/lib/zone_record/store/file.js similarity index 81% rename from lib/zone_record/store/toml.js rename to lib/zone_record/store/file.js index 9717c8d..4ff5651 100644 --- a/lib/zone_record/store/toml.js +++ b/lib/zone_record/store/file.js @@ -1,39 +1,19 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { parse, stringify } from 'smol-toml' +import FileStore from '../../store/file.js' import ZoneRecordBase from './base.js' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -function resolveStorePath(filename) { - const base = process.env.NICTOOL_DATA_STORE_PATH - if (base) return path.join(base, filename) - return path.resolve(__dirname, '../../../conf.d', filename) -} - -class ZoneRecordRepoTOML extends ZoneRecordBase { +class ZoneRecordRepoFile extends ZoneRecordBase { constructor(args = {}) { super(args) - this._filePath = resolveStorePath('zone_record.toml') + this.file = new FileStore('zone_record') } async _load() { - try { - const str = await fs.readFile(this._filePath, 'utf8') - const data = parse(str) - return Array.isArray(data.zone_record) ? data.zone_record : [] - } catch (err) { - if (err.code === 'ENOENT') return [] - throw err - } + return this.file.load('zone_record') } async _save(records) { - await fs.mkdir(path.dirname(this._filePath), { recursive: true }) - await fs.writeFile(this._filePath, stringify({ zone_record: records })) + return this.file.save('zone_record', records) } async create(args) { @@ -152,4 +132,4 @@ class ZoneRecordRepoTOML extends ZoneRecordBase { } } -export default ZoneRecordRepoTOML +export default ZoneRecordRepoFile diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index c961d41..3b4ed24 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -1,4 +1,5 @@ import * as RR from '@nictool/dns-resource-record' +import { applyMap, getMap, unApplyMap } from '@nictool/dns-resource-record' import ZoneRecordBase from './base.js' import Mysql from '../../mysql.js' @@ -201,208 +202,4 @@ function objectToDb(obj) { return obj } -function applyMap(obj, map) { - // map dns-r-r (RFC/IETF) field names to NicTool 2.0 DB fields - - for (const [key, value] of Object.entries(map)) { - if (Array.isArray(value)) { - obj[key] = `'${value.map((a) => obj[a]).join("','")}'` - for (const f of value) { - delete obj[f] - } - } else { - obj[key] = obj[value] - } - - delete obj[value] - } -} - -function unApplyMap(obj, map) { - // map NicTool 2.0 DB fields to dns-r-r (RFC/IETF) field names - if (obj.type === 'NAPTR') { - const [flags, service, regexp] = obj.address.slice(1, -1).split("','") - obj.flags = flags ?? '' - obj.service = service ?? '' - obj.regexp = regexp ?? '' - delete obj.address - delete map.address - } - if (obj.type === 'NSEC3') { - const [algo, flags, iters, salt, bitmaps, next] = obj.address.slice(1, -1).split("','") - obj['hash algorithm'] = /^\d+$/.test(algo) ? parseInt(algo) : (algo ?? '') - obj.flags = /^\d+$/.test(flags) ? parseInt(flags) : (flags ?? '') - obj.iterations = /^\d+$/.test(iters) ? parseInt(iters) : (iters ?? '') - obj.salt = salt - obj['type bit maps'] = bitmaps - obj['next hashed owner name'] = next - delete obj.address - delete map.address - } - if (obj.type === 'NSEC3PARAM') { - const [algo, flags, iters, salt] = obj.address.slice(1, -1).split("','") - obj['hash algorithm'] = /^\d+$/.test(algo) ? parseInt(algo) : (algo ?? '') - obj.flags = /^\d+$/.test(flags) ? parseInt(flags) : (flags ?? '') - obj.iterations = /^\d+$/.test(iters) ? parseInt(iters) : (iters ?? '') - obj.salt = salt - delete obj.address - delete map.address - } - if (obj.type === 'SOA') { - const [one, two, three, four, five, six, seven] = obj.address.slice(1, -1).split("','") - obj.mname = one - obj.rname = two - obj.serial = parseInt(three) - obj.refresh = parseInt(four) - obj.retry = parseInt(five) - obj.expire = parseInt(six) - obj.minimum = parseInt(seven) - delete obj.address - delete map.address - } - - for (const [key, value] of Object.entries(map)) { - switch (value) { - case 'key tag': // DS record - case 'port': // SRV - case 'certificate usage': // SMIMEA - case 'algorithm': // IPSECKEY - case 'flags': // KEY - case 'matching type': // TLSA - obj[value] = parseInt(obj[key]) - break - default: - obj[value] = obj[key] - } - delete obj[key] - } -} - // map of NicTool 2.0 fields to RR field names -function getMap(rrType) { - switch (rrType) { - case 'CAA': - return { - weight: 'flags', - other: 'tag', - address: 'value', - } - case 'CERT': - return { - other: 'cert type', - priority: 'key tag', - weight: 'algorithm', - address: 'certificate', - } - case 'CNAME': - return { address: 'cname' } - case 'DNAME': - return { address: 'target' } - case 'DNSKEY': - return { - address: 'publickey', - weight: 'flags', - priority: 'protocol', - other: 'algorithm', - } - case 'DS': - return { - address: 'digest', - weight: 'digest type', - priority: 'algorithm', - other: 'key tag', - } - case 'HINFO': - return { address: 'os', other: 'cpu' } - case 'HTTPS': - return { - address: 'target name', - other: 'params', - } - case 'IPSECKEY': - return { - address: 'gateway', - description: 'publickey', - weight: 'precedence', - priority: 'gateway type', - other: 'algorithm', - } - case 'KEY': - return { - address: 'publickey', - weight: 'protocol', - priority: 'algorithm', - other: 'flags', - } - case 'MX': - return { weight: 'preference', address: 'exchange' } - case 'NAPTR': - return { - weight: 'order', - priority: 'preference', - address: ['flags', 'service', 'regexp'], - description: 'replacement', - } - case 'NS': - return { address: 'dname' } - case 'NSEC': - return { - address: 'next domain', - description: 'type bit maps', - } - case 'NSEC3': - return { - address: ['hash algorithm', 'flags', 'iterations', 'salt', 'type bit maps', 'next hashed owner name'], - } - case 'NSEC3PARAM': - return { - address: ['hash algorithm', 'flags', 'iterations', 'salt'], - } - case 'NXT': - return { - address: 'next domain', - description: 'type bit map', - } - case 'OPENPGPKEY': - return { address: 'public key' } - case 'PTR': - return { address: 'dname' } - case 'SMIMEA': - return { - address: 'certificate association data', - weight: 'matching type', - priority: 'selector', - other: 'certificate usage', - } - case 'SOA': - return { - address: ['mname', 'rname', 'serial', 'refresh', 'retry', 'expire', 'minimum'], - } - case 'SPF': - return { address: 'data' } - case 'SSHFP': - return { - address: 'fingerprint', - weight: 'algorithm', - priority: 'fptype', - } - case 'SRV': - return { address: 'target', other: 'port' } - case 'SVCB': - return { - address: 'target name', - other: 'params', - } - case 'TLSA': - return { - weight: 'certificate usage', - priority: 'selector', - address: 'certificate association data', - other: 'matching type', - } - case 'TXT': - return { address: 'data' } - case 'URI': - return { address: 'target' } - } -} diff --git a/lib/zone_record/test/index.js b/lib/zone_record/test/index.js index 50f5e6e..5e98f3b 100644 --- a/lib/zone_record/test/index.js +++ b/lib/zone_record/test/index.js @@ -15,7 +15,7 @@ describe('zone_record', function () { it('CREATE accepts omitted ttl and stores 0', async () => { const testCase = { id: 60001, - zid: 1, + zid: 4096, // the fixture zone, as the rrs/*.json cases use owner: 'missing-ttl.example.com.', type: 'A', address: '203.0.113.45', @@ -96,7 +96,9 @@ describe('zone_record', function () { describe('pagination, search & sort', function () { // Tests run against a shared DB; a unique owner token isolates these // assertions from any other records that already exist in the zone. - const pZid = 60200 + // + // The zone has to be one the fixtures create + const pZid = 4096 // the fixture zone, as the rrs/*.json cases use const token = 'zrpgtok' const letters = ['ccc', 'aaa', 'eee', 'bbb', 'ddd'] // created out of sorted order const idBase = 60210 diff --git a/package.json b/package.json index b5bfa39..0406711 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "@nictool/api", - "version": "3.0.0", + "version": "3.0.3", "description": "NicTool API", - "main": "index.js", + "main": "server.js", "type": "module", "files": [ "CHANGELOG.md", - "conf.d", "html", "lib", "routes", "sql", - "server.js" + "server.js", + "!**/*.test.js" ], "scripts": { "clean": "rm -rf coverage node_modules package-lock.json", @@ -50,7 +50,7 @@ "@eslint/js": "^10.0.1", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", - "globals": "^17.7.0" + "globals": "^17.8.0" }, "dependencies": { "@hapi/cookie": "^12.0.1", @@ -60,12 +60,12 @@ "@hapi/jwt": "^3.2.4", "@hapi/vision": "^7.0.3", "@msimerson/hapi-openapi": "^18.0.3", - "@nictool/dns-resource-record": "^1.8.1", + "@nictool/dns-resource-record": "^1.8.2", "@nictool/validate": "^0.9.1", "joi": "^18.2.3", - "mysql2": "^3.23.1", + "mysql2": "^3.23.2", "qs": "^6.15.3", - "smol-toml": "^1.7.0" + "smol-toml": "^1.7.1" }, "prettier": { "printWidth": 110, diff --git a/routes/nameserver.test.js b/routes/nameserver.test.js index 5103fbb..ef80f95 100644 --- a/routes/nameserver.test.js +++ b/routes/nameserver.test.js @@ -56,7 +56,6 @@ describe('nameserver routes', () => { it(`POST /nameserver (${case2Id})`, async () => { const testCase = JSON.parse(JSON.stringify(nsCase)) testCase.id = case2Id // make it unique - testCase.gid = case2Id testCase.name = 'c.ns.example.com.' const res = await server.inject({ diff --git a/sql/01_nt_group.sql b/sql/01_nt_group.sql index 79ccee1..4595ada 100644 --- a/sql/01_nt_group.sql +++ b/sql/01_nt_group.sql @@ -2,8 +2,7 @@ # Copyright 2001 Dajoba, LLC - # Copyright 2004-2024 The Network People, Inc. -DROP TABLE IF EXISTS nt_group; -CREATE TABLE `nt_group` ( +CREATE TABLE IF NOT EXISTS `nt_group` ( nt_group_id INT UNSIGNED NOT NULL AUTO_INCREMENT, parent_group_id INT UNSIGNED NOT NULL DEFAULT 0, name varchar(255) NOT NULL, @@ -15,8 +14,7 @@ CREATE TABLE `nt_group` ( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -DROP TABLE IF EXISTS nt_group_log; -CREATE TABLE nt_group_log( +CREATE TABLE IF NOT EXISTS nt_group_log( nt_group_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT, nt_group_id INT UNSIGNED NOT NULL, nt_user_id INT UNSIGNED NOT NULL, @@ -27,24 +25,23 @@ CREATE TABLE nt_group_log( name VARCHAR(255), PRIMARY KEY (`nt_group_log_id`), KEY `nt_group_log_idx1` (`nt_group_id`), - KEY `nt_group_log_idx2` (`timestamp`) - /* CONSTRAINT `nt_group_log_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_group_log_idx2` (`timestamp`), + CONSTRAINT `nt_group_log_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; -DROP TABLE IF EXISTS nt_group_subgroups; -CREATE TABLE nt_group_subgroups( +CREATE TABLE IF NOT EXISTS nt_group_subgroups( nt_group_id INT UNSIGNED NOT NULL, nt_subgroup_id INT UNSIGNED NOT NULL, `rank` INT UNSIGNED NOT NULL, KEY `nt_group_subgroups_idx1` (`nt_group_id`), - KEY `nt_group_subgroups_idx2` (`nt_subgroup_id`) - /* CONSTRAINT `nt_group_subgroups_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_group_subgroups_idx2` (`nt_subgroup_id`), + CONSTRAINT `nt_group_subgroups_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -INSERT INTO `nt_group` (`nt_group_id`, `parent_group_id`, `name`) +INSERT IGNORE INTO `nt_group` (`nt_group_id`, `parent_group_id`, `name`) VALUES (1,0,'NicTool'); -INSERT INTO nt_group_log(nt_group_id, nt_user_id, action, timestamp, modified_group_id, parent_group_id) +INSERT IGNORE INTO nt_group_log(nt_group_id, nt_user_id, action, timestamp, modified_group_id, parent_group_id) VALUES (1, 1, 'added', UNIX_TIMESTAMP(), 1, 0); diff --git a/sql/02_nt_user.sql b/sql/02_nt_user.sql index 7e79d1b..04b99d5 100644 --- a/sql/02_nt_user.sql +++ b/sql/02_nt_user.sql @@ -16,8 +16,7 @@ # -DROP TABLE IF EXISTS nt_user; -CREATE TABLE nt_user( +CREATE TABLE IF NOT EXISTS nt_user( nt_user_id INT UNSIGNED AUTO_INCREMENT NOT NULL, nt_group_id INT UNSIGNED NOT NULL, first_name VARCHAR(120), @@ -34,8 +33,7 @@ CREATE TABLE nt_user( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -DROP TABLE IF EXISTS nt_user_log; -CREATE TABLE nt_user_log( +CREATE TABLE IF NOT EXISTS nt_user_log( nt_user_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, nt_group_id INT UNSIGNED NOT NULL, nt_user_id INT UNSIGNED NOT NULL, @@ -51,19 +49,17 @@ CREATE TABLE nt_user_log( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; -DROP TABLE IF EXISTS nt_user_session; -CREATE TABLE nt_user_session( +CREATE TABLE IF NOT EXISTS nt_user_session( nt_user_session_id INT UNSIGNED NOT NULL AUTO_INCREMENT, nt_user_id INT UNSIGNED NOT NULL, nt_user_session VARCHAR(100) NOT NULL, last_access INT UNSIGNED NOT NULL, PRIMARY KEY (`nt_user_session_id`), - KEY `nt_user_session_idx1` (`nt_user_id`,`nt_user_session`) - /* CONSTRAINT `nt_user_session_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_user_session_idx1` (`nt_user_id`,`nt_user_session`), + CONSTRAINT `nt_user_session_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -DROP TABLE IF EXISTS nt_user_session_log; -CREATE TABLE nt_user_session_log( +CREATE TABLE IF NOT EXISTS nt_user_session_log( nt_user_session_log_id INT UNSIGNED AUTO_INCREMENT NOT NULL, nt_user_id INT UNSIGNED NOT NULL, action ENUM('login','logout','timeout') NOT NULL, @@ -71,12 +67,11 @@ CREATE TABLE nt_user_session_log( nt_user_session_id INT UNSIGNED, nt_user_session VARCHAR(100), PRIMARY KEY (`nt_user_session_log_id`), - KEY `nt_user_id` (`nt_user_id`) - /* CONSTRAINT `nt_user_session_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_user_id` (`nt_user_id`), + CONSTRAINT `nt_user_session_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; -DROP TABLE IF EXISTS nt_user_global_log; -CREATE TABLE nt_user_global_log( +CREATE TABLE IF NOT EXISTS nt_user_global_log( nt_user_global_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT, nt_user_id INT UNSIGNED NOT NULL, timestamp INT UNSIGNED NOT NULL, @@ -90,6 +85,6 @@ CREATE TABLE nt_user_global_log( title VARCHAR(255), description VARCHAR(255), PRIMARY KEY (`nt_user_global_log_id`), - KEY `nt_user_global_log_idx1` (`nt_user_id`) - /* CONSTRAINT `nt_user_global_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_user_global_log_idx1` (`nt_user_id`), + CONSTRAINT `nt_user_global_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; diff --git a/sql/04_nt_nameserver.sql b/sql/04_nt_nameserver.sql index afbe6d9..3e5a73a 100644 --- a/sql/04_nt_nameserver.sql +++ b/sql/04_nt_nameserver.sql @@ -2,8 +2,7 @@ # Copyright 2001 Dajoba, LLC - # Copyright 2004-2024 The Network People, Inc. -DROP TABLE IF EXISTS nt_nameserver; -CREATE TABLE nt_nameserver( +CREATE TABLE IF NOT EXISTS nt_nameserver( nt_nameserver_id SMALLINT UNSIGNED AUTO_INCREMENT NOT NULL, nt_group_id INT UNSIGNED NOT NULL, name VARCHAR(127) NOT NULL, @@ -18,17 +17,23 @@ CREATE TABLE nt_nameserver( export_interval SMALLINT UNSIGNED, export_serials tinyint(1) UNSIGNED NOT NULL DEFAULT '1', export_status varchar(255) NULL DEFAULT NULL, + -- v3 runtime configuration, driving the nameserver supervisor. Stored as + -- JSON because the shape is engine-specific and evolves with the engines. + engine VARCHAR(32) NULL DEFAULT NULL, + listen JSON NULL DEFAULT NULL, + publisher JSON NULL DEFAULT NULL, + transport JSON NULL DEFAULT NULL, + dnssec JSON NULL DEFAULT NULL, deleted TINYINT(1) UNSIGNED DEFAULT 0 NOT NULL, PRIMARY KEY (`nt_nameserver_id`), KEY `nt_nameserver_idx1` (`name`), KEY `nt_nameserver_idx2` (`deleted`), - KEY `nt_group_id` (`nt_group_id`) - /* CONSTRAINT `nt_nameserver_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_group_id` (`nt_group_id`), + CONSTRAINT `nt_nameserver_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -DROP TABLE IF EXISTS nt_nameserver_log; -CREATE TABLE nt_nameserver_log( +CREATE TABLE IF NOT EXISTS nt_nameserver_log( nt_nameserver_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT, nt_group_id INT UNSIGNED NOT NULL, nt_user_id INT UNSIGNED NOT NULL, @@ -51,8 +56,7 @@ CREATE TABLE nt_nameserver_log( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; -DROP TABLE IF EXISTS nt_nameserver_export_type; -CREATE TABLE `nt_nameserver_export_type` ( +CREATE TABLE IF NOT EXISTS `nt_nameserver_export_type` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(16) NOT NULL DEFAULT '', `descr` varchar(56) NOT NULL DEFAULT '', @@ -60,7 +64,7 @@ CREATE TABLE `nt_nameserver_export_type` ( PRIMARY KEY (`id`) ) DEFAULT CHARSET=utf8mb4; -INSERT INTO `nt_nameserver_export_type` (`id`, `name`, `descr`, `url`) +INSERT IGNORE INTO `nt_nameserver_export_type` (`id`, `name`, `descr`, `url`) VALUES (1,'djbdns','djbdns (tinydns & axfrdns)','cr.yp.to/djbdns.html'), (2,'bind','BIND (zone files)', 'www.isc.org/downloads/bind/'), (3,'maradns','MaraDNS', 'maradns.samiam.org'), @@ -70,22 +74,21 @@ VALUES (1,'djbdns','djbdns (tinydns & axfrdns)','cr.yp.to/djbdns.html'), (7,'dynect','DynECT Standard DNS','dyn.com/managed-dns/'), (8,'knot','Knot DNS','www.knot-dns.cz'); -INSERT INTO nt_nameserver(nt_group_id, name, ttl, description, address, +INSERT IGNORE INTO nt_nameserver(nt_group_id, name, ttl, description, address, export_type_id, logdir, datadir, export_interval) values (1,'ns1.example.com.',86400,'ns east', '198.93.97.188','1','/etc/tinydns-ns1/log/main/', '/etc/tinydns-ns1/root/',120); -INSERT INTO nt_nameserver(nt_group_id, name, ttl, description, address, +INSERT IGNORE INTO nt_nameserver(nt_group_id, name, ttl, description, address, export_type_id, logdir, datadir, export_interval) values (1,'ns2.example.com.',86400,'ns west', '216.133.235.6','1','/etc/tinydns-ns2/log/main/','/etc/tinydns-ns2/root/',120); -INSERT INTO nt_nameserver(nt_group_id, name, ttl, description, address, +INSERT IGNORE INTO nt_nameserver(nt_group_id, name, ttl, description, address, export_type_id, logdir, datadir, export_interval) values (1,'ns3.example.com.',86400,'ns test', '127.0.0.1','2','/var/log', '/etc/namedb/master/',120); -INSERT INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 1); -INSERT INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 2); -INSERT INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 3); +INSERT IGNORE INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 1); +INSERT IGNORE INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 2); +INSERT IGNORE INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 3); -DROP TABLE IF EXISTS nt_nameserver_export_log; -CREATE TABLE nt_nameserver_export_log( +CREATE TABLE IF NOT EXISTS nt_nameserver_export_log( nt_nameserver_export_log_id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY, nt_nameserver_id SMALLINT UNSIGNED NOT NULL, date_start timestamp NULL DEFAULT NULL, @@ -96,6 +99,3 @@ CREATE TABLE nt_nameserver_export_log( partial tinyint(1) UNSIGNED NOT NULL DEFAULT 0, KEY `nt_nameserver_export_log_idx1` (`nt_nameserver_id`) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; - -DROP TABLE IF EXISTS nt_nameserver_qlog; -DROP TABLE IF EXISTS nt_nameserver_qlogfile; \ No newline at end of file diff --git a/sql/06_resource_records.sql b/sql/06_resource_records.sql index e7ae115..60b93cc 100644 --- a/sql/06_resource_records.sql +++ b/sql/06_resource_records.sql @@ -1,7 +1,6 @@ # Copyright 2004-2024 The Network People, Inc. -DROP TABLE IF EXISTS resource_record_type; -CREATE TABLE resource_record_type ( +CREATE TABLE IF NOT EXISTS resource_record_type ( id smallint(2) unsigned NOT NULL, name varchar(10) NOT NULL, description varchar(55) NULL DEFAULT NULL, @@ -12,7 +11,7 @@ CREATE TABLE resource_record_type ( UNIQUE `name` (`name`) ) DEFAULT CHARSET=utf8mb4; -INSERT INTO `resource_record_type` (`id`, `name`, `description`, `reverse`, `forward`, `obsolete`) +INSERT IGNORE INTO `resource_record_type` (`id`, `name`, `description`, `reverse`, `forward`, `obsolete`) VALUES (1,'A','Address',1,1,0), (2,'NS','Name Server',1,1,0), diff --git a/sql/08_nt_zone.sql b/sql/08_nt_zone.sql index fe2ca3a..2d27431 100644 --- a/sql/08_nt_zone.sql +++ b/sql/08_nt_zone.sql @@ -16,8 +16,7 @@ # -DROP TABLE IF EXISTS nt_zone; -CREATE TABLE nt_zone( +CREATE TABLE IF NOT EXISTS nt_zone( nt_zone_id INT UNSIGNED AUTO_INCREMENT NOT NULL, nt_group_id INT UNSIGNED NOT NULL, zone VARCHAR(255) NOT NULL, @@ -40,8 +39,7 @@ CREATE TABLE nt_zone( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; -DROP TABLE IF EXISTS nt_zone_log; -CREATE TABLE nt_zone_log( +CREATE TABLE IF NOT EXISTS nt_zone_log( nt_zone_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT, nt_group_id INT UNSIGNED NOT NULL, nt_user_id INT UNSIGNED NOT NULL, @@ -63,14 +61,14 @@ CREATE TABLE nt_zone_log( KEY `nt_zone_log_idx2` (`nt_zone_id`), KEY `nt_zone_log_idx3` (`action`), KEY `nt_group_id` (`nt_group_id`), - KEY `nt_user_id` (`nt_user_id`) - /* CONSTRAINT `nt_zone_log_ibfk_3` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE, - ** CONSTRAINT `nt_zone_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE, - ** CONSTRAINT `nt_zone_log_ibfk_2` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_user_id` (`nt_user_id`), + CONSTRAINT `nt_zone_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `nt_zone_log_ibfk_2` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `nt_zone_log_ibfk_3` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; -CREATE TABLE nt_zone_nameserver ( +CREATE TABLE IF NOT EXISTS nt_zone_nameserver ( nt_zone_id int(10) unsigned NOT NULL, nt_nameserver_id smallint(5) unsigned NOT NULL, UNIQUE KEY `zone_ns_id` (`nt_zone_id`,`nt_nameserver_id`) diff --git a/sql/09_nt_zone_record.sql b/sql/09_nt_zone_record.sql index 0214a21..554c483 100644 --- a/sql/09_nt_zone_record.sql +++ b/sql/09_nt_zone_record.sql @@ -15,8 +15,7 @@ # Suite 225, San Francisco, CA 94107, USA # -DROP TABLE IF EXISTS nt_zone_record; -CREATE TABLE nt_zone_record( +CREATE TABLE IF NOT EXISTS nt_zone_record( nt_zone_record_id INT UNSIGNED AUTO_INCREMENT NOT NULL, nt_zone_id INT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL, @@ -34,13 +33,12 @@ CREATE TABLE nt_zone_record( KEY `nt_zone_record_idx1` (`name`), KEY `nt_zone_record_idx2` (address(191)), KEY `nt_zone_record_idx3` (`nt_zone_id`), - KEY `nt_zone_record_idx4` (`deleted`) - /* CONSTRAINT `nt_zone_record_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_zone_record_idx4` (`deleted`), + CONSTRAINT `nt_zone_record_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; -DROP TABLE IF EXISTS nt_zone_record_log; -CREATE TABLE nt_zone_record_log( +CREATE TABLE IF NOT EXISTS nt_zone_record_log( nt_zone_record_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT, nt_zone_id INT UNSIGNED NOT NULL, nt_user_id INT UNSIGNED NOT NULL, @@ -61,9 +59,9 @@ CREATE TABLE nt_zone_record_log( KEY `nt_zone_record_log_idx2` (`nt_zone_record_id`), KEY `nt_zone_record_log_idx3` (`nt_zone_id`), KEY `nt_zone_record_log_idx4` (`action`), - KEY `nt_user_id` (`nt_user_id`) - /* CONSTRAINT `nt_zone_record_log_ibfk_3` FOREIGN KEY (`nt_zone_record_id`) REFERENCES `nt_zone_record` (`nt_zone_record_id`) ON DELETE CASCADE ON UPDATE CASCADE, - ** CONSTRAINT `nt_zone_record_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE, - ** CONSTRAINT `nt_zone_record_log_ibfk_2` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_user_id` (`nt_user_id`), + CONSTRAINT `nt_zone_record_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `nt_zone_record_log_ibfk_2` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `nt_zone_record_log_ibfk_3` FOREIGN KEY (`nt_zone_record_id`) REFERENCES `nt_zone_record` (`nt_zone_record_id`) ON DELETE CASCADE ON UPDATE CASCADE ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED; diff --git a/sql/10_nt_perm.sql b/sql/10_nt_perm.sql index 5ab958e..e94040f 100644 --- a/sql/10_nt_perm.sql +++ b/sql/10_nt_perm.sql @@ -15,8 +15,7 @@ # Suite 225, San Francisco, CA 94107, USA # -DROP TABLE IF EXISTS nt_perm; -CREATE TABLE nt_perm( +CREATE TABLE IF NOT EXISTS nt_perm( nt_perm_id INT UNSIGNED AUTO_INCREMENT NOT NULL, nt_group_id INT UNSIGNED DEFAULT NULL, nt_user_id INT UNSIGNED DEFAULT NULL, @@ -57,10 +56,9 @@ CREATE TABLE nt_perm( KEY `nt_perm_idx2` (`nt_user_id`) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -INSERT into nt_perm VALUES(1,1,0,NULL,NULL,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,0); +INSERT IGNORE INTO nt_perm VALUES(1,1,0,NULL,NULL,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,0); -DROP TABLE IF EXISTS nt_delegate; -CREATE TABLE nt_delegate( +CREATE TABLE IF NOT EXISTS nt_delegate( #nt_delegate_id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY, nt_group_id INT UNSIGNED NOT NULL, nt_object_id INT UNSIGNED NOT NULL, @@ -97,13 +95,12 @@ CREATE TABLE nt_delegate( deleted TINYINT(1) UNSIGNED DEFAULT 0 NOT NULL, KEY `nt_delegate_idx1` (`nt_group_id`,`nt_object_id`,`nt_object_type`), - KEY `nt_delegate_idx2` (`nt_object_id`,`nt_object_type`) - /* CONSTRAINT `nt_delegate_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */ + KEY `nt_delegate_idx2` (`nt_object_id`,`nt_object_type`), + CONSTRAINT `nt_delegate_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE ); -DROP TABLE IF EXISTS nt_delegate_log; -CREATE TABLE nt_delegate_log( +CREATE TABLE IF NOT EXISTS nt_delegate_log( nt_delegate_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, nt_user_id INT UNSIGNED NOT NULL, nt_user_name VARCHAR(50), diff --git a/sql/12_nt_options.sql b/sql/12_nt_options.sql index 107cf8c..1850801 100644 --- a/sql/12_nt_options.sql +++ b/sql/12_nt_options.sql @@ -1,7 +1,6 @@ # Copyright 2004-2024 The Network People, Inc. -DROP TABLE IF EXISTS nt_options; -CREATE TABLE nt_options ( +CREATE TABLE IF NOT EXISTS nt_options ( option_id int(11) unsigned NOT NULL auto_increment, option_name varchar(64) NOT NULL default '', option_value text NOT NULL, @@ -9,8 +8,8 @@ CREATE TABLE nt_options ( UNIQUE KEY `option_name` (`option_name`) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -INSERT INTO `nt_options` -VALUES (1,'db_version','2.34'), +INSERT IGNORE INTO `nt_options` +VALUES (1,'db_version','2.41'), (2,'session_timeout','45'), (3,'default_group','NicTool') ; diff --git a/sql/schema.test.js b/sql/schema.test.js new file mode 100644 index 0000000..b054735 --- /dev/null +++ b/sql/schema.test.js @@ -0,0 +1,189 @@ +// Applies sql/*.sql the way the configurator's /nt/init-schema does. +// +// Regression guard for two things: the files hold many statements per file +// (so the connection needs multipleStatements), and they must be idempotent +// +// Skips when MySQL is unreachable. +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, it, before, after } from 'node:test' + +import mysql from 'mysql2/promise' + +const sqlDir = fileURLToPath(new URL('./', import.meta.url)) + +const DSN = process.env.NICTOOL_TEST_DSN ?? 'mysql://nictool:lootcin!mysql@127.0.0.1:3306/nictool' + +function connOpts() { + const u = new URL(DSN) + return { + host: u.hostname, + port: Number(u.port) || 3306, + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + database: u.pathname.replace(/^\//, ''), + connectTimeout: 4000, + multipleStatements: true, + } +} + +let conn = null +let skip = false + +before(async () => { + try { + conn = await mysql.createConnection(connOpts()) + } catch (err) { + skip = `MySQL unreachable: ${err.code ?? err.message}` + } +}) + +after(async () => { + await conn?.end() +}) + +const schemaFiles = async () => (await fs.readdir(sqlDir)).filter((f) => f.endsWith('.sql')).sort() + +/** + * Parse the CREATE TABLE blocks into { table -> { columns, keys, fks } }. + * Enough structure to validate referential integrity statically, which matters + * because the constraints are only exercised against a freshly built database. + */ +async function parseSchema() { + const tables = {} + for (const f of await schemaFiles()) { + const sql = await fs.readFile(path.join(sqlDir, f), 'utf8') + const blocks = sql.matchAll(/CREATE TABLE IF NOT EXISTS\s+`?(\w+)`?\s*\(([\s\S]*?)\n\)\s*[^;]*;/g) + for (const [, name, body] of blocks) { + const columns = {} + const keys = new Set() + const fks = [] + + for (const raw of body.split('\n')) { + const line = raw.trim().replace(/,$/, '') + if (!line || line.startsWith('--') || line.startsWith('#')) continue + + const fk = line.match( + /CONSTRAINT\s+`(\w+)`\s+FOREIGN KEY\s*\(`(\w+)`\)\s*REFERENCES\s+`(\w+)`\s*\(`(\w+)`\)/i, + ) + if (fk) { + fks.push({ name: fk[1], column: fk[2], refTable: fk[3], refColumn: fk[4] }) + continue + } + + const key = line.match(/^(?:PRIMARY KEY|UNIQUE KEY|UNIQUE|KEY)\s*`?(\w+)?`?\s*\(([^)]*)\)/i) + if (key) { + const first = key[2].split(',')[0].trim().replace(/`/g, '') + keys.add(first) + continue + } + + const col = line.match(/^`?(\w+)`?\s+([A-Za-z]+(?:\([^)]*\))?(?:\s+UNSIGNED)?)/i) + if (col) columns[col[1]] = col[2].toLowerCase().replace(/\s+/g, ' ') + } + tables[name] = { columns, keys, fks, file: f } + } + } + return tables +} + +describe('foreign keys', () => { + it('reference a table and column that exist', async (t) => { + const tables = await parseSchema() + let count = 0 + + for (const [name, def] of Object.entries(tables)) { + for (const fk of def.fks) { + count++ + assert.ok(def.columns[fk.column], `${name}.${fk.column} (${fk.name}) is not a column`) + assert.ok(tables[fk.refTable], `${fk.name} references unknown table ${fk.refTable}`) + assert.ok( + tables[fk.refTable].columns[fk.refColumn], + `${fk.name} references unknown column ${fk.refTable}.${fk.refColumn}`, + ) + } + } + + assert.ok(count >= 10, `expected the 2.41 constraint set, found ${count}`) + t.diagnostic(`${count} foreign keys validated`) + }) + + it('use matching column types on both sides', async () => { + const tables = await parseSchema() + + for (const [name, def] of Object.entries(tables)) { + for (const fk of def.fks) { + const child = tables[name].columns[fk.column] + const parent = tables[fk.refTable].columns[fk.refColumn] + // MySQL rejects a FK whose types differ; auto_increment on the parent + // is not part of the type. + assert.equal( + child.replace(/ unsigned/, ''), + parent.replace(/ unsigned/, ''), + `${fk.name}: ${name}.${fk.column} is ${child} but ${fk.refTable}.${fk.refColumn} is ${parent}`, + ) + } + } + }) + + it('are all covered by the upgrade migration, and vice versa', async () => { + const tables = await parseSchema() + const declared = new Set(Object.values(tables).flatMap((t) => t.fks.map((fk) => fk.name))) + + const migration = await fs.readFile(path.join(sqlDir, 'upgrade', '05_enable_foreign_keys.sql'), 'utf8') + const migrated = new Set([...migration.matchAll(/CALL nt_add_fk\('\w+',\s*'(\w+)'/g)].map((m) => m[1])) + + const missing = [...declared].filter((n) => !migrated.has(n)) + const extra = [...migrated].filter((n) => !declared.has(n)) + + assert.deepEqual(missing, [], 'constraints an existing database would never get') + assert.deepEqual(extra, [], 'constraints the migration adds but the schema does not declare') + }) + + it('are indexed on the child side, as InnoDB requires', async () => { + const tables = await parseSchema() + + for (const [name, def] of Object.entries(tables)) { + for (const fk of def.fks) { + assert.ok( + def.keys.has(fk.column), + `${fk.name}: ${name}.${fk.column} needs a leading index for the constraint`, + ) + } + } + }) +}) + +describe('sql schema', () => { + it('ships no destructive DDL in the install path', async (t) => { + for (const f of await schemaFiles()) { + const sql = await fs.readFile(path.join(sqlDir, f), 'utf8') + assert.ok(!/^DROP TABLE/m.test(sql), `${f} must not DROP TABLE; use sql/upgrade/`) + assert.ok(!/^CREATE TABLE(?! IF NOT EXISTS)/m.test(sql), `${f} must use CREATE TABLE IF NOT EXISTS`) + } + t.diagnostic('sql/upgrade/ holds the destructive cleanup and is not applied on install') + }) + + it('applies cleanly, twice, without duplicating seed rows', async (t) => { + if (skip) return t.skip(skip) + + const files = await schemaFiles() + const seedCount = async () => (await conn.query('SELECT COUNT(*) n FROM resource_record_type'))[0][0].n + + for (const pass of [1, 2]) { + for (const f of files) { + const sql = await fs.readFile(path.join(sqlDir, f), 'utf8') + await assert.doesNotReject(() => conn.query(sql), `${f} failed on pass ${pass}`) + } + } + + assert.equal(await seedCount(), 29, 'INSERT IGNORE kept the seed rows unique') + }) + + it('declares the 2.x db_version the upgrade path detects', async () => { + const sql = await fs.readFile(path.join(sqlDir, '12_nt_options.sql'), 'utf8') + assert.match(sql, /'db_version','2\.41'/) + }) +}) diff --git a/sql/90_nt_summary.sql b/sql/upgrade/01_drop_obsolete_summary_tables.sql similarity index 100% rename from sql/90_nt_summary.sql rename to sql/upgrade/01_drop_obsolete_summary_tables.sql diff --git a/sql/upgrade/02_drop_obsolete_qlog_tables.sql b/sql/upgrade/02_drop_obsolete_qlog_tables.sql new file mode 100644 index 0000000..5239d2d --- /dev/null +++ b/sql/upgrade/02_drop_obsolete_qlog_tables.sql @@ -0,0 +1,6 @@ +# Copyright 2004-2024 The Network People, Inc. +# +# Query-logging tables from NicTool 2.x. v3 does not write them. + +DROP TABLE IF EXISTS nt_nameserver_qlog; +DROP TABLE IF EXISTS nt_nameserver_qlogfile; diff --git a/sql/upgrade/03_nameserver_runtime_columns.sql b/sql/upgrade/03_nameserver_runtime_columns.sql new file mode 100644 index 0000000..7946060 --- /dev/null +++ b/sql/upgrade/03_nameserver_runtime_columns.sql @@ -0,0 +1,14 @@ +# Copyright 2004-2024 The Network People, Inc. +# +# Adds the v3 nameserver runtime configuration columns to a database created by +# NicTool 2.x. New installs get these from sql/04_nt_nameserver.sql instead. +# +# MySQL has no ALTER TABLE ... ADD COLUMN IF NOT EXISTS, so re-running this +# errors with ER_DUP_FIELDNAME. That is safe to ignore. + +ALTER TABLE nt_nameserver + ADD COLUMN engine VARCHAR(32) NULL DEFAULT NULL, + ADD COLUMN listen JSON NULL DEFAULT NULL, + ADD COLUMN publisher JSON NULL DEFAULT NULL, + ADD COLUMN transport JSON NULL DEFAULT NULL, + ADD COLUMN dnssec JSON NULL DEFAULT NULL; diff --git a/sql/upgrade/04_clear_orphan_rows.sql b/sql/upgrade/04_clear_orphan_rows.sql new file mode 100644 index 0000000..4ae2818 --- /dev/null +++ b/sql/upgrade/04_clear_orphan_rows.sql @@ -0,0 +1,105 @@ +# Copyright 2004-2024 The Network People, Inc. +# +# Prepares a NicTool 2.x database for 05_enable_foreign_keys.sql. Safe to +# re-run. Two things block those constraints: +# +# 1. Zero datetimes. Adding a constraint rebuilds the table, and MySQL 8's +# NO_ZERO_DATE re-validates every row, so '0000-00-00 00:00:00' left by +# MySQL 5.x aborts the ALTER with errno 1292. +# 2. Orphan rows — DESTRUCTIVE, see below. +# + +# --------------------------------------------------------------------------- +# Zero datetimes -> NULL +# +# Lossless: the API already reads these as absent (see the Date.parse guard in +# lib/zone_record/store/mysql.js and the last_publish check in the zone store). +# sql_mode is relaxed for the session because strict mode rejects the literal +# in the comparison as well as in the write. +# --------------------------------------------------------------------------- + +SET @nt_old_sql_mode = @@SESSION.sql_mode; +SET SESSION sql_mode = ''; + +UPDATE nt_zone_record SET timestamp = NULL WHERE timestamp = '0000-00-00 00:00:00'; +UPDATE nt_zone SET last_publish = NULL WHERE last_publish = '0000-00-00 00:00:00'; + +SET SESSION sql_mode = @nt_old_sql_mode; + +# nt_zone.last_modified is NOT NULL, so a zero there has no lossless +# replacement. It blocks nothing today; deal with it if nt_zone is ever +# rebuilt: +# UPDATE nt_zone SET last_modified = '1970-01-01 00:00:01' +# WHERE last_modified = '0000-00-00 00:00:00'; + +# --------------------------------------------------------------------------- +# Orphan rows — DESTRUCTIVE +# +# A database that ran with the constraints disabled accumulates rows whose +# parent has since been deleted. With ON DELETE CASCADE in force those rows +# would have gone automatically, so removing them restores the state the schema +# has always intended. They are almost entirely audit-log entries for objects +# that no longer exist. +# +# Check what would be removed before running this: +# +# SELECT COUNT(*) FROM nt_zone_record c +# LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id +# WHERE p.nt_zone_id IS NULL; + +DELETE c FROM nt_group_log c + LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id + WHERE p.nt_group_id IS NULL; + +DELETE c FROM nt_group_subgroups c + LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id + WHERE p.nt_group_id IS NULL; + +DELETE c FROM nt_user_session c + LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id + WHERE p.nt_user_id IS NULL; + +DELETE c FROM nt_user_session_log c + LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id + WHERE p.nt_user_id IS NULL; + +DELETE c FROM nt_user_global_log c + LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id + WHERE p.nt_user_id IS NULL; + +DELETE c FROM nt_nameserver c + LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id + WHERE p.nt_group_id IS NULL; + +DELETE c FROM nt_delegate c + LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id + WHERE p.nt_group_id IS NULL; + +# zone_record before its log, so the log's own parent check sees the result. +DELETE c FROM nt_zone_record c + LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id + WHERE p.nt_zone_id IS NULL; + +DELETE c FROM nt_zone_log c + LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id + WHERE p.nt_zone_id IS NULL; + +DELETE c FROM nt_zone_log c + LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id + WHERE p.nt_group_id IS NULL; + +DELETE c FROM nt_zone_log c + LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id + WHERE p.nt_user_id IS NULL; + +DELETE c FROM nt_zone_record_log c + LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id + WHERE p.nt_zone_id IS NULL; + +DELETE c FROM nt_zone_record_log c + LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id + WHERE p.nt_user_id IS NULL; + +DELETE c FROM nt_zone_record_log c + LEFT JOIN nt_zone_record p ON c.nt_zone_record_id = p.nt_zone_record_id + WHERE p.nt_zone_record_id IS NULL; diff --git a/sql/upgrade/05_enable_foreign_keys.sql b/sql/upgrade/05_enable_foreign_keys.sql new file mode 100644 index 0000000..a817531 --- /dev/null +++ b/sql/upgrade/05_enable_foreign_keys.sql @@ -0,0 +1,79 @@ +# Copyright 2004-2024 The Network People, Inc. +# +# Adds the referential constraints to a database created while they were +# disabled. New installs get these from sql/*.sql instead. +# +# Run 04_clear_orphan_rows.sql first: adding a constraint rebuilds the table, +# which re-validates every row. A failure with errno 150 means orphan rows +# remain; errno 1292 means a zero datetime does. +# +# Idempotent — each constraint is added only if absent, so this can be re-run +# after a partial failure without erroring on what already landed. +# +# mysql nictool < 05_enable_foreign_keys.sql + +DROP PROCEDURE IF EXISTS nt_add_fk; + +DELIMITER // + +CREATE PROCEDURE nt_add_fk(IN tbl VARCHAR(64), IN fk VARCHAR(64), IN clause TEXT) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = tbl + AND CONSTRAINT_NAME = fk + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + ) THEN + SET @nt_ddl = CONCAT('ALTER TABLE `', tbl, '` ADD CONSTRAINT `', fk, '` ', clause); + PREPARE nt_stmt FROM @nt_ddl; + EXECUTE nt_stmt; + DEALLOCATE PREPARE nt_stmt; + END IF; +END // + +DELIMITER ; + +CALL nt_add_fk('nt_group_log', 'nt_group_log_ibfk_1', + 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_group_subgroups', 'nt_group_subgroups_ibfk_1', + 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_user_session', 'nt_user_session_ibfk_1', + 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_user_session_log', 'nt_user_session_log_ibfk_1', + 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_user_global_log', 'nt_user_global_log_ibfk_1', + 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_nameserver', 'nt_nameserver_ibfk_1', + 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_delegate', 'nt_delegate_ibfk_1', + 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_zone_log', 'nt_zone_log_ibfk_1', + 'FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_zone_log', 'nt_zone_log_ibfk_2', + 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_zone_log', 'nt_zone_log_ibfk_3', + 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_zone_record', 'nt_zone_record_ibfk_1', + 'FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_zone_record_log', 'nt_zone_record_log_ibfk_1', + 'FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_zone_record_log', 'nt_zone_record_log_ibfk_2', + 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +CALL nt_add_fk('nt_zone_record_log', 'nt_zone_record_log_ibfk_3', + 'FOREIGN KEY (`nt_zone_record_id`) REFERENCES `nt_zone_record` (`nt_zone_record_id`) ON DELETE CASCADE ON UPDATE CASCADE'); + +DROP PROCEDURE nt_add_fk; diff --git a/test/backends/json.sh b/test/backends/json.sh new file mode 100755 index 0000000..ea6d980 --- /dev/null +++ b/test/backends/json.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# JSON backend lifecycle for test/run.sh + +setup() { + export NICTOOL_DATA_STORE_PATH="./test/conf.d" + mkdir -p test/conf.d + $NODE test/fixtures.js setup +} + +cleanup() { + echo "cleaning JSON test store" + rm -f test/conf.d/*.json +} + +run_tests() { + # Run serially: the file store uses shared files; parallel workers cause concurrent-write corruption + for f in \ + lib/group/test/index.js \ + lib/nameserver/test/index.js \ + lib/nameserver/test/runtime.js \ + lib/permission/test/index.js \ + lib/session/test/index.js \ + lib/user/test/index.js \ + lib/zone/test/index.js \ + lib/zone_record/test/index.js \ + lib/config.test.js \ + lib/util.test.js \ + routes/group.test.js \ + routes/nameserver.test.js \ + routes/permission.test.js \ + routes/session.test.js \ + routes/user.test.js \ + routes/zone.test.js \ + routes/zone_record.test.js; do + $NODE --test --test-reporter=spec "$f" || exit 1 + done +} diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh index 7958552..ad27184 100755 --- a/test/backends/mysql.sh +++ b/test/backends/mysql.sh @@ -21,6 +21,9 @@ run_tests() { $NODE --test --test-reporter=spec \ lib/*/test/index.js \ lib/*/test/mysql.js \ + lib/*/test/runtime.js \ lib/*.test.js \ + lib/store/*.test.js \ + sql/*.test.js \ routes/*.test.js } diff --git a/test/backends/toml.sh b/test/backends/toml.sh index a045e69..03f1764 100755 --- a/test/backends/toml.sh +++ b/test/backends/toml.sh @@ -17,6 +17,7 @@ run_tests() { for f in \ lib/group/test/index.js \ lib/nameserver/test/index.js \ + lib/nameserver/test/runtime.js \ lib/permission/test/index.js \ lib/session/test/index.js \ lib/user/test/index.js \ diff --git a/test/env.js b/test/env.js new file mode 100644 index 0000000..b786814 --- /dev/null +++ b/test/env.js @@ -0,0 +1,44 @@ +/** + * Isolate a test from the environment's config overrides. + * + * `applyEnvOverrides` in lib/config.js lets the environment win over api.json, + * which is what a container relies on — docker-compose.yml sets NICTOOL_DB_HOST + * and NICTOOL_HTTP_HOST. A test asserting what a config *file* holds therefore + * has to clear them, or it passes on a workstation and fails in Docker. + */ +const OVERRIDE_KEYS = [ + 'NICTOOL_CONF_DIR', + 'NICTOOL_DATA_STORE', + 'NICTOOL_DATA_STORE_DSN', + 'NICTOOL_DATA_STORE_PATH', + 'NICTOOL_DB_HOST', + 'NICTOOL_DB_NAME', + 'NICTOOL_DB_PORT', + 'NICTOOL_DB_USER', + 'NICTOOL_DB_USER_PASSWORD', + 'NICTOOL_HTTP_HOST', + 'NICTOOL_HTTP_PORT', +] + +export { OVERRIDE_KEYS } + +/** + * Clear every config override and return a function restoring what was there. + * + * const restoreEnv = clearConfigEnv() + * after(restoreEnv) + */ +export function clearConfigEnv(keys = OVERRIDE_KEYS) { + const saved = {} + for (const key of keys) { + saved[key] = process.env[key] + delete process.env[key] + } + + return function restore() { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} diff --git a/test/run.sh b/test/run.sh index fcd4c7a..6de7990 100755 --- a/test/run.sh +++ b/test/run.sh @@ -6,7 +6,7 @@ NODE="node --no-warnings=ExperimentalWarning" BACKEND="${NICTOOL_DATA_STORE:-mysql}" case "$BACKEND" in - toml|mysql) ;; + json|toml|mysql) ;; *) echo "Unknown NICTOOL_DATA_STORE: $BACKEND" >&2; exit 1 ;; esac