diff --git a/CHANGELOG.md b/CHANGELOG.md index fb9f079..2f38458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - create: allocate ids internally and reject caller-supplied ids - zone: `nameservers` holds the zone's nameserver ids on every store, and POST /zone defaults it to the group's usable nameservers - fix: one live zone per name, ignoring case and a trailing dot +- fix(sql): store existing zone names lowercase without the trailing dot ### [3.0.3] - 2026-07-27 diff --git a/sql/12_nt_options.sql b/sql/12_nt_options.sql index 1850801..6997884 100644 --- a/sql/12_nt_options.sql +++ b/sql/12_nt_options.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS nt_options ( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; INSERT IGNORE INTO `nt_options` -VALUES (1,'db_version','2.41'), +VALUES (1,'db_version','2.42'), (2,'session_timeout','45'), (3,'default_group','NicTool') ; diff --git a/sql/schema.test.js b/sql/schema.test.js index d3066d5..aef47b1 100644 --- a/sql/schema.test.js +++ b/sql/schema.test.js @@ -199,6 +199,62 @@ describe('sql schema', () => { ) }) + it('lowercases zone names, leaving live zones that would share a name', async () => { + const migration = await fs.readFile(path.join(sqlDir, 'upgrade', '07_lowercase_zone_names.sql'), 'utf8') + const suffix = `lowercase-${Date.now()}.test` + const rows = { + upper: [`Upper.${suffix}`, 0], + dotted: [`dotted.${suffix}.`, 0], + taken: [`Taken.${suffix}`, 0], + takenBy: [`taken.${suffix}`, 0], + deleted: [`Gone.${suffix}`, 1], + liveBeside: [`gone.${suffix}`, 0], + same: [`same.${suffix}`, 0], + sameAgain: [`same.${suffix}`, 0], + } + const ids = {} + for (const [key, [zone, deleted]] of Object.entries(rows)) { + const [r] = await conn.query( + "INSERT INTO nt_zone (nt_group_id, zone, deleted, last_modified) VALUES (1, ?, ?, '2020-01-01')", + [zone, deleted], + ) + ids[key] = r.insertId + } + const read = async () => { + const [found] = await conn.query( + 'SELECT nt_zone_id, zone, last_modified FROM nt_zone WHERE nt_zone_id IN (?)', + [Object.values(ids)], + ) + return Object.fromEntries( + Object.entries(ids).map(([k, id]) => [k, found.find((r) => r.nt_zone_id === id)]), + ) + } + + try { + const before = await read() + const [results] = await conn.query(migration) + const after = await read() + assert.equal(after.upper.zone, `upper.${suffix}`) + assert.equal(after.dotted.zone, `dotted.${suffix}`) + assert.equal(after.taken.zone, `Taken.${suffix}`, 'a live zone keeps a name another live zone has') + assert.equal(after.deleted.zone, `gone.${suffix}`, 'a deleted zone may share a name') + assert.deepEqual(after.upper.last_modified, before.upper.last_modified) + + const reported = results.find(Array.isArray).map((r) => r.nt_zone_id) + const ours = Object.entries(ids).filter(([, id]) => reported.includes(id)) + assert.deepEqual( + ours.map(([k]) => k), + ['taken', 'takenBy', 'same', 'sameAgain'], + 'every live zone sharing a name, whatever its spelling', + ) + + await assert.doesNotReject(() => conn.query(migration), 'the migration is not re-runnable') + assert.deepEqual(await read(), after) + } finally { + await conn.query('DELETE FROM nt_zone WHERE nt_zone_id IN (?)', [Object.values(ids)]) + } + }) + it('adds the v3 export types to an existing database, and again on a re-run', async () => { const migration = await fs.readFile( path.join(sqlDir, 'upgrade', '06_add_nameserver_export_types.sql'), @@ -217,6 +273,6 @@ describe('sql schema', () => { it('declares the 2.x db_version the upgrade path detects', async () => { const sql = await fs.readFile(path.join(sqlDir, '12_nt_options.sql'), 'utf8') - assert.match(sql, /'db_version','2\.41'/) + assert.match(sql, /'db_version','2\.42'/) }) }) diff --git a/sql/upgrade/07_lowercase_zone_names.sql b/sql/upgrade/07_lowercase_zone_names.sql new file mode 100644 index 0000000..b876297 --- /dev/null +++ b/sql/upgrade/07_lowercase_zone_names.sql @@ -0,0 +1,40 @@ +# Copyright 2004-2026 The Network People, Inc. +# +# Stores every zone name in the form NicTool 3 matches: lowercase, without +# the trailing dot. A zone created through the 2.x API or web UI keeps the +# case it was given (2.x imports lowercase it). NicTool 3 matches names +# exactly, so it can't find such a zone by name, and the zone doesn't block +# a second one of the same name. NicTool 2.42's sql/upgrade.pl does the +# same rewrite. +# +# A live zone is left alone when another live zone has the same canonical +# name. The SELECT at the end lists every live zone that shares its name with +# another, whatever its spelling, for an admin to rename or delete. DNS +# names are case-insensitive, so the zone itself is unchanged, and +# last_modified is kept so it doesn't read as edited. +# +# Idempotent, so it is safe to re-run: +# +# mysql nictool < 07_lowercase_zone_names.sql + +CREATE TEMPORARY TABLE nt_zone_name_taken AS +SELECT LOWER(TRIM(TRAILING '.' FROM zone)) AS canonical + FROM nt_zone + WHERE deleted = 0 + GROUP BY canonical +HAVING COUNT(*) > 1; + +UPDATE nt_zone + SET zone = LOWER(TRIM(TRAILING '.' FROM zone)), + last_modified = last_modified + WHERE zone <> LOWER(TRIM(TRAILING '.' FROM zone)) + AND (deleted = 1 + OR LOWER(TRIM(TRAILING '.' FROM zone)) NOT IN (SELECT canonical FROM nt_zone_name_taken)); + +SELECT nt_zone_id, zone AS `live zone sharing its name with another` + FROM nt_zone + WHERE deleted = 0 + AND LOWER(TRIM(TRAILING '.' FROM zone)) IN (SELECT canonical FROM nt_zone_name_taken) + ORDER BY LOWER(TRIM(TRAILING '.' FROM zone)), nt_zone_id; + +DROP TEMPORARY TABLE nt_zone_name_taken;