Compare commits

..
3 Commits
Author SHA1 Message Date
Deploymentcenter BotandClaude Opus 5 c9c821a44a fix(webui): Ansichtsrettung suchte am falschen Ort, Suche findet Nummern
#288 war mit dem Transport allein nicht behoben. Die Ansichtsrettung fragte
dokumentweit nach der offenen Unteransicht:

    document.querySelector('.subtab-content.active')

Im Markup tragen aber ACHT Unteransichten diese Klasse - jedes Modul hat seine
eigene Startansicht, und switchMainTab() raeumt nur innerhalb des Moduls auf,
das es oeffnet. Zurueck kam deshalb immer die erste in Dokumentreihenfolge
(sub-projects-list), voellig unabhaengig davon, was gerade sichtbar war. Wer im
Bugtracker filterte, waere also auf der Projektverwaltung gelandet.

Dass die Rettung fuer POST-Formulare zu funktionieren schien, lag an den
wenigen, die ihren Anker fest in der action stehen haben. Gesucht wird jetzt
erst das aktive Modul und darin die Unteransicht.

Zusaetzlich tragen die drei Filterformulare das Feld "view" fest im Markup.
Dort ist ohne Zweifel bekannt, wo sie stehen - das haengt an keiner DOM-Abfrage
mehr. Und restoreViewFromHash() faellt bei einer unbekannten Unteransicht auf
das Modul zurueck, statt wortlos im Dashboard zu landen.

Nachgewiesen an einer DOM-Probe, die die Modulstruktur aus index.php nachbaut
und die echten Funktionen darauf laufen laesst: alte Fassung -> tab-projects,
neue Fassung -> sub-bugtracker-items.

Ausserdem, aus derselben Sitzung gemeldet: Die Suche findet Eintraege jetzt
ueber ihre Nummer. Sie ist das, was ueberall zitiert wird, und stand in keiner
der durchsuchten Spalten - "288" lief ins Leere. "288" findet jetzt das Item
mit dieser Nummer und alles, was die Zahl im Text hat; "#288" ausschliesslich
dieses eine. Gilt fuer Bugtracker, Fehler-Stream und den Parameter search der
API, der bei der Gelegenheit auch in die OpenAPI-Beschreibung gekommen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:03:08 +02:00
Deploymentcenter BotandClaude Opus 5 0c1d269665 test(e2e): whoami absichern und die gemeldete Fassung festnageln
Vier Faelle, die genau die Fehler wiederfinden wuerden, um die es in 2.7.1
ging:

- whoami nennt Bindung und Rechte, und zwar dieselben, die provisioniert
  wurden
- whoami gibt den Klartextwert des Tokens nicht heraus. Eine Auskunft ueber
  ein Token darf das Token nicht weiterreichen
- whoami ohne Token antwortet 401
- Health meldet dieselbe Fassung, die changelog.json fuehrt. Das ist der
  Fall, der #346 nicht wiederkommen laesst: Ein fester Wert in der
  Konfiguration faellt sonst niemandem auf, weil die Datei gitignored ist

Ausserdem: Wird der Shared Key an whoami geschickt, sagt der Endpunkt das
jetzt (not_a_token) statt "Token unbekannt". Der Shared Key ist kein Token -
er hat weder Bindung noch Rechteliste, und ohne den Hinweis sucht der
Aufrufer den Fehler bei seinem Token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:09:38 +02:00
Deploymentcenter BotandClaude Opus 5 3b56839a1e fix(2.7.1): Auskuenfte, die stimmen - Fassung, Health, doctor, Ansicht
Der erste Durchlauf der Testinstanz hat neun Bugs zutage gefoerdert, und fast
alle waren dieselbe Sorte Fehler: eine Auskunft, die nicht stimmte.

Am folgenreichsten die Fassung. config/config.php fuehrte app.version als feste
Zeichenkette und stand seit 2.0.0 darauf, waehrend 2.7.0 lief - unbemerkt, weil
die Datei gitignored ist und in keinem Diff auftaucht. Zwei gemeldete Bugs sind
daran fehldiagnostiziert worden ("der Server ist zu alt"). src/bootstrap.php
leitet die Fassung jetzt aus "current" in public/docs/changelog.json ab.

Im Einzelnen:

- #346 Fassung aus dem Changelog statt aus der Konfiguration
- #60  /api/health ueberlebt eine leere Datenbank: eigener Check "schema",
       healthy=false mit Begruendung statt HTTP 500
- #54  dc-testkit doctor prueft Mindestfassung (semantisch) und Rechte, statt
       sie nur anzuzeigen - dafuer neu: GET /api/tokens/v1/whoami
- #53  dc-testkit.sh unter /testkit/ erreichbar (Route fehlte, der
       dokumentierte Bezugsweg hat nie funktioniert) und mit LF ausgeliefert
       (deploy.py/deploy_dev.py entfernen CR bei .sh/.bash beim Upload)
- #288 Filter und Formulare werfen nicht mehr aufs Dashboard zurueck: die
       offene Ansicht wandert bei GET als "view" in den Query-String, und die
       Filter loesen ueber requestSubmit() aus - form.submit() erzeugt kein
       submit-Ereignis, weshalb der zentrale Zuhoerer dort nie lief
- #347 /api/bugtracker/v1/roadmap auch ohne .php erreichbar
- #55  Fassungshinweis in TESTUMGEBUNG.md 4.6 (serverseitig war das mit 2.6.0
       bereits behoben)

Nicht geprueft: php -l. Auf dem Arbeitsplatz ist kein PHP installiert; die
Abnahme laeuft ueber die Testinstanz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:06:33 +02:00
16 changed files with 772 additions and 60 deletions
+14
View File
@@ -28,11 +28,13 @@ Options -Indexes
RewriteRule ^api/bugtracker/v1/report/?$ public/api/bugtracker/v1/report.php [L,QSA] RewriteRule ^api/bugtracker/v1/report/?$ public/api/bugtracker/v1/report.php [L,QSA]
RewriteRule ^api/bugtracker/v1/projects/?$ public/api/bugtracker/v1/projects.php [L,QSA] RewriteRule ^api/bugtracker/v1/projects/?$ public/api/bugtracker/v1/projects.php [L,QSA]
RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA] RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA]
RewriteRule ^api/bugtracker/v1/roadmap/?$ public/api/bugtracker/v1/roadmap.php [L,QSA]
RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA] RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA]
RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA] RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA]
RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA] RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA]
RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA] RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA]
RewriteRule ^api/tokens/v1/whoami/?$ public/api/tokens/v1/whoami.php [L,QSA]
RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA] RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA]
RewriteRule ^api/supervisor/v1(?:/(.*))?$ public/api/supervisor/v1/index.php [L,QSA] RewriteRule ^api/supervisor/v1(?:/(.*))?$ public/api/supervisor/v1/index.php [L,QSA]
@@ -46,6 +48,18 @@ Options -Indexes
RewriteRule ^docs/?$ public/docs/index.php [L,QSA] RewriteRule ^docs/?$ public/docs/index.php [L,QSA]
RewriteRule ^docs/(.+)$ public/docs/$1 [L,QSA] RewriteRule ^docs/(.+)$ public/docs/$1 [L,QSA]
# ------------------------------------------------------------------
# 3b. Werkzeugkasten fuer die Test-VM (public/testkit/)
#
# TESTUMGEBUNG.md nennt als Bezugsweg
# curl -fsSL https://dc.mhdf.de/testkit/dc-testkit.sh
# Ohne diese Regel lief der Aufruf in ein 404: ausgeliefert war die
# Datei nur unter /public/testkit/. Der dokumentierte Weg hat also
# nie funktioniert - was als "Server ist noch zu alt" gedeutet
# wurde und in den Behelf per scp fuehrte.
# ------------------------------------------------------------------
RewriteRule ^testkit/(.+)$ public/testkit/$1 [L,QSA]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 4. WebUI # 4. WebUI
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+4
View File
@@ -26,6 +26,10 @@ if (!function_exists('dc_env')) {
return [ return [
'app' => [ 'app' => [
'name' => 'Deploymentcenter', 'name' => 'Deploymentcenter',
// Rueckfallebene. Massgeblich ist "current" aus
// public/docs/changelog.json - src/bootstrap.php setzt den Wert von
// dort. Hier wurde der Wert nicht nachgezogen - /api/health meldete
// deshalb bis 2.7.0 unveraendert 2.0.0.
'version' => '2.0.0', 'version' => '2.0.0',
'url' => dc_env('DC_APP_URL', 'https://dc.example.com'), 'url' => dc_env('DC_APP_URL', 'https://dc.example.com'),
'timezone' => 'Europe/Berlin', 'timezone' => 'Europe/Berlin',
+15 -5
View File
@@ -126,12 +126,16 @@ chmod +x ~/bin/dc-testkit
``` ```
> ⚠️ **Beides — dieser Download und `dc-testkit license` — setzt Deploymentcenter 2.6.1 voraus.** > ⚠️ **Beides — dieser Download und `dc-testkit license` — setzt Deploymentcenter 2.6.1 voraus.**
> Solange die Fassung auf dem Server älter ist, gibt es die Datei unter `/testkit/` nicht, und der > Ist die Fassung auf dem Server älter, fehlt der Endpunkt `/api/license/v1/dev-key`. Er fällt
> Endpunkt `/api/license/v1/dev-key` fehlt. Der fehlende Endpunkt fällt dabei **nicht** mit 404 > dabei **nicht** mit 404 auf: Die Lizenz-API antwortet bei unbekannten Pfadsegmenten mit ihrer
> auf: Die Lizenz-API antwortet bei unbekannten Pfadsegmenten mit ihrer Verfügbarkeitsprüfung, also > Verfügbarkeitsprüfung, also mit HTTP 200. Das Testkit erkennt das und sagt es; wer von Hand
> mit HTTP 200. Das Testkit erkennt das und sagt es; wer von Hand `curl`t, sucht sonst lange. > `curl`t, sucht sonst lange. Seit 2.7.1 vergleicht `dc-testkit doctor` die Fassung von sich aus,
> statt sie nur anzuzeigen.
> >
> Bis dahin geht es auch per `scp` aus dem Repo: > **Der Download unter `/testkit/` funktionierte bis 2.7.1 gar nicht** — und zwar unabhängig von
> der Fassung: Im Webroot lag die Datei nur unter `/public/testkit/`, die Route auf `/testkit/`
> fehlte in der `.htaccess`. Wer vor 2.7.1 ein 404 sah, hat deshalb nicht unbedingt einen zu alten
> Server vor sich. Ausweichweg für alte Stände:
> `scp public/testkit/dc-testkit.sh testvm:~/bin/dc-testkit` > `scp public/testkit/dc-testkit.sh testvm:~/bin/dc-testkit`
Dann je Projekt eine Konfiguration — **das Token gehört in die Datei, nicht in die Befehlszeile**, Dann je Projekt eine Konfiguration — **das Token gehört in die Datei, nicht in die Befehlszeile**,
@@ -397,6 +401,12 @@ am selben Problem. Antwortet der Server mit `409 already_claimed`, nimm das näc
### 4.6 Befunde, die ein anderes Projekt betreffen ### 4.6 Befunde, die ein anderes Projekt betreffen
> ⚠️ **Alles in diesem Abschnitt setzt Deploymentcenter 2.6.0 voraus** (`triage_external`,
> Deckelung, `reported_from`, die Ablehnung von Laufzeit-Token). Ein älterer Server nimmt eine
> Fremdmeldung an, legt sie aber als gewöhnliches Item mit `status=open` und ohne `reported_from`
> ab — ohne das zu sagen. Wer das beobachtet, hat nicht drei Fehler vor sich, sondern einen zu
> alten Server; `dc-testkit doctor` sagt es seit 2.7.1 von sich aus.
**Das ist der wichtigste Abschnitt dieser Datei.** Ein Agent auf der Test-VM stolpert **Das ist der wichtigste Abschnitt dieser Datei.** Ein Agent auf der Test-VM stolpert
zwangsläufig über Dinge, die nicht seinem Projekt gehören: Der PolyTrader-Agent findet einen zwangsläufig über Dinge, die nicht seinem Projekt gehören: Der PolyTrader-Agent findet einen
Fehler im `update-agent`. Ihm fällt auf, dass diese Anleitung an einer Stelle lügt. Er hätte Fehler im `update-agent`. Ihm fällt auf, dass diese Anleitung an einer Stelle lügt. Er hätte
+48 -1
View File
@@ -47,6 +47,34 @@ try {
$logDir = DC_VAR . '/log'; $logDir = DC_VAR . '/log';
$checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)]; $checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)];
// --- Schema vorhanden? ---
// Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen
// sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten
// Migration gibt es dc_tokens nicht - und daran starb die Rechtepruefung
// weiter unten mit einer PDOException. Ausgerechnet der Endpunkt, der ueber
// kaputte Zustaende Auskunft geben soll, antwortete dann mit HTTP 500 statt
// mit healthy=false und einer Begruendung. Betroffen war jede Neuinstallation
// zwischen Deployment und erster Migration.
$schemaReady = false;
if ($db !== null) {
try {
$schemaReady = $db->query("SHOW TABLES LIKE 'dc_tokens'")->fetchColumn() !== false;
} catch (Throwable $e) {
Logger::error('Health-Check: Schemapruefung fehlgeschlagen', ['error' => $e->getMessage()]);
}
$checks['schema'] = $schemaReady
? ['ok' => true]
: [
'ok' => false,
'message' => 'Datenbankschema fehlt - die Migrationen wurden noch nicht ausgefuehrt (install_db.php aufrufen).',
];
if (!$schemaReady) {
$healthy = false;
}
}
$response = [ $response = [
'healthy' => $healthy, 'healthy' => $healthy,
'app' => Config::get('app.name', 'Deploymentcenter'), 'app' => Config::get('app.name', 'Deploymentcenter'),
@@ -56,7 +84,26 @@ $response = [
]; ];
// --- Detailinformationen nur fuer Authentifizierte --- // --- Detailinformationen nur fuer Authentifizierte ---
if ($db !== null && ApiAuth::resolve($db, 'bugtracker:read') !== null) { // Die Rechtepruefung selbst bleibt zusaetzlich abgesichert: ein halb
// migriertes Schema besteht die Pruefung oben, kann hier aber trotzdem ueber
// eine fehlende Spalte stolpern. Ein Fehlschlag wird wie "nicht
// authentifiziert" behandelt - dann kommt die Kurzfassung heraus, und die
// Antwort bleibt auswertbar.
$authenticated = false;
if ($db !== null && $schemaReady) {
try {
$authenticated = ApiAuth::resolve($db, 'bugtracker:read') !== null;
} catch (Throwable $e) {
Logger::warning('Health-Check: Rechtepruefung nicht moeglich', ['error' => $e->getMessage()]);
$response['checks']['schema'] = [
'ok' => false,
'message' => 'Datenbankschema unvollstaendig - die Rechtepruefung war nicht moeglich.',
];
$response['healthy'] = false;
}
}
if ($authenticated) {
try { try {
$status = Migrator::status($db); $status = Migrator::status($db);
$response['schema'] = [ $response['schema'] = [
+43 -1
View File
@@ -374,6 +374,7 @@ $spec = [
['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Mehrere kommagetrennt'], ['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Mehrere kommagetrennt'],
['name' => 'target_agent', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'target_agent', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'unclaimed_only', 'in' => 'query', 'schema' => ['type' => 'boolean']], ['name' => 'unclaimed_only', 'in' => 'query', 'schema' => ['type' => 'boolean']],
['name' => 'search', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags, Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit vorangestellter Raute (#288) ausschliesslich dieses eine.'],
['name' => 'updated_since', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time'], 'description' => 'Delta-Abfrage fuer Polling'], ['name' => 'updated_since', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time'], 'description' => 'Delta-Abfrage fuer Polling'],
['name' => 'order', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['newest', 'oldest', 'updated', 'severity', 'occurrences']]], ['name' => 'order', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['newest', 'oldest', 'updated', 'severity', 'occurrences']]],
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 100, 'maximum' => 500]], ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 100, 'maximum' => 500]],
@@ -406,7 +407,7 @@ $spec = [
], ],
], ],
'/api/bugtracker/v1/roadmap.php' => [ '/api/bugtracker/v1/roadmap' => [
'get' => [ 'get' => [
'tags' => ['Bugtracker'], 'tags' => ['Bugtracker'],
'summary' => 'Roadmap lesen', 'summary' => 'Roadmap lesen',
@@ -1229,6 +1230,47 @@ $spec = [
'responses' => ['201' => ['description' => 'Sub-Token erstellt'], '403' => $errorResponse], 'responses' => ['201' => ['description' => 'Sub-Token erstellt'], '403' => $errorResponse],
], ],
], ],
'/api/tokens/v1/whoami' => [
'get' => [
'tags' => ['Tokens'],
'summary' => 'Auskunft ueber das eigene Token',
'description' =>
"Meldet Besitzerart, Projektbindung, Rechte, Umgebung und Frist des uebergebenen Tokens.\n\n"
. "Gedacht fuer Werkzeuge, die ihre Voraussetzungen pruefen wollen, BEVOR sie etwas tun. "
. "Ohne diesen Endpunkt liesse sich ein Recht nur durch Ausprobieren feststellen - und das "
. "hat Nebenwirkungen: Ein Test auf `bugtracker:manage` uebernaehme ein Item, einer auf "
. "`license:dev` stellte einen Schluessel aus.\n\n"
. "`is_workbench` ist der Wert, der ueber Fremdmeldungen entscheidet: nur ein Token der "
. "Besitzerart `dev_agent` darf in ein fremdes Projekt melden.\n\n"
. "Der Klartextwert des Tokens wird nicht ausgeliefert. Ein widerrufenes oder abgelaufenes "
. "Token bekommt `401`.",
'responses' => [
'200' => [
'description' => 'Angaben zum Token',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'token_id' => ['type' => 'string'],
'name' => ['type' => 'string'],
'type' => ['type' => 'string', 'enum' => ['master', 'sub']],
'owner_type' => ['type' => 'string', 'enum' => TokenManager::OWNER_TYPES],
'owner_identity' => ['type' => 'string', 'nullable' => true],
'is_workbench' => ['type' => 'boolean', 'description' => 'owner_type = dev_agent'],
'project_slug' => ['type' => 'string', 'nullable' => true],
'environment' => ['type' => 'string', 'enum' => TokenManager::ENVIRONMENTS],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'Stand VOR diesem Aufruf.'],
'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
'parent_token_id' => ['type' => 'string', 'nullable' => true],
],
]]],
],
'401' => $errorResponse,
],
],
],
], ],
]; ];
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* GET /api/tokens/v1/whoami
*
* Auskunft ueber das eigene Token: Besitzerart, Projektbindung, Rechte,
* Umgebung, Frist.
*
* Warum es das gibt: Bis hierher konnte ein Aufrufer nur ausprobieren, was
* sein Token darf - und das geht nicht ohne Nebenwirkung. "Darf ich
* bugtracker:manage?" haette geheissen, ein Item zu uebernehmen; "habe ich
* license:dev?" haette einen Schluessel ausgestellt. Also meldete
* dc-testkit doctor nur "gueltig - der Bugtracker antwortet" und lief mit
* einem Laufzeit-Token glatt durch, dem beides fehlte. Der Lauf scheiterte
* dann beim ersten Schritt, der die Rechte wirklich brauchte.
*
* Ausgeliefert wird ausschliesslich, was der Aufrufer ohnehin besitzt. Der
* Klartextwert des Tokens ist nicht dabei.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\TokenManager;
Http::beginJson(['GET', 'OPTIONS'], true);
$presented = Http::bearerToken();
if ($presented === null) {
Http::fail(401, 'unauthorized', 'Kein Token uebergeben. Erwartet als Authorization: Bearer <token> oder X-Agent-Token.');
}
$db = Db::init();
$manager = new TokenManager($db);
// Ohne geforderten Scope: die Frage lautet "wer bin ich", nicht "darf ich
// etwas". Widerruf, kaskadierender Widerruf und Ablauf werden trotzdem
// geprueft - ein entwertetes Token bekommt hier keine Auskunft.
$token = $manager->validateToken($presented, null);
if ($token === null) {
// Der haeufigste Irrtum an dieser Stelle: Es wurde der Shared Key
// geschickt. Der ist kein Token - er hat weder Bindung noch Rechteliste,
// und die Frage "wer bin ich" hat fuer ihn keine Antwort. Ohne diesen
// Hinweis sucht der Aufrufer den Fehler bei seinem Token.
$sharedKey = (string)Config::get('security.shared_key', '');
if ($sharedKey !== '' && hash_equals($sharedKey, $presented)) {
Http::fail(
401,
'not_a_token',
'Das ist der Shared Key, kein Agenten-Token. Er hat keine Bindung und keine '
. 'Rechteliste - dieser Endpunkt kann darueber nichts sagen.'
);
}
Http::fail(401, 'invalid_token', 'Token unbekannt, widerrufen oder abgelaufen.');
}
$scopes = TokenManager::decodeScopes($token['scopes']);
$isWorkbench = ($token['owner_type'] ?? null) === 'dev_agent';
Http::ok([
'token_id' => (string)$token['token_id'],
'name' => (string)$token['name'],
'type' => (string)$token['type'],
'owner_type' => (string)$token['owner_type'],
'owner_identity' => $token['owner_identity'] !== null ? (string)$token['owner_identity'] : null,
// Werkbank-Token duerfen in fremde Projekte melden, Laufzeit-Token nicht.
// Der Unterschied entscheidet ueber Fremdmeldungen und ist deshalb der
// erste Wert, den ein Werkzeug pruefen sollte.
'is_workbench' => $isWorkbench,
'project_slug' => $token['project_slug'] !== null ? (string)$token['project_slug'] : null,
'environment' => (string)$token['environment'],
'scopes' => $scopes,
'created_at' => $token['created_at'] !== null ? (string)$token['created_at'] : null,
'last_used_at' => $token['last_used_at'] !== null ? (string)$token['last_used_at'] : null,
'expires_at' => $token['expires_at'] !== null ? (string)$token['expires_at'] : null,
'parent_token_id' => $token['parent_token_id'] !== null ? (string)$token['parent_token_id'] : null,
]);
+33 -1
View File
@@ -70,6 +70,38 @@ Das zurückgegebene `sub_token` wird **nur einmal** ausgeliefert.
Ist ein Token an ein Projekt gebunden, greifen alle Aufrufe automatisch nur Ist ein Token an ein Projekt gebunden, greifen alle Aufrufe automatisch nur
auf dieses Projekt zu — ein Zugriff auf ein anderes liefert `403 project_forbidden`. auf dieses Projekt zu — ein Zugriff auf ein anderes liefert `403 project_forbidden`.
### Was kann mein Token? (ab 2.7.1)
```bash
curl https://dc.mhdf.de/api/tokens/v1/whoami \
-H "Authorization: Bearer $DC_TOKEN"
```
```json
{
"status": "success",
"name": "Werkbank polytrader",
"type": "master",
"owner_type": "dev_agent",
"is_workbench": true,
"project_slug": "polytrader",
"environment": "all",
"scopes": ["bugtracker:manage", "license:dev"],
"last_used_at": "2026-08-30 09:12:44",
"expires_at": null
}
```
Gedacht für Werkzeuge, die ihre Voraussetzungen prüfen wollen, **bevor** sie
etwas tun. Ohne diese Auskunft ließe sich ein Recht nur durch Ausprobieren
feststellen — und das hat Nebenwirkungen: eine Probe auf `bugtracker:manage`
übernähme ein Item, eine auf `license:dev` stellte einen Schlüssel aus.
`is_workbench` ist der Wert, der über Fremdmeldungen entscheidet: nur ein Token
der Besitzerart `dev_agent` darf in ein fremdes Projekt melden. Der
Klartextwert des Tokens wird nicht ausgeliefert; ein widerrufenes oder
abgelaufenes Token bekommt `401`.
--- ---
## 2. Projekte finden ## 2. Projekte finden
@@ -243,7 +275,7 @@ curl "https://dc.mhdf.de/api/bugtracker/v1/manage?action=list&project_slug=myapp
| `status`, `severity` | Mehrere Werte kommagetrennt | | `status`, `severity` | Mehrere Werte kommagetrennt |
| `type`, `environment`, `project_slug` | Einzelwert oder `all` | | `type`, `environment`, `project_slug` | Einzelwert oder `all` |
| `target_agent`, `claimed_by`, `push_id` | Exakte Übereinstimmung | | `target_agent`, `claimed_by`, `push_id` | Exakte Übereinstimmung |
| `search` | Volltext über Titel, Beschreibung, Fehlermeldung, Tags, Dateipfad | | `search` | Volltext über Titel, Beschreibung, Fehlermeldung, Tags, Dateipfad. Eine Zahl findet zusätzlich das Item mit dieser **Nummer**, `#288` ausschließlich dieses eine |
| `unclaimed_only` | `true` — nur Items, die kein Agent bearbeitet | | `unclaimed_only` | `true` — nur Items, die kein Agent bearbeitet |
| `updated_since` | ISO-8601 — **Delta-Abfrage für effizientes Polling** | | `updated_since` | ISO-8601 — **Delta-Abfrage für effizientes Polling** |
| `order` | `newest`, `oldest`, `updated`, `severity`, `occurrences` | | `order` | `newest`, `oldest`, `updated`, `severity`, `occurrences` |
+63 -1
View File
@@ -1,8 +1,70 @@
{ {
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.", "_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
"schema": 1, "schema": 1,
"current": "2.7.0", "current": "2.7.1",
"versions": [ "versions": [
{
"version": "2.7.1",
"date": "2026-08-30",
"summary": "Aufraeumen nach dem ersten Durchlauf der Testinstanz: Was dort auffiel, war fast durchweg dieselbe Sorte Fehler - eine Auskunft, die nicht stimmte. Der Server nannte eine falsche Fassung, doctor meldete Bereitschaft ohne zu pruefen, /api/health starb an dem Zustand, ueber den es Auskunft geben sollte, und das WebUI zeigte nach jedem Filter eine andere Ansicht als die verlangte.",
"actionRequired": false,
"changes": [
{
"area": "server",
"breaking": false,
"title": "Die gemeldete Fassung kommt aus dem Changelog statt aus der Konfiguration",
"text": "config/config.php fuehrte 'version' als Zeichenkette, und die stand seit jeher auf 2.0.0 - waehrend 2.7.0 lief. Das war keine Schoenheitsfrage: /api/health meldete 2.0.0, selbst gemeldete Fehler trugen eine falsche build_version, und jede Pruefung gegen eine Mindestfassung schlug auf einem aktuellen Server fehl. Genau daraus entstand der Eindruck, die Testinstanz laufe auf veralteter Software. src/bootstrap.php liest die Fassung jetzt aus 'current' in public/docs/changelog.json - der Datei, die bei jedem Release ohnehin gepflegt wird. Der Wert in der Konfiguration bleibt Rueckfallebene."
},
{
"area": "api",
"breaking": false,
"title": "/api/health ueberlebt eine leere Datenbank",
"text": "Auf einer frisch aufgesetzten Instanz - Anwendung deployt, Migrationen noch nicht gelaufen - endete GET /api/health mit HTTP 500: die Rechtepruefung griff auf dc_tokens zu, und diese Tabelle gab es noch nicht. Ausgerechnet der Endpunkt, der ueber kaputte Zustaende Auskunft geben soll, fiel bei einem kaputten Zustand aus. Neu ist eine eigene Pruefung 'schema': fehlt das Schema, kommt healthy=false mit dem Hinweis auf install_db.php statt eines Serverfehlers. Die Rechtepruefung selbst ist zusaetzlich abgesichert, damit auch ein halb migrierter Stand eine auswertbare Antwort liefert."
},
{
"area": "api",
"breaking": false,
"title": "Neu: GET /api/tokens/v1/whoami - Auskunft ueber das eigene Token",
"text": "Meldet Besitzerart, Projektbindung, Rechte, Umgebung und Frist des uebergebenen Tokens; der Klartextwert wird nicht ausgeliefert, ein widerrufenes oder abgelaufenes Token bekommt 401. Bis hierher liess sich ein Recht nur durch Ausprobieren feststellen - und das hat Nebenwirkungen: eine Probe auf bugtracker:manage uebernaehme ein Item, eine auf license:dev stellte einen Schluessel aus. Deshalb pruefte kein Werkzeug seine Voraussetzungen, sondern lief hinein. 'is_workbench' ist der Wert, der ueber Fremdmeldungen entscheidet."
},
{
"area": "tooling",
"breaking": false,
"title": "dc-testkit doctor prueft Mindestfassung und Rechte, statt sie nur anzuzeigen",
"text": "doctor meldete 'Bereit.', obwohl der Server die benoetigten Endpunkte nicht hatte und das Token die noetigen Rechte nicht trug - der Lauf scheiterte dann beim ersten Schritt, der beides wirklich brauchte. Die Fassung stand dabei schon in der Ausgabe, verglichen wurde sie nur nie. Jetzt: Vergleich gegen DC_MIN_SERVER (2.6.1) nach semantischer Ordnung, und ueber /api/tokens/v1/whoami die Pruefung auf Besitzerart dev_agent sowie auf bugtracker:manage, license:dev und updateservice:read - fehlende Rechte werden einzeln benannt. Gehoert das Token zu einem anderen Projekt als dem bearbeiteten, sagt doctor das ebenfalls. Gegen einen aelteren Server faellt er auf die grobe Pruefung zurueck und weist ausdruecklich darauf hin, dass die Rechte ungeprueft blieben."
},
{
"area": "betrieb",
"breaking": false,
"title": "dc-testkit.sh: erreichbar unter /testkit/ und mit Unix-Zeilenenden",
"text": "Zwei Fehler auf demselben Weg. Erstens fehlte die Route: TESTUMGEBUNG.md nennt 'curl https://dc.mhdf.de/testkit/dc-testkit.sh', ausgeliefert war die Datei aber nur unter /public/testkit/ - der dokumentierte Bezugsweg hat nie funktioniert und wurde als 'Server noch zu alt' gedeutet. Zweitens die Zeilenenden: .gitattributes fuehrt *.sh als eol=lf, normalisiert wird aber erst beim Einchecken - hochgeladen wird der Arbeitsbaum. Von einem Windows-Rechner aus landete das Skript deshalb mit CRLF im Webroot und scheiterte auf Linux mit 'env: bash\\r: No such file or directory'. scripts/deploy.py und scripts/deploy_dev.py entfernen das CR bei .sh und .bash jetzt beim Hochladen, unabhaengig vom Zustand der Arbeitskopie."
},
{
"area": "webui",
"breaking": false,
"title": "Filter und Formulare werfen nicht mehr aufs Dashboard zurueck",
"text": "Welche Ansicht sichtbar ist, hing allein am Anker der URL - und die Ansichtsrettung, die das auffangen sollte, griff aus zwei Gruenden nicht.\n\nErstens fragte sie dokumentweit nach der offenen Unteransicht. Im Markup tragen aber ACHT Unteransichten die Klasse \"active\": jedes Modul hat seine eigene Startansicht, und switchMainTab() raeumt nur innerhalb des Moduls auf, das es oeffnet. Zurueck kam deshalb immer die erste in Dokumentreihenfolge, unabhaengig davon, was gerade sichtbar war. Dass die Rettung trotzdem zu funktionieren schien, lag an den wenigen Formularen, die ihren Anker fest in der action stehen haben. Gesucht wird jetzt erst das aktive Modul und darin die Unteransicht.\n\nZweitens fielen die Filter durch das Netz: Es sind GET-Formulare, und ein GET-Formular verwirft beim Absenden den Anker seiner action; ausserdem loesten sie ueber form.submit() aus, was gar kein submit-Ereignis erzeugt. Der zentrale Zuhoerer haengt die offene Ansicht bei GET jetzt als Parameter \"view\" an den Query-String, ausgeloest wird ueber requestSubmit(), und beim Laden wird die Ansicht daraus wiederhergestellt. Die drei Filterformulare tragen das Feld zusaetzlich fest im Markup - dort ist ohne Zweifel bekannt, wo sie stehen, und das haengt an keiner DOM-Abfrage mehr."
},
{
"area": "api",
"breaking": false,
"title": "/api/bugtracker/v1/roadmap ist jetzt auch ohne .php erreichbar",
"text": "Jeder andere Endpunkt kommt ohne Dateiendung aus, dieser nicht: Die Route fehlte in der .htaccess, der Aufruf lief in ein 404, und die OpenAPI-Beschreibung fuehrte deshalb als einzigen Pfad einen mit .php. Wer dem Muster der uebrigen API folgte, lief auf. Die Route ist nachgetragen, die Beschreibung nennt jetzt den Pfad ohne Endung. Der alte Pfad mit .php funktioniert unveraendert weiter - er faellt auf die allgemeine Regel fuer /api/ zurueck."
},
{
"area": "webui",
"breaking": false,
"title": "Die Suche findet Eintraege ueber ihre Nummer",
"text": "Die Nummer ist das, was ueberall zitiert wird - in Commits, in Kommentaren, im Changelog - und war ausgerechnet ueber die Suche nicht auffindbar: Sie steht in keiner der durchsuchten Spalten. Wer \"288\" eingab, bekam nichts und musste die Liste durchblaettern. Jetzt sucht die Nummer mit, im Bugtracker wie im Fehler-Stream und ebenso ueber den Parameter search der API. Zwei Schreibweisen, zwei Bedeutungen: \"288\" findet das Item mit dieser Nummer UND alles, was die Zahl im Text stehen hat; \"#288\" ist eindeutig gemeint und liefert ausschliesslich dieses eine Item."
},
{
"area": "api",
"breaking": false,
"title": "Der Parameter search ist jetzt beschrieben",
"text": "action=list kennt search seit jeher, in der OpenAPI-Beschreibung stand er nicht. Nachgetragen, mitsamt dem Verhalten bei Zahlen."
}
]
},
{ {
"version": "2.7.0", "version": "2.7.0",
"date": "2026-08-29", "date": "2026-08-29",
+135 -38
View File
@@ -4018,7 +4018,14 @@ chmod +x update-agent
<div class="card" style="padding:0.85rem 1.1rem;"> <div class="card" style="padding:0.85rem 1.1rem;">
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.45rem; align-items:center;"> <form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.45rem; align-items:center;">
<select name="rm_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-roadmap-board">
<select name="rm_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option> <option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?> <?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>> <option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
@@ -4027,7 +4034,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="rm_lage" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="rm_lage" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
// Voreinstellung ist "aktiv" und nicht "alle": Eine Roadmap, // Voreinstellung ist "aktiv" und nicht "alle": Eine Roadmap,
// die mit sechzig erledigten Eintraegen beginnt, beantwortet // die mit sechzig erledigten Eintraegen beginnt, beantwortet
@@ -4046,7 +4053,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="rm_epic" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="rm_epic" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🗂️ Alle Bereiche</option> <option value="all">🗂️ Alle Bereiche</option>
<?php foreach ($roadmapEpics as $rmE): ?> <?php foreach ($roadmapEpics as $rmE): ?>
<option value="<?= e((string)$rmE['epic']) ?>" <?= $rmEpic === (string)$rmE['epic'] ? 'selected' : '' ?>> <option value="<?= e((string)$rmE['epic']) ?>" <?= $rmEpic === (string)$rmE['epic'] ? 'selected' : '' ?>>
@@ -4055,7 +4062,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="rm_build" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="rm_build" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🎯 Alle Zielfassungen</option> <option value="all">🎯 Alle Zielfassungen</option>
<?php foreach ($roadmapBuilds as $rmB): ?> <?php foreach ($roadmapBuilds as $rmB): ?>
<option value="<?= e((string)$rmB) ?>" <?= $rmBuild === (string)$rmB ? 'selected' : '' ?>><?= e((string)$rmB) ?></option> <option value="<?= e((string)$rmB) ?>" <?= $rmBuild === (string)$rmB ? 'selected' : '' ?>><?= e((string)$rmB) ?></option>
@@ -4641,7 +4648,14 @@ chmod +x update-agent
und ging bei jedem Auto-Refresh verloren. und ging bei jedem Auto-Refresh verloren.
--> -->
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;"> <form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-bugtracker-items">
<select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option> <option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?> <?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $btFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>> <option value="<?= e($p['slug']) ?>" <?= $btFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
@@ -4650,7 +4664,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="bt_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="bt_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$envOptions = [ $envOptions = [
'all' => '🌍 Alle Umgebungen', 'all' => '🌍 Alle Umgebungen',
@@ -4664,7 +4678,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="bt_type" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="bt_type" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$typeOptions = [ $typeOptions = [
'all' => '📂 Alle Typen', 'all' => '📂 Alle Typen',
@@ -4676,7 +4690,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="bt_severity" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="bt_severity" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$sevOptions = [ $sevOptions = [
'all' => '🔥 Alle Schweregrade', 'all' => '🔥 Alle Schweregrade',
@@ -4692,7 +4706,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="bt_status" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="bt_status" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$statusOptions = [ $statusOptions = [
'all' => '📌 Alle Status', 'all' => '📌 Alle Status',
@@ -4720,7 +4734,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="bt_order" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="bt_order" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$orderOptions = [ $orderOptions = [
'newest' => '🕒 Neueste zuerst', 'newest' => '🕒 Neueste zuerst',
@@ -4735,7 +4749,8 @@ chmod +x update-agent
</select> </select>
<input type="text" name="bt_q" value="<?= e($btFilters['search']) ?>" class="form-input" <input type="text" name="bt_q" value="<?= e($btFilters['search']) ?>" class="form-input"
style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen..."> style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
<button type="submit" class="btn btn-sm">Filtern</button> <button type="submit" class="btn btn-sm">Filtern</button>
<?php if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all' || $btFilters['status'] !== 'all' || $btFilters['severity'] !== 'all' || $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?> <?php if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all' || $btFilters['status'] !== 'all' || $btFilters['severity'] !== 'all' || $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?>
@@ -5026,7 +5041,14 @@ chmod +x update-agent
</h2> </h2>
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;"> <form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-bugtracker-errors">
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option> <option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?> <?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $errFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>> <option value="<?= e($p['slug']) ?>" <?= $errFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
@@ -5035,7 +5057,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="err_level" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="err_level" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$levelOptions = [ $levelOptions = [
'all' => '🎚️ Alle Klassen', 'all' => '🎚️ Alle Klassen',
@@ -5048,7 +5070,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="err_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="err_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$errEnvOptions = [ $errEnvOptions = [
'all' => '🌍 Alle Umgebungen', 'all' => '🌍 Alle Umgebungen',
@@ -5062,7 +5084,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="err_show" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="err_show" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$showOptions = [ $showOptions = [
'active' => '🔔 Nur relevante', 'active' => '🔔 Nur relevante',
@@ -5074,7 +5096,7 @@ chmod +x update-agent
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="err_hours" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()"> <select name="err_hours" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php <?php
$hourOptions = [ $hourOptions = [
'0' => '🕒 Gesamter Zeitraum', '0' => '🕒 Gesamter Zeitraum',
@@ -5089,7 +5111,8 @@ chmod +x update-agent
</select> </select>
<input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input" <input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input"
style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen..."> style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
<button type="submit" class="btn btn-sm">Filtern</button> <button type="submit" class="btn btn-sm">Filtern</button>
<a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a> <a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a>
</form> </form>
@@ -6246,42 +6269,96 @@ SYSTEM
} }
/** /**
* Merkt sich beim Absenden, welche Unteransicht offen war. * Ansichtsrettung: Merkt sich beim Absenden, welche Unteransicht offen
* war, und stellt sie nach dem Neuladen wieder her.
* *
* Der Server haengt diesen Wert nach dem Speichern wieder an die URL. * Ohne das landete man stets auf dem Dashboard - man legte einen
* Ohne das landete man stets auf dem Dashboard des Moduls - man legte * Monitor an und wurde aus dem Formular geworfen, und jede
* einen Monitor an und wurde aus dem Formular geworfen. * Filteraenderung im Bugtracker warf einen dorthin zurueck.
* *
* Als zentraler Zuhoerer umgesetzt, damit auch kuenftige Formulare das * Als zentraler Zuhoerer umgesetzt, damit auch kuenftige Formulare das
* Verhalten automatisch erben. * Verhalten automatisch erben. Die drei Funktionen darunter gehoeren
* zusammen: dcCurrentView() liest die Ansicht ab, dcRememberView()
* haengt sie an das Formular, dcSubmit() sorgt dafuer, dass beim
* Absenden aus einem onchange ueberhaupt ein submit-Ereignis entsteht.
*/ */
document.addEventListener('submit', (event) => { function dcCurrentView() {
const form = event.target; // Erst das Modul, dann die Unteransicht DARIN. Die Reihenfolge ist
if (!(form instanceof HTMLFormElement)) { // der Kern der Sache: Im Markup tragen ACHT Unteransichten die
return; // Klasse "active" - jedes Modul hat seine eigene Startansicht, und
} // switchMainTab() raeumt nur innerhalb des Moduls auf, das es
if ((form.method || '').toLowerCase() !== 'post') { // oeffnet. Ein dokumentweites querySelector('.subtab-content.active')
return; // liefert deshalb immer die erste in Dokumentreihenfolge
} // (sub-projects-list), voellig unabhaengig davon, was gerade
if (form.querySelector('input[name="_return"]')) { // sichtbar ist. Genau daran ist die Ansichtsrettung fuer POST seit
return; // jeher gescheitert - und der Anschein, sie funktioniere, kam von
} // den wenigen Formularen, die ihren Anker fest in der action
// stehen haben.
const activeSub = document.querySelector('.subtab-content.active');
const activeTab = document.querySelector('.main-content > .tab-content.active'); const activeTab = document.querySelector('.main-content > .tab-content.active');
const target = activeSub ? activeSub.id : (activeTab ? activeTab.id : ''); if (!activeTab) {
return '';
}
const activeSub = activeTab.querySelector('.subtab-content.active');
return activeSub ? activeSub.id : activeTab.id;
}
/**
* Haengt die gerade offene Ansicht an ein Formular.
*
* POST: als _return - dc_redirect() macht daraus den Anker der
* Weiterleitung.
* GET: als view - ein Anker ueberlebt die Serialisierung eines
* GET-Formulars nicht, der Query-String schon. Genau daran
* scheiterten die Filter: nach jeder Aenderung stand die Seite
* wieder auf dem Dashboard, weil die neue URL keinen Anker
* mehr trug.
*/
function dcRememberView(form) {
const isPost = (form.method || 'get').toLowerCase() === 'post';
const name = isPost ? '_return' : 'view';
if (form.querySelector('input[name="' + name + '"]')) {
return;
}
const target = dcCurrentView();
if (!target) { if (!target) {
return; return;
} }
const field = document.createElement('input'); const field = document.createElement('input');
field.type = 'hidden'; field.type = 'hidden';
field.name = '_return'; field.name = name;
field.value = target; field.value = target;
form.appendChild(field); form.appendChild(field);
}
document.addEventListener('submit', (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
dcRememberView(form);
}, true); }, true);
/**
* Absenden aus einem onchange heraus.
*
* form.submit() loest KEIN submit-Ereignis aus - der Zuhoerer oben
* lief deshalb bei den Filtern nie, obwohl es ihn gab.
* requestSubmit() tut es. Wo es fehlt, wird die Ansicht vorher von
* Hand angehaengt.
*/
function dcSubmit(form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
return;
}
dcRememberView(form);
form.submit();
}
// Sidebar Toggle Handler // Sidebar Toggle Handler
function toggleSidebar() { function toggleSidebar() {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
@@ -6926,7 +7003,18 @@ SYSTEM
* Modulnamen. Der Rueckweg landete deshalb immer auf dem Dashboard. * Modulnamen. Der Rueckweg landete deshalb immer auf dem Dashboard.
*/ */
function restoreViewFromHash() { function restoreViewFromHash() {
const hash = location.hash.replace('#', ''); let hash = location.hash.replace('#', '');
// Ohne Anker: der Wert aus dem Query-String, den dcRememberView()
// an GET-Formulare haengt. Nur eigene Ansichtskennungen zulassen -
// der Wert steuert, was angezeigt wird.
if (!hash) {
const fromQuery = new URLSearchParams(location.search).get('view');
if (fromQuery && /^(?:tab|sub)-[a-z0-9-]{1,60}$/.test(fromQuery)) {
hash = fromQuery;
}
}
if (!hash) { if (!hash) {
return; return;
} }
@@ -6945,6 +7033,15 @@ SYSTEM
const moduleName = moduleOfSubtab(hash); const moduleName = moduleOfSubtab(hash);
if (!moduleName) { if (!moduleName) {
// Unbekannte Unteransicht - dann wenigstens das Modul, wenn es
// sich aus dem Namen ergibt. Wortlos im Dashboard zu landen ist
// die schlechteste aller Antworten: Der Nutzer sieht nicht,
// dass etwas schiefging, sondern nur, dass er woanders ist.
const rest = hash.substring(4);
const guess = Object.keys(subnavItems).find(m => rest.startsWith(m + '-'));
if (guess) {
switchMainTab(guess, null, null, false);
}
return; return;
} }
+124 -4
View File
@@ -46,6 +46,13 @@ DC_PROJECT="${DC_PROJECT:-}"
DC_TOKEN="${DC_TOKEN:-}" DC_TOKEN="${DC_TOKEN:-}"
DC_CHANNEL="${DC_CHANNEL:-prod}" DC_CHANNEL="${DC_CHANNEL:-prod}"
# Aelteste Fassung des Deploymentcenters, mit der dieses Testkit vollstaendig
# arbeiten kann. Darunter fehlen /api/license/v1/dev-key (dc-testkit license),
# der Bezug ueber /testkit/ und /api/tokens/v1/whoami (Rechtepruefung in
# doctor). Die Zahl stand vorher nur in einer Fehlermeldung mitten im Ablauf -
# doctor kannte sie nicht und meldete "Bereit" auf einem zu alten Server.
DC_MIN_SERVER="${DC_MIN_SERVER:-2.6.1}"
# Die Maschinenkennung entscheidet, welchen DEV-Schluessel der Server # Die Maschinenkennung entscheidet, welchen DEV-Schluessel der Server
# heraussucht. Sie muss ueber Laeufe hinweg gleich bleiben - sonst sammelt sich # heraussucht. Sie muss ueber Laeufe hinweg gleich bleiben - sonst sammelt sich
# je Lauf ein weiterer Schluessel an, bis die Grenze je Projekt greift. # je Lauf ein weiterer Schluessel an, bis die Grenze je Projekt greift.
@@ -241,6 +248,52 @@ ensure_state() {
chmod 700 "$dir" 2>/dev/null || true chmod 700 "$dir" 2>/dev/null || true
} }
# ---------------------------------------------------------------------------
# Fassungen und Rechte
# ---------------------------------------------------------------------------
# Ist $1 aelter als $2? 0 = ja, 1 = nein, 2 = nicht vergleichbar.
#
# Nicht "sort -V": das fehlt auf BusyBox-Systemen, und die Antwort waere dort
# still falsch statt laut fehlend. Ein Vorabkennzeichen (1.2.3-rc1) wird
# abgeschnitten - fuer die Frage "reicht der Server aus" genuegt das.
version_lt() {
local a="${1%%-*}" b="${2%%-*}" i av bv
for i in 1 2 3; do
av=$(printf '%s' "$a" | cut -d. -f$i)
bv=$(printf '%s' "$b" | cut -d. -f$i)
av="${av:-0}"; bv="${bv:-0}"
case "$av$bv" in
''|*[!0-9]*) return 2 ;;
esac
[ "$av" -lt "$bv" ] && return 0
[ "$av" -gt "$bv" ] && return 1
done
return 1
}
# Deckt die Rechteliste $1 (JSON-Array aus whoami) das Recht $2 ab?
#
# Die Wildcards muessen mitgedacht werden: "*" deckt alles ab, "bugtracker:*"
# alles darunter. Sonst meldete das Werkzeug ein Recht als fehlend, das der
# Server anerkennt - und schickte jemanden ein Token neu bauen, das passt.
has_scope() {
local list="$1" want="$2" prefix
prefix="${2%%:*}"
case "$list" in
*'"*"'*) return 0 ;;
*"\"$want\""*) return 0 ;;
*"\"$prefix:*\""*) return 0 ;;
esac
return 1
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# doctor - laeuft hier ueberhaupt etwas? # doctor - laeuft hier ueberhaupt etwas?
# #
@@ -263,9 +316,34 @@ cmd_doctor() {
step "Deploymentcenter unter $DC_URL" step "Deploymentcenter unter $DC_URL"
if api GET /api/health; then if api GET /api/health; then
local healthy local healthy server_version
healthy=$(printf '%s' "$API_BODY" | json_get '.healthy' || echo '?') healthy=$(printf '%s' "$API_BODY" | json_get '.healthy' || echo '?')
ok "erreichbar, healthy=$healthy, Fassung $(printf '%s' "$API_BODY" | json_get '.version' || echo '?')" server_version=$(printf '%s' "$API_BODY" | json_get '.version' || echo '')
ok "erreichbar, healthy=$healthy, Fassung ${server_version:-?}"
# Die Fassung stand doctor immer schon vor Augen - verglichen wurde sie
# nur nie. Deshalb meldete er "Bereit" und der erste Schritt, der den
# Server wirklich braucht, scheiterte danach mit "kennt
# /api/license/v1/dev-key noch nicht".
if [ -z "$server_version" ]; then
warn "der Server nennt keine Fassung - $DC_MIN_SERVER nicht pruefbar"
else
version_lt "$server_version" "$DC_MIN_SERVER"
case $? in
0)
warn "zu alt: $server_version - gebraucht wird mindestens $DC_MIN_SERVER"
warn " Es fehlen: dc-testkit license (dev-key), der Bezug ueber /testkit/"
warn " und die Rechtepruefung. Der Server muss deployt werden."
failed=1
;;
2)
warn "Fassung \"$server_version\" ist nicht auswertbar - $DC_MIN_SERVER ungeprueft"
;;
*)
ok "erfuellt die Mindestfassung $DC_MIN_SERVER"
;;
esac
fi
else else
warn "nicht erreichbar: $(api_error)" warn "nicht erreichbar: $(api_error)"
failed=1 failed=1
@@ -275,8 +353,50 @@ cmd_doctor() {
if [ -z "$DC_TOKEN" ]; then if [ -z "$DC_TOKEN" ]; then
warn "kein Token gesetzt (DC_TOKEN)" warn "kein Token gesetzt (DC_TOKEN)"
failed=1 failed=1
elif api GET /api/bugtracker/v1/projects; then elif api GET /api/tokens/v1/whoami; then
ok "gueltig - der Bugtracker antwortet" # Fruehere Fassungen meldeten hier nur "gueltig - der Bugtracker
# antwortet". Das lief auch mit einem Laufzeit-Token durch, dem
# bugtracker:manage und license:dev fehlten - der Lauf scheiterte dann
# erst dort, wo die Rechte wirklich gebraucht wurden.
local tok_name tok_owner tok_project tok_scopes tok_workbench want
tok_name=$(printf '%s' "$API_BODY" | json_get '.name' || echo '?')
tok_owner=$(printf '%s' "$API_BODY" | json_get '.owner_type' || echo '?')
tok_project=$(printf '%s' "$API_BODY" | json_get '.project_slug' || echo '')
tok_scopes=$(printf '%s' "$API_BODY" | json_get '.scopes' || echo '[]')
tok_workbench=$(printf '%s' "$API_BODY" | json_get '.is_workbench' || echo 'false')
ok "gueltig - \"$tok_name\", Besitzerart $tok_owner, Projekt ${tok_project:-keins}"
dim " Rechte: $tok_scopes"
if [ "$tok_workbench" != 'true' ]; then
warn "kein Werkbank-Token: Besitzerart ist $tok_owner, gebraucht wird dev_agent"
warn " Ein Laufzeit-Token darf nicht in fremde Projekte melden (TESTUMGEBUNG.md 4.2)."
failed=1
fi
for want in bugtracker:manage license:dev updateservice:read; do
if ! has_scope "$tok_scopes" "$want"; then
warn "Recht fehlt: $want"
failed=1
fi
done
if [ -n "$tok_project" ] && [ -n "$DC_PROJECT" ] && [ "$tok_project" != "$DC_PROJECT" ]; then
warn "Token gehoert zu $tok_project, gearbeitet wird an $DC_PROJECT"
warn " Je Projekt ein eigenes Token - sonst ist die Herkunft einer Meldung nicht mehr ablesbar."
failed=1
fi
elif [ "$API_STATUS" = '404' ]; then
# Server ohne whoami. Dann bleibt nur die alte, grobe Pruefung - und
# der ausdrueckliche Hinweis, dass die Rechte ungeprueft sind. Still
# "gueltig" zu melden waere genau der Fehler, der hier behoben wird.
if api GET /api/bugtracker/v1/projects; then
warn "gueltig, aber die Rechte sind ungeprueft"
warn " /api/tokens/v1/whoami gibt es erst ab $DC_MIN_SERVER."
else
warn "Token wird nicht angenommen: $(api_error)"
failed=1
fi
else else
warn "Token wird nicht angenommen: $(api_error)" warn "Token wird nicht angenommen: $(api_error)"
failed=1 failed=1
+31 -1
View File
@@ -17,6 +17,7 @@ gibt gruenes Licht fuer Code, den er nie gesehen hat.
import os import os
import json import json
import ftplib import ftplib
import io
import subprocess import subprocess
import sys import sys
import hashlib import hashlib
@@ -110,6 +111,35 @@ def should_ignore(rel_path):
return True return True
return False return False
# Dateien, die auf dem Server ausgefuehrt oder von einer Shell gelesen werden.
# Fuer sie ist CRLF kein Schoenheitsfehler, sondern ein Abbruch.
LF_REQUIRED_SUFFIXES = {'.sh', '.bash'}
def open_for_upload(full_path):
"""
Oeffnet eine Datei zum Hochladen und entfernt bei Shell-Skripten das CR.
Warum das noetig ist, obwohl .gitattributes "*.sh text eol=lf" fuehrt:
Normalisiert wird beim EINCHECKEN. Eine Arbeitskopie, die vorher schon
CRLF hatte, bleibt unberuehrt - und genau die laedt dieses Skript hoch,
denn es liest den Arbeitsbaum, nicht den Index. storbinary uebertraegt
Byte fuer Byte. So landete public/testkit/dc-testkit.sh mit CRLF im
Webroot und scheiterte auf der Linux-VM mit
"env: bash\r: No such file or directory".
Die Umwandlung hier schuetzt unabhaengig davon, von welchem Rechner
ausgeliefert wird und in welchem Zustand die Arbeitskopie ist.
"""
if os.path.splitext(full_path)[1].lower() not in LF_REQUIRED_SUFFIXES:
return open(full_path, 'rb')
with open(full_path, 'rb') as handle:
data = handle.read()
return io.BytesIO(data.replace(b'\r\n', b'\n'))
def collect_changes(cache): def collect_changes(cache):
"""Ermittelt alle Dateien, die sich seit dem letzten Deployment geaendert haben.""" """Ermittelt alle Dateien, die sich seit dem letzten Deployment geaendert haben."""
files_to_upload = [] files_to_upload = []
@@ -216,7 +246,7 @@ def deploy():
ftp.cwd('/') ftp.cwd('/')
print(f"Uploading {rel_file} ...") print(f"Uploading {rel_file} ...")
with open(full_path, 'rb') as f: with open_for_upload(full_path) as f:
ftp.storbinary(f"STOR {remote_file_path}", f) ftp.storbinary(f"STOR {remote_file_path}", f)
new_cache[rel_file] = file_hash new_cache[rel_file] = file_hash
+15 -2
View File
@@ -203,6 +203,19 @@ def should_ignore(rel_path):
return False return False
# Dateien, die auf der Test-VM ausgefuehrt werden. CRLF ist dort kein
# Schoenheitsfehler, sondern ein Abbruch: "env: bash\r: No such file or
# directory". .gitattributes normalisiert erst beim Einchecken - hochgeladen
# wird der Arbeitsbaum. Deshalb hier, wie in deploy.py.
LF_REQUIRED_SUFFIXES = {'.sh', '.bash'}
def normalize_for_upload(rel, data):
if os.path.splitext(rel)[1].lower() not in LF_REQUIRED_SUFFIXES:
return data
return data.replace(b'\r\n', b'\n')
def collect(cache, generated, full=False): def collect(cache, generated, full=False):
"""(remote_pfad, bytes, hash) fuer alles, was uebertragen werden muss.""" """(remote_pfad, bytes, hash) fuer alles, was uebertragen werden muss."""
items = [] items = []
@@ -214,10 +227,10 @@ def collect(cache, generated, full=False):
rel = ('%s/%s' % (rel_dir, f)).lstrip('/') rel = ('%s/%s' % (rel_dir, f)).lstrip('/')
if should_ignore(rel): if should_ignore(rel):
continue continue
items.append((rel, (Path(root) / f).read_bytes())) items.append((rel, normalize_for_upload(rel, (Path(root) / f).read_bytes())))
for rel in sorted(generated): for rel in sorted(generated):
items.append((rel, generated[rel])) items.append((rel, normalize_for_upload(rel, generated[rel])))
out = [] out = []
for rel, data in items: for rel, data in items:
+29 -2
View File
@@ -1121,16 +1121,43 @@ final class BugRepo
// Volltextsuche. Jede Spalte bekommt einen eigenen Platzhalter - // Volltextsuche. Jede Spalte bekommt einen eigenen Platzhalter -
// derselbe Name mehrfach ist bei nativen Prepared Statements unzulaessig. // derselbe Name mehrfach ist bei nativen Prepared Statements unzulaessig.
//
// Die Nummer sucht mit. Sie ist das, was ueberall zitiert wird - in
// Commits, in Kommentaren, im Changelog -, und war ausgerechnet ueber
// die Suche nicht auffindbar: Sie steht in keiner der durchsuchten
// Spalten. Wer "288" eingab, bekam nichts und musste die Liste
// durchblaettern.
//
// Zwei Schreibweisen, zwei Bedeutungen:
// #288 - eindeutig gemeint ist dieses eine Item. Nur die ID.
// 288 - kann auch im Text vorkommen. ID ODER Volltext.
$search = $filters['search'] ?? null; $search = $filters['search'] ?? null;
if (is_string($search) && trim($search) !== '') { if (is_string($search) && trim($search) !== '') {
$needle = '%' . trim($search) . '%'; $roh = trim($search);
$columns = ['title', 'description', 'error_message', 'push_id', 'target_agent', 'tags', 'file_path']; $text = ltrim($roh, '#');
// Die Laengengrenze haelt die Umwandlung nach int im Rahmen; eine
// 30-stellige Zahl waere ohnehin keine Item-Nummer.
$istNummer = $text !== '' && strlen($text) <= 18 && ctype_digit($text);
$nurNummer = $istNummer && $roh !== $text;
$parts = []; $parts = [];
if ($istNummer) {
$parts[] = 'id = :search_id';
$params[':search_id'] = (int)$text;
}
if (!$nurNummer) {
$needle = '%' . ($text !== '' ? $text : $roh) . '%';
$columns = ['title', 'description', 'error_message', 'push_id', 'target_agent', 'tags', 'file_path'];
foreach ($columns as $i => $column) { foreach ($columns as $i => $column) {
$ph = ':search_' . $i; $ph = ':search_' . $i;
$parts[] = $column . ' LIKE ' . $ph; $parts[] = $column . ' LIKE ' . $ph;
$params[$ph] = $needle; $params[$ph] = $needle;
} }
}
$where[] = '(' . implode(' OR ', $parts) . ')'; $where[] = '(' . implode(' OR ', $parts) . ')';
} }
+37
View File
@@ -50,6 +50,43 @@ if (!is_array($dcConfig)) {
exit(1); exit(1);
} }
// --- Fassung: aus dem Changelog, nicht aus der Konfiguration ---
// Die ausgelieferte Fassung ist eine Eigenschaft des Standes, keine
// Einstellung der Installation. In config/config.php stand sie als
// Zeichenkette - und blieb dort auf 2.0.0 stehen, waehrend laengst 2.7.0 lief.
// Das war keine Schoenheitsfrage: /api/health meldete 2.0.0, Fehlermeldungen
// trugen eine falsche build_version, und wer die Fassung gegen eine
// Mindestanforderung prueft (dc-testkit doctor), bekam auf einem aktuellen
// Server eine Absage. public/docs/changelog.json wird bei jedem Release
// ohnehin gepflegt und ist damit die belastbarere Quelle. Der Wert aus der
// Konfiguration bleibt Rueckfallebene, falls die Datei fehlt.
//
// Gelesen wird nur der Kopf der Datei: sie ist rund 55 KB gross, und dafuer
// bei jedem Aufruf einen json_decode zu bezahlen, waere fuer eine einzelne
// Zeichenkette zu teuer. Findet sich der Schluessel dort nicht - etwa weil
// jemand die Datei umsortiert hat -, wird sie doch vollstaendig gelesen.
$dcChangelogFile = DC_ROOT . '/public/docs/changelog.json';
if (is_file($dcChangelogFile)) {
$dcVersion = null;
$dcHead = (string)file_get_contents($dcChangelogFile, false, null, 0, 4096);
if (preg_match('/"current"\s*:\s*"([^"]+)"/', $dcHead, $dcMatch) === 1) {
$dcVersion = $dcMatch[1];
} else {
$dcMeta = json_decode((string)file_get_contents($dcChangelogFile), true);
if (is_array($dcMeta) && is_string($dcMeta['current'] ?? null)) {
$dcVersion = $dcMeta['current'];
}
}
if (is_string($dcVersion) && $dcVersion !== '') {
if (!isset($dcConfig['app']) || !is_array($dcConfig['app'])) {
$dcConfig['app'] = [];
}
$dcConfig['app']['version'] = $dcVersion;
}
}
Deploymentcenter\Core\Config::load($dcConfig); Deploymentcenter\Core\Config::load($dcConfig);
date_default_timezone_set((string)Deploymentcenter\Core\Config::get('app.timezone', 'UTC')); date_default_timezone_set((string)Deploymentcenter\Core\Config::get('app.timezone', 'UTC'));
+41
View File
@@ -184,6 +184,47 @@ def _(ctx):
erwarte(ctx['bug_id'] in ids, "Item #%s nicht in der Liste (%s)" % (ctx['bug_id'], ids[:10])) erwarte(ctx['bug_id'] in ids, "Item #%s nicht in der Liste (%s)" % (ctx['bug_id'], ids[:10]))
@test('bugtracker', 'Suche nach der Nummer findet das Item')
def _(ctx):
"""
Die Nummer ist das, was ueberall zitiert wird - und war ausgerechnet
ueber die Suche nicht auffindbar: Sie steht in keiner der durchsuchten
Spalten. Wer sie eingab, bekam nichts.
"""
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': PROJEKT,
'search': str(ctx['bug_id']), 'limit': 50})
erwarte_erfolg(r)
ids = [i.get('id') for i in _liste(r, 'items', 'bugs', 'data') if isinstance(i, dict)]
erwarte(ctx['bug_id'] in ids,
"Suche nach %s findet das Item nicht (%s)" % (ctx['bug_id'], ids[:10]))
@test('bugtracker', 'Suche mit Raute liefert genau dieses Item')
def _(ctx):
"""
Mit Raute ist die Absicht eindeutig - dann darf kein Treffer dabei sein,
der die Zahl nur im Text stehen hat.
"""
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': PROJEKT,
'search': '#%s' % ctx['bug_id'], 'limit': 50})
erwarte_erfolg(r)
ids = [i.get('id') for i in _liste(r, 'items', 'bugs', 'data') if isinstance(i, dict)]
erwarte(ids == [ctx['bug_id']],
"Erwartet genau [%s], bekommen %s" % (ctx['bug_id'], ids))
@test('bugtracker', 'Suche nach einer fremden Nummer findet nichts')
def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': PROJEKT,
'search': '#999999', 'limit': 50})
erwarte_erfolg(r)
ids = [i.get('id') for i in _liste(r, 'items', 'bugs', 'data') if isinstance(i, dict)]
erwarte(ids == [], "Unerwartete Treffer: %s" % ids[:10])
@test('bugtracker', 'Einzelabruf liefert das Item') @test('bugtracker', 'Einzelabruf liefert das Item')
def _(ctx): def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/manage', r = ctx.client.get('/api/bugtracker/v1/manage',
+53
View File
@@ -46,6 +46,27 @@ def _(ctx):
erwarte(zusatz, "Mit Token kommen keine zusaetzlichen Angaben: %s" % r.kurz(300)) erwarte(zusatz, "Mit Token kommen keine zusaetzlichen Angaben: %s" % r.kurz(300))
@test('kern', 'Health meldet die Fassung aus dem Changelog')
def _(ctx):
"""
Die Fassung stand frueher als feste Zeichenkette in config/config.php und
blieb dort auf 2.0.0 stehen, waehrend 2.7.0 lief. Zwei gemeldete Fehler
sind daran fehldiagnostiziert worden ("der Server ist zu alt"). Sie kommt
jetzt aus public/docs/changelog.json - und dieser Fall haelt beides
zusammen.
"""
import json
pfad = PROJECT_DIR / 'public' / 'docs' / 'changelog.json'
erwartet = json.loads(pfad.read_text(encoding='utf-8'))['current']
r = ctx.client.get('/api/health', token=None)
erwarte_status(r, 200)
erwarte(r.feld('version') == erwartet,
"Server meldet %r, changelog.json fuehrt %r"
% (r.feld('version'), erwartet))
@test('kern', 'OpenAPI-Beschreibung ist gueltig') @test('kern', 'OpenAPI-Beschreibung ist gueltig')
def _(ctx): def _(ctx):
"""Die Beschreibung ist der Vertrag mit den SDKs - sie muss ladbar bleiben.""" """Die Beschreibung ist der Vertrag mit den SDKs - sie muss ladbar bleiben."""
@@ -221,6 +242,38 @@ def _(ctx):
erwarte_fehler(r, http=(401, 403)) erwarte_fehler(r, http=(401, 403))
@test('token', 'whoami nennt Bindung und Rechte des eigenen Tokens')
def _(ctx):
"""
Ohne diese Auskunft liesse sich ein Recht nur durch Ausprobieren
feststellen - und das hat Nebenwirkungen: eine Probe auf
bugtracker:manage uebernaehme ein Item, eine auf license:dev stellte
einen Schluessel aus.
"""
r = ctx.client.get('/api/tokens/v1/whoami', token=ctx['sub_token'])
erwarte_erfolg(r)
erwarte(r.feld('type') == 'sub', "type ist %r" % r.feld('type'))
erwarte(sorted(r.feld('scopes', default=[])) == sorted(ctx['sub_scopes']),
"whoami meldet %s, provisioniert wurde %s"
% (r.feld('scopes'), ctx['sub_scopes']))
erwarte(isinstance(r.feld('is_workbench'), bool),
"is_workbench fehlt oder ist kein Wahrheitswert: %s" % r.kurz(300))
@test('token', 'whoami liefert den Klartextwert nicht aus')
def _(ctx):
"""Eine Auskunft ueber ein Token darf das Token nicht weiterreichen."""
r = ctx.client.get('/api/tokens/v1/whoami', token=ctx['sub_token'])
erwarte(ctx['sub_token'] not in (r.body or ''),
"Der Tokenwert steht in der Antwort: %s" % r.kurz(300))
@test('token', 'whoami ohne Token abgewiesen')
def _(ctx):
r = ctx.client.get('/api/tokens/v1/whoami', token=None)
erwarte_fehler(r, http=401)
@test('token', 'Provisionierung ohne Master-Token abgewiesen') @test('token', 'Provisionierung ohne Master-Token abgewiesen')
def _(ctx): def _(ctx):
r = ctx.client.post('/api/tokens/v1/provision', token=None, r = ctx.client.post('/api/tokens/v1/provision', token=None,