0
0
Fork 0
Codesys-MCP-SP21-plus/tests/unit/version-pin.test.ts
Karstein Phobic Nyvold Kvistad a4f8426369 version pin: refuse to save a project on a mismatched CODESYS install (v0.14.0)
The open_project pre-flight was supposed to prevent this, but it reads
the project's profile from projectinspectiondata.auxiliary inside the
.project ZIP -- and a plain .project is NOT a ZIP. Verified across
3.5.19.20 .. 3.5.22.10: magic 23 89 ED 33, unzip -l fails, no plaintext
profile string. So inspectProjectFile always throws, the pre-flight
catches and proceeds, and the guard is dead code on real projects.
It also only ever covered open_project -- not the release pipeline.

Consequence in the field: a server bound to SP21 Patch 5 opened and
re-saved a 3.5.19.20 vessel project during release_project_version.
4,639,776 -> 4,688,496 bytes, committed and tagged. The tagged binary
stopped being the software on the boat.

Since the version cannot be recovered from the file, pin it in the repo.
New src/version-pin.ts resolves, most specific first:
  1. .codesys-version next to the .project (one line, "3.5.19.20" or
     "CODESYS V3.5 SP19"; comments and blanks skipped) -- the only
     option when seeding a project with no release history
  2. library.md's "CODESYS Development System" row -- every project
     gets a pin for free after its first release

Policy is asymmetric so it guards the dangerous path without breaking
existing repos: bump_project_version and release_project_version refuse
on mismatch AND on no-pin; get_project_info, mirror_export and
list_project_libraries only warn on mismatch and proceed when unpinned.
Both saving tools accept allowVersionUpgrade: true to override.

Parsing note: library.md's row carries no dotted profile version, so it
is matched on the SP/Patch label only -- a naive version regex would
otherwise bind to the ScriptEngine's 4.2.0.0. Covered by a regression
test.

25 new unit tests; suite 180/180.

Also fixes an unrelated pre-existing test failure: the ASCII-only script
template check used readdirSync without filtering, so a local untracked
src/scripts/__pycache__/ made it fail with EISDIR.
2026-07-24 14:18:01 +02:00

195 lines
7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
parseProfileVersion,
parseProfileLabel,
parsePinText,
parseLibraryMdProfile,
resolveVersionPin,
decideVersionPin,
label,
} from '../../src/version-pin';
describe('parseProfileVersion', () => {
it('maps rawPatch/10 to the install-dir patch convention', () => {
expect(parseProfileVersion('3.5.19.20')).toEqual({ sp: 19, patch: 2 });
expect(parseProfileVersion('3.5.21.50')).toEqual({ sp: 21, patch: 5 });
expect(parseProfileVersion('3.5.22.10')).toEqual({ sp: 22, patch: 1 });
});
it('treats a zero raw patch as patch 0', () => {
expect(parseProfileVersion('3.5.18.0')).toEqual({ sp: 18, patch: 0 });
});
it('returns undefined for non-version text', () => {
expect(parseProfileVersion('CODESYS V3.5 SP19')).toBeUndefined();
});
});
describe('parseProfileLabel', () => {
it('parses SP with and without a patch', () => {
expect(parseProfileLabel('CODESYS V3.5 SP21 Patch 5')).toEqual({ sp: 21, patch: 5 });
expect(parseProfileLabel('CODESYS V3.5 SP19')).toEqual({ sp: 19, patch: 0 });
});
it('returns undefined when there is no SP token', () => {
expect(parseProfileLabel('3.5.19.20')).toBeUndefined();
});
});
describe('parsePinText', () => {
it('accepts either form', () => {
expect(parsePinText('3.5.19.20')).toEqual({ sp: 19, patch: 2 });
expect(parsePinText('CODESYS V3.5 SP19')).toEqual({ sp: 19, patch: 0 });
});
it('prefers the dotted version when a line carries both', () => {
expect(parsePinText('CODESYS V3.5 SP21 Patch 5 (3.5.21.50)')).toEqual({ sp: 21, patch: 5 });
});
});
describe('parseLibraryMdProfile', () => {
const row =
'| CODESYS Development System | `CODESYS.exe CODESYS V3.5 SP21 Patch 5, ScriptEngine.plugin 4.2.0.0` |';
it('reads the SP/patch out of the generated table row', () => {
expect(parseLibraryMdProfile(`# Library inventory\n\n${row}\n`)).toEqual({ sp: 21, patch: 5 });
});
it('does not mistake the ScriptEngine version for a profile version', () => {
// Regression: a naive parseProfileVersion on this row matches "4.2.0.0".
expect(parseLibraryMdProfile(row)).toEqual({ sp: 21, patch: 5 });
});
it('returns undefined when the row is absent', () => {
expect(parseLibraryMdProfile('| Project Information.Version | `1.0.0.0` |')).toBeUndefined();
});
});
describe('resolveVersionPin', () => {
let dir: string;
let proj: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'version-pin-'));
proj = path.join(dir, 'Thing.project');
fs.writeFileSync(proj, 'not-a-zip');
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('returns undefined when neither source exists', () => {
expect(resolveVersionPin(proj)).toBeUndefined();
});
it('reads library.md when no pin file is present', () => {
fs.writeFileSync(
path.join(dir, 'library.md'),
'| CODESYS Development System | `CODESYS.exe CODESYS V3.5 SP22 Patch 1` |'
);
expect(resolveVersionPin(proj)).toMatchObject({ sp: 22, patch: 1, source: 'library.md' });
});
it('prefers .codesys-version over library.md', () => {
fs.writeFileSync(
path.join(dir, 'library.md'),
'| CODESYS Development System | `CODESYS.exe CODESYS V3.5 SP22 Patch 1` |'
);
fs.writeFileSync(path.join(dir, '.codesys-version'), '3.5.19.20\n');
expect(resolveVersionPin(proj)).toMatchObject({ sp: 19, patch: 2, source: '.codesys-version' });
});
it('skips comments and blank lines in the pin file', () => {
fs.writeFileSync(
path.join(dir, '.codesys-version'),
'# authored on the MarinerX house standard\n\n3.5.19.20\n'
);
expect(resolveVersionPin(proj)).toMatchObject({ sp: 19, patch: 2 });
});
it('falls through to library.md when the pin file is unparseable', () => {
fs.writeFileSync(path.join(dir, '.codesys-version'), 'whatever\n');
fs.writeFileSync(
path.join(dir, 'library.md'),
'| CODESYS Development System | `CODESYS.exe CODESYS V3.5 SP22 Patch 1` |'
);
expect(resolveVersionPin(proj)).toMatchObject({ sp: 22, patch: 1, source: 'library.md' });
});
});
describe('decideVersionPin', () => {
const projectFilePath = 'C:\\plc\\Thing\\Thing.project';
const pin = (sp: number, patch: number) =>
({ sp, patch, source: '.codesys-version' as const, raw: `3.5.${sp}.${patch * 10}` });
it('proceeds on an exact match', () => {
expect(
decideVersionPin(pin(19, 2), { sp: 19, patch: 2 }, { saves: true, projectFilePath })
).toEqual({ action: 'proceed' });
});
it('refuses a saving tool when the server is newer', () => {
const d = decideVersionPin(pin(19, 2), { sp: 21, patch: 5 }, { saves: true, projectFilePath });
expect(d.action).toBe('refuse');
expect(d.action === 'refuse' && d.message).toContain('UPGRADE');
});
it('refuses a saving tool when the server is older', () => {
const d = decideVersionPin(pin(22, 1), { sp: 21, patch: 5 }, { saves: true, projectFilePath });
expect(d.action).toBe('refuse');
expect(d.action === 'refuse' && d.message).toContain('DOWNGRADE');
});
it('treats a patch-level difference as a mismatch too', () => {
// The MarinerX7 failure was an SP difference, but SP21 P5 -> SP21 P2 also
// rewrites the file; nothing here should special-case same-SP.
const d = decideVersionPin(pin(21, 2), { sp: 21, patch: 5 }, { saves: true, projectFilePath });
expect(d.action).toBe('refuse');
});
it('only warns a read-only tool on mismatch', () => {
const d = decideVersionPin(pin(19, 2), { sp: 21, patch: 5 }, { saves: false, projectFilePath });
expect(d.action).toBe('proceed-with-warning');
expect(d.action === 'proceed-with-warning' && d.message).toContain('do NOT run a release');
});
it('refuses a saving tool when no pin is available', () => {
const d = decideVersionPin(undefined, { sp: 21, patch: 5 }, { saves: true, projectFilePath });
expect(d.action).toBe('refuse');
expect(d.action === 'refuse' && d.message).toContain('.codesys-version');
});
it('lets an unpinned read-only tool through, so existing repos keep working', () => {
expect(
decideVersionPin(undefined, { sp: 21, patch: 5 }, { saves: false, projectFilePath })
).toEqual({ action: 'proceed' });
});
it('honours an explicit allowVersionUpgrade override', () => {
expect(
decideVersionPin(pin(19, 2), { sp: 21, patch: 5 }, {
saves: true,
allowUpgrade: true,
projectFilePath,
})
).toEqual({ action: 'proceed' });
});
it('stays out of the way when the server profile cannot be parsed', () => {
expect(
decideVersionPin(pin(19, 2), undefined, { saves: true, projectFilePath })
).toEqual({ action: 'proceed' });
});
});
describe('label', () => {
it('omits "Patch 0"', () => {
expect(label({ sp: 19, patch: 0 })).toBe('CODESYS V3.5 SP19');
expect(label({ sp: 21, patch: 5 })).toBe('CODESYS V3.5 SP21 Patch 5');
});
});