fixed sonarqube findings
Sonarqube Scanner / Prepare environment (push) Successful in 7s
Sonarqube Scanner / Build and test (push) Failing after 22s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 5s

This commit is contained in:
Florian Zumpe
2026-10-18 17:48:46 +02:00
parent 0e4cdfbd54
commit ae798034c5
11 changed files with 181 additions and 46 deletions
+13
View File
@@ -226,3 +226,16 @@ Wegwichtel verwendet keine Werbe- oder Tracking-Cookies und bindet keine Analyse
Der Initialisierungsbildschirm bleibt nach erfolgreichem Laden sichtbar. Erst eine bewusste Pointer- oder Tastaturaktion öffnet die eigentliche Anwendung. Das Informationsfenster und die Datenschutzeinstellungen können bereits auf dem Startbildschirm verwendet werden, ohne die Anwendung weiterzuschalten. Der Initialisierungsbildschirm bleibt nach erfolgreichem Laden sichtbar. Erst eine bewusste Pointer- oder Tastaturaktion öffnet die eigentliche Anwendung. Das Informationsfenster und die Datenschutzeinstellungen können bereits auf dem Startbildschirm verwendet werden, ohne die Anwendung weiterzuschalten.
Gemeinsame Entwürfe und Wichtelcodes: [Gruppenbearbeitung.md](Gruppenbearbeitung.md). Gemeinsame Entwürfe und Wichtelcodes: [Gruppenbearbeitung.md](Gruppenbearbeitung.md).
## Deployment-Freigabe
Automatische Deployments benötigen ein erfolgreiches SonarQube Quality Gate.
Der Analysejob erzeugt danach eine Freigabe für den geprüften Commit, den
Workflow-Lauf und dessen aktuellen Versuch. Ohne passende Freigabe darf das
Deployment nicht fortfahren. Das SSH-Skript übernimmt ausschließlich diesen
Commit und prüft ihn vor dem Neustart des App-Dienstes. So kann ein inzwischen
weiter fortgeschrittener Branch nicht unbemerkt als ungeprüfter Stand starten.
Der ausdrücklich manuell ausgewählte Workflow-Aufruf `job=deploy` bleibt als
bestehender Override verfügbar. Er benötigt eine bewusste Auswahl und ersetzt
keine neue Analyse. Ein gewöhnlicher Push kann diesen Override nicht verwenden.
+19
View File
@@ -178,3 +178,22 @@ Rechte fremder Urheber und bestehende Lizenzen bleiben davon unberührt.
Vor Änderungen an Gruppenrechten und Code-Eingabe zusätzlich Vor Änderungen an Gruppenrechten und Code-Eingabe zusätzlich
`docs/agents/GRUPPENBEARBEITUNG.md` lesen und bei Übergaben ausdrücklich nennen. `docs/agents/GRUPPENBEARBEITUNG.md` lesen und bei Übergaben ausdrücklich nennen.
## Deployment-Freigabe nach dem Quality Gate
Am 18. Oktober 2026 ist Lauf 1828 für Commit 0e4cdfb trotz fehlgeschlagenem
Quality Gate bis zum erfolgreichen Deployment gelaufen. Die vorhandene
jobweite `if`-Bedingung allein hat das Deployment nicht verhindert.
- Automatische Deployments benötigen ein erst nach erfolgreichem Gate erzeugtes
Freigabeartefakt für genau Commit, Workflow-Lauf und Laufversuch.
- Im Deployment-Schritt muss die Freigabe geprüft werden; im SSH-Skript müssen
eine fehlende Freigabe und ein abweichender Commit vor Änderungen abgewiesen
werden. Nur den freigegebenen Commit übernehmen und vor dem Neustart prüfen.
- Der bestehende ausdrückliche manuelle Aufruf `job=deploy` bleibt ein bewusster
Override. Ein Push darf diese Ausnahme nicht aktivieren.
- Verhaltenstests in `test/deployment-workflow.test.js` ausführen. Sie führen die
tatsächlichen Shell-Blöcke mit isolierten Dateien und simulierten externen
Befehlen aus; keine echten SSH-Verbindungen oder Neustarts im Test.
- Die neue Freigabekette muss nach dem nächsten Check-in im echten Workflow
bestätigt werden. Lokale Shell-Tests ersetzen keine erfolgreiche Pipeline.
+1 -1
View File
@@ -615,7 +615,7 @@
.group-code img { width: 100%; height: 64px; object-fit: contain; } .group-code img { width: 100%; height: 64px; object-fit: contain; }
.group-actions { max-width: min(18rem, 52vw); flex-wrap: wrap; justify-content: flex-end; } .group-actions { max-width: min(18rem, 52vw); flex-wrap: wrap; justify-content: flex-end; }
.group-actions .editor-secondary-button { font-size: .85rem; } .group-actions .editor-secondary-button { font-size: .85rem; }
.group-status { min-height: 1.5rem; color: #9b2c2c; } .group-status { display: block; min-height: 1.5rem; color: #9b2c2c; }
@media (prefers-reduced-motion: reduce) { .group-wheel-viewport { scroll-behavior: auto; } } @media (prefers-reduced-motion: reduce) { .group-wheel-viewport { scroll-behavior: auto; } }
.point-compare-dialog { width: min(56rem, calc(100vw - 2rem)); } .point-compare-dialog { width: min(56rem, calc(100vw - 2rem)); }
+1 -1
View File
@@ -607,7 +607,7 @@
<p id="group-expiry"></p> <p id="group-expiry"></p>
<button id="group-edit" type="button" data-i18n="groups.edit">Entwurf bearbeiten</button> <button id="group-edit" type="button" data-i18n="groups.edit">Entwurf bearbeiten</button>
</section> </section>
<p id="group-status" class="group-status" role="status"></p> <output id="group-status" class="group-status"></output>
</dialog> </dialog>
<dialog id="point-compare-dialog" class="group-dialog point-compare-dialog" aria-labelledby="point-compare-title"> <dialog id="point-compare-dialog" class="group-dialog point-compare-dialog" aria-labelledby="point-compare-title">
<h2 id="point-compare-title" data-i18n="point-locks.compare-title">Die Station wurde geändert</h2> <h2 id="point-compare-title" data-i18n="point-locks.compare-title">Die Station wurde geändert</h2>
+18 -18
View File
@@ -214,7 +214,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function showListView() { function showListView() {
void ns.GroupPoints?.release(); ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
ns.RouteRecorder?.endEditing(); ns.RouteRecorder?.endEditing();
setDialogTitle('Streckenverwaltung'); setDialogTitle('Streckenverwaltung');
@@ -224,7 +224,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function showRouteView() { function showRouteView() {
void ns.GroupPoints?.release(); ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
setDialogTitle(routeDialogTitle()); setDialogTitle(routeDialogTitle());
setHidden(byId(ids.routeListView), true); setHidden(byId(ids.routeListView), true);
@@ -478,8 +478,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
renderRouteList(); renderRouteList();
setStatus(''); setStatus('');
// Local drafts are usable even while this request is waiting for a connection. // Local drafts are usable even while this request is waiting for a connection.
void refreshPublicRoutes(requestGeneration); refreshPublicRoutes(requestGeneration);
void refreshSubmittedRoutes(requestGeneration); refreshSubmittedRoutes(requestGeneration);
} }
async function refreshSubmittedRoutes(requestGeneration) { async function refreshSubmittedRoutes(requestGeneration) {
@@ -536,7 +536,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
setDialogExpanded(true); setDialogExpanded(true);
showListView(); showListView();
await guarded(loadRoutes); await guarded(loadRoutes);
void ns.Groups?.refresh?.(); ns.Groups?.refresh?.();
} }
async function openNewLocalRoute() { async function openNewLocalRoute() {
@@ -546,7 +546,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function closeEditor() { function closeEditor() {
if (pointSaveInProgress) return; if (pointSaveInProgress) return;
void ns.GroupPoints?.release(); ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
ns.RouteRecorder?.endEditing(); ns.RouteRecorder?.endEditing();
const dialog = byId(ids.dialog); const dialog = byId(ids.dialog);
@@ -948,8 +948,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function saveRoute() { async function saveRoute() {
const values = routeFromForm(); const values = routeFromForm();
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name")); if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
if (state.currentSource !== 'local') await saveServerRoute(values); if (state.currentSource === 'local') await saveLocalRoute(values);
else await saveLocalRoute(values); else await saveServerRoute(values);
} }
function safeGpxFilename(name) { function safeGpxFilename(name) {
@@ -1157,7 +1157,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function newPoi() { function newPoi() {
void ns.GroupPoints?.release(); ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
const position = currentPosition(); const position = currentPosition();
const lat = position?.lat ?? null; const lat = position?.lat ?? null;
@@ -1195,9 +1195,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (draft) { if (draft) {
state.pendingAudioFile = draft.media?.audio || null; state.pendingAudioFile = draft.media?.audio || null;
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) })); state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
const hiddenImages = state.groupDeletions.map(item => item.path); const hiddenImages = new Set(state.groupDeletions.map(item => item.path));
state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.includes(`pictures/${image.id}`)), state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.has(`pictures/${image.id}`)),
...(hiddenImages.includes('audio') ? { audioUrl: null } : {}) }; ...(hiddenImages.has('audio') ? { audioUrl: null } : {}) };
renderPoiMediaPreview(); renderPoiMediaPreview();
updatePoiMediaStatus(); updatePoiMediaStatus();
setStatus(ns.I18n.t(state.groupDeletePoint ? 'point-locks.delete-restored' : 'point-locks.restored')); setStatus(ns.I18n.t(state.groupDeletePoint ? 'point-locks.delete-restored' : 'point-locks.restored'));
@@ -1325,7 +1325,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method); await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method);
if (state.currentSource === 'group') { state.pendingAudioFile = null; await rememberGroupPoint(poiFromForm(), currentPoiId); } if (state.currentSource === 'group') { state.pendingAudioFile = null; await rememberGroupPoint(poiFromForm(), currentPoiId); }
} }
for (const pending of [...state.pendingImages]) { for (const pending of state.pendingImages) {
await uploadOne(`${basePath}/pictures`, 'picture', pending.file); await uploadOne(`${basePath}/pictures`, 'picture', pending.file);
if (state.currentSource === 'group') { if (state.currentSource === 'group') {
state.pendingImages = state.pendingImages.filter(item => item !== pending); state.pendingImages = state.pendingImages.filter(item => item !== pending);
@@ -1366,7 +1366,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await ns.GroupPoints.remove(currentRouteId, 'new'); await ns.GroupPoints.remove(currentRouteId, 'new');
await ns.GroupPoints.acceptNew(currentRouteId, saved); await ns.GroupPoints.acceptNew(currentRouteId, saved);
} }
for (const deletion of [...state.groupDeletions]) { for (const deletion of state.groupDeletions) {
await sendDelete(`/routes/${currentRouteId}/pois/${savedPoiId}/${deletion.path}`); await sendDelete(`/routes/${currentRouteId}/pois/${savedPoiId}/${deletion.path}`);
state.groupDeletions = state.groupDeletions.filter(item => item !== deletion); state.groupDeletions = state.groupDeletions.filter(item => item !== deletion);
await rememberGroupPoint(values, savedPoiId); await rememberGroupPoint(values, savedPoiId);
@@ -1725,14 +1725,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
byId(ids.dialog)?.addEventListener(eventName, event => { byId(ids.dialog)?.addEventListener(eventName, event => {
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; } if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
releasePoiPreview(); releasePoiPreview();
void ns.GroupPoints?.release(); ns.GroupPoints?.release();
ns.RouteRecorder?.endEditing(); ns.RouteRecorder?.endEditing();
}); });
} }
activate(byId(ids.button), () => guarded(openEditor)); activate(byId(ids.button), () => guarded(openEditor));
activate(byId(ids.close), closeEditor); activate(byId(ids.close), closeEditor);
activate(byId(ids.newRoute), newRoute); activate(byId(ids.newRoute), newRoute);
activate(byId(ids.routeBack), () => { showListView(); void guarded(loadRoutes); }); activate(byId(ids.routeBack), () => { showListView(); guarded(loadRoutes); });
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute)); activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms')); activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms'));
activate(byId(ids.addPoi), newPoi); activate(byId(ids.addPoi), newPoi);
@@ -1742,8 +1742,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection)); byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event))); activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));
activate(byId(ids.poiDelete), () => { activate(byId(ids.poiDelete), () => {
if (state.currentSource === 'group') { state.groupDeletePoint = true; void guarded(savePoi); } if (state.currentSource === 'group') { state.groupDeletePoint = true; guarded(savePoi); }
else void guarded(deleteCurrentPoi); else guarded(deleteCurrentPoi);
}); });
activate(byId(ids.poiCancel), showRouteView); activate(byId(ids.poiCancel), showRouteView);
activate(byId(ids.routeList), event => guarded(() => routeListAction(event))); activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
+1 -1
View File
@@ -178,7 +178,7 @@ globalThis.Wegwichtel ??= {};
} }
} finally { touching = false; } } finally { touching = false; }
} }
globalThis.setInterval?.(() => { void keepActive(); }, 30_000); globalThis.setInterval?.(() => { keepActive(); }, 30_000);
function headersFor(path) { function headersFor(path) {
const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path); const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path);
if (!match || !current || String(current.routeId) !== match[1] || String(current.poiId) !== match[2]) return {}; if (!match || !current || String(current.routeId) !== match[1] || String(current.poiId) !== match[2]) return {};
+19 -17
View File
@@ -22,12 +22,12 @@ globalThis.Wegwichtel ??= {};
button.addEventListener('pointerup', event => { button.addEventListener('pointerup', event => {
if (event.isPrimary === false || (event.button && event.button !== 0)) return; if (event.isPrimary === false || (event.button && event.button !== 0)) return;
event.preventDefault(); event.preventDefault();
void action(); action();
}); });
button.addEventListener('keydown', event => { button.addEventListener('keydown', event => {
if (!['Enter', ' '].includes(event.key)) return; if (!['Enter', ' '].includes(event.key)) return;
event.preventDefault(); event.preventDefault();
void action(); action();
}); });
} }
@@ -147,7 +147,7 @@ globalThis.Wegwichtel ??= {};
function openEntry() { function openEntry() {
dialogGeneration += 1; dialogGeneration += 1;
selection = Array(codeLength).fill(0); selection = new Array(codeLength).fill(0);
element(ids.status).textContent = ''; element(ids.status).textContent = '';
element(ids.entry).hidden = false; element(ids.entry).hidden = false;
element(ids.invitation).hidden = true; element(ids.invitation).hidden = true;
@@ -297,19 +297,7 @@ globalThis.Wegwichtel ??= {};
if (routeId && revision !== null && /^\d+$/.test(revision)) revisions.set(routeId, Number(revision)); if (routeId && revision !== null && /^\d+$/.test(revision)) revisions.set(routeId, Number(revision));
} }
function init() { async function joinSelectedGroup() {
globalThis.setInterval?.(() => { void refreshInvitation(); }, 30_000);
activate(element('group-share-start'), () => finishShare(true));
activate(element('group-share-close'), () => finishShare(false));
element('group-share-dialog').addEventListener('close', () => {
const resolve = shareDecision; shareDecision = null; resolve?.(false);
});
document.addEventListener('wegwichtel:point-drafts-changed', () => { void renderPending(); void ns.StorageUsage?.refresh(); });
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), openEntry);
activate(element(ids.close), () => element(ids.dialog).close());
activate(element(ids.join), () => guarded(async () => {
const generation = dialogGeneration; const generation = dialogGeneration;
const motifs = ns.GroupSymbols.list(); const motifs = ns.GroupSymbols.list();
let payload; let payload;
@@ -324,7 +312,21 @@ globalThis.Wegwichtel ??= {};
if (!element(ids.dialog).open || generation !== dialogGeneration) return; if (!element(ids.dialog).open || generation !== dialogGeneration) return;
await edit(payload.group); await edit(payload.group);
await refresh(); await refresh();
})); }
function init() {
globalThis.setInterval?.(() => { refreshInvitation(); }, 30_000);
activate(element('group-share-start'), () => finishShare(true));
activate(element('group-share-close'), () => finishShare(false));
element('group-share-dialog').addEventListener('close', () => {
const resolve = shareDecision; shareDecision = null; resolve?.(false);
});
document.addEventListener('wegwichtel:point-drafts-changed', () => { renderPending(); ns.StorageUsage?.refresh(); });
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), openEntry);
activate(element(ids.close), () => element(ids.dialog).close());
activate(element(ids.join), () => guarded(joinSelectedGroup));
activate(element(ids.edit), () => guarded(() => edit(currentGroup))); activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { isLoopbackAddress } from './route-write-access.js';
import { guardPoint, clearPointLocks, hasPointLocks } from './point-locks.js'; import { guardPoint, clearPointLocks, hasPointLocks } from './point-locks.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -163,7 +164,7 @@ export function listGroups(db, req) {
} }
function failureKey(req) { function failureKey(req) {
const localProxy = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket?.remoteAddress); const localProxy = isLoopbackAddress(req.socket?.remoteAddress);
return hash(String(localProxy && req.headers['x-real-ip'] || req.socket?.remoteAddress || 'unknown')); return hash(String(localProxy && req.headers['x-real-ip'] || req.socket?.remoteAddress || 'unknown'));
} }
+8 -2
View File
@@ -25,7 +25,10 @@ function read(routeId, poiId) {
} }
return lock; return lock;
} }
catch (error) { if (error.code === 'ENOENT') return null; throw error; } catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
} }
function write(routeId, poiId, lock) { function write(routeId, poiId, lock) {
const target = filename(routeId, poiId); const target = filename(routeId, poiId);
@@ -86,7 +89,10 @@ export function hasPointLocks(routeId) {
const directory = path.join(routeDirectory(routeId), 'locks'); const directory = path.join(routeDirectory(routeId), 'locks');
let entries; let entries;
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } try { entries = fs.readdirSync(directory, { withFileTypes: true }); }
catch (error) { if (error.code === 'ENOENT') return false; throw error; } catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
let locked = false; let locked = false;
for (const entry of entries) { for (const entry of entries) {
if (entry.isFile() && /^\d+\.json$/.test(entry.name) && read(routeId, Number(entry.name.slice(0, -5)))) locked = true; if (entry.isFile() && /^\d+\.json$/.test(entry.name) && read(routeId, Number(entry.name.slice(0, -5)))) locked = true;
+1 -1
View File
@@ -13,7 +13,7 @@ function hasForwardingHeader(req, name) {
return value != null && String(value).trim().length > 0; return value != null && String(value).trim().length > 0;
} }
function isLoopbackAddress(address) { export function isLoopbackAddress(address) {
if (typeof address !== 'string' || address.length === 0) return false; if (typeof address !== 'string' || address.length === 0) return false;
if (address === '::1') return true; if (address === '::1') return true;
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
const workflow = fs.readFileSync(new URL('../.gitea/workflows/build-and-deploy.yaml', import.meta.url), 'utf8');
const commit = 'a'.repeat(40);
function script(name, attribute = 'run') {
const lines = workflow.split('\n');
const start = lines.findIndex(line => line.trim() === `- name: ${name}`);
assert.notEqual(start, -1);
const offset = lines.findIndex((line, index) => index > start && line.trim() === `${attribute}: |-`);
assert.notEqual(offset, -1);
const indent = lines[offset].search(/\S/) + 2;
const result = [];
for (const line of lines.slice(offset + 1)) {
if (line.trim() && line.search(/\S/) < indent) break;
result.push(line.slice(indent));
}
return result.join('\n');
}
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wegwichtel-deploy-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const output = path.join(directory, 'output');
const env = { ...process.env, EXPECTED_COMMIT: commit, WORKFLOW_RUN: '1828', WORKFLOW_ATTEMPT: '1',
RUN_EVENT: 'push', SELECTED_JOB: '', GITHUB_OUTPUT: output };
function run(source, extra = {}) { return spawnSync('sh', ['-s'], { input: source, cwd: directory, env: { ...env, ...extra }, encoding: 'utf8' }); }
function proof(value = `${commit}:1828:1`) {
fs.mkdirSync(path.join(directory, 'deployment-approval'), { recursive: true });
fs.writeFileSync(path.join(directory, 'deployment-approval', 'quality-gate-passed.txt'), value);
}
return { directory, output, env, run, proof };
}
test('a deployment approval is written only after the Quality Gate step succeeded', t => {
const h = fixture(t), source = script('Write deployment approval');
for (const outcome of ['', 'failure', 'skipped', 'cancelled']) {
assert.notEqual(h.run(source, { GATE_OUTCOME: outcome }).status, 0);
assert.equal(fs.existsSync(path.join(h.directory, 'quality-gate-passed.txt')), false);
}
assert.equal(h.run(source, { GATE_OUTCOME: 'success' }).status, 0);
assert.equal(fs.readFileSync(path.join(h.directory, 'quality-gate-passed.txt'), 'utf8'), `${commit}:1828:1\n`);
});
test('push deployments reject missing, mismatching and previous-attempt approval artifacts', t => {
const h = fixture(t), source = script('Verify deployment approval');
assert.notEqual(h.run(source).status, 0);
for (const proof of ['wrong', `${'b'.repeat(40)}:1828:1`, `${commit}:1827:1`, `${commit}:1828:0`]) {
h.proof(proof); assert.notEqual(h.run(source).status, 0);
assert.equal(fs.existsSync(h.output), false);
}
h.proof(); assert.equal(h.run(source).status, 0);
assert.equal(fs.readFileSync(h.output, 'utf8'), `commit=${commit}\n`);
});
test('only an explicit manual deploy can use the existing manual override', t => {
const h = fixture(t), source = script('Verify deployment approval');
assert.notEqual(h.run(source, { RUN_EVENT: 'push', SELECTED_JOB: 'deploy' }).status, 0);
assert.notEqual(h.run(source, { RUN_EVENT: 'workflow_dispatch', SELECTED_JOB: 'all' }).status, 0);
assert.equal(h.run(source, { RUN_EVENT: 'workflow_dispatch', SELECTED_JOB: 'deploy' }).status, 0);
assert.equal(fs.readFileSync(h.output, 'utf8'), `commit=${commit}\n`);
});
test('the remote script refuses missing approval before git access and restarts only the approved commit', t => {
const h = fixture(t), source = script('Deploy Wegwichtel', 'script');
const log = path.join(h.directory, 'calls');
const stubs = String.raw`
cd() { return 0; }
git() {
printf 'git %s\n' "$*" >> "$TASK_LOG"
if [ "$1" = merge ] && [ "$MERGE_FAILS" = yes ]; then return 1; fi
if [ "$1" = rev-parse ]; then printf '%s\n' "$ACTUAL_COMMIT"; fi
}
systemctl() { printf 'systemctl %s\n' "$*" >> "$TASK_LOG"; }
`;
const invoke = (approval, actual = commit, mergeFails = 'no') => h.run(stubs + source, {
DEPLOY_APPROVED_COMMIT: approval, ACTUAL_COMMIT: actual, TASK_LOG: log, MERGE_FAILS: mergeFails
});
assert.notEqual(invoke('').status, 0); assert.equal(fs.existsSync(log), false);
assert.notEqual(invoke('b'.repeat(40)).status, 0); assert.equal(fs.existsSync(log), false);
assert.notEqual(invoke(commit, 'b'.repeat(40)).status, 0);
assert.doesNotMatch(fs.readFileSync(log, 'utf8'), /systemctl/);
fs.rmSync(log);
assert.notEqual(invoke(commit, commit, 'yes').status, 0);
assert.doesNotMatch(fs.readFileSync(log, 'utf8'), /systemctl/);
fs.rmSync(log);
assert.equal(invoke(commit).status, 0);
const calls = fs.readFileSync(log, 'utf8');
assert.match(calls, new RegExp(`git merge --ff-only ${commit}`));
assert.match(calls, /systemctl --user restart wegwichtel.service/);
});