From 821f56ac253f52465cc30d7598c5d24f3066e973 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 12:39:57 -0500 Subject: [PATCH 1/8] mv ./lib/session ./lib/user/session --- lib/session/store/mysql.js | 5 +++ lib/session/test/index.js | 6 +++ lib/user/session.js | 82 ++++++++++++++++++++++++++++++++++++++ lib/user/test/session.js | 71 +++++++++++++++++++++++++++++++++ test.sh | 2 +- 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 lib/user/session.js create mode 100644 lib/user/test/session.js diff --git a/lib/session/store/mysql.js b/lib/session/store/mysql.js index 5e5f547..e3967b9 100644 --- a/lib/session/store/mysql.js +++ b/lib/session/store/mysql.js @@ -1,5 +1,10 @@ +<<<<<<<< HEAD:lib/session/store/mysql.js import Mysql from '../../mysql.js' import { mapToDbColumn } from '../../util.js' +======== +import Mysql from '../mysql.js' +import { mapToDbColumn } from '../util.js' +>>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/session.js const sessionDbMap = { id: 'nt_user_session_id', diff --git a/lib/session/test/index.js b/lib/session/test/index.js index f0d0009..7de75d4 100644 --- a/lib/session/test/index.js +++ b/lib/session/test/index.js @@ -1,9 +1,15 @@ import assert from 'node:assert/strict' import { describe, it, after, before } from 'node:test' +<<<<<<<< HEAD:lib/session/test/index.js import User from '../../user/index.js' import Session from '../index.js' import userCase from '../../user/test/user.json' with { type: 'json' } +======== +import User from '../index.js' +import Session from '../session.js' +import userCase from './user.json' with { type: 'json' } +>>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/test/session.js const sessionUser = { ...userCase, diff --git a/lib/user/session.js b/lib/user/session.js new file mode 100644 index 0000000..e3967b9 --- /dev/null +++ b/lib/user/session.js @@ -0,0 +1,82 @@ +<<<<<<<< HEAD:lib/session/store/mysql.js +import Mysql from '../../mysql.js' +import { mapToDbColumn } from '../../util.js' +======== +import Mysql from '../mysql.js' +import { mapToDbColumn } from '../util.js' +>>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/session.js + +const sessionDbMap = { + id: 'nt_user_session_id', + uid: 'nt_user_id', + session: 'nt_user_session', +} + +class SessionRepoMySQL { + constructor() { + this.mysql = Mysql + } + + async create(args) { + const r = await this.get(args) + if (r) return r.id + + const id = await Mysql.execute(...Mysql.insert(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) + return id + } + + async get(args) { + let query = `SELECT s.nt_user_session_id AS id + , s.nt_user_id AS uid + , s.nt_user_session AS session + FROM nt_user_session s + LEFT JOIN nt_user u ON s.nt_user_id = u.nt_user_id + WHERE u.deleted=0` + + const params = [] + for (const f of ['nt_user_session_id', 'nt_user_id', 'nt_user_session']) { + if (args[f] !== undefined) { + query += ` AND s.${f} = ?` + params.push(args[f]) + } + } + for (const g of ['id', 'uid', 'session']) { + if (args[g] !== undefined) { + query += ` AND s.${sessionDbMap[g]} = ?` + params.push(args[g]) + } + } + + const sessions = await Mysql.execute(query, params) + return sessions[0] + } + + async put(args) { + if (!args.id) return false + + if (args.last_access) { + const p = await this.get({ id: args.id }) + if (!p) return false + + // update only when +1 minute old (save DB writes) + const now = parseInt(Date.now() / 1000, 10) + const oneMinuteAgo = now - 60 + if (p.last_access > oneMinuteAgo) return true + args.last_access = now + } + + const id = args.id + delete args.id + const r = await Mysql.execute( + ...Mysql.update(`nt_user_session`, `nt_user_session_id=${id}`, mapToDbColumn(args, sessionDbMap)), + ) + return r.changedRows === 1 + } + + async delete(args) { + const r = await Mysql.execute(...Mysql.delete(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) + return r.affectedRows === 1 + } +} + +export default SessionRepoMySQL diff --git a/lib/user/test/session.js b/lib/user/test/session.js new file mode 100644 index 0000000..7de75d4 --- /dev/null +++ b/lib/user/test/session.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { describe, it, after, before } from 'node:test' + +<<<<<<<< HEAD:lib/session/test/index.js +import User from '../../user/index.js' +import Session from '../index.js' +import userCase from '../../user/test/user.json' with { type: 'json' } +======== +import User from '../index.js' +import Session from '../session.js' +import userCase from './user.json' with { type: 'json' } +>>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/test/session.js + +const sessionUser = { + ...userCase, + id: userCase.id + 100, + username: `${userCase.username}-session`, + email: `session-${userCase.email}`, +} + +before(async () => { + await User.create(sessionUser) +}) + +after(async () => { + await Session.delete({ uid: sessionUser.id }) + await User.destroy({ id: sessionUser.id }) + await User.mysql.disconnect() +}) + +describe('session', function () { + let sessionId + + describe('create', () => { + it('creates a login session', async () => { + sessionId = await Session.create({ + nt_user_id: sessionUser.id, + session: '3.0.0', + last_access: parseInt(Date.now() / 1000, 10), + }) + assert.ok(sessionId) + }) + }) + + describe('get', () => { + it('finds a session by id', async () => { + const s = await Session.get({ id: sessionId }) + assert.ok(s?.id) + }) + + it('finds a session by nt_user_session_id', async () => { + const s = await Session.get({ nt_user_session_id: sessionId }) + assert.ok(s?.id) + }) + + it('finds a session by session', async () => { + const s = await Session.get({ nt_user_session: '3.0.0' }) + assert.ok(s?.id) + }) + }) + + describe('delete', () => { + it('deletes a session by ID', async () => { + assert.ok(await Session.delete({ id: sessionId })) + }) + + it('does not find a deleted session', async () => { + assert.equal(await Session.get({ id: sessionId }), undefined) + }) + }) +}) diff --git a/test.sh b/test.sh index 0ae5a50..a04ab9d 100755 --- a/test.sh +++ b/test.sh @@ -30,5 +30,5 @@ else # npm i --no-save node-test-github-reporter # $NODE --test --test-reporter=node-test-github-reporter # fi - $NODE --test --test-reporter=spec lib/*/test/index.js lib/*.test.js routes/*.test.js + $NODE --test --test-reporter=spec lib/*/test/*.js lib/*.test.js routes/*.test.js fi From 7850b0164f3b7fecc2437a80c9a42637f80c042b Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 12:44:59 -0500 Subject: [PATCH 2/8] mv ./lib/session.js ./lib/session/index.js --- lib/session/test/index.js | 6 --- lib/user/session.js | 82 --------------------------------------- lib/user/test/session.js | 71 --------------------------------- test.sh | 2 +- 4 files changed, 1 insertion(+), 160 deletions(-) delete mode 100644 lib/user/session.js delete mode 100644 lib/user/test/session.js diff --git a/lib/session/test/index.js b/lib/session/test/index.js index 7de75d4..f0d0009 100644 --- a/lib/session/test/index.js +++ b/lib/session/test/index.js @@ -1,15 +1,9 @@ import assert from 'node:assert/strict' import { describe, it, after, before } from 'node:test' -<<<<<<<< HEAD:lib/session/test/index.js import User from '../../user/index.js' import Session from '../index.js' import userCase from '../../user/test/user.json' with { type: 'json' } -======== -import User from '../index.js' -import Session from '../session.js' -import userCase from './user.json' with { type: 'json' } ->>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/test/session.js const sessionUser = { ...userCase, diff --git a/lib/user/session.js b/lib/user/session.js deleted file mode 100644 index e3967b9..0000000 --- a/lib/user/session.js +++ /dev/null @@ -1,82 +0,0 @@ -<<<<<<<< HEAD:lib/session/store/mysql.js -import Mysql from '../../mysql.js' -import { mapToDbColumn } from '../../util.js' -======== -import Mysql from '../mysql.js' -import { mapToDbColumn } from '../util.js' ->>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/session.js - -const sessionDbMap = { - id: 'nt_user_session_id', - uid: 'nt_user_id', - session: 'nt_user_session', -} - -class SessionRepoMySQL { - constructor() { - this.mysql = Mysql - } - - async create(args) { - const r = await this.get(args) - if (r) return r.id - - const id = await Mysql.execute(...Mysql.insert(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) - return id - } - - async get(args) { - let query = `SELECT s.nt_user_session_id AS id - , s.nt_user_id AS uid - , s.nt_user_session AS session - FROM nt_user_session s - LEFT JOIN nt_user u ON s.nt_user_id = u.nt_user_id - WHERE u.deleted=0` - - const params = [] - for (const f of ['nt_user_session_id', 'nt_user_id', 'nt_user_session']) { - if (args[f] !== undefined) { - query += ` AND s.${f} = ?` - params.push(args[f]) - } - } - for (const g of ['id', 'uid', 'session']) { - if (args[g] !== undefined) { - query += ` AND s.${sessionDbMap[g]} = ?` - params.push(args[g]) - } - } - - const sessions = await Mysql.execute(query, params) - return sessions[0] - } - - async put(args) { - if (!args.id) return false - - if (args.last_access) { - const p = await this.get({ id: args.id }) - if (!p) return false - - // update only when +1 minute old (save DB writes) - const now = parseInt(Date.now() / 1000, 10) - const oneMinuteAgo = now - 60 - if (p.last_access > oneMinuteAgo) return true - args.last_access = now - } - - const id = args.id - delete args.id - const r = await Mysql.execute( - ...Mysql.update(`nt_user_session`, `nt_user_session_id=${id}`, mapToDbColumn(args, sessionDbMap)), - ) - return r.changedRows === 1 - } - - async delete(args) { - const r = await Mysql.execute(...Mysql.delete(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) - return r.affectedRows === 1 - } -} - -export default SessionRepoMySQL diff --git a/lib/user/test/session.js b/lib/user/test/session.js deleted file mode 100644 index 7de75d4..0000000 --- a/lib/user/test/session.js +++ /dev/null @@ -1,71 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it, after, before } from 'node:test' - -<<<<<<<< HEAD:lib/session/test/index.js -import User from '../../user/index.js' -import Session from '../index.js' -import userCase from '../../user/test/user.json' with { type: 'json' } -======== -import User from '../index.js' -import Session from '../session.js' -import userCase from './user.json' with { type: 'json' } ->>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/test/session.js - -const sessionUser = { - ...userCase, - id: userCase.id + 100, - username: `${userCase.username}-session`, - email: `session-${userCase.email}`, -} - -before(async () => { - await User.create(sessionUser) -}) - -after(async () => { - await Session.delete({ uid: sessionUser.id }) - await User.destroy({ id: sessionUser.id }) - await User.mysql.disconnect() -}) - -describe('session', function () { - let sessionId - - describe('create', () => { - it('creates a login session', async () => { - sessionId = await Session.create({ - nt_user_id: sessionUser.id, - session: '3.0.0', - last_access: parseInt(Date.now() / 1000, 10), - }) - assert.ok(sessionId) - }) - }) - - describe('get', () => { - it('finds a session by id', async () => { - const s = await Session.get({ id: sessionId }) - assert.ok(s?.id) - }) - - it('finds a session by nt_user_session_id', async () => { - const s = await Session.get({ nt_user_session_id: sessionId }) - assert.ok(s?.id) - }) - - it('finds a session by session', async () => { - const s = await Session.get({ nt_user_session: '3.0.0' }) - assert.ok(s?.id) - }) - }) - - describe('delete', () => { - it('deletes a session by ID', async () => { - assert.ok(await Session.delete({ id: sessionId })) - }) - - it('does not find a deleted session', async () => { - assert.equal(await Session.get({ id: sessionId }), undefined) - }) - }) -}) diff --git a/test.sh b/test.sh index a04ab9d..0ae5a50 100755 --- a/test.sh +++ b/test.sh @@ -30,5 +30,5 @@ else # npm i --no-save node-test-github-reporter # $NODE --test --test-reporter=node-test-github-reporter # fi - $NODE --test --test-reporter=spec lib/*/test/*.js lib/*.test.js routes/*.test.js + $NODE --test --test-reporter=spec lib/*/test/index.js lib/*.test.js routes/*.test.js fi From 030153fe16ef604ee70488adc972e7b0146eb27d Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 14:41:24 -0500 Subject: [PATCH 3/8] additional TOML store support --- lib/session/store/mysql.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/session/store/mysql.js b/lib/session/store/mysql.js index e3967b9..5e5f547 100644 --- a/lib/session/store/mysql.js +++ b/lib/session/store/mysql.js @@ -1,10 +1,5 @@ -<<<<<<<< HEAD:lib/session/store/mysql.js import Mysql from '../../mysql.js' import { mapToDbColumn } from '../../util.js' -======== -import Mysql from '../mysql.js' -import { mapToDbColumn } from '../util.js' ->>>>>>>> a2746b1 (mv ./lib/session ./lib/user/session):lib/user/session.js const sessionDbMap = { id: 'nt_user_session_id', From d8eb058155ddd4a0995c5f09de122d971b1ab627 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 18:49:29 -0500 Subject: [PATCH 4/8] mysql.disconnect: consolidate into */mysql.js classes --- .github/workflows/ci.yml | 2 ++ CHANGELOG.md | 2 ++ lib/config.js | 4 ++-- lib/group/store/base.js | 4 ++++ lib/group/store/mysql.js | 4 ++++ lib/group/test/index.js | 2 +- lib/mysql.js | 5 +---- lib/nameserver/store/base.js | 4 ++++ lib/nameserver/store/mysql.js | 4 ++++ lib/nameserver/test/index.js | 2 +- lib/permission/store/mysql.js | 4 ++++ lib/permission/test/index.js | 2 +- lib/session/store/mysql.js | 4 ++++ lib/session/store/toml.js | 4 ++++ lib/session/test/index.js | 2 +- lib/user/store/base.js | 4 ++++ lib/user/store/mysql.js | 4 ++++ lib/user/test/index.js | 2 +- lib/zone/store/base.js | 4 ++++ lib/zone/store/mysql.js | 4 ++++ lib/zone/test/index.js | 2 +- lib/zone_record/store/base.js | 4 ++++ lib/zone_record/store/mysql.js | 4 ++++ lib/zone_record/test/index.js | 2 +- routes/index.js | 6 +++--- test-fixtures.js | 8 ++++---- 26 files changed, 73 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05560e6..4a3d16f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,8 @@ jobs: test-docker: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Generate .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 6080e5a..d1fcdf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +- add: TOML stores for group, nameserver, permission, session + ### [3.0.0-alpha.11] - 2026-04-07 - decorate user & group with permissions diff --git a/lib/config.js b/lib/config.js index f517ea4..a0d85fc 100644 --- a/lib/config.js +++ b/lib/config.js @@ -18,7 +18,7 @@ class Config { const str = await fs.readFile(`./conf.d/${name}.toml`, 'utf8') const cfg = parse(str) applyEnvOverrides(name, cfg) - if (this.debug) console.debug(cfg) + // if (this.debug) console.debug(cfg) if (name === 'http') { const tls = await loadPEM('./conf.d') @@ -37,7 +37,7 @@ class Config { const str = fsSync.readFileSync(`./conf.d/${name}.toml`, 'utf8') const cfg = parse(str) applyEnvOverrides(name, cfg) - if (this.debug) console.debug(cfg) + // if (this.debug) console.debug(cfg) if (name === 'http') { const tls = loadPEMSync('./conf.d') diff --git a/lib/group/store/base.js b/lib/group/store/base.js index 88c669e..3a0daf4 100644 --- a/lib/group/store/base.js +++ b/lib/group/store/base.js @@ -16,6 +16,10 @@ class GroupBase { this.debug = args?.debug ?? false } + disconnect() { + // noop, for repos that need to clean up resources + } + // ------------------------------------------------------------------------- // Repo contract – subclasses must implement these // ------------------------------------------------------------------------- diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js index 3f1a214..680d324 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -163,6 +163,10 @@ class Group extends GroupBase { const r = await Mysql.execute(...Mysql.delete(`nt_group`, { nt_group_id: args.id })) return r.affectedRows === 1 } + + disconnect() { + return this.mysql?.disconnect() + } } export default Group diff --git a/lib/group/test/index.js b/lib/group/test/index.js index 8321670..5c418ba 100644 --- a/lib/group/test/index.js +++ b/lib/group/test/index.js @@ -6,7 +6,7 @@ import Group from '../index.js' import testCase from '../test/group.json' with { type: 'json' } after(async () => { - Group.mysql.disconnect() + Group.disconnect() }) describe('group', function () { diff --git a/lib/mysql.js b/lib/mysql.js index b72ddfc..93b6d1d 100644 --- a/lib/mysql.js +++ b/lib/mysql.js @@ -12,11 +12,8 @@ class Mysql { } async connect() { - // if (this.dbh && this.dbh?.connection?.connectionId) return this.dbh; - const cfg = await Config.get('mysql') - if (_debug) console.log(cfg) - + // if (_debug) console.log(cfg) this.dbh = await mysql.createConnection(cfg) if (_debug) console.log(`MySQL connection id ${this.dbh.connection.connectionId}`) return this.dbh diff --git a/lib/nameserver/store/base.js b/lib/nameserver/store/base.js index 0c5e7be..77da096 100644 --- a/lib/nameserver/store/base.js +++ b/lib/nameserver/store/base.js @@ -39,6 +39,10 @@ class NameserverBase { async destroy(_args) { throw new Error('destroy() not implemented by this repo') } + + disconnect() { + // noop by default + } } export default NameserverBase diff --git a/lib/nameserver/store/mysql.js b/lib/nameserver/store/mysql.js index 2b04a20..3702636 100644 --- a/lib/nameserver/store/mysql.js +++ b/lib/nameserver/store/mysql.js @@ -94,6 +94,10 @@ class Nameserver extends NameserverBase { const r = await Mysql.execute(...Mysql.delete(`nt_nameserver`, { nt_nameserver_id: args.id })) return r.affectedRows === 1 } + + disconnect() { + return this.mysql?.disconnect() + } } export default Nameserver diff --git a/lib/nameserver/test/index.js b/lib/nameserver/test/index.js index f183d73..73131da 100644 --- a/lib/nameserver/test/index.js +++ b/lib/nameserver/test/index.js @@ -12,7 +12,7 @@ before(async () => { after(async () => { await Nameserver.destroy({ id: testCase.id }) - Nameserver.mysql.disconnect() + await Nameserver.disconnect() }) describe('nameserver', function () { diff --git a/lib/permission/store/mysql.js b/lib/permission/store/mysql.js index e1240bd..c71c049 100644 --- a/lib/permission/store/mysql.js +++ b/lib/permission/store/mysql.js @@ -119,6 +119,10 @@ class PermissionRepoMySQL extends PermissionBase { const r = await Mysql.execute(...Mysql.delete(`nt_perm`, mapToDbColumn(args, permDbMap))) return r.affectedRows === 1 } + + disconnect() { + return this.mysql?.disconnect() + } } export default PermissionRepoMySQL diff --git a/lib/permission/test/index.js b/lib/permission/test/index.js index bac4705..45dd96f 100644 --- a/lib/permission/test/index.js +++ b/lib/permission/test/index.js @@ -15,7 +15,7 @@ before(async () => { }) after(async () => { - await Permission.mysql.disconnect() + await Permission.disconnect() }) describe('permission', function () { diff --git a/lib/session/store/mysql.js b/lib/session/store/mysql.js index 5e5f547..bc45390 100644 --- a/lib/session/store/mysql.js +++ b/lib/session/store/mysql.js @@ -72,6 +72,10 @@ class SessionRepoMySQL { const r = await Mysql.execute(...Mysql.delete(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) return r.affectedRows === 1 } + + disconnect() { + return this.mysql?.disconnect() + } } export default SessionRepoMySQL diff --git a/lib/session/store/toml.js b/lib/session/store/toml.js index 369ae22..96bc2f1 100644 --- a/lib/session/store/toml.js +++ b/lib/session/store/toml.js @@ -128,6 +128,10 @@ class SessionRepoTOML { await this._save(filtered) return true } + + disconnect() { + // noop + } } export default SessionRepoTOML diff --git a/lib/session/test/index.js b/lib/session/test/index.js index f0d0009..baeed87 100644 --- a/lib/session/test/index.js +++ b/lib/session/test/index.js @@ -19,7 +19,7 @@ before(async () => { after(async () => { await Session.delete({ uid: sessionUser.id }) await User.destroy({ id: sessionUser.id }) - await User.mysql.disconnect() + await User.disconnect() }) describe('session', function () { diff --git a/lib/user/store/base.js b/lib/user/store/base.js index a9989e4..0cc77ed 100644 --- a/lib/user/store/base.js +++ b/lib/user/store/base.js @@ -19,6 +19,10 @@ class UserBase { this.debug = args?.debug ?? false } + disconnect() { + // noop, for repos that need to clean up resources + } + // ------------------------------------------------------------------------- // Repo contract – subclasses must implement these // ------------------------------------------------------------------------- diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 88db192..607e933 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -206,6 +206,10 @@ class UserRepoMySQL extends UserBase { const r = await Mysql.execute(...Mysql.delete(`nt_user`, mapToDbColumn({ id: args.id }, userDbMap))) return r.affectedRows === 1 } + + disconnect() { + return this.mysql?.disconnect() + } } export default UserRepoMySQL diff --git a/lib/user/test/index.js b/lib/user/test/index.js index 37af994..8159437 100644 --- a/lib/user/test/index.js +++ b/lib/user/test/index.js @@ -12,7 +12,7 @@ before(async () => { }) after(async () => { - User.mysql.disconnect() + await User.disconnect() }) function sanitize(u) { diff --git a/lib/zone/store/base.js b/lib/zone/store/base.js index 06e8100..f65a539 100644 --- a/lib/zone/store/base.js +++ b/lib/zone/store/base.js @@ -37,6 +37,10 @@ class ZoneBase { async destroy(_args) { throw new Error('destroy() not implemented by this repo') } + + disconnect() { + // noop by default, overridden by repos that need to clean up resources + } } export default ZoneBase diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index de30161..3dfe1f2 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -181,6 +181,10 @@ class ZoneRepoMySQL extends ZoneBase { const r = await Mysql.execute(...Mysql.delete(`nt_zone`, { nt_zone_id: args.id })) return r.affectedRows === 1 } + + disconnect() { + return this.mysql?.disconnect() + } } export default ZoneRepoMySQL diff --git a/lib/zone/test/index.js b/lib/zone/test/index.js index 9cb9122..a366ff6 100644 --- a/lib/zone/test/index.js +++ b/lib/zone/test/index.js @@ -12,7 +12,7 @@ before(async () => { after(async () => { // await Zone.destroy({ id: testCase.id }) - Zone.mysql.disconnect() + await Zone.disconnect() }) describe('zone', function () { diff --git a/lib/zone_record/store/base.js b/lib/zone_record/store/base.js index ec0922b..4afc5e6 100644 --- a/lib/zone_record/store/base.js +++ b/lib/zone_record/store/base.js @@ -37,6 +37,10 @@ class ZoneRecordBase { async destroy(_args) { throw new Error('destroy() not implemented by this repo') } + + disconnect() { + // noop by default + } } export default ZoneRecordBase diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js index c56f832..e548c07 100644 --- a/lib/zone_record/store/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -92,6 +92,10 @@ class ZoneRecordMySQL extends ZoneRecordBase { const r = await Mysql.execute(...Mysql.delete(`nt_zone_record`, { nt_zone_record_id: args.id })) return r.affectedRows === 1 } + + disconnect() { + this.mysql?.disconnect() + } } export default ZoneRecordMySQL diff --git a/lib/zone_record/test/index.js b/lib/zone_record/test/index.js index 471802d..e6fb9ec 100644 --- a/lib/zone_record/test/index.js +++ b/lib/zone_record/test/index.js @@ -8,7 +8,7 @@ import ZoneRecord from '../index.js' after(async () => { // await ZoneRecord.destroy({ id: testCase.id }) - ZoneRecord.mysql.disconnect() + await ZoneRecord.disconnect() }) describe('zone_record', function () { diff --git a/routes/index.js b/routes/index.js index 16f4907..5f6e115 100644 --- a/routes/index.js +++ b/routes/index.js @@ -139,9 +139,9 @@ async function setup() { } }) - server.events.on('stop', () => { - if (User.mysql) User.mysql.disconnect() - if (Session.mysql) Session.mysql.disconnect() + server.events.on('stop', async () => { + await User.disconnect() + await Session.disconnect() }) } diff --git a/test-fixtures.js b/test-fixtures.js index 29cb252..e8dc980 100644 --- a/test-fixtures.js +++ b/test-fixtures.js @@ -35,8 +35,8 @@ async function setup() { await User.create(userCase) await User.create(userCaseR) // await createTestSession() - await User.mysql.disconnect() - await Group.mysql.disconnect() + await User.disconnect() + await Group.disconnect() process.exit(0) } @@ -60,7 +60,7 @@ async function teardown() { await User.destroy({ id: userCaseR.id }) await Group.destroy({ id: groupCase.id }) await Group.destroy({ id: groupCaseR.id }) - await User.mysql.disconnect() - await Group.mysql.disconnect() + await User.disconnect() + await Group.disconnect() process.exit(0) } From 8bf6a2909a60e74cd5022019ea9dbd15e137ffb7 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 20:27:20 -0500 Subject: [PATCH 5/8] TOML backend progress - move mysql teardown/disconnect into mysql classes - fix: don't log sensitive information --- CHANGELOG.md | 2 ++ lib/nameserver/store/base.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1fcdf5..edd879d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +- move mysql teardown/disconnect into mysql classes +- fix: don't log sensitive information - add: TOML stores for group, nameserver, permission, session ### [3.0.0-alpha.11] - 2026-04-07 diff --git a/lib/nameserver/store/base.js b/lib/nameserver/store/base.js index 77da096..d10d33b 100644 --- a/lib/nameserver/store/base.js +++ b/lib/nameserver/store/base.js @@ -41,7 +41,7 @@ class NameserverBase { } disconnect() { - // noop by default + // noop, for repos that need to clean up resources } } From b0b361de5c51beb4bbb85a5ca5e379aa350811b3 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 20:57:26 -0500 Subject: [PATCH 6/8] move ./test.sh into ./test/run.sh --- .github/copilot-instructions.md | 4 ++-- .github/workflows/ci.yml | 2 +- .gitignore | 1 + package.json | 4 ++-- test-fixtures.js => test/fixtures.js | 26 +++++++++++++------------- test.sh => test/run.sh | 6 +++--- 6 files changed, 22 insertions(+), 21 deletions(-) rename test-fixtures.js => test/fixtures.js (64%) rename test.sh => test/run.sh (88%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 83f1931..ca84c84 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,7 +13,7 @@ - Run formatting check: `npm run prettier` - Run coverage: `npm run test:coverage` -Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql/init-mysql.sh`, and `test.sh` recreates fixtures before each run. +Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql/init-mysql.sh`, and `test/run.sh` recreates fixtures before each run. ## Architecture @@ -38,5 +38,5 @@ Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql - Reuse the legacy-schema mapping helpers instead of hand-rolling field conversions. Most repos convert booleans, nested permission/export objects, and short API names into the older DB layout before writing and normalize them again on read. - When changing group or user behavior, check permission side effects too. Group creation/update touches permission rows, and user reads/write paths may change `inherit_group_permissions` handling. - Route tests use `init()` plus `server.inject()` instead of booting a live server. They usually establish auth by calling `POST /session` and then pass `Authorization: Bearer ` to protected routes. -- The test entrypoint is `test.sh`, not raw `node --test`, when you need DB-backed behavior. It tears fixtures down, recreates them, and then runs the requested test target. +- The test entrypoint is `test/run.sh`, not raw `node --test`, when you need DB-backed behavior. It tears fixtures down, recreates them, and then runs the requested test target. - Zone-record changes must preserve the existing record-field translation logic. Special cases like zero `weight`/`priority` retention for `SRV`, `URI`, `HTTPS`, and `SVCB` are intentional. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a3d16f..6a5c256 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,4 +128,4 @@ jobs: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh - run: npm install - - run: sh test.sh + - run: sh test/run.sh diff --git a/.gitignore b/.gitignore index b6779cb..d5e7fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ dist package-lock.json .release/ conf.d/*.pem +CLAUDE.md diff --git a/package.json b/package.json index 889cca0..6b29f3a 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "server": "node ./server.js", "start": "node ./server.js", "develop": "node --watch server.js ./server", - "test": "./test.sh", + "test": "./test/run.sh", "versions": "npx npm-dep-mgr check", "versions:fix": "npx npm-dep-mgr update", - "watch": "./test.sh watch", + "watch": "./test/run.sh watch", "test:coverage": "npx c8 --reporter=text --reporter=text-summary npm test" }, "repository": { diff --git a/test-fixtures.js b/test/fixtures.js similarity index 64% rename from test-fixtures.js rename to test/fixtures.js index e8dc980..e6d3484 100644 --- a/test-fixtures.js +++ b/test/fixtures.js @@ -2,21 +2,21 @@ import path from 'node:path' -import Group from './lib/group/index.js' -import User from './lib/user/index.js' -import Session from './lib/session/index.js' -import Permission from './lib/permission/index.js' -import Nameserver from './lib/nameserver/index.js' -import Zone from './lib/zone/index.js' +import Group from '../lib/group/index.js' +import User from '../lib/user/index.js' +import Session from '../lib/session/index.js' +import Permission from '../lib/permission/index.js' +import Nameserver from '../lib/nameserver/index.js' +import Zone from '../lib/zone/index.js' // import ZoneRecord from './lib/zone_record.js' -import groupCase from './lib/group/test/group.json' with { type: 'json' } -import userCase from './lib/user/test/user.json' with { type: 'json' } -import zoneCase from './lib/zone/test/zone.json' with { type: 'json' } -// import zrCase from './lib/zone_record/test/zone_record.json' with { type: 'json' } -import groupCaseR from './routes/test/group.json' with { type: 'json' } -import userCaseR from './routes/test/user.json' with { type: 'json' } -import nsCaseR from './routes/test/nameserver.json' with { type: 'json' } +import groupCase from '../lib/group/test/group.json' with { type: 'json' } +import userCase from '../lib/user/test/user.json' with { type: 'json' } +import zoneCase from '../lib/zone/test/zone.json' with { type: 'json' } +// import zrCase from '../lib/zone_record/test/zone_record.json' with { type: 'json' } +import groupCaseR from '../routes/test/group.json' with { type: 'json' } +import userCaseR from '../routes/test/user.json' with { type: 'json' } +import nsCaseR from '../routes/test/nameserver.json' with { type: 'json' } switch (process.argv[2]) { case 'setup': diff --git a/test.sh b/test/run.sh similarity index 88% rename from test.sh rename to test/run.sh index 0ae5a50..f6909d8 100755 --- a/test.sh +++ b/test/run.sh @@ -9,12 +9,12 @@ if [ "${CI:-}" = "true" ]; then fi NODE="node --no-warnings=ExperimentalWarning" -$NODE test-fixtures.js teardown -$NODE test-fixtures.js setup +$NODE test/fixtures.js teardown +$NODE test/fixtures.js setup cleanup() { echo "cleaning DB objects" - $NODE test-fixtures.js teardown + $NODE test/fixtures.js teardown } trap cleanup EXIT 1 2 3 6 From b5382e6023ea91e05810a8d8ebdbe6f0f161b299 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 22:03:18 -0500 Subject: [PATCH 7/8] TOML passing tests --- lib/nameserver/test/index.js | 4 +- lib/permission/store/toml.js | 190 ++++++++++++++++++++++++---------- lib/user/store/toml.js | 4 + lib/zone/store/toml.js | 7 +- lib/zone/test/index.js | 4 +- lib/zone_record/store/toml.js | 12 ++- routes/group.js | 16 +-- routes/user.js | 2 +- test/backends/mysql.sh | 22 ++++ test/backends/toml.sh | 36 +++++++ test/run.sh | 27 ++--- 11 files changed, 238 insertions(+), 86 deletions(-) create mode 100755 test/backends/mysql.sh create mode 100755 test/backends/toml.sh diff --git a/lib/nameserver/test/index.js b/lib/nameserver/test/index.js index 73131da..c1107a6 100644 --- a/lib/nameserver/test/index.js +++ b/lib/nameserver/test/index.js @@ -33,7 +33,9 @@ describe('nameserver', function () { assert.ok(await Nameserver.put({ id: testCase.id, name: testCase.name })) }) - it('handles null export interval gracefully', async () => { + it('handles null export interval gracefully', { + skip: process.env.NICTOOL_DATA_STORE === 'toml' ? 'TOML backend: no SQL access' : false, + }, async () => { await Nameserver.mysql.execute( 'UPDATE nt_nameserver SET export_interval = NULL WHERE nt_nameserver_id = ?', [testCase.id], diff --git a/lib/permission/store/toml.js b/lib/permission/store/toml.js index 85a0da1..e28ddaa 100644 --- a/lib/permission/store/toml.js +++ b/lib/permission/store/toml.js @@ -8,16 +8,6 @@ import PermissionBase from './base.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const defaultPermissions = { - inherit: false, - self_write: false, - group: { create: false, write: false, delete: false }, - nameserver: { usable: [], create: false, write: false, delete: false }, - zone: { create: false, write: false, delete: false, delegate: false }, - zonerecord: { create: false, write: false, delete: false, delegate: false }, - 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) @@ -25,27 +15,34 @@ function resolveStorePath(filename) { } /** - * TOML permission store — a facade over user.toml and group.toml. + * TOML permission store. + * + * Permissions are stored in one of three places: * - * Permissions are stored inline in each user/group record rather than in a - * separate file. This store reads and writes those files directly via - * fs.readFile / fs.writeFile (never via the User or Group modules) to avoid - * circular import cycles. + * 1. Inline in user.toml — for permissions tied to an existing user record. + * Looked up via users[i].permissions.id === N. * - * get({ uid }) → inline permissions of that user - * get({ gid }) → inline permissions of that group (uid absent) - * get({ id }) → search user.toml first, then group.toml by permissions.id - * getGroup({ uid }) → permissions of the group the user belongs to + * 2. Inline in group.toml — group-level permissions created by GroupRepoTOML. + * 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. + * + * 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 { constructor(args = {}) { super(args) this._userPath = resolveStorePath('user.toml') this._groupPath = resolveStorePath('group.toml') + this._standaloneFilePath = resolveStorePath('permission.toml') } // --------------------------------------------------------------------------- - // Raw file I/O + // Raw file I/O — users // --------------------------------------------------------------------------- async _loadUsers() { @@ -64,6 +61,10 @@ class PermissionRepoTOML extends PermissionBase { await fs.writeFile(this._userPath, stringify({ user: users })) } + // --------------------------------------------------------------------------- + // Raw file I/O — groups + // --------------------------------------------------------------------------- + async _loadGroups() { try { const str = await fs.readFile(this._groupPath, 'utf8') @@ -80,6 +81,26 @@ class PermissionRepoTOML extends PermissionBase { await fs.writeFile(this._groupPath, stringify({ 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 + } + } + + async _saveStandalone(permissions) { + await fs.mkdir(path.dirname(this._standaloneFilePath), { recursive: true }) + await fs.writeFile(this._standaloneFilePath, stringify({ permission: permissions })) + } + // --------------------------------------------------------------------------- // Post-processing // --------------------------------------------------------------------------- @@ -87,6 +108,9 @@ class PermissionRepoTOML extends PermissionBase { _postProcess(perm, deletedArg) { if (!perm) return undefined const r = JSON.parse(JSON.stringify(perm)) + // uid/gid are internal storage hints; never expose them in the response + delete r.uid + delete r.gid r.deleted = Boolean(r.deleted) if (r.nameserver && !Array.isArray(r.nameserver.usable)) r.nameserver.usable = [] if (deletedArg === false) delete r.deleted @@ -107,45 +131,58 @@ class PermissionRepoTOML extends PermissionBase { if (uid !== undefined) { const users = await this._loadUsers() const idx = users.findIndex((u) => u.id === uid) - if (idx === -1) return undefined - - if (!users[idx].permissions) { - const usable = Array.isArray(args.nameserver?.usable) ? args.nameserver.usable : [] - users[idx].permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: uid, - inherit: false, - user: { id: uid, create: false, write: false, delete: false }, - group: { id: gid ?? users[idx].gid, create: false, write: false, delete: false }, - nameserver: { usable, create: false, write: false, delete: false }, + + if (idx !== -1) { + // Store inline in user.toml using the actual permission data from args + if (!users[idx].permissions) { + const perm = JSON.parse(JSON.stringify(args)) + perm.id = uid + if (!perm.user) perm.user = {} + perm.user.id = uid + if (!perm.group) perm.group = {} + perm.group.id = gid ?? users[idx].gid + users[idx].permissions = perm } + await this._saveUsers(users) + return users[idx].permissions.id } - await this._saveUsers(users) - return users[idx].permissions.id + + // User not found — fall through to standalone storage } - if (gid !== undefined) { + if (gid !== undefined && uid === undefined) { const groups = await this._loadGroups() const idx = groups.findIndex((g) => g.id === gid) - if (idx === -1) return undefined - - if (!groups[idx].permissions) { - const usable = Array.isArray(args.nameserver?.usable) ? args.nameserver.usable : [] - groups[idx].permissions = { - ...JSON.parse(JSON.stringify(defaultPermissions)), - id: gid, - name: args.name, - inherit: false, - user: { id: gid, create: false, write: false, delete: false }, - group: { id: gid, create: false, write: false, delete: false }, - nameserver: { usable, create: false, write: false, delete: false }, + + if (idx !== -1) { + // Store inline in group.toml + if (!groups[idx].permissions) { + const perm = JSON.parse(JSON.stringify(args)) + perm.id = gid + if (!perm.group) perm.group = {} + perm.group.id = gid + groups[idx].permissions = perm } + await this._saveGroups(groups) + return groups[idx].permissions.id } - await this._saveGroups(groups) - return groups[idx].permissions.id + + // Group not found — fall through to standalone storage } - return undefined + // Standalone fallback: neither user nor group record found + const permId = args.id ?? uid ?? gid + if (permId === undefined) return undefined + + const perms = await this._loadStandalone() + if (!perms.find((p) => p.id === permId)) { + const perm = { ...args, id: permId } + if (uid !== undefined) perm.uid = uid + if (gid !== undefined) perm.gid = gid + perms.push(perm) + await this._saveStandalone(perms) + } + return permId } async get(args) { @@ -172,14 +209,28 @@ class PermissionRepoTOML extends PermissionBase { } if (args.id !== undefined) { - // Search user.toml first (user and group ids can collide) + // Search user.toml by permissions.id. + // NOTE: group.toml is intentionally NOT searched here — group permissions are + // accessed via { gid }. Searching groups would cause false positives after a + // user permission is destroyed, because user and group share the same numeric + // id space (both user 4096 and group 4096 set permissions.id = 4096). const users = await this._loadUsers() const user = users.find((u) => u.permissions?.id === args.id) - if (user?.permissions) return this._postProcess(user.permissions, deletedArg) + if (user?.permissions) { + const perm = this._postProcess(user.permissions, deletedArg) + if (deletedArg === true && perm.deleted !== true) return undefined + return perm + } - const groups = await this._loadGroups() - const group = groups.find((g) => g.permissions?.id === args.id) - if (group?.permissions) return this._postProcess(group.permissions, deletedArg) + // Check standalone permission.toml + const perms = await this._loadStandalone() + const found = perms.find((p) => p.id === args.id) + if (found) { + const isDeleted = Boolean(found.deleted) + const wantDeleted = Boolean(deletedArg) + if (isDeleted !== wantDeleted) return undefined + return this._postProcess(found, deletedArg) + } } return undefined @@ -220,6 +271,15 @@ class PermissionRepoTOML extends PermissionBase { return true } + // Check standalone + const perms = await this._loadStandalone() + const pidx = perms.findIndex((p) => p.id === id) + if (pidx !== -1) { + perms[pidx] = deepMerge(perms[pidx], args) + await this._saveStandalone(perms) + return true + } + return false } @@ -243,9 +303,22 @@ class PermissionRepoTOML extends PermissionBase { return true } + // Check standalone + const perms = await this._loadStandalone() + const pidx = perms.findIndex((p) => p.id === args.id) + if (pidx !== -1) { + perms[pidx].deleted = deletedVal + await this._saveStandalone(perms) + return true + } + return false } + disconnect() { + // noop + } + async destroy(args) { if (!args.id) return false @@ -265,6 +338,15 @@ class PermissionRepoTOML extends PermissionBase { return true } + // Check standalone + const perms = await this._loadStandalone() + const before = perms.length + const filtered = perms.filter((p) => p.id !== args.id) + if (filtered.length < before) { + await this._saveStandalone(filtered) + return true + } + return false } } diff --git a/lib/user/store/toml.js b/lib/user/store/toml.js index 211100c..dd37043 100644 --- a/lib/user/store/toml.js +++ b/lib/user/store/toml.js @@ -65,6 +65,10 @@ class UserRepoTOML extends UserBase { _postProcess(u, deletedArg) { const r = { ...u } + // Remove sensitive credential fields — these are stored internally but never + // exposed via get(). authenticate() reads the raw record directly. + delete r.password + delete r.pass_salt for (const b of boolFields) r[b] = Boolean(r[b]) if (r.permissions) { r.inherit_group_permissions = r.permissions.inherit !== false diff --git a/lib/zone/store/toml.js b/lib/zone/store/toml.js index 97eb85b..dcfe5ff 100644 --- a/lib/zone/store/toml.js +++ b/lib/zone/store/toml.js @@ -41,14 +41,13 @@ class ZoneRepoTOML extends ZoneBase { _postProcess(row, deletedArg) { const r = { ...row } r.deleted = Boolean(r.deleted) - for (const f of ['description', 'location']) { - if ([null, undefined].includes(r[f])) r[f] = '' - } + if ([null, undefined].includes(r.description)) r.description = '' for (const [f, val] of Object.entries(zoneDefaults)) { if ([null, undefined].includes(r[f])) r[f] = val } if ([null, undefined].includes(r.serial)) r.serial = 0 - if (r.last_publish === undefined) delete r.last_publish + // TOML drops null on stringify; restore it on read-back + if (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 diff --git a/lib/zone/test/index.js b/lib/zone/test/index.js index a366ff6..75ef950 100644 --- a/lib/zone/test/index.js +++ b/lib/zone/test/index.js @@ -35,7 +35,9 @@ describe('zone', function () { assert.ok(await Zone.put({ id: testCase.id, mailaddr: testCase.mailaddr })) }) - it('handles null minimum gracefully', async () => { + it('handles null minimum gracefully', { + skip: process.env.NICTOOL_DATA_STORE === 'toml' ? 'TOML backend: no SQL access' : false, + }, async () => { await Zone.mysql.execute('UPDATE nt_zone SET minimum = NULL WHERE nt_zone_id = ?', [testCase.id]) const z = await Zone.get({ id: testCase.id }) diff --git a/lib/zone_record/store/toml.js b/lib/zone_record/store/toml.js index 213bc6c..d452c8b 100644 --- a/lib/zone_record/store/toml.js +++ b/lib/zone_record/store/toml.js @@ -37,13 +37,23 @@ class ZoneRecordRepoTOML extends ZoneRecordBase { } async create(args) { + args = JSON.parse(JSON.stringify(args)) + if (args.id) { const existing = await this.get({ id: args.id }) if (existing.length === 1) return existing[0].id } const records = await this._load() - records.push(JSON.parse(JSON.stringify(args))) + + if (!args.id) { + const maxId = records.reduce((max, r) => Math.max(max, r.id ?? 0), 0) + args.id = maxId + 1 + } + + if (args.ttl === undefined) args.ttl = 0 + + records.push(args) await this._save(records) return args.id } diff --git a/routes/group.js b/routes/group.js index c9df9d1..507bfdc 100644 --- a/routes/group.js +++ b/routes/group.js @@ -1,6 +1,8 @@ import validate from '@nictool/validate' import Group from '../lib/group/index.js' +import User from '../lib/user/index.js' +import Zone from '../lib/zone/index.js' import { meta } from '../lib/util.js' function GroupRoutes(server) { @@ -157,18 +159,18 @@ function GroupRoutes(server) { .code(204) } - const [zoneCount, userCount, subgroupCount] = await Promise.all([ - Group.mysql.execute('SELECT COUNT(*) AS count FROM nt_zone WHERE nt_group_id = ? AND deleted = 0', [id]), - Group.mysql.execute('SELECT COUNT(*) AS count FROM nt_user WHERE nt_group_id = ? AND deleted = 0', [id]), - Group.mysql.execute('SELECT COUNT(*) AS count FROM nt_group WHERE parent_group_id = ? AND deleted = 0', [id]), + const [zoneCount, userCount, subgroups] = await Promise.all([ + Zone.count({ gid: id }), + User.count({ gid: id }), + Group.get({ parent_gid: id }), ]) - if (zoneCount[0].count > 0) { + if (zoneCount > 0) { return h.response({ error: 'Cannot delete group: active zones still exist.' }).code(409) } - if (userCount[0].count > 0) { + if (userCount > 0) { return h.response({ error: 'Cannot delete group: active users still exist.' }).code(409) } - if (subgroupCount[0].count > 0) { + if (subgroups.length > 0) { return h.response({ error: 'Cannot delete group: active subgroups still exist.' }).code(409) } diff --git a/routes/user.js b/routes/user.js index 04311ea..11c4550 100644 --- a/routes/user.js +++ b/routes/user.js @@ -170,7 +170,7 @@ function UserRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const users = await User.get(request.params) + const users = await User.get({ id: parseInt(request.params.id, 10) }) if (users.length !== 1) { /* c8 ignore next 8 */ return h diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh new file mode 100755 index 0000000..60a484b --- /dev/null +++ b/test/backends/mysql.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# MySQL backend lifecycle for test/run.sh + +setup() { + # Set up test database connection for CI (GitHub Actions) + if [ "${CI:-}" = "true" ]; then + sed -i.bak 's/^user[[:space:]]*=.*/user = "root"/' conf.d/mysql.toml + sed -i.bak 's/^password[[:space:]]*=.*/password = "root"/' conf.d/mysql.toml + fi + + $NODE test/fixtures.js teardown + $NODE test/fixtures.js setup +} + +cleanup() { + echo "cleaning DB objects" + $NODE test/fixtures.js teardown +} + +run_tests() { + $NODE --test --test-reporter=spec lib/*/test/index.js lib/*.test.js routes/*.test.js +} diff --git a/test/backends/toml.sh b/test/backends/toml.sh new file mode 100755 index 0000000..a045e69 --- /dev/null +++ b/test/backends/toml.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# TOML 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 TOML test store" + rm -f test/conf.d/*.toml +} + +run_tests() { + # Run serially: TOML uses shared files; parallel workers cause concurrent-write corruption + for f in \ + lib/group/test/index.js \ + lib/nameserver/test/index.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/run.sh b/test/run.sh index f6909d8..20a91c5 100755 --- a/test/run.sh +++ b/test/run.sh @@ -2,21 +2,18 @@ set -eu -# set up test database connection for CI (GitHub Actions) -if [ "${CI:-}" = "true" ]; then - sed -i.bak 's/^user[[:space:]]*=.*/user = "root"/' conf.d/mysql.toml - sed -i.bak 's/^password[[:space:]]*=.*/password = "root"/' conf.d/mysql.toml -fi - NODE="node --no-warnings=ExperimentalWarning" -$NODE test/fixtures.js teardown -$NODE test/fixtures.js setup +BACKEND="${NICTOOL_DATA_STORE:-mysql}" + +case "$BACKEND" in + toml|mysql) ;; + *) echo "Unknown NICTOOL_DATA_STORE: $BACKEND" >&2; exit 1 ;; +esac -cleanup() { - echo "cleaning DB objects" - $NODE test/fixtures.js teardown -} +# shellcheck source=backends/mysql.sh +. "$(dirname "$0")/backends/${BACKEND}.sh" +setup trap cleanup EXIT 1 2 3 6 if [ $# -ge 1 ]; then @@ -26,9 +23,5 @@ if [ $# -ge 1 ]; then $NODE --test --test-reporter=spec "$1" fi else - # if [ -n "$GITHUB_WORKFLOW" ]; then - # npm i --no-save node-test-github-reporter - # $NODE --test --test-reporter=node-test-github-reporter - # fi - $NODE --test --test-reporter=spec lib/*/test/index.js lib/*.test.js routes/*.test.js + run_tests fi From 3fe779cdc7a99790ab57f1690350b2777472352a Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 22:59:22 -0500 Subject: [PATCH 8/8] TOML passing tests --- lib/config.js | 4 +++- lib/config.test.js | 19 ++++++++++++++---- lib/group/store/mysql.js | 18 +++++++++-------- lib/nameserver/test/index.js | 17 ---------------- lib/nameserver/test/mysql.js | 36 ++++++++++++++++++++++++++++++++++ lib/permission/store/mysql.js | 4 +--- lib/user/store/mysql.js | 37 ++++++++++++++++++++++++++++++++--- lib/zone/store/toml.js | 8 ++++++-- lib/zone/test/index.js | 14 ------------- lib/zone/test/mysql.js | 33 +++++++++++++++++++++++++++++++ lib/zone_record/store/toml.js | 3 ++- routes/group.js | 6 ++---- routes/index.js | 10 +++++----- routes/nameserver.js | 4 +--- routes/user.js | 4 +--- routes/zone.js | 10 +++------- routes/zone_record.js | 4 +--- test/backends/mysql.sh | 6 +++++- 18 files changed, 158 insertions(+), 79 deletions(-) create mode 100644 lib/nameserver/test/mysql.js create mode 100644 lib/zone/test/mysql.js diff --git a/lib/config.js b/lib/config.js index a0d85fc..bf1a76a 100644 --- a/lib/config.js +++ b/lib/config.js @@ -102,7 +102,9 @@ function loadPEMSync(dir) { } function parsePEMBlocks(content) { - const keyMatch = content.match(/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )?PRIVATE KEY-----/) + const keyMatch = content.match( + /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )?PRIVATE KEY-----/, + ) const certMatches = [...content.matchAll(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)] if (!keyMatch && !certMatches.length) return null diff --git a/lib/config.test.js b/lib/config.test.js index 351b2a7..a1a4c28 100644 --- a/lib/config.test.js +++ b/lib/config.test.js @@ -3,7 +3,15 @@ import { describe, it, before, after } from 'node:test' import Config from './config.js' -const envOverrideKeys = ['NICTOOL_DB_HOST', 'NICTOOL_DB_PORT', 'NICTOOL_DB_USER', 'NICTOOL_DB_USER_PASSWORD', 'NICTOOL_DB_NAME', 'NICTOOL_HTTP_HOST', 'NICTOOL_HTTP_PORT'] +const envOverrideKeys = [ + 'NICTOOL_DB_HOST', + 'NICTOOL_DB_PORT', + 'NICTOOL_DB_USER', + 'NICTOOL_DB_USER_PASSWORD', + 'NICTOOL_DB_NAME', + 'NICTOOL_HTTP_HOST', + 'NICTOOL_HTTP_PORT', +] describe('config', () => { const savedEnv = {} @@ -27,19 +35,22 @@ describe('config', () => { describe('get', () => { it(`loads mysql config`, async () => { const cfg = await Config.get('mysql') - delete cfg.password; delete cfg.user + delete cfg.password + delete cfg.user assert.deepEqual(cfg, mysqlCfg) }) it(`loads mysql config synchronously`, () => { const cfg = Config.getSync('mysql') - delete cfg.password; delete cfg.user + delete cfg.password + delete cfg.user }) it(`loads mysql config (from cache)`, async () => { process.env.NODE_DEBUG = 1 const cfg = await Config.get('mysql') - delete cfg.password; delete cfg.user + delete cfg.password + delete cfg.user assert.deepEqual(cfg, mysqlCfg) process.env.NODE_DEBUG = '' }) diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js index 680d324..104743d 100644 --- a/lib/group/store/mysql.js +++ b/lib/group/store/mysql.js @@ -41,11 +41,13 @@ class Group extends GroupBase { async addToSubgroups(gid, parent_gid, rank = 1000) { if (!parent_gid || parent_gid === 0) return - await Mysql.execute(...Mysql.insert('nt_group_subgroups', { - nt_group_id: parent_gid, - nt_subgroup_id: gid, - rank, - })) + await Mysql.execute( + ...Mysql.insert('nt_group_subgroups', { + nt_group_id: parent_gid, + nt_subgroup_id: gid, + rank, + }), + ) const parent = await this.get({ id: parent_gid }) if (parent.length === 1 && parent[0].parent_gid !== 0) { @@ -73,9 +75,9 @@ class Group extends GroupBase { if (include_subgroups) { const subgroupRows = await Mysql.execute( 'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?', - [args.id] + [args.id], ) - const gids = [args.id, ...subgroupRows.map(r => r.nt_subgroup_id)] + const gids = [args.id, ...subgroupRows.map((r) => r.nt_subgroup_id)] where.push(`g.nt_group_id IN (${gids.join(',')})`) } else { where.push('g.nt_group_id = ?') @@ -135,7 +137,7 @@ class Group extends GroupBase { if (perm) { await Permission.put({ id: perm.id, - nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] } + nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] }, }) } } diff --git a/lib/nameserver/test/index.js b/lib/nameserver/test/index.js index c1107a6..39e01e6 100644 --- a/lib/nameserver/test/index.js +++ b/lib/nameserver/test/index.js @@ -33,23 +33,6 @@ describe('nameserver', function () { assert.ok(await Nameserver.put({ id: testCase.id, name: testCase.name })) }) - it('handles null export interval gracefully', { - skip: process.env.NICTOOL_DATA_STORE === 'toml' ? 'TOML backend: no SQL access' : false, - }, async () => { - await Nameserver.mysql.execute( - 'UPDATE nt_nameserver SET export_interval = NULL WHERE nt_nameserver_id = ?', - [testCase.id], - ) - - const ns = await Nameserver.get({ id: testCase.id }) - assert.equal(ns[0].export.interval, undefined) - - await Nameserver.mysql.execute( - 'UPDATE nt_nameserver SET export_interval = ? WHERE nt_nameserver_id = ?', - [0, testCase.id], - ) - }) - it('deletes a nameserver', async () => { assert.ok(await Nameserver.delete({ id: testCase.id })) let g = await Nameserver.get({ id: testCase.id, deleted: 1 }) diff --git a/lib/nameserver/test/mysql.js b/lib/nameserver/test/mysql.js new file mode 100644 index 0000000..041b323 --- /dev/null +++ b/lib/nameserver/test/mysql.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { describe, it, before, after } from 'node:test' + +import Nameserver from '../index.js' + +import baseCase from './nameserver.json' with { type: 'json' } + +// Use a distinct id so this test never races with index.js (same fixture id = concurrent NULL mutation) +const testCase = { ...baseCase, id: 9001 } + +before(async () => { + await Nameserver.destroy({ id: testCase.id }) + await Nameserver.create(testCase) +}) + +after(async () => { + await Nameserver.destroy({ id: testCase.id }) + await Nameserver.disconnect() +}) + +describe('nameserver (mysql)', function () { + it('handles null export interval gracefully', async () => { + await Nameserver.mysql.execute( + 'UPDATE nt_nameserver SET export_interval = NULL WHERE nt_nameserver_id = ?', + [testCase.id], + ) + + const ns = await Nameserver.get({ id: testCase.id }) + assert.equal(ns[0].export.interval, undefined) + + await Nameserver.mysql.execute( + 'UPDATE nt_nameserver SET export_interval = ? WHERE nt_nameserver_id = ?', + [testCase.export.interval, testCase.id], + ) + }) +}) diff --git a/lib/permission/store/mysql.js b/lib/permission/store/mysql.js index c71c049..3f673c2 100644 --- a/lib/permission/store/mysql.js +++ b/lib/permission/store/mysql.js @@ -60,9 +60,7 @@ class PermissionRepoMySQL extends PermissionBase { if (!('nt_user_id' in dbArgs) && !('nt_perm_id' in dbArgs)) { conditions.push('p.nt_user_id IS NULL') } - const query = conditions.length - ? `${baseQuery} WHERE ${conditions.join(' AND ')}` - : baseQuery + const query = conditions.length ? `${baseQuery} WHERE ${conditions.join(' AND ')}` : baseQuery const rows = await Mysql.execute(query, params) if (rows.length === 0) return diff --git a/lib/user/store/mysql.js b/lib/user/store/mysql.js index 607e933..352dbc1 100644 --- a/lib/user/store/mysql.js +++ b/lib/user/store/mysql.js @@ -83,7 +83,7 @@ class UserRepoMySQL extends UserBase { } async get(args) { - const origDeleted = args.deleted // capture before defaulting/removing + const origDeleted = args.deleted // capture before defaulting/removing args = JSON.parse(JSON.stringify(args)) if (args.deleted === undefined) args.deleted = false @@ -108,9 +108,9 @@ class UserRepoMySQL extends UserBase { if (include_subgroups) { const subgroupRows = await Mysql.execute( 'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?', - [args.gid] + [args.gid], ) - const gids = [args.gid, ...subgroupRows.map(r => r.nt_subgroup_id)] + const gids = [args.gid, ...subgroupRows.map((r) => r.nt_subgroup_id)] where.push(`nt_group_id IN (${gids.join(',')})`) } else { where.push('nt_group_id = ?') @@ -157,6 +157,37 @@ class UserRepoMySQL extends UserBase { return rows } + async count(args = {}) { + args = JSON.parse(JSON.stringify(args)) + if (args.deleted === undefined) args.deleted = false + + const params = [] + const where = [] + + if (args.id !== undefined) { + where.push('nt_user_id = ?') + params.push(args.id) + } + if (args.gid !== undefined) { + where.push('nt_group_id = ?') + params.push(args.gid) + } + if (args.username !== undefined) { + where.push('username = ?') + params.push(args.username) + } + if (args.deleted !== undefined) { + where.push('deleted = ?') + params.push(args.deleted === true ? 1 : 0) + } + + let query = 'SELECT COUNT(*) AS count FROM nt_user' + if (where.length > 0) query += ` WHERE ${where.join(' AND ')}` + + const rows = await Mysql.execute(query, params) + return rows[0].count + } + async put(args) { if (!args.id) return false const id = args.id diff --git a/lib/zone/store/toml.js b/lib/zone/store/toml.js index dcfe5ff..bcc3f6e 100644 --- a/lib/zone/store/toml.js +++ b/lib/zone/store/toml.js @@ -86,7 +86,9 @@ class ZoneRepoTOML extends ZoneBase { // Search filters if (search) { const s = search.trim().toLowerCase() - zones = zones.filter((z) => z.zone?.toLowerCase().includes(s) || z.description?.toLowerCase().includes(s)) + zones = zones.filter( + (z) => z.zone?.toLowerCase().includes(s) || z.description?.toLowerCase().includes(s), + ) } if (zone_like) { const s = zone_like.trim().toLowerCase() @@ -134,7 +136,9 @@ class ZoneRepoTOML extends ZoneBase { if (search) { const s = search.trim().toLowerCase() - zones = zones.filter((z) => z.zone?.toLowerCase().includes(s) || z.description?.toLowerCase().includes(s)) + zones = zones.filter( + (z) => z.zone?.toLowerCase().includes(s) || z.description?.toLowerCase().includes(s), + ) } if (zone_like) { const s = zone_like.trim().toLowerCase() diff --git a/lib/zone/test/index.js b/lib/zone/test/index.js index 75ef950..d15548b 100644 --- a/lib/zone/test/index.js +++ b/lib/zone/test/index.js @@ -35,20 +35,6 @@ describe('zone', function () { assert.ok(await Zone.put({ id: testCase.id, mailaddr: testCase.mailaddr })) }) - it('handles null minimum gracefully', { - skip: process.env.NICTOOL_DATA_STORE === 'toml' ? 'TOML backend: no SQL access' : false, - }, async () => { - await Zone.mysql.execute('UPDATE nt_zone SET minimum = NULL WHERE nt_zone_id = ?', [testCase.id]) - - const z = await Zone.get({ id: testCase.id }) - assert.equal(z[0].minimum, 3600) - - await Zone.mysql.execute('UPDATE nt_zone SET minimum = ? WHERE nt_zone_id = ?', [ - testCase.minimum, - testCase.id, - ]) - }) - describe('deletes a zone', async () => { it('can delete a zone', async () => { assert.ok(await Zone.delete({ id: testCase.id })) diff --git a/lib/zone/test/mysql.js b/lib/zone/test/mysql.js new file mode 100644 index 0000000..b8ac9fc --- /dev/null +++ b/lib/zone/test/mysql.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { describe, it, before, after } from 'node:test' + +import Zone from '../index.js' + +import baseCase from './zone.json' with { type: 'json' } + +// Use a distinct id so this test never races with index.js (same fixture id = concurrent NULL mutation) +const testCase = { ...baseCase, id: 9001 } + +before(async () => { + await Zone.destroy({ id: testCase.id }) + await Zone.create(testCase) +}) + +after(async () => { + await Zone.destroy({ id: testCase.id }) + await Zone.disconnect() +}) + +describe('zone (mysql)', function () { + it('handles null minimum gracefully', async () => { + await Zone.mysql.execute('UPDATE nt_zone SET minimum = NULL WHERE nt_zone_id = ?', [testCase.id]) + + const z = await Zone.get({ id: testCase.id }) + assert.equal(z[0].minimum, 3600) + + await Zone.mysql.execute('UPDATE nt_zone SET minimum = ? WHERE nt_zone_id = ?', [ + testCase.minimum, + testCase.id, + ]) + }) +}) diff --git a/lib/zone_record/store/toml.js b/lib/zone_record/store/toml.js index d452c8b..35d70fd 100644 --- a/lib/zone_record/store/toml.js +++ b/lib/zone_record/store/toml.js @@ -84,7 +84,8 @@ class ZoneRecordRepoTOML extends ZoneRecordBase { if (args.zid !== undefined) records = records.filter((r) => r.zid === args.zid) if (args.type !== undefined) records = records.filter((r) => r.type === args.type) if (args.deleted === false) records = records.filter((r) => !r.deleted) - else if (args.deleted !== undefined) records = records.filter((r) => Boolean(r.deleted) === Boolean(args.deleted)) + else if (args.deleted !== undefined) + records = records.filter((r) => Boolean(r.deleted) === Boolean(args.deleted)) return records.map((r) => { const out = { ...r } diff --git a/routes/group.js b/routes/group.js index 507bfdc..0f5febc 100644 --- a/routes/group.js +++ b/routes/group.js @@ -25,13 +25,11 @@ function GroupRoutes(server) { include_subgroups: request.query.include_subgroups === true, } if (request.query.parent_gid !== undefined) getArgs.parent_gid = request.query.parent_gid - if (request.query.name !== undefined) getArgs.name = request.query.name + if (request.query.name !== undefined) getArgs.name = request.query.name const groups = await Group.get(getArgs) - return h - .response({ group: groups, meta: { api: meta.api, msg: `here are your groups` } }) - .code(200) + return h.response({ group: groups, meta: { api: meta.api, msg: `here are your groups` } }).code(200) }, }, { diff --git a/routes/index.js b/routes/index.js index 5f6e115..3cb47c8 100644 --- a/routes/index.js +++ b/routes/index.js @@ -46,18 +46,18 @@ async function setup() { failAction: async (request, h, err) => { if (process.env.NODE_ENV === 'production') { // In production, log detailed error internally, but send a generic one to the client - console.error('ValidationError:', err.message); - throw h.boom.badRequest(`Invalid request payload input`); + console.error('ValidationError:', err.message) + throw h.boom.badRequest(`Invalid request payload input`) } else { // In development, return the full error details - console.error(err); - throw err; // Hapi/Boom handles this error object correctly + console.error(err) + throw err // Hapi/Boom handles this error object correctly } }, options: { abortEarly: false, }, - } + }, }, }) diff --git a/routes/nameserver.js b/routes/nameserver.js index ec2d13a..3fb4ea1 100644 --- a/routes/nameserver.js +++ b/routes/nameserver.js @@ -83,9 +83,7 @@ function NameserverRoutes(server) { if (nameservers.length === 0) nameservers = await Nameserver.get({ id, deleted: 1 }) if (nameservers.length === 0) { - return h - .response({ meta: { api: meta.api, msg: `I couldn't find that nameserver` } }) - .code(404) + return h.response({ meta: { api: meta.api, msg: `I couldn't find that nameserver` } }).code(404) } await Nameserver.put({ id, ...request.payload }) diff --git a/routes/user.js b/routes/user.js index 11c4550..85f7b6e 100644 --- a/routes/user.js +++ b/routes/user.js @@ -143,9 +143,7 @@ function UserRoutes(server) { const users = await User.get({ id }) if (!users.length) { - return h - .response({ meta: { api: meta.api, msg: `user not found` } }) - .code(404) + return h.response({ meta: { api: meta.api, msg: `user not found` } }).code(404) } delete users[0].gid diff --git a/routes/zone.js b/routes/zone.js index 6d695d9..7f45426 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -116,17 +116,13 @@ function ZoneRoutes(server) { if (zones.length === 0) zones = await Zone.get({ id, deleted: 1 }) if (zones.length === 0) { - return h - .response({ meta: { api: meta.api, msg: `I couldn't find that zone` } }) - .code(404) + return h.response({ meta: { api: meta.api, msg: `I couldn't find that zone` } }).code(404) } await Zone.put({ id, ...request.payload }) const updated = await Zone.get({ id }) - return h - .response({ zone: updated, meta: { api: meta.api, msg: `the zone was updated` } }) - .code(200) + return h.response({ zone: updated, meta: { api: meta.api, msg: `the zone was updated` } }).code(200) }, }, { @@ -153,7 +149,7 @@ function ZoneRoutes(server) { const ns = nsRows.map((row) => { const zoneFqdn = row.zone.endsWith('.') ? row.zone : `${row.zone}.` - const dname = row.name.endsWith('.') ? row.name : `${row.name}.` + const dname = row.name.endsWith('.') ? row.name : `${row.name}.` return { owner: zoneFqdn, ttl: row.ttl, dname } }) diff --git a/routes/zone_record.js b/routes/zone_record.js index 0ebf823..9a4af7e 100644 --- a/routes/zone_record.js +++ b/routes/zone_record.js @@ -107,9 +107,7 @@ function ZoneRecordRoutes(server) { const zrs = await ZoneRecord.get({ id }) if (zrs.length === 0) { - return h - .response({ meta: { api: meta.api, msg: `I couldn't find that zone record` } }) - .code(404) + return h.response({ meta: { api: meta.api, msg: `I couldn't find that zone record` } }).code(404) } await ZoneRecord.put({ id, ...request.payload }) diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh index 60a484b..7958552 100755 --- a/test/backends/mysql.sh +++ b/test/backends/mysql.sh @@ -18,5 +18,9 @@ cleanup() { } run_tests() { - $NODE --test --test-reporter=spec lib/*/test/index.js lib/*.test.js routes/*.test.js + $NODE --test --test-reporter=spec \ + lib/*/test/index.js \ + lib/*/test/mysql.js \ + lib/*.test.js \ + routes/*.test.js }