mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
* 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>
65 lines
2.6 KiB
JavaScript
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);
|
|
});
|