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 05560e6..6a5c256 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 @@ -126,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/CHANGELOG.md b/CHANGELOG.md index 6080e5a..edd879d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ 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 - decorate user & group with permissions diff --git a/lib/config.js b/lib/config.js index f517ea4..bf1a76a 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') @@ -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/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..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 : [] }, }) } } @@ -163,6 +165,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..d10d33b 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, for repos that need to clean up resources + } } 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..39e01e6 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 () { @@ -33,21 +33,6 @@ describe('nameserver', function () { assert.ok(await Nameserver.put({ id: testCase.id, name: testCase.name })) }) - 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 = ?', - [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 e1240bd..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 @@ -119,6 +117,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/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/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..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 @@ -206,6 +237,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/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/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/store/toml.js b/lib/zone/store/toml.js index 97eb85b..bcc3f6e 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 @@ -87,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() @@ -135,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 9cb9122..d15548b 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 () { @@ -35,18 +35,6 @@ describe('zone', function () { assert.ok(await Zone.put({ id: testCase.id, mailaddr: testCase.mailaddr })) }) - 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, - ]) - }) - 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/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/store/toml.js b/lib/zone_record/store/toml.js index 213bc6c..35d70fd 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 } @@ -74,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/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/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/routes/group.js b/routes/group.js index c9df9d1..0f5febc 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) { @@ -23,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) }, }, { @@ -157,18 +157,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/index.js b/routes/index.js index 16f4907..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, }, - } + }, }, }) @@ -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/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 04311ea..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 @@ -170,7 +168,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/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.sh b/test.sh deleted file mode 100755 index 0ae5a50..0000000 --- a/test.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh - -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 - -cleanup() { - echo "cleaning DB objects" - $NODE test-fixtures.js teardown -} - -trap cleanup EXIT 1 2 3 6 - -if [ $# -ge 1 ]; then - if [ "$1" = "watch" ]; then - $NODE --test --watch - else - $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 -fi diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh new file mode 100755 index 0000000..7958552 --- /dev/null +++ b/test/backends/mysql.sh @@ -0,0 +1,26 @@ +#!/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/mysql.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-fixtures.js b/test/fixtures.js similarity index 58% rename from test-fixtures.js rename to test/fixtures.js index 29cb252..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': @@ -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) } diff --git a/test/run.sh b/test/run.sh new file mode 100755 index 0000000..20a91c5 --- /dev/null +++ b/test/run.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +set -eu + +NODE="node --no-warnings=ExperimentalWarning" +BACKEND="${NICTOOL_DATA_STORE:-mysql}" + +case "$BACKEND" in + toml|mysql) ;; + *) echo "Unknown NICTOOL_DATA_STORE: $BACKEND" >&2; exit 1 ;; +esac + +# shellcheck source=backends/mysql.sh +. "$(dirname "$0")/backends/${BACKEND}.sh" + +setup +trap cleanup EXIT 1 2 3 6 + +if [ $# -ge 1 ]; then + if [ "$1" = "watch" ]; then + $NODE --test --watch + else + $NODE --test --test-reporter=spec "$1" + fi +else + run_tests +fi