screentinker/server/test/ghcr-check.test.js
Fabian Mendoza 34f1cb9e7c
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
feat(dashboard): version indicator + GHCR update check (#165)
* feat(dashboard): version indicator + GHCR update check with admin panel

- Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter)
- Extend /api/version with latest_version and update_available
- Add POST /api/admin/check-update (force GHCR poll)
- Add POST /api/admin/trigger-update (Docker compose or manual instructions)
- Sidebar footer: version label + amber badge when update available
- Admin > System: version comparison card with Check/Update buttons
- 14 new tests (10 unit + 4 integration), 68/68 passing

Closes #163

* fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout

Review follow-up on #165 (the two blockers):

- trigger-update runs `docker compose up -d` on the HOST via docker.sock
  (root-equivalent) but was behind requireAdmin, i.e. reachable by any
  workspace-level admin. On a multi-tenant host that's a customer, not the infra
  operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates
  it further). check-update stays requireAdmin — it's a read-only GHCR poll.

- ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default
  timeout, so a hung GHCR connection never settled — leaving `inFlight` set
  forever (the finally never ran), which wedged the background poller AND hung
  any awaited checkNow (/api/admin/check-update). Add a 10s AbortController
  timeout on both requests so the try/catch/finally always fire.

All 405 server tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ScreenTinker <hello@screentinker.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:40:06 -05:00

65 lines
2.6 KiB
JavaScript

'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const ghcrCheck = require('../lib/ghcr-check');
// =========== extractSemverTags ===========
test('extractSemverTags: returns only valid x.y.z semver tags, pre-release suffixes excluded', () => {
const input = ['latest', '1.9.4', 'v1.9.4', '1.9.4-beta', '1.10.0', '2.0.0-rc1', '1.0.0-alpha'];
const result = ghcrCheck.extractSemverTags(input);
// Pre-release (-beta, -rc1, -alpha) and non-semver (latest, v1.9.4) excluded
assert.deepEqual(result, ['1.10.0', '1.9.4']);
});
test('extractSemverTags: sorts numeric components correctly (10 > 9)', () => {
const input = ['1.2.3', '1.10.0', '1.9.4', '1.2.10'];
const result = ghcrCheck.extractSemverTags(input);
assert.deepEqual(result, ['1.10.0', '1.9.4', '1.2.10', '1.2.3']);
});
test('extractSemverTags: empty input returns empty array', () => {
assert.deepEqual(ghcrCheck.extractSemverTags([]), []);
});
test('extractSemverTags: all non-semver tags returns empty array', () => {
assert.deepEqual(ghcrCheck.extractSemverTags(['latest', 'dev', 'beta']), []);
});
test('extractSemverTags: handles single valid tag', () => {
assert.deepEqual(ghcrCheck.extractSemverTags(['1.0.0']), ['1.0.0']);
});
// =========== compareVersions ===========
test('compareVersions: equal versions return 0', () => {
assert.equal(ghcrCheck.compareVersions('1.0.0', '1.0.0'), 0);
assert.equal(ghcrCheck.compareVersions('2.5.7', '2.5.7'), 0);
});
test('compareVersions: a < b returns negative', () => {
assert.ok(ghcrCheck.compareVersions('1.9.4', '1.10.0') < 0, 'minor bump');
assert.ok(ghcrCheck.compareVersions('1.0.0', '2.0.0') < 0, 'major bump');
assert.ok(ghcrCheck.compareVersions('1.0.0', '1.0.1') < 0, 'patch bump');
assert.ok(ghcrCheck.compareVersions('1.0.0', '1.1.0') < 0, 'minor bump 0->1');
});
test('compareVersions: a > b returns positive', () => {
assert.ok(ghcrCheck.compareVersions('2.0.0', '1.9.4') > 0, 'major larger');
assert.ok(ghcrCheck.compareVersions('1.10.0', '1.9.4') > 0, 'minor larger');
assert.ok(ghcrCheck.compareVersions('1.0.1', '1.0.0') > 0, 'patch larger');
});
test('compareVersions: non-semver input returns NaN', () => {
assert.ok(Number.isNaN(ghcrCheck.compareVersions('abc', '1.0.0')));
assert.ok(Number.isNaN(ghcrCheck.compareVersions('1.0.0', 'latest')));
assert.ok(Number.isNaN(ghcrCheck.compareVersions('1.0', '1.0.0')));
});
// =========== getLatestVersion (sync cache read) ===========
test('getLatestVersion: returns null before any poll', () => {
assert.equal(ghcrCheck.getLatestVersion(), null);
});