fix(2.8.1): Sechs Befunde, eine Bauart - die Voreinstellung, die einsprang

Drei Agenten haben von aussen gemeldet, und fast jeder Befund hat dieselbe
Form: Irgendwo sprang ein Vorgabewert ein, obwohl die richtige Antwort
bekannt war - meist das Projekt, an das der Aufrufer ohnehin gebunden ist.

#351  manage?action=next antwortete durchgaengig mit HTTP 500. In
      BugRepo::buildFilter() stand :project_scope zweimal in derselben
      Anweisung; mit ATTR_EMULATE_PREPARES=false bindet PDO jeden Namen
      genau einmal - zwei Marker, ein Wert, SQLSTATE[HY093]. Getroffen war
      nur "next", weil nur dort der Rueckkanal-Filter gesetzt wird. Damit
      gab es keine exklusive Uebernahme. Es ist der VIERTE Fall dieser
      Bauart, deshalb prueft scripts/pruefe_platzhalter.py das jetzt
      mechanisch vor jedem Ausliefern.

#382  Meldungen ohne project_slug landeten im Scheinprojekt "default" -
#387  ohne dc_projects-Zeile, also project_id NULL. Die Antwort sah wie ein
#397  Erfolg aus, und danach sperrte dieselbe Token-Bindung den Zugriff
      (403 project_forbidden). Der Rueckfall aufs Heimatprojekt war an
      !isWorkbench gebunden; ein Werkbank-Token ohne Slug bekam gar keinen
      Vorgabewert. Jetzt: genannter Slug, sonst Heimatprojekt, sonst 400.
      Ein unbekanntes Projekt wird abgewiesen statt still angelegt.

#364  Ohne project_slug lieferten list, stats und die Roadmap ALLE
#176  Projekte statt des eigenen, weil die Vorgabe auf "all" stand und ein
      Werkbank-Token beim Lesen absichtlich nicht unter die Schranke faellt.
      Gemessen: 393 Items, davon 28 eigene. Ausgerechnet der erste Aufruf
      jeder Sitzung. Die Roadmap antwortet ausserdem schmal - 55 Felder je
      Eintrag waren 628.392 Zeichen und fuellten das Kontextfenster.

#404  Watchdog-Monitore bekamen nie ein Projekt: upsertHeartbeat() wurde
      keines uebergeben, obwohl authorizeSource() die Token-Bindung liest.
      Befunde erbten dadurch "default" - dc_findings(project_slug=...) war
      fuer eine laufende, meldende Instanz leer.

#131  Wo der Leseweg richtig verengte, sagte er es nicht: HTTP 200 mit dem
      umgeschriebenen Filter sieht aus wie eine Auskunft ueber das
      angefragte Projekt. Ein Agent, der vor dem Melden dc_search fragt,
      legt so das Duplikat an. Jetzt scoped_to und scope_note im Umschlag -
      nur bei tatsaechlicher Verengung.

#354  dc-testkit 1.1.0 wurde zweimal mit verschiedenem Inhalt ausgeliefert
      (1898 und 1930 Zeilen), 1.0.0 davor ebenso. Der Absatz ueber
      DC_TESTKIT_VERSION stand die ganze Zeit im Skript und hat nicht
      getragen. Jetzt 1.1.1 plus ein Riegel in deploy.py, der abbricht,
      wenn sich der Inhalt aendert und die Nummer nicht.

Warum die Testsuite all das nicht sah: Ihr Master-Token ist an kein Projekt
gebunden und nimmt deshalb ueberall den anderen Zweig. Es gab sogar einen
Testfall fuer action=next - er lief gruen, waehrend der Aufruf in der Praxis
ausnahmslos scheiterte. Die neuen Faelle benutzen ausdruecklich
projektgebundene Token; testctl kann dafuer jetzt owner_type und scopes
waehlen.

Ausserdem: Die Agentenvorlage nennt fuer die SDK-Fassung dc_link_project
statt einer abgeschriebenen Zahl - "SDK 2.5.1" stand dort als Beispiel und
wurde als Wert uebernommen.

Nicht enthalten: die 20 Altlasten in "default". Sie sind ueber die API nicht
erreichbar; sql/wartung/2026-08-31_default-projekt-aufraeumen.sql liegt dafuer
bereit (alle 20 haben einen titelgleichen Zwilling im richtigen Projekt).

156 e2e-Faelle gruen, davon 9 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-31 20:43:23 +02:00
co-authored by Claude Opus 5
parent 8274a6c0ac
commit 0e93345ab8
20 changed files with 1394 additions and 56 deletions
+20 -9
View File
@@ -365,20 +365,31 @@ Reagiere auf `code`, nicht auf den Text:
### Halte fest, gegen welche Fassung du integriert hast ### Halte fest, gegen welche Fassung du integriert hast
**Pflicht bei jeder Änderung an der Deploymentcenter-Anbindung.** Trage die **Pflicht bei jeder Änderung an der Deploymentcenter-Anbindung.** Melde die
Fassung in dieser Datei ein, direkt hier: Fassung mit `dc_link_project` (`to_slug: "deploymentcenter"`,
`integrated_version: "<deine Fassung>"`). Damit steht zentral, welche Projekte
von einem SDK-Fehler betroffen sind — `dc_projects` weist die Beziehung dann
bei jedem Projekt aus.
Wer zusätzlich eine Zeile in dieser Datei will, schreibt sie so:
``` ```
Deploymentcenter-Anbindung: SDK 2.5.1, integriert am 2026-08-14 Deploymentcenter-Anbindung: SDK <x.y.z>, integriert am <JJJJ-MM-TT>
``` ```
> Die spitzen Klammern sind Platzhalter und gehören ersetzt. Eine hier
> eingetragene Zahl ist eine Abschrift: Sie veraltet still, sobald jemand das
> Nachziehen vergisst, und niemand merkt es. `dc_link_project` ist die
> belastbare Auskunft, diese Zeile die bequeme.
Der Wert steht in `DeploymentcenterSdk.Version`. Ohne ihn muss beim nächsten Der Wert steht in `DeploymentcenterSdk.Version`. Ohne ihn muss beim nächsten
Mal die gesamte Historie gelesen werden. Mal die gesamte Historie gelesen werden.
**Bevor du die Anbindung anfasst,** frag zuerst, was sich seitdem geändert hat: **Bevor du die Anbindung anfasst,** frag zuerst, was sich seitdem geändert hat
— mit der Fassung, gegen die du integriert hast:
``` ```
GET /api/updateservice/v1/changelog?since=2.5.1 GET /api/updateservice/v1/changelog?since=<x.y.z>
``` ```
Die Antwort nennt nur die neueren Fassungen, dazu `action_items` (Punkte mit Die Antwort nennt nur die neueren Fassungen, dazu `action_items` (Punkte mit
@@ -388,7 +399,7 @@ zu tun.
Kommt `"count": 0` zurück, bist du auf Stand und musst gar nichts lesen. Kommt `"count": 0` zurück, bist du auf Stand und musst gar nichts lesen.
Zum Nachschlagen für Menschen: `https://dc.mhdf.de/docs/changelog.php?since=2.5.1` Zum Nachschlagen für Menschen: `https://dc.mhdf.de/docs/changelog.php?since=<x.y.z>`
**Nach getaner Arbeit die Zeile oben auf die neue Fassung setzen.** **Nach getaner Arbeit die Zeile oben auf die neue Fassung setzen.**
``` ```
@@ -582,9 +593,9 @@ was als Nächstes dran ist und wer schon dransitzt. Eine ROADMAP.md im Repo
zeigt nur eine erzeugte Kopie; zwischen den dc:roadmap-Markern nichts von Hand zeigt nur eine erzeugte Kopie; zwischen den dc:roadmap-Markern nichts von Hand
ändern. ändern.
Deploymentcenter-Anbindung: SDK 2.5.1 Deploymentcenter-Anbindung: siehe dc_link_project (to_slug=deploymentcenter).
Bevor du sie anfasst: GET /api/updateservice/v1/changelog?since=2.5.1 — Bevor du sie anfasst: GET /api/updateservice/v1/changelog?since=<eigene Fassung>
zeigt nur, was seitdem dazukam. Danach die Zeile hochsetzen. — zeigt nur, was seitdem dazukam. Danach dc_link_project erneut aufrufen.
Vollständige Beschreibung: https://dc.mhdf.de/api/openapi.json Vollständige Beschreibung: https://dc.mhdf.de/api/openapi.json
``` ```
+20
View File
@@ -233,6 +233,15 @@ const TOOLS = [
updated_since: { type: 'string', description: 'ISO-Zeitpunkt.' }, updated_since: { type: 'string', description: 'ISO-Zeitpunkt.' },
limit: { type: 'integer', minimum: 1, maximum: 200, default: 25 }, limit: { type: 'integer', minimum: 1, maximum: 200, default: 25 },
offset: { type: 'integer', minimum: 0, default: 0 }, offset: { type: 'integer', minimum: 0, default: 0 },
fields: {
type: 'string',
enum: ['full', 'compact'],
default: 'full',
description:
'full (Vorgabe) liefert die Fundstellen vollstaendig. compact schneidet '
+ 'auf die Uebersichtsfelder zurueck - sinnvoll, wenn viele Treffer '
+ 'erwartet werden und nur die Titel zaehlen.',
},
}, },
}, },
run: (args) => call('GET', '/api/bugtracker/v1/manage/', { run: (args) => call('GET', '/api/bugtracker/v1/manage/', {
@@ -400,6 +409,16 @@ const TOOLS = [
epic: { type: 'string', description: 'Nur ein Themenbereich, z. B. "A".' }, epic: { type: 'string', description: 'Nur ein Themenbereich, z. B. "A".' },
q: { type: 'string', description: 'Volltextsuche.' }, q: { type: 'string', description: 'Volltextsuche.' },
limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 }, limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 },
fields: {
type: 'string',
enum: ['compact', 'full'],
default: 'compact',
description:
'compact (Vorgabe) liefert die Uebersichtsfelder. full liefert jeden '
+ 'Eintrag vollstaendig - das sind rund fuenfzig Felder je Eintrag und '
+ 'fuellt bei einer vollen Roadmap das Kontextfenster. Einzelheiten zu '
+ 'einem Eintrag holt dc_get_item.',
},
}, },
}, },
run: (args) => call('GET', '/api/bugtracker/v1/roadmap.php', { run: (args) => call('GET', '/api/bugtracker/v1/roadmap.php', {
@@ -410,6 +429,7 @@ const TOOLS = [
epic: args.epic, epic: args.epic,
q: args.q, q: args.q,
limit: args.limit ?? 50, limit: args.limit ?? 50,
fields: args.fields ?? 'compact',
}, },
}), }),
}, },
+49 -8
View File
@@ -105,6 +105,11 @@ $author = $context['actor'];
// muss die Landschaft sehen. // muss die Landschaft sehen.
$boundProject = ApiAuth::projectFilter($context); $boundProject = ApiAuth::projectFilter($context);
$readProject = ApiAuth::projectFilter($context, true); $readProject = ApiAuth::projectFilter($context, true);
$homeProject = ApiAuth::homeProject($context);
// Was der Aufrufer ANGEFRAGT hat - unabhaengig davon, was er bekommt.
// Gebraucht fuer den Hinweis auf eine Verengung, siehe verengungsHinweis().
$angefragtesProjekt = Http::str('project_slug') ?? Http::str('project');
$itemId = resolveItemId(); $itemId = resolveItemId();
@@ -120,8 +125,16 @@ switch ($action) {
// "Http::str('project_slug') ?? $boundProject" - damit konnte ein an // "Http::str('project_slug') ?? $boundProject" - damit konnte ein an
// Projekt X gebundenes Token die Zahlen jedes anderen Projekts abrufen, // Projekt X gebundenes Token die Zahlen jedes anderen Projekts abrufen,
// indem es den Slug einfach mitschickte. // indem es den Slug einfach mitschickte.
$slug = $readProject ?? Http::str('project_slug'); //
Http::ok(['stats' => $repo->getStats($slug)]); // Die Vorgabe ohne Angabe ist das Heimatprojekt, nicht "alle": Ein
// Werkbank-Token faellt beim Lesen nicht unter $readProject, bekam
// also die Zahlen aller Projekte in einem Topf - dieselbe stille
// Verwechslung wie bei list und roadmap.
$slug = $readProject ?? Http::str('project_slug') ?? $homeProject;
Http::ok(array_merge(
['stats' => $repo->getStats($slug)],
ApiAuth::scopeNotice($angefragtesProjekt, (string)$slug)
));
case 'get': case 'get':
requireItemId($itemId); requireItemId($itemId);
@@ -133,9 +146,9 @@ switch ($action) {
Http::ok(['item' => $item]); Http::ok(['item' => $item]);
case 'list': case 'list':
$filters = collectFilters($readProject); $filters = collectFilters($readProject, $homeProject);
$result = $repo->getItems($filters); $result = $repo->getItems($filters);
Http::ok([ Http::ok(array_merge([
'count' => count($result['items']), 'count' => count($result['items']),
'total' => $result['total'], 'total' => $result['total'],
'limit' => $result['limit'], 'limit' => $result['limit'],
@@ -143,7 +156,7 @@ switch ($action) {
'has_more' => $result['has_more'], 'has_more' => $result['has_more'],
'filters' => $filters, 'filters' => $filters,
'items' => $result['items'], 'items' => $result['items'],
]); ], ApiAuth::scopeNotice($angefragtesProjekt, (string)$filters['project_slug'])));
// -------------------------------------------------------------- schreiben // -------------------------------------------------------------- schreiben
case 'claim': case 'claim':
@@ -160,7 +173,7 @@ switch ($action) {
Http::ok(['item' => $claimed, 'message' => sprintf('Item #%d uebernommen.', $itemId)]); Http::ok(['item' => $claimed, 'message' => sprintf('Item #%d uebernommen.', $itemId)]);
case 'next': case 'next':
$filters = collectFilters($boundProject); $filters = collectFilters($boundProject, ApiAuth::homeProject($context));
// Der Rueckkanal: zusaetzlich zu den eigenen Items auch fremde, die auf // Der Rueckkanal: zusaetzlich zu den eigenen Items auch fremde, die auf
// Nachpruefung warten und bei denen dieses Projekt als betroffen // Nachpruefung warten und bei denen dieses Projekt als betroffen
@@ -440,12 +453,34 @@ function assertProject(
/** /**
* Sammelt Filter aus Query und Body. * Sammelt Filter aus Query und Body.
* *
* @param ?string $boundProject Zwang: aus dieser Bindung kommt das Token nicht heraus.
* @param ?string $homeProject Vorgabe: wo der Aufrufer zuhause ist.
* @return array<string,mixed> * @return array<string,mixed>
*/ */
function collectFilters(?string $boundProject): array function collectFilters(?string $boundProject, ?string $homeProject = null): array
{ {
// WELCHES PROJEKT, WENN KEINES GENANNT IST?
//
// Hier stand schlicht 'all'. Das klingt harmlos und war es nicht: Ein
// Werkbank-Token IST an ein Projekt gebunden, faellt beim Lesen aber
// absichtlich nicht unter die Schranke - es soll die Landschaft sehen
// duerfen (siehe $readProject weiter oben). Damit war $boundProject null,
// die Vorgabe griff, und der Agent bekam die Items aller Projekte.
//
// Ausgerechnet am Sitzungsanfang: CLAUDE.md macht dc_roadmap zum ersten
// Aufruf. Der Agent sah einen fremden Backlog - den er nach derselben
// Regel nicht bearbeiten darf. Die Werkzeugbeschreibung sagte dazu das
// Gegenteil ("Ohne project_slug das eigene Projekt"), was den Widerspruch
// lange verdeckt hat.
//
// Jetzt gilt: ein ausdruecklich genannter Slug, sonst das Heimatprojekt,
// und erst ohne beides "alle". Wer die Landschaft sehen will, schickt
// weiterhin project_slug=all - nur ist das jetzt eine Entscheidung statt
// eines Nebeneffekts.
$angefragt = Http::str('project_slug') ?? Http::str('project');
$filters = [ $filters = [
'project_slug' => Http::str('project_slug') ?? Http::str('project') ?? 'all', 'project_slug' => $angefragt ?? $homeProject ?? 'all',
'environment' => Http::str('environment') ?? Http::str('env') ?? 'all', 'environment' => Http::str('environment') ?? Http::str('env') ?? 'all',
'type' => Http::str('type') ?? 'all', 'type' => Http::str('type') ?? 'all',
'status' => Http::str('status') ?? 'all', 'status' => Http::str('status') ?? 'all',
@@ -458,6 +493,12 @@ function collectFilters(?string $boundProject): array
'order' => Http::str('order') ?? 'newest', 'order' => Http::str('order') ?? 'newest',
'limit' => Http::int('limit', 100), 'limit' => Http::int('limit', 100),
'offset' => Http::int('offset', 0), 'offset' => Http::int('offset', 0),
// Hier bleibt der volle Datensatz die Vorgabe: Wer sucht, will die
// Fundstelle sehen, nicht nur ihren Titel. fields=compact schneidet
// auf die Uebersichtsfelder zurueck - dieselbe Auswahl, die die
// Roadmap standardmaessig liefert (BugRepo::COMPACT_FIELDS).
'fields' => Http::str('fields') === 'compact' ? 'compact' : 'full',
]; ];
if (Http::input('unclaimed_only') !== null) { if (Http::input('unclaimed_only') !== null) {
+58 -4
View File
@@ -66,13 +66,65 @@ ApiAuth::enforceProject($context, $projectSlug, true);
$homeProject = ApiAuth::homeProject($context); $homeProject = ApiAuth::homeProject($context);
// Ein Laufzeit-Token schreibt immer in sein eigenes Projekt. // Wohin die Meldung geht, wenn der Melder es nicht sagt.
if ($homeProject !== null && !ApiAuth::isWorkbench($context)) { //
// Hier stand die Bedingung "!isWerkbank": ein Laufzeit-Token wurde auf sein
// Heimatprojekt festgenagelt, ein Werkbank-Token nicht - denn das darf ja
// ausdruecklich in fremde Projekte melden. Uebersehen wurde der Fall, um den
// es hier geht: Ein Werkbank-Token, das GAR KEIN Projekt nennt, wollte nicht
// "irgendwohin", sondern nach Hause. Ohne Slug fiel es durch bis zur
// Voreinstellung in BugRepo::reportItem() - und die hiess 'default'.
//
// 'default' ist kein Projekt. Es steht in keiner dc_projects-Zeile, also
// blieb project_id NULL, und die Bindung desselben Tokens schlug beim
// naechsten Zugriff zu: action=update antwortete project_forbidden. Der
// Server legte Items in einem Projekt ab, das derselbe Melder nicht mehr
// anfassen durfte - und die Antwort sah wie ein Erfolg aus.
//
// Die Regel jetzt in einem Satz: Ein Slug im Body gilt (soweit die Bindung
// ihn erlaubt), sonst gilt das Heimatprojekt. Ein Ziel zu raten ist an dieser
// Stelle keine Option mehr.
$nennenswert = $projectSlug !== null && $projectSlug !== '';
if ($homeProject !== null && (!$nennenswert || !ApiAuth::isWorkbench($context))) {
$data['project_slug'] = $homeProject; $data['project_slug'] = $homeProject;
} }
// --- Ist das Ziel ueberhaupt ein Projekt? -----------------------------------
$repo = new BugRepo($db);
$ziel = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null;
if ($ziel === null || $ziel === '') {
Http::fail(
400,
'missing_project',
'Es ist nicht erkennbar, wohin diese Meldung gehoert: weder wurde "project_slug" '
. 'mitgeschickt, noch ist dieses Token an ein Projekt gebunden. Die Projektliste '
. 'liefert GET /api/bugtracker/v1/projects.'
);
}
// Ein ausdruecklich genanntes Projekt muss es geben. Frueher wurde ein Tippfehler
// klaglos angenommen und das Item mit project_id NULL abgelegt - sichtbar in
// keiner Projektansicht, unerreichbar fuer jedes projektgebundene Token.
//
// Das HEIMATPROJEKT wird bewusst nicht geprueft: Steht in der Token-Bindung ein
// Slug ohne dc_projects-Zeile, ist das ein Fehler in der Tokenverwaltung. Eine
// Meldung deshalb abzuweisen hiesse, sie zu verlieren - und der Melder koennte
// nichts daran aendern.
if ($ziel !== $homeProject && !$repo->projectExists($ziel)) {
Http::fail(
400,
'unknown_project',
sprintf('Das Projekt "%s" gibt es nicht. Bekannte Slugs nennt GET /api/bugtracker/v1/projects.', $ziel),
null,
['requested_project' => $ziel, 'home_project' => $homeProject]
);
}
// --- Fremdmeldung? ---------------------------------------------------------- // --- Fremdmeldung? ----------------------------------------------------------
$target = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null; $target = $ziel;
$isForeign = $homeProject !== null $isForeign = $homeProject !== null
&& $target !== null && $target !== '' && $target !== null && $target !== ''
&& $target !== $homeProject; && $target !== $homeProject;
@@ -123,7 +175,6 @@ if (empty($data['client_ref'])) {
} }
} }
$repo = new BugRepo($db);
$result = $repo->reportItem($data); $result = $repo->reportItem($data);
// Bekannt und in einer spaeteren Fassung behoben. Diese Antwort geht jeder // Bekannt und in einer spaeteren Fassung behoben. Diese Antwort geht jeder
@@ -164,6 +215,9 @@ if (!empty($result['fix_insufficient'])) {
Http::ok([ Http::ok([
'item_id' => $result['id'], 'item_id' => $result['id'],
// Wohin die Meldung tatsaechlich ging. Fehlte bisher - deshalb konnte
// eine Meldung im Nichts landen, ohne dass die Antwort es verriet.
'project_slug' => $result['project_slug'] ?? null,
'is_new' => $result['is_new'], 'is_new' => $result['is_new'],
'idempotent_hit' => $result['idempotent_hit'], 'idempotent_hit' => $result['idempotent_hit'],
'occurrence_count' => $result['occurrence_count'], 'occurrence_count' => $result['occurrence_count'],
+33 -5
View File
@@ -66,8 +66,19 @@ $author = $context['actor'];
$readProject = ApiAuth::projectFilter($context, true); $readProject = ApiAuth::projectFilter($context, true);
$writeProject = ApiAuth::projectFilter($context); $writeProject = ApiAuth::projectFilter($context);
$requested = Http::str('project_slug') ?? Http::str('project') ?? 'all'; // Ohne Angabe gilt das Heimatprojekt - nicht "alle".
$scope = $readProject ?? $requested; //
// Hier stand "?? 'all'". Ein Werkbank-Token faellt beim Lesen absichtlich
// nicht unter $readProject (es soll die Landschaft sehen duerfen), also griff
// die Vorgabe, und dc_roadmap lieferte die Vorhaben aller Projekte. Da
// CLAUDE.md dc_roadmap zum ersten Aufruf jeder Sitzung macht, war das der
// erste Eindruck: ein fremder Backlog, den derselbe Agent nach derselben
// Regel nicht bearbeiten darf.
//
// project_slug=all bleibt moeglich und ist jetzt eine Ansage statt einer
// Voreinstellung.
$angefragt = Http::str('project_slug') ?? Http::str('project');
$scope = $readProject ?? $angefragt ?? ApiAuth::homeProject($context) ?? 'all';
switch ($action) { switch ($action) {
case 'epics': case 'epics':
@@ -133,12 +144,29 @@ switch ($action) {
'order' => 'roadmap', 'order' => 'roadmap',
'limit' => Http::int('limit', 200) ?: 200, 'limit' => Http::int('limit', 200) ?: 200,
'offset' => Http::int('offset', 0) ?: 0, 'offset' => Http::int('offset', 0) ?: 0,
// Standardmaessig die schmale Sicht.
//
// Vorher kam der volle Datensatz mit rund fuenfzig Feldern je
// Eintrag - fuer 57 Vorhaben etwa 137.000 Zeichen. Da CLAUDE.md
// diesen Aufruf an den Anfang jeder Sitzung stellt, fuellte
// ausgerechnet er das Kontextfenster, bevor die Arbeit begann.
//
// Eine Roadmap beantwortet "was ist dran, wer sitzt dran". Die
// Begruendung steht ohnehin nicht hier, sondern hinter doc_anchor;
// die Einzelheiten holt dc_get_item. fields=full liefert weiterhin
// alles, fuer den Fall, dass jemand genau das braucht.
'fields' => Http::str('fields') === 'full' ? 'full' : 'compact',
]; ];
$result = $repo->getItems($filters); $result = $repo->getItems($filters);
$result['fields'] = $filters['fields'];
$result['stats'] = $roadmap->stats($scope); $result['stats'] = $roadmap->stats($scope);
Http::ok($result); // Wenn ein gebundenes Token nach einem fremden Projekt fragt, bekommt
// es die eigene Roadmap - das ist richtig, darf aber nicht wie eine
// Auskunft ueber das angefragte Projekt aussehen (#131).
Http::ok(array_merge($result, ApiAuth::scopeNotice($angefragt, $scope)));
} }
case 'set': { case 'set': {
@@ -218,7 +246,7 @@ switch ($action) {
} }
case 'reorder': { case 'reorder': {
$project = requireProject($writeProject ?? $requested, $context); $project = requireProject($writeProject ?? $angefragt, $context);
$epic = Http::str('epic'); $epic = Http::str('epic');
$ids = Http::input('ids'); $ids = Http::input('ids');
@@ -235,7 +263,7 @@ switch ($action) {
} }
case 'epic': { case 'epic': {
$project = requireProject($writeProject ?? $requested, $context); $project = requireProject($writeProject ?? $angefragt, $context);
$epic = Http::str('epic'); $epic = Http::str('epic');
$title = Http::str('title'); $title = Http::str('title');
+15 -1
View File
@@ -65,6 +65,20 @@ if ($boundProject !== null) {
$projectSlug = $boundProject; $projectSlug = $boundProject;
} }
// Kein Ziel, kein Ablegen. Hier stand weiter unten "?? 'default'" - dieselbe
// Falltuer wie im Bugtracker-Eingang: 'default' ist kein Projekt, das Item
// bekam project_id NULL und war danach in keiner Projektansicht zu finden.
// Ein Laufzeitfehler ohne Absender ist nichts wert; er soll abgewiesen werden,
// solange man ihn noch richtigstellen kann.
if ($projectSlug === null || $projectSlug === '') {
Http::fail(
400,
'missing_project',
'Es ist nicht erkennbar, zu welchem Projekt dieser Fehler gehoert: weder wurde '
. '"project_slug" mitgeschickt, noch ist dieses Token an ein Projekt gebunden.'
);
}
// --- Eingaben normalisieren --- // --- Eingaben normalisieren ---
$exception = trimOrNull($data['exception'] ?? $data['exception_class'] ?? $data['type'] ?? null); $exception = trimOrNull($data['exception'] ?? $data['exception_class'] ?? $data['type'] ?? null);
$message = trimOrNull($data['message'] ?? $data['error_message'] ?? null); $message = trimOrNull($data['message'] ?? $data['error_message'] ?? null);
@@ -85,7 +99,7 @@ if (!in_array($level, BugRepo::ERROR_LEVELS, true)) {
$repo = new BugRepo($db); $repo = new BugRepo($db);
$result = $repo->reportItem([ $result = $repo->reportItem([
'project_slug' => $projectSlug ?? 'default', 'project_slug' => $projectSlug,
'type' => 'bug', 'type' => 'bug',
'title' => buildTitle($exception, $message), 'title' => buildTitle($exception, $message),
'error_message' => $message, 'error_message' => $message,
+36 -7
View File
@@ -59,11 +59,21 @@ switch ($action) {
Http::fail(400, 'missing_source', 'Das Feld "source" wird benoetigt.'); Http::fail(400, 'missing_source', 'Das Feld "source" wird benoetigt.');
} }
authorizeSource($db, $source); $pingKontext = authorizeSource($db, $source);
$instance = Http::str('instance') ?? 'default'; $instance = Http::str('instance') ?? 'default';
$metrics = Http::input('metrics'); $metrics = Http::input('metrics');
// Das Projekt kommt aus der Token-Bindung, nicht aus dem Body - wie
// bei report.php. Ein Monitor soll nicht behaupten koennen, zu einem
// fremden Projekt zu gehoeren.
//
// null bleibt es nur bei einem ungebundenen Zugang (Shared Key,
// Session oder ein Alt-Token aus watchdog_agent_tokens). Dann
// ueberschreibt der Ping eine frueher bekannte Zuordnung nicht,
// sondern laesst sie stehen - siehe COALESCE in upsertHeartbeat().
$pingProjekt = $pingKontext !== null ? ApiAuth::homeProject($pingKontext) : null;
$monitor = $monitorRepo->upsertHeartbeat( $monitor = $monitorRepo->upsertHeartbeat(
$source, $source,
$instance, $instance,
@@ -78,7 +88,8 @@ switch ($action) {
Http::input('checks'), Http::input('checks'),
// Welche Version laeuft hier? Optional - bestehende Agenten // Welche Version laeuft hier? Optional - bestehende Agenten
// schicken das Feld nicht und laufen unveraendert weiter. // schicken das Feld nicht und laufen unveraendert weiter.
Http::str('version') ?? Http::str('app_version') Http::str('version') ?? Http::str('app_version'),
$pingProjekt
); );
// Numerische Werte in den Verlauf uebernehmen, damit sich Trends // Numerische Werte in den Verlauf uebernehmen, damit sich Trends
@@ -110,6 +121,11 @@ switch ($action) {
'last_status' => $monitor['last_status'], 'last_status' => $monitor['last_status'],
'last_seen_utc' => $monitor['last_seen_utc'], 'last_seen_utc' => $monitor['last_seen_utc'],
'app_version' => $monitor['app_version'] ?? null, 'app_version' => $monitor['app_version'] ?? null,
// Wohin dieser Monitor gehoert. Steht hier null, erben seine
// Befunde das Scheinprojekt "default" und sind unter dem
// eigenen Projekt nicht auffindbar - der Melder soll das an
// der Antwort sehen und nicht erst am ausbleibenden Befund.
'project_slug' => $monitor['project_slug'] ?? null,
'state_changed' => (bool)($monitor['_state_changed'] ?? false), 'state_changed' => (bool)($monitor['_state_changed'] ?? false),
'failing_checks' => $monitor['_failing_checks'] ?? [], 'failing_checks' => $monitor['_failing_checks'] ?? [],
], ],
@@ -219,16 +235,29 @@ function requirePost(): void
} }
/** /**
* Prueft die Berechtigung, fuer eine bestimmte Source zu melden. * Prueft die Berechtigung, fuer eine bestimmte Source zu melden, und LIEFERT
* DEN KONTEXT ZURUECK.
* *
* Akzeptiert zentrale Tokens (dc_tokens, Scope watchdog:ping) und die * Akzeptiert zentrale Tokens (dc_tokens, Scope watchdog:ping) und die
* aelteren, an eine Source gebundenen Agent-Tokens. * aelteren, an eine Source gebundenen Agent-Tokens.
*
* Die Rueckgabe ist neu: Vorher gab die Funktion void zurueck und warf den
* Kontext weg - samt der Projektbindung des Tokens.
* Genau die fehlte danach beim Anlegen des Monitors, und die Befunde landeten
* im Scheinprojekt "default" (#404). Die Angabe wurde also gelesen und
* anschliessend fallengelassen.
*
* null heisst "berechtigt, aber ohne Kontext": ein Alt-Token aus
* watchdog_agent_tokens. Diese Token kennen kein Projekt.
*
* @return ?array Kontext aus ApiAuth::resolve(), oder null bei einem Alt-Token.
*/ */
function authorizeSource(PDO $db, string $source): void function authorizeSource(PDO $db, string $source): ?array
{ {
// Zentrale Token-Hierarchie, Shared Key oder Session // Zentrale Token-Hierarchie, Shared Key oder Session
if (ApiAuth::resolve($db, 'watchdog:ping', null, true) !== null) { $context = ApiAuth::resolve($db, 'watchdog:ping', null, true);
return; if ($context !== null) {
return $context;
} }
// Alt-Tokens aus watchdog_agent_tokens // Alt-Tokens aus watchdog_agent_tokens
@@ -236,7 +265,7 @@ function authorizeSource(PDO $db, string $source): void
if ($presented !== null) { if ($presented !== null) {
$legacy = new LegacyTokenManager($db); $legacy = new LegacyTokenManager($db);
if ($legacy->validateToken($presented, $source)) { if ($legacy->validateToken($presented, $source)) {
return; return null;
} }
} }
+65 -1
View File
@@ -1,8 +1,72 @@
{ {
"_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.8.0", "current": "2.8.1",
"versions": [ "versions": [
{
"version": "2.8.1",
"date": "2026-08-31",
"summary": "Sechs Befunde aus der Fremdmeldung, und fast alle gehen auf dieselbe Bauart zurueck: eine Voreinstellung, die einsprang, wo die richtige Antwort bekannt gewesen waere - meist das Projekt, an das der Aufrufer ohnehin gebunden ist. dc_next_task antwortete durchgaengig mit 500. Meldungen ohne project_slug landeten im Scheinprojekt \"default\" und waren danach fuer ihren eigenen Melder gesperrt; Watchdog-Monitore landeten dort auf einem zweiten Weg. Der Lesepfad lieferte ohne Slug alle Projekte statt des eigenen - ausgerechnet beim ersten Aufruf jeder Sitzung, und in einer Groesse, die das Kontextfenster fuellte. Und wo er richtig verengte, sagte er es nicht.",
"actionRequired": true,
"changes": [
{
"area": "api",
"breaking": false,
"title": "dc_next_task antwortete immer mit HTTP 500",
"text": "POST manage?action=next scheiterte bei jedem Aufruf, mit und ohne Parameter, waehrend claim, release, list und stats am selben Endpunkt mit demselben Token durchliefen. Ursache war ein benannter Platzhalter, der in derselben Abfrage zweimal stand: BugRepo::buildFilter() setzte :project_scope fuer den Rueckkanal (fremde Items, die auf Nachpruefung warten) an zwei Stellen ein. Mit PDO::ATTR_EMULATE_PREPARES = false bereitet MySQL die Anweisung selbst vor, PDO bindet jeden Namen genau einmal - zwei Marker, ein Wert, SQLSTATE[HY093]. Getroffen war nur \"next\", weil nur dort der Rueckkanal-Filter gesetzt wird. Damit gab es keine exklusive Uebernahme; zwei Agenten konnten unbemerkt am selben Item arbeiten. Behoben durch zwei Platzhalter fuer denselben Wert.",
"action": "Wer wegen des 500ers ohne Uebernahme gearbeitet hat: laufende Arbeit jetzt mit dc_next_task absichern."
},
{
"area": "api",
"breaking": true,
"title": "Meldungen ohne project_slug gehen ins Heimatprojekt, nicht nach \"default\"",
"text": "POST /report legte eine Meldung ohne project_slug im Projekt \"default\" ab. Das ist kein Projekt: Es hat keine dc_projects-Zeile, project_id blieb NULL, und das Item tauchte in keiner Projektansicht auf. Schlimmer noch war die Folge - dasselbe Token, das die Meldung angelegt hatte, bekam beim naechsten Zugriff 403 project_forbidden, weil es an sein eigenes Projekt gebunden ist. Der Server legte Items dort ab, wo der Melder sie nicht mehr anfassen durfte, und die Antwort sah wie ein Erfolg aus. Betroffen war der dokumentierte Normalfall: Das Weglassen des Slugs ist ausdruecklich vorgesehen. Ursache war eine Bedingung, die nur Laufzeit-Token auf ihr Heimatprojekt festnagelte; ein Werkbank-Token ohne Slug fiel durch bis zur Voreinstellung. Jetzt gilt: genannter Slug vor Heimatprojekt, und ohne beides eine Absage (400 missing_project) statt eines Ratens. Ein ausdruecklich genanntes, unbekanntes Projekt wird ebenfalls abgewiesen (400 unknown_project) statt stillschweigend angelegt. Dieselbe Falltuer war in POST /api/errors/v1/report und im WebUI-Formular offen und ist dort mitgeschlossen.",
"action": "Bereits in \"default\" gelandete Items sind ueber die API nicht erreichbar und muessen mit Adminrechten umgehaengt oder geloescht werden."
},
{
"area": "api",
"breaking": true,
"title": "Ohne project_slug gilt das eigene Projekt - beim Lesen wie beim Melden",
"text": "dc_roadmap, dc_search, manage?action=list und ?action=stats lieferten ohne project_slug die Eintraege ALLER Projekte. Die Werkzeugbeschreibung sagte das Gegenteil (\"Ohne project_slug das eigene Projekt\"), was den Widerspruch lange verdeckt hat. Grund war eine Voreinstellung auf \"all\": Ein Werkbank-Token ist an ein Projekt gebunden, faellt beim Lesen aber absichtlich nicht unter die Projektschranke - es soll die Landschaft sehen duerfen. Damit war der Zwangsfilter leer, die Vorgabe griff, und ein Agent bekam einen fremden Backlog vorgelegt, den er nach derselben Regel nicht bearbeiten darf. Da CLAUDE.md dc_roadmap zum ersten Aufruf jeder Sitzung macht, war das der erste Eindruck. Jetzt gilt: genannter Slug, sonst Heimatprojekt, und erst ohne beides \"alle\". project_slug=all bleibt moeglich und ist damit eine Ansage statt eines Nebeneffekts.",
"action": "Wer bisher auf die projektuebergreifende Antwort gebaut hat, schickt jetzt ausdruecklich project_slug=all."
},
{
"area": "mcp",
"breaking": true,
"title": "dc_roadmap antwortet schmal statt vollstaendig",
"text": "Die Roadmap lieferte je Eintrag den vollen Datensatz mit rund fuenfzig Feldern, darunter description, stack_trace, evidence und context_json - fuer 57 Eintraege rund 137.000 Zeichen. Ausgerechnet der erste Aufruf einer Sitzung fuellte damit das Kontextfenster, bevor die Arbeit begann. Eine Uebersicht beantwortet \"was ist dran und wer sitzt dran\"; dafuer braucht es keinen Stacktrace. dc_roadmap liefert deshalb standardmaessig die Uebersichtsfelder (BugRepo::COMPACT_FIELDS); Einzelheiten holt dc_get_item, die Begruendung steht ohnehin hinter doc_anchor. fields=full liefert weiterhin alles, dc_search hat fields=compact als Gegenstueck.",
"action": null
},
{
"area": "testkit",
"breaking": false,
"title": "dc-testkit 1.1.1 - und ein Riegel gegen die doppelt ausgelieferte Fassung",
"text": "1.1.0 wurde zweimal ausgeliefert: mit 1898 und, im Zuge von 2.8.0, mit 1930 Zeilen. Der Unterschied sass vollstaendig in cmd_confirm - die aeltere Kopie kannte nur den Weg ueber status=closed, nicht den ueber verified_in_build. Jede Nachpruefung von so einer Maschine landete still im alten Verfahren, und selfupdate sah \"gleiche Fassung\" und tat nichts. Dasselbe war 1.0.0 schon passiert (1215 und 1335 Zeilen). Der Absatz ueber DC_TESTKIT_VERSION stand die ganze Zeit im Skript; er hat nicht getragen. scripts/deploy.py vergleicht jetzt vor dem Hochladen gegen den zuletzt ausgelieferten Stand und bricht ab, wenn sich der Inhalt geaendert hat, waehrend die Fassungsnummer stehen blieb.",
"action": "Auf allen Testmaschinen dc-testkit selfupdate ausfuehren; erwartet wird danach 1.1.1."
},
{
"area": "docs",
"breaking": false,
"title": "Die SDK-Fassung wird gemeldet, nicht abgeschrieben",
"text": "Die Agentenvorlage zeigte die Zeile \"Deploymentcenter-Anbindung: SDK 2.5.1\" mit einer konkreten Zahl. Sie war als Beispiel gemeint und wurde als Wert uebernommen - in Projekt-CLAUDE.md-Dateien stand danach 2.5.1, waehrend dc_link_project laengst 2.6.0 auswies. Eine Angabe, die an N Stellen von Hand nachgezogen werden muss, ist nach dem ersten Vergessen falsch, und zwar schweigend. Die Vorlage nennt jetzt dc_link_project als die belastbare Auskunft und benutzt sichtbare Platzhalter, wo eine Fassung hingehoert.",
"action": null
},
{
"area": "api",
"breaking": false,
"title": "Watchdog-Monitore erben das Projekt ihres Ping-Tokens",
"text": "watchdog_monitors.project_slug gibt es seit Migration 014, geschrieben hat die Spalte nie jemand: upsertHeartbeat() bekam das Projekt gar nicht erst uebergeben. Der Monitor entstand mit project_slug NULL, und jeder daraus abgeleitete Supervisor-Befund fiel auf das Scheinprojekt \"default\" zurueck - dieselbe Endstation wie bei den Meldungen ohne project_slug, nur ueber einen anderen Weg. Wirkung: dc_findings(project_slug=...) lieferte fuer eine laufende, meldende Instanz null Befunde. Wer dem Leitfaden folgt und dort anfaengt, schliesst daraus \"nichts auffaellig\" - waehrend ein deviation.self-Befund mit elf Vorkommnissen bereitstand. Die Zuordnung war dabei die ganze Zeit bekannt: Das Ping-Token ist an ein Projekt gebunden, und authorizeSource() liest die Bindung ohnehin - sie wurde nur nicht weitergereicht. Das Projekt kommt jetzt aus der Token-Bindung, nie aus dem Body, und ein Ping ohne Bindung (Shared Key, Alt-Token) ueberschreibt eine bekannte Zuordnung nicht. Die Ping-Antwort nennt project_slug.",
"action": "Bestandsmonitore behalten project_slug NULL, bis ihre Anwendung das naechste Mal mit einem projektgebundenen Token pingt."
},
{
"area": "api",
"breaking": false,
"title": "Ein verengter Leseweg sagt jetzt, dass er verengt hat",
"text": "Ein an Projekt X gebundenes Token, das nach Projekt Y fragte, bekam beim Schreiben eine klare Absage (403 project_forbidden) und beim Lesen HTTP 200 - mit den Items von X und einem Umschlag, in dem der Projektfilter bereits auf X umgeschrieben war. Die Antwort sah damit aus wie eine gueltige Auskunft ueber Y. Das zaehlt, weil die Prompt-Vorlage jeden Agenten vor jeder Meldung durch dc_search schickt (\"gibt es das schon?\"): Auf \"gibt es das im Deploymentcenter schon?\" kam eine leere, plausibel aussehende Antwort - und das Duplikat wurde angelegt. Einem Melder ist genau das beinahe passiert; tatsaechlich waren es 71 Items. Die Schranke selbst bleibt unveraendert; list, stats und die Roadmap legen bei einer Verengung jetzt scoped_to und scope_note in den Umschlag. Ohne Verengung steht dort nichts - ein Hinweis, der immer kommt, wird nicht gelesen.",
"action": null
}
]
},
{ {
"version": "2.8.0", "version": "2.8.0",
"date": "2026-08-31", "date": "2026-08-31",
+19 -1
View File
@@ -1018,9 +1018,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#tab-bugtracker'); dc_redirect('#tab-bugtracker');
} }
// Das Formular hat eine Pflichtauswahl ueber die echten Projekte;
// leer kommt hier nur an, wenn jemand am Formular vorbei postet.
// Frueher wurde daraus stillschweigend 'default' - ein Projekt,
// das es nicht gibt, mit project_id NULL und einem Item, das in
// keiner Ansicht mehr auftauchte.
$btZiel = trim((string)($_POST['project_slug'] ?? ''));
if ($btZiel === '') {
dc_flash('Bitte ein Projekt auswaehlen.', 'danger');
dc_redirect('#tab-bugtracker');
}
$bugRepo = new BugRepo($pdo); $bugRepo = new BugRepo($pdo);
if (!$bugRepo->projectExists($btZiel)) {
dc_flash(sprintf('Das Projekt "%s" gibt es nicht.', $btZiel), 'danger');
dc_redirect('#tab-bugtracker');
}
$result = $bugRepo->reportItem([ $result = $bugRepo->reportItem([
'project_slug' => trim((string)($_POST['project_slug'] ?? 'default')), 'project_slug' => $btZiel,
'type' => (string)($_POST['type'] ?? 'bug'), 'type' => (string)($_POST['type'] ?? 'bug'),
'title' => $title, 'title' => $title,
'description' => trim((string)($_POST['description'] ?? '')), 'description' => trim((string)($_POST['description'] ?? '')),
+13 -1
View File
@@ -34,7 +34,19 @@ set -euo pipefail
# die ausgelieferte Fassung die fehlenden Rechte des Tokens laengst angemeckert # die ausgelieferte Fassung die fehlenden Rechte des Tokens laengst angemeckert
# haette. Dieselbe Falle, in die vorher die Serverfassung getappt ist (2.0.0 in # haette. Dieselbe Falle, in die vorher die Serverfassung getappt ist (2.0.0 in
# der Konfiguration, waehrend 2.7.0 lief). # der Konfiguration, waehrend 2.7.0 lief).
DC_TESTKIT_VERSION="1.1.0" #
# UND SIE IST TROTZ DIESES ABSATZES WIEDER ZUGESCHNAPPT. 1.1.0 wurde zweimal
# ausgeliefert: einmal mit 1898 Zeilen, dann - im Zuge von 2.8.0 - mit 1930.
# Der Unterschied sass vollstaendig in cmd_confirm: die aeltere Kopie kannte
# nur den alten Weg ueber status=closed, nicht den ueber verified_in_build.
# Jede Nachpruefung von so einer Maschine landete still im alten Verfahren.
# selfupdate sah "gleiche Fassung" und tat nichts.
#
# Ein Absatz allein traegt diese Regel offensichtlich nicht. Seit 2.8.1 prueft
# scripts/deploy.py vor dem Hochladen, ob sich der Inhalt gegenueber dem
# zuletzt ausgelieferten Stand geaendert hat, waehrend diese Zahl stehen blieb
# - und bricht dann ab.
DC_TESTKIT_VERSION="1.1.1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Konfiguration # Konfiguration
+115 -7
View File
@@ -18,6 +18,7 @@ import os
import json import json
import ftplib import ftplib
import io import io
import re
import subprocess import subprocess
import sys import sys
import hashlib import hashlib
@@ -210,10 +211,76 @@ def upload_priority(entry):
return (rank, rel_file) return (rank, rel_file)
TESTKIT_DATEI = 'public/testkit/dc-testkit.sh'
TESTKIT_CACHE_SCHLUESSEL = '__dc_testkit_version__'
def testkit_fassung():
"""Liest DC_TESTKIT_VERSION aus dem Testkit-Skript."""
pfad = os.path.join(PROJECT_DIR, TESTKIT_DATEI)
if not os.path.exists(pfad):
return None
muster = re.compile(r'^DC_TESTKIT_VERSION="([^"]*)"')
with open(pfad, 'r', encoding='utf-8', errors='replace') as f:
for zeile in f:
treffer = muster.match(zeile)
if treffer:
return treffer.group(1)
return None
def pruefe_testkit_fassung(cache, changes):
"""
Verhindert, dass zweimal derselbe Fassungsstand ausgeliefert wird.
Das Testkit liegt auf jeder Testmaschine als Kopie, und selfupdate
entscheidet allein anhand von DC_TESTKIT_VERSION, ob es sich lohnt zu
holen. Bleibt die Zahl stehen, waehrend sich der Inhalt aendert, bleiben
alte Kopien liegen - und melden dabei brav die neue Fassungsnummer.
Genau das ist zweimal passiert: 1.0.0 mit 1215 und mit 1335 Zeilen, dann
1.1.0 mit 1898 und mit 1930. Beim zweiten Mal fehlte der aelteren Kopie
der 2.8.0-Weg in cmd_confirm; jede Nachpruefung von dieser Maschine lief
still im alten Verfahren weiter.
Verglichen wird gegen den Stand, den DIESE Maschine zuletzt hochgeladen
hat - dieselbe Grundlage, auf der deploy.py ohnehin entscheidet, was sich
geaendert hat. Ohne Cache (frische Arbeitskopie) wird nicht geprueft: dort
wird ohnehin alles neu uebertragen, und eine Warnung ohne Vergleichspunkt
waere geraten.
"""
wird_hochgeladen = any(rel == TESTKIT_DATEI for rel, _, _ in changes)
if not wird_hochgeladen:
return
zuletzt = cache.get(TESTKIT_CACHE_SCHLUESSEL)
if zuletzt is None:
return
jetzt = testkit_fassung()
if jetzt is None or jetzt != zuletzt:
return
print("")
print(f" ABBRUCH: {TESTKIT_DATEI} hat sich geaendert, aber")
print(f" DC_TESTKIT_VERSION steht weiterhin auf {jetzt}.")
print("")
print(" Damit wuerde dieselbe Fassungsnummer mit anderem Inhalt ausgeliefert.")
print(" selfupdate auf den Testmaschinen sieht dann 'gleiche Fassung' und")
print(" holt nichts - alte Kopien bleiben liegen und melden trotzdem die")
print(" neue Nummer. Genau so ist Bugtracker #354 entstanden.")
print("")
print(" Zu tun: DC_TESTKIT_VERSION hochzaehlen und erneut ausliefern.")
print("")
sys.exit(1)
def dry_run(): def dry_run():
"""Zeigt an, was uebertragen wuerde, ohne eine Verbindung aufzubauen.""" """Zeigt an, was uebertragen wuerde, ohne eine Verbindung aufzubauen."""
cache = load_cache() cache = load_cache()
changes = collect_changes(cache) changes = collect_changes(cache)
pruefe_testkit_fassung(cache, changes)
if not changes: if not changes:
print("Keine Aenderungen - der Server ist auf dem aktuellen Stand.") print("Keine Aenderungen - der Server ist auf dem aktuellen Stand.")
@@ -231,6 +298,19 @@ def deploy():
cache = load_cache() cache = load_cache()
new_cache = dict(cache) new_cache = dict(cache)
# Erst sehen, was ansteht - dann erst eine Verbindung aufbauen.
#
# Vorher wurde zuerst eingewaehlt und danach gesammelt; ein Lauf ohne
# Aenderungen oeffnete also eine FTP-Sitzung, nur um sie sofort wieder zu
# schliessen. Und die Fassungspruefung unten will abbrechen, bevor
# irgendetwas den Server erreicht.
files_to_upload = collect_changes(cache)
pruefe_testkit_fassung(cache, files_to_upload)
if not files_to_upload:
print("No changed files to upload. Remote is up to date!")
return
print(f"Connecting to FTP {config['host']}...") print(f"Connecting to FTP {config['host']}...")
try: try:
if config.get('secure', False): if config.get('secure', False):
@@ -247,13 +327,6 @@ def deploy():
print(f"FTP Connection failed: {e}") print(f"FTP Connection failed: {e}")
sys.exit(1) sys.exit(1)
files_to_upload = collect_changes(cache)
if not files_to_upload:
print("No changed files to upload. Remote is up to date!")
ftp.quit()
return
print(f"Found {len(files_to_upload)} file(s) to upload:") print(f"Found {len(files_to_upload)} file(s) to upload:")
for rel_file, full_path, file_hash in files_to_upload: for rel_file, full_path, file_hash in files_to_upload:
print(f" -> {rel_file}") print(f" -> {rel_file}")
@@ -272,6 +345,15 @@ def deploy():
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
# Womit diese Maschine das Testkit zuletzt beliefert hat. Nur so
# hat pruefe_testkit_fassung() beim naechsten Lauf einen
# Vergleichspunkt.
if rel_file == TESTKIT_DATEI:
fassung = testkit_fassung()
if fassung is not None:
new_cache[TESTKIT_CACHE_SCHLUESSEL] = fassung
uploaded += 1 uploaded += 1
finally: finally:
# Der Cache wird auch bei einem Abbruch geschrieben. Sonst beginnt der # Der Cache wird auch bei einem Abbruch geschrieben. Sonst beginnt der
@@ -323,10 +405,36 @@ def testlauf() -> None:
print("") print("")
def platzhalter_pruefung() -> None:
"""
Statischer Suchlauf ueber alle SQL-Ausdruecke (scripts/pruefe_platzhalter.py).
Derselbe benannte Platzhalter zweimal in einer Anweisung ergibt mit
ATTR_EMULATE_PREPARES = false einen sicheren HTTP 500. Das ist im
Deploymentcenter viermal passiert - zuletzt in BugRepo::buildFilter(),
wo es manage?action=next vollstaendig unbrauchbar machte (#351).
Nach dem dritten Mal stand der Vorsatz "ein Suchlauf ueber alle
prepare()-Aufrufe gehoert zur Durchsicht" in der Testsuite. Ein Vorsatz
hat das vierte Mal nicht verhindert; ein Aufruf an dieser Stelle schon.
Er kostet keine Sekunde und braucht kein Netz.
"""
skript = SCRIPT_DIR / 'pruefe_platzhalter.py'
if not skript.exists():
return
ergebnis = subprocess.run([sys.executable, str(skript), str(PROJECT_DIR)])
if ergebnis.returncode != 0:
print(" Ausliefern abgebrochen - siehe oben.")
sys.exit(ergebnis.returncode)
if __name__ == '__main__': if __name__ == '__main__':
if '--dry-run' in sys.argv or '-n' in sys.argv: if '--dry-run' in sys.argv or '-n' in sys.argv:
platzhalter_pruefung()
dry_run() dry_run()
else: else:
platzhalter_pruefung()
if '--ohne-test' in sys.argv: if '--ohne-test' in sys.argv:
print("Testlauf uebersprungen (--ohne-test).") print("Testlauf uebersprungen (--ohne-test).")
else: else:
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""
Sucht vorbereitete Anweisungen, in denen derselbe benannte Platzhalter
mehrfach vorkommt.
WARUM ES DIESES SKRIPT GIBT
---------------------------
Core\\Db setzt PDO::ATTR_EMULATE_PREPARES = false. MySQL bereitet die Anweisung
dann selbst vor, und PDO uebersetzt benannte Platzhalter in positionsgebundene
- wobei jeder Name genau EINMAL gebunden wird. Steht ":foo" zweimal im SQL,
hat die Anweisung zwei Marker und bekommt einen Wert. Ergebnis: SQLSTATE[HY093]
und ein HTTP 500.
Das ist im Deploymentcenter viermal passiert:
1. Volltextsuche im Bugtracker
2. ProjectLinks::between()
3. ProjectLinks::forProject()
4. BugRepo::buildFilter(), Rueckkanal fuer Nachpruefungen (Bugtracker #351)
Im vierten Fall traf es ausschliesslich manage?action=next - der einzige
Aufruf, der den betroffenen Filterzweig setzt. claim, release, list und stats
liefen durch dieselbe Funktion und blieben heil, was die Suche lange in die
falsche Richtung geschickt hat.
Nach dem dritten Mal stand in tests/e2e/suite_bugtracker.py der Satz: "ein
Suchlauf ueber alle prepare()-Aufrufe gehoert zur Durchsicht". Er stand dort
als Vorsatz. Das vierte Mal kam trotzdem. Deshalb jetzt als Skript.
AUFRUF
------
python scripts/pruefe_platzhalter.py # ganzes Repository
python scripts/pruefe_platzhalter.py src/ # nur ein Teilbaum
Rueckgabewert 1, wenn etwas gefunden wurde - damit es sich in einen
Vorab-Check haengen laesst.
VERKETTUNG
----------
Geprueft wird nicht je Literal, sondern je VERKETTETEM AUSDRUCK. Das ist der
Punkt, an dem die erste Fassung dieses Skripts versagt hat: Der Fehler in
BugRepo::buildFilter() stand in
'(project_slug = :project_scope
OR (status = "' . self::STATUS_AWAITING_VERIFICATION . '"
... WHERE project_slug = :project_scope)))'
also in ZWEI Literalen, in jedem genau einmal. Pro Literal gezaehlt war das
unauffaellig; erst der zusammengesetzte Text hat den Namen doppelt. Ein
Pruefer, der genau den Fehler nicht findet, der ihn ausgeloest hat, ist keiner.
Zusammengefasst werden zwei aufeinanderfolgende Literale, wenn zwischen ihnen
ein "." steht und kein ";" "," "[" oder "]". Damit faellt der haeufige Fall
"Liste mehrerer eigenstaendiger Anweisungen in einem Array" heraus - dort
trennt ein Komma, und jede Anweisung wird fuer sich gebunden.
GRENZEN
-------
Verkettungen ueber einen Funktionsaufruf mit Komma hinweg
('... :a' . f($x, 1) . '... :a') fallen durch. Dafuer waere ein PHP-Parser
noetig, und der Aufwand stuende in keinem Verhaeltnis.
"""
import collections
import io
import os
import re
import sys
PLATZHALTER = re.compile(r":([a-zA-Z_][a-zA-Z0-9_]*)")
# Ohne diese Woerter ist ein Literal mit ":name" kein SQL, sondern etwa eine
# Zeitangabe ("%H:%i") oder ein Doppelpunkt in Prosa.
SQL_WORTE = re.compile(
r"\b(SELECT|INSERT|UPDATE|DELETE|WHERE|VALUES|SET|FROM|AND|OR|IN|LIKE)\b",
re.I,
)
BS = chr(92)
UEBERSPRINGEN = {".git", "__pycache__", "node_modules", "artifacts", "bin", "obj"}
# Steht eines dieser Zeichen zwischen zwei Literalen, gehoeren sie NICHT zu
# demselben Ausdruck: Komma und Semikolon trennen, eckige Klammern deuten auf
# eine Liste eigenstaendiger Anweisungen.
TRENNER = set(";,[]")
# Wie weit zwei Literale hoechstens auseinanderliegen duerfen, um noch als
# verkettet zu gelten. Verhindert, dass ueber halbe Dateien hinweg
# zusammengefasst wird, wenn die Klammernerkennung einmal danebengreift.
MAX_LUECKE = 300
def literale(src):
"""
Liefert (zeile, inhalt, anfang, ende) je einfach begrenztem Stringliteral.
Kommentare und doppelt begrenzte Zeichenketten werden uebersprungen -
sonst meldet ein Beispiel-SQL im Kommentar einen Treffer, den es nicht
gibt.
"""
i = 0
n = len(src)
while i < n:
c = src[i]
if c == "/" and i + 1 < n and src[i + 1] == "/":
i = src.find("\n", i)
if i < 0:
return
continue
if c == "/" and i + 1 < n and src[i + 1] == "*":
j = src.find("*/", i + 2)
i = (j + 2) if j >= 0 else n
continue
if c == "#":
i = src.find("\n", i)
if i < 0:
return
continue
if c == '"':
i += 1
while i < n:
if src[i] == BS:
i += 2
continue
if src[i] == '"':
break
i += 1
i += 1
continue
if c == "'":
zeile = src[:i].count("\n") + 1
anfang = i
i += 1
start = i
while i < n:
if src[i] == BS:
i += 2
continue
if src[i] == "'":
break
i += 1
yield zeile, src[start:i], anfang, i + 1
i += 1
continue
i += 1
def verkettet(src, ende_a, anfang_b):
"""Gehoeren zwei benachbarte Literale zu demselben verketteten Ausdruck?"""
luecke = src[ende_a:anfang_b]
if len(luecke) > MAX_LUECKE:
return False
if "." not in luecke:
return False
return not any(z in TRENNER for z in luecke)
def gruppen(src):
"""
Fasst verkettete Literale zusammen.
Liefert (zeile, zusammengesetzter_text) je Ausdruck - also genau den Text,
den PDO am Ende zu sehen bekommt.
"""
aktuell = None
for zeile, text, anfang, ende in literale(src):
if aktuell is not None and verkettet(src, aktuell["ende"], anfang):
aktuell["text"] += text
aktuell["ende"] = ende
continue
if aktuell is not None:
yield aktuell["zeile"], aktuell["text"]
aktuell = {"zeile": zeile, "text": text, "ende": ende}
if aktuell is not None:
yield aktuell["zeile"], aktuell["text"]
def pruefe(wurzel):
befunde = []
for root, dirs, files in os.walk(wurzel):
dirs[:] = [d for d in dirs if d not in UEBERSPRINGEN]
for name in sorted(files):
if not name.endswith(".php"):
continue
pfad = os.path.join(root, name)
src = io.open(pfad, encoding="utf-8", errors="replace").read()
for zeile, lit in gruppen(src):
if not SQL_WORTE.search(lit):
continue
zaehler = collections.Counter(PLATZHALTER.findall(lit))
doppelt = {k: v for k, v in sorted(zaehler.items()) if v > 1}
if doppelt:
befunde.append((pfad, zeile, doppelt))
return befunde
def main():
wurzel = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
os.path.dirname(os.path.abspath(__file__)), os.pardir
)
befunde = pruefe(wurzel)
if not befunde:
print("Platzhalter-Pruefung: nichts gefunden.")
return 0
print("")
print(" Derselbe benannte Platzhalter steht mehrfach in einer Anweisung.")
print(" Mit ATTR_EMULATE_PREPARES = false endet das in SQLSTATE[HY093]")
print(" und damit in HTTP 500 - siehe Kopf dieses Skripts.")
print("")
for pfad, zeile, doppelt in befunde:
stellen = ", ".join("%s (%dx)" % (k, v) for k, v in doppelt.items())
print(" %s:%d %s" % (pfad, zeile, stellen))
print("")
print(" Abhilfe: je Vorkommen ein eigener Name, beide mit demselben Wert")
print(" belegt. Siehe BugRepo::buildFilter(), :project_scope /")
print(" :project_scope_affects.")
print("")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,179 @@
-- ===========================================================================
-- Die Leichen im Scheinprojekt "default" aufraeumen
--
-- Bugtracker #382 / #387. Bis 2.8.0 legte POST /report eine Meldung ohne
-- project_slug im Projekt "default" ab. Das ist kein Projekt: Es hat keine
-- Zeile in dc_projects, also blieb project_id NULL. Die Items sind seitdem
--
-- * in keiner Projektansicht sichtbar (die Auswahl kennt nur echte Projekte),
-- * ueber die API nicht mehr erreichbar - dasselbe Token, das sie angelegt
-- hat, bekommt 403 project_forbidden, weil es an sein eigenes Projekt
-- gebunden ist,
-- * und aus dem WebUI nicht loeschbar, weil es dort keine Loeschfunktion
-- fuer Items gibt.
--
-- Ab 2.8.1 entsteht nichts Neues mehr dieser Art: Ohne Slug gilt das
-- Heimatprojekt des Tokens, und ohne beides gibt es eine Absage statt eines
-- Ratens. Dieses Skript raeumt nur den Altbestand.
--
-- ---------------------------------------------------------------------------
-- NICHT AUTOMATISCH AUSGEFUEHRT. Es liegt bewusst NICHT in sql/migrations/ -
-- dort wird jede .sql beim Start angewendet (siehe Core\Migrator), und ein
-- DELETE gehoert nicht in einen Vorgang, der ungefragt laeuft.
--
-- Von Hand, Abschnitt fuer Abschnitt, und Abschnitt 1 zuerst.
-- ---------------------------------------------------------------------------
--
-- Achtung beim Loeschen: bugtracker_comments und bugtracker_item_affects
-- haengen mit ON DELETE CASCADE an den Items. Was hier faellt, nimmt seine
-- Kommentare und Betroffenenvermerke mit.
-- ===========================================================================
-- ---------------------------------------------------------------------------
-- 1. ANSEHEN. Was liegt ueberhaupt dort?
-- ---------------------------------------------------------------------------
-- created_by ist die einzige verlaessliche Spur, wem ein Item gehoert:
-- reported_from blieb bei genau diesen Meldungen leer. Grund war die
-- Reihenfolge im alten Code - "Fremdmeldung?" wurde am mitgeschickten Slug
-- entschieden, und der fehlte ja gerade. Deshalb griff weder die
-- Begruendungspflicht noch der Fremdmeldungs-Eingang.
SELECT
id,
created_by,
reported_from,
type,
severity,
status,
build_version,
created_at,
LEFT(title, 70) AS titel
FROM bugtracker_items
WHERE project_slug = 'default'
ORDER BY id;
-- Wer hat wie viel dort abgelegt? Das ist die Grundlage fuer Abschnitt 2:
-- Ein created_by, das eindeutig zu einem Projekt gehoert, laesst sich
-- umhaengen. Ein gemischtes nicht.
SELECT
created_by,
COUNT(*) AS anzahl,
MIN(id) AS von,
MAX(id) AS bis
FROM bugtracker_items
WHERE project_slug = 'default'
GROUP BY created_by
ORDER BY anzahl DESC;
-- Gegenprobe: Gibt es "default" womoeglich doch als Projekt? Wenn diese
-- Abfrage eine Zeile liefert, ist die Lage eine andere als hier beschrieben -
-- dann bitte erst klaeren, wer es wann angelegt hat.
SELECT * FROM dc_projects WHERE slug = 'default';
-- ---------------------------------------------------------------------------
-- BEFUND VOM 31.08.2026 - der Bestand ist aufgenommen
-- ---------------------------------------------------------------------------
-- Es sind 20 Items. created_by ordnet sie eindeutig zu, und JEDES hat einen
-- titelgleichen Zwilling im richtigen Projekt:
--
-- clawddotnet 352, 353, 365, 366, 368, 369, 370 -> #373-#379
-- ibkrtrader 355, 356, 357, 358, 359, 360,
-- 361, 362, 367 -> #388-#396
-- polytrader 371, 372, 380, 381 -> #383-#386
--
-- An jedem Original haengt genau EIN Kommentar: der automatische
-- Erfassungsvermerk ("Bug im System erfasst."). Es gibt also keine Historie,
-- die beim Loeschen verlorenginge - der Abgleich in Abschnitt 1 sollte das
-- vor dem Loeschen trotzdem noch einmal bestaetigen, denn seither kann
-- jemand kommentiert haben.
--
-- Deshalb ist Abschnitt 3 (loeschen) hier der richtige Weg und Abschnitt 2
-- (umhaengen) nur die Rueckfallebene, falls der Abgleich etwas anderes zeigt.
-- ---------------------------------------------------------------------------
-- 2. UMHAENGEN statt loeschen - die Rueckfallebene.
-- ---------------------------------------------------------------------------
-- Zu benutzen, wenn an einem Item doch etwas haengt, das in der Neuablage
-- fehlt. Dann waere allerdings der Zwilling zu loeschen, sonst steht derselbe
-- Befund zweimal im Projekt.
-- UPDATE bugtracker_items
-- SET project_slug = 'clawddotnet',
-- project_id = (SELECT id FROM dc_projects WHERE slug = 'clawddotnet')
-- WHERE project_slug = 'default'
-- AND id IN (352, 353, 365, 366, 368, 369, 370);
-- UPDATE bugtracker_items
-- SET project_slug = 'ibkrtrader',
-- project_id = (SELECT id FROM dc_projects WHERE slug = 'ibkrtrader')
-- WHERE project_slug = 'default'
-- AND id IN (355, 356, 357, 358, 359, 360, 361, 362, 367);
-- UPDATE bugtracker_items
-- SET project_slug = 'polytrader',
-- project_id = (SELECT id FROM dc_projects WHERE slug = 'polytrader')
-- WHERE project_slug = 'default'
-- AND id IN (371, 372, 380, 381);
-- ---------------------------------------------------------------------------
-- 3. LOESCHEN - der vorgesehene Weg.
-- ---------------------------------------------------------------------------
-- Der Inhalt steht vollstaendig im jeweils richtigen Projekt (siehe Befund
-- oben). Die Altablage ist reiner Ballast: unsichtbar, unerreichbar, und sie
-- verfaelscht jede Zaehlung ueber alle Projekte.
--
-- Erst zaehlen. Kommt hier nicht 20 heraus, hat sich der Bestand seit dem
-- 31.08.2026 geaendert - dann zurueck zu Abschnitt 1.
-- SELECT COUNT(*) AS wird_geloescht
-- FROM bugtracker_items
-- WHERE project_slug = 'default'
-- AND id IN (352, 353, 355, 356, 357, 358, 359, 360, 361, 362,
-- 365, 366, 367, 368, 369, 370, 371, 372, 380, 381);
-- Sicherungskopie, falls die Zuordnung spaeter doch noch gebraucht wird.
-- Kostet nichts und macht den Schritt umkehrbar.
-- CREATE TABLE bugtracker_items_default_backup_20260831 AS
-- SELECT * FROM bugtracker_items WHERE project_slug = 'default';
-- DELETE FROM bugtracker_items
-- WHERE project_slug = 'default'
-- AND id IN (352, 353, 355, 356, 357, 358, 359, 360, 361, 362,
-- 365, 366, 367, 368, 369, 370, 371, 372, 380, 381);
-- ---------------------------------------------------------------------------
-- 4. NACHSEHEN. Sollte 0 liefern, wenn alles umgehaengt oder geloescht ist.
-- ---------------------------------------------------------------------------
-- SELECT COUNT(*) AS noch_in_default
-- FROM bugtracker_items
-- WHERE project_slug = 'default';
-- ---------------------------------------------------------------------------
-- 5. Gibt es weitere verwaiste Slugs?
-- ---------------------------------------------------------------------------
-- "default" war die haeufigste Falltuer, aber nicht die einzige moegliche:
-- Ein Tippfehler im project_slug wurde bis 2.8.0 ebenso klaglos angenommen.
-- Ab 2.8.1 weist der Endpunkt ein unbekanntes Projekt mit 400 unknown_project
-- ab; der Altbestand bleibt davon unberuehrt.
SELECT
b.project_slug,
COUNT(*) AS anzahl,
MIN(b.created_at) AS aeltestes,
MAX(b.created_at) AS juengstes
FROM bugtracker_items b
LEFT JOIN dc_projects p ON p.slug = b.project_slug
WHERE p.id IS NULL
GROUP BY b.project_slug
ORDER BY anzahl DESC;
+44
View File
@@ -219,6 +219,50 @@ final class ApiAuth
return is_string($bound) && $bound !== '' ? $bound : null; return is_string($bound) && $bound !== '' ? $bound : null;
} }
/**
* Sagt es, wenn eine Antwort ein anderes Projekt betrifft als das angefragte.
*
* DAS PROBLEM IST DIE STILLE, nicht die Verengung. Ein an Projekt X
* gebundenes Token, das nach Projekt Y fragt, bekommt beim SCHREIBEN eine
* klare Absage (403 project_forbidden) und beim LESEN HTTP 200 - mit den
* Items von X und einem Umschlag, in dem der Projektfilter bereits auf X
* umgeschrieben ist. Die Antwort sieht damit aus wie eine gueltige
* Auskunft ueber Y.
*
* Das ist keine theoretische Verwechslung: Die Prompt-Vorlage schickt
* jeden Agenten vor jeder Meldung durch dc_search ("gibt es das schon?").
* Mit der falschen Tokenart bekommt er auf "gibt es das im
* Deploymentcenter schon?" eine leere, plausibel aussehende Antwort - und
* legt das Duplikat an. Genau so ist es einem Melder beinahe passiert;
* tatsaechlich waren es 71 Items.
*
* Die Schranke bleibt unveraendert. Sie sagt jetzt nur, dass sie zugegangen
* ist. (#131)
*
* @param ?string $requested Was der Aufrufer angefragt hat, oder null.
* @param string $actual Worueber tatsaechlich geantwortet wird.
* @return array{scoped_to?:string,scope_note?:string} Leer, wenn nichts verengt wurde.
*/
public static function scopeNotice(?string $requested, string $actual): array
{
if ($requested === null || $requested === '' || $requested === $actual) {
return [];
}
return [
'scoped_to' => $actual,
'scope_note' => sprintf(
'Angefragt war "%s", geantwortet wird ueber "%s": Dieses Token ist an "%s" '
. 'gebunden. Die Antwort sagt NICHTS ueber "%s" aus - insbesondere heisst eine '
. 'leere Liste hier nicht, dass es dort nichts gibt.',
$requested,
$actual,
$actual,
$requested
),
];
}
private static function stringOrNull($value): ?string private static function stringOrNull($value): ?string
{ {
if (!is_string($value)) { if (!is_string($value)) {
+120 -4
View File
@@ -159,13 +159,50 @@ final class BugRepo
// ================================================================== // ==================================================================
/** /**
* Nimmt einen Bug, Feature Request oder eine Idee entgegen. * Nimmt eine Meldung entgegen und liefert, was daraus geworden ist.
* *
* @return array<string,mixed> * Der Mantel um reportItemIntern() tut zwei Dinge, die jede Rueckgabe
* betreffen und deshalb nicht in jedem einzelnen return stehen sollen:
*
* 1. Er BESTEHT auf einem Zielprojekt. Hier stand einmal
* "?? 'default'" - eine Voreinstellung, die aussah wie eine
* Hoeflichkeit und eine Falltuer war: 'default' ist kein Projekt,
* es hat keine dc_projects-Zeile, also blieb project_id NULL. Die
* Meldung war angelegt, quittiert - und fuer den Melder unerreichbar,
* weil sein Token an ein anderes Projekt gebunden ist. Wer nicht
* weiss, wohin eine Meldung gehoert, soll das erfahren, nicht raten.
* 2. Er schreibt project_slug in die Antwort. Ohne diese Angabe sah eine
* Meldung, die im Nichts landete, genauso aus wie eine, die ankam.
*
* @throws \InvalidArgumentException wenn kein Zielprojekt genannt ist
*/ */
public function reportItem(array $data): array public function reportItem(array $data): array
{ {
$projectSlug = self::text($data['project_slug'] ?? null) ?? 'default'; $projectSlug = self::text($data['project_slug'] ?? null);
if ($projectSlug === null) {
throw new \InvalidArgumentException(
'Eine Meldung braucht ein Zielprojekt (project_slug). Frueher fiel dieser Fall '
. 'auf das Scheinprojekt "default" zurueck; die Meldung war danach fuer den '
. 'Melder nicht mehr erreichbar.'
);
}
$result = $this->reportItemIntern($data, $projectSlug);
$result['project_slug'] = $projectSlug;
return $result;
}
/** Gibt es dieses Projekt wirklich? */
public function projectExists(string $slug): bool
{
return $this->projectIdForSlug($slug) !== null;
}
/** @param string $projectSlug Bereits geprueft, nie leer. */
private function reportItemIntern(array $data, string $projectSlug): array
{
$type = (($data['type'] ?? 'bug') === 'feature_request') ? 'feature_request' : 'bug'; $type = (($data['type'] ?? 'bug') === 'feature_request') ? 'feature_request' : 'bug';
$title = self::text($data['title'] ?? null) $title = self::text($data['title'] ?? null)
@@ -895,9 +932,70 @@ final class BugRepo
// Abfragen // Abfragen
// ================================================================== // ==================================================================
/**
* Die schmale Sicht auf ein Item.
*
* Ein bugtracker_item hat rund fuenfzig Spalten, darunter description,
* stack_trace, evidence, resolution_notes und context_json - jede fuer
* sich beliebig lang. Eine Liste von 57 Eintraegen kam so auf rund
* 137.000 Zeichen.
*
* Das ist nicht bloss unschoen. dc_roadmap ist laut CLAUDE.md der ERSTE
* Aufruf jeder Sitzung; ausgerechnet er fuellte damit das Kontextfenster,
* bevor die Arbeit begann. Eine Uebersicht beantwortet "was ist dran und
* wer sitzt dran" - dafuer braucht es keinen Stacktrace. Wer die Details
* eines Eintrags will, holt ihn einzeln (dc_get_item); dort stehen sie
* vollstaendig und genau einmal.
*
* @var list<string>
*/
public const COMPACT_FIELDS = [
'id',
'project_slug',
'type',
'title',
'status',
'severity',
'epic',
'roadmap_rank',
'target_build',
'claimed_by',
'lease_until',
'defer_trigger',
'doc_anchor',
'occurrence_count',
'environment',
'updated_at',
];
/**
* Schneidet ein Item auf COMPACT_FIELDS zurueck.
*
* Beschnitten wird in PHP, nicht in der SELECT-Liste: Welche Spalten eine
* Installation wirklich hat, haengt am Stand der Migrationen. Eine feste
* Spaltenliste im SQL wuerde auf einer Instanz, der eine Migration fehlt,
* mit einem Fehler abbrechen statt mit einem fehlenden Feld.
*
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
private static function compactItem(array $item): array
{
$schmal = [];
foreach (self::COMPACT_FIELDS as $feld) {
if (array_key_exists($feld, $item)) {
$schmal[$feld] = $item[$feld];
}
}
return $schmal;
}
/** /**
* Gefilterte Liste. Unterstuetzt Pagination und Delta-Abfragen. * Gefilterte Liste. Unterstuetzt Pagination und Delta-Abfragen.
* *
* fields=compact schneidet jeden Eintrag auf COMPACT_FIELDS zurueck.
*
* @return array{items:list<array<string,mixed>>,total:int,limit:int,offset:int,has_more:bool} * @return array{items:list<array<string,mixed>>,total:int,limit:int,offset:int,has_more:bool}
*/ */
public function getItems(array $filters = []): array public function getItems(array $filters = []): array
@@ -922,6 +1020,10 @@ final class BugRepo
$stmt->execute($params); $stmt->execute($params);
$items = $stmt->fetchAll() ?: []; $items = $stmt->fetchAll() ?: [];
if (($filters['fields'] ?? null) === 'compact') {
$items = array_map([self::class, 'compactItem'], $items);
}
return [ return [
'items' => $items, 'items' => $items,
'total' => $total, 'total' => $total,
@@ -998,11 +1100,25 @@ final class BugRepo
$affectedFor = $filters['include_affected_verification'] ?? null; $affectedFor = $filters['include_affected_verification'] ?? null;
if (is_string($affectedFor) && $affectedFor !== '' && $affectedFor !== 'all') { if (is_string($affectedFor) && $affectedFor !== '' && $affectedFor !== 'all') {
// ZWEI Platzhalter fuer denselben Wert, nicht einer zweimal.
//
// Hier stand :project_scope an beiden Stellen. Mit
// PDO::ATTR_EMULATE_PREPARES = false (siehe Core\Db) bereitet MySQL
// die Anweisung selbst vor: PDO uebersetzt benannte Platzhalter in
// positionsgebundene und bindet jeden Namen genau EINMAL. Die
// Anweisung hatte damit zwei Marker und bekam einen Wert - jeder
// Aufruf endete in SQLSTATE[HY093] und damit in HTTP 500.
//
// Getroffen hat es nur ?action=next: Nur dort wird
// include_affected_verification gesetzt. claim, release, list und
// stats laufen durch denselben Filter und blieben deshalb heil -
// was die Suche lange in die falsche Richtung geschickt hat.
$where[] = '(project_slug = :project_scope $where[] = '(project_slug = :project_scope
OR (status = "' . self::STATUS_AWAITING_VERIFICATION . '" OR (status = "' . self::STATUS_AWAITING_VERIFICATION . '"
AND id IN (SELECT item_id FROM bugtracker_item_affects AND id IN (SELECT item_id FROM bugtracker_item_affects
WHERE project_slug = :project_scope)))'; WHERE project_slug = :project_scope_affects)))';
$params[':project_scope'] = $affectedFor; $params[':project_scope'] = $affectedFor;
$params[':project_scope_affects'] = $affectedFor;
// Die einfache Gleichheit weiter unten wuerde die Oeffnung sofort // Die einfache Gleichheit weiter unten wuerde die Oeffnung sofort
// wieder zunageln. // wieder zunageln.
+23 -3
View File
@@ -87,7 +87,8 @@ final class MonitorRepo
?string $groupKey = null, ?string $groupKey = null,
?string $os = null, ?string $os = null,
$checks = null, $checks = null,
?string $appVersion = null ?string $appVersion = null,
?string $projectSlug = null
): array { ): array {
$metricsJson = (is_array($metrics) || is_object($metrics)) $metricsJson = (is_array($metrics) || is_object($metrics))
? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
@@ -140,11 +141,11 @@ final class MonitorRepo
INSERT INTO watchdog_monitors ( INSERT INTO watchdog_monitors (
source, instance, type, state, last_state_change_utc, expected_interval_sec, source, instance, type, state, last_state_change_utc, expected_interval_sec,
last_seen_utc, last_status, last_message, metrics_json, health_json, last_seen_utc, last_status, last_message, metrics_json, health_json,
failing_checks, group_key, os, app_version, created_utc, updated_utc failing_checks, group_key, os, app_version, project_slug, created_utc, updated_utc
) VALUES ( ) VALUES (
:source, :instance, :type, :state, UTC_TIMESTAMP(), :interval, :source, :instance, :type, :state, UTC_TIMESTAMP(), :interval,
UTC_TIMESTAMP(), :last_status, :message, :metrics, :health, UTC_TIMESTAMP(), :last_status, :message, :metrics, :health,
:failing, :group_key, :os, :app_version, UTC_TIMESTAMP(), UTC_TIMESTAMP() :failing, :group_key, :os, :app_version, :project_slug, UTC_TIMESTAMP(), UTC_TIMESTAMP()
) )
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
-- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von -- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von
@@ -165,6 +166,11 @@ final class MonitorRepo
-- COALESCE, damit ein Agent, der die Version nicht mitschickt, -- COALESCE, damit ein Agent, der die Version nicht mitschickt,
-- eine frueher gemeldete nicht loescht. -- eine frueher gemeldete nicht loescht.
app_version = COALESCE(VALUES(app_version), app_version), app_version = COALESCE(VALUES(app_version), app_version),
-- Ebenso das Projekt: Ein Ping mit einem ungebundenen Token
-- (etwa ueber den Shared Key oder ein Alt-Token aus
-- watchdog_agent_tokens) darf eine bekannte Zuordnung nicht
-- wieder loeschen. Zuordnen kann nur, wer sie kennt.
project_slug = COALESCE(VALUES(project_slug), project_slug),
updated_utc = VALUES(updated_utc) updated_utc = VALUES(updated_utc)
'); ');
@@ -182,6 +188,20 @@ final class MonitorRepo
':group_key' => $groupKey, ':group_key' => $groupKey,
':os' => $os, ':os' => $os,
':app_version' => $appVersion !== null ? mb_substr($appVersion, 0, 64) : null, ':app_version' => $appVersion !== null ? mb_substr($appVersion, 0, 64) : null,
// Zu welchem Projekt gehoert dieser Monitor?
//
// Die Spalte gibt es seit Migration 014, geschrieben hat sie hier
// nie jemand. Ein Heartbeat legte den Monitor also mit
// project_slug NULL an, und jeder daraus abgeleitete Befund fiel
// auf das Scheinprojekt "default" zurueck (FindingRepo). Wirkung:
// dc_findings(project_slug="ibkrtrader") lieferte null Befunde,
// waehrend ein deviation.self-Befund mit elf Vorkommnissen
// bereitstand - nur unter einem Projekt, das es nicht gibt.
//
// Die Zuordnung war die ganze Zeit bekannt: Das Ping-Token ist an
// ein Projekt gebunden, und authorizeSource() liest die Bindung
// ohnehin. Sie wurde nur nicht weitergereicht. (#404)
':project_slug' => $projectSlug !== null ? mb_substr($projectSlug, 0, 64) : null,
]); ]);
$monitor = $this->getMonitor($source, $instance); $monitor = $this->getMonitor($source, $instance);
+231
View File
@@ -650,6 +650,237 @@ def _(ctx):
erwarte_erfolg(r) erwarte_erfolg(r)
# ---------------------------------------------------------------------------
# 2.8.1 - vier Befunde aus der Fremdmeldung
# ---------------------------------------------------------------------------
# Allen vier ist gemeinsam, dass sie nur mit einem PROJEKTGEBUNDENEN Token
# auftreten. Das Master-Token des Testlaufs ist an kein Projekt gebunden und
# nimmt deshalb ueberall den anderen Zweig - deswegen war die Suite gruen,
# waehrend drei Agenten dieselben Fehler von aussen meldeten.
#
# Marke "roadmap", obwohl es der Sache nach Bugtracker-Faelle sind: Sie
# brauchen ctx['werkbank_token'], und das entsteht weiter oben in dieser
# Gruppe. Mit der Marke "bugtracker" liefen sie im vollen Lauf zwar mit,
# wurden unter --gruppe bugtracker aber uebersprungen - eine Pruefung, die
# sich beim Nachsehen selbst wegfiltert, ist keine.
@test('roadmap', 'action=next laeuft mit einem projektgebundenen Token')
def _(ctx):
"""
BEFUND #351: manage?action=next antwortete durchgaengig mit HTTP 500 -
ueber MCP, curl und dc-testkit gleichermassen, mit und ohne Parameter.
claim, release, list und stats liefen am selben Endpunkt mit demselben
Token durch.
Ursache war ein benannter Platzhalter, der zweimal in derselben Anweisung
stand (:project_scope in BugRepo::buildFilter, Rueckkanal fuer
Nachpruefungen). Mit ATTR_EMULATE_PREPARES = false bindet PDO jeden Namen
genau einmal - zwei Marker, ein Wert, SQLSTATE[HY093].
Warum die Suite das nicht sah: Der Zweig wird nur betreten, wenn das
Token an ein Projekt gebunden ist. Der bestehende next-Testfall benutzt
das ungebundene Master-Token und ging deshalb daran vorbei. Dieser hier
nimmt ausdruecklich das Werkbank-Token.
Geprueft wird nur, DASS es durchlaeuft. Ob Items zurueckkommen, haengt am
Zustand der Testdatenbank und ist hier nicht die Frage.
"""
r = ctx.client.post('/api/bugtracker/v1/manage',
query={'action': 'next'},
token=ctx['werkbank_token'],
body={'limit': 1})
erwarte_erfolg(r)
@test('roadmap', 'Meldung ohne project_slug geht ins Heimatprojekt')
def _(ctx):
"""
BEFUND #382/#387: Eine Meldung ohne project_slug landete im Projekt
"default". Das ist kein Projekt - es hat keine dc_projects-Zeile, also
blieb project_id NULL. Die Meldung war angelegt und quittiert, aber fuer
denselben Melder danach unerreichbar: action=update antwortete 403
project_forbidden, weil sein Token an sein eigenes Projekt gebunden ist.
Der Server legte Items dort ab, wo der Melder sie nicht anfassen darf -
und die Antwort sah wie ein Erfolg aus. Zwanzig Items sind so entstanden.
Das Weglassen ist der dokumentierte Normalfall, deshalb steht dieser
Testfall hier und nicht als Randfall.
"""
r = ctx.client.post('/api/bugtracker/v1/report',
token=ctx['werkbank_token'],
body={
'title': 'e2e: Meldung ohne Projektangabe',
'description': 'Angelegt von tests/e2e. Darf geloescht werden.',
'severity': 'low',
'environment': 'testing',
'client_ref': 'e2e-ohne-slug-%d' % int(time.time()),
})
erwarte_erfolg(r)
erwarte(r.feld('project_slug') == PROJEKT,
'Die Meldung ging nach %r statt ins Heimatprojekt %r'
% (r.feld('project_slug'), PROJEKT))
# Gegenprobe am Item selbst: Die Antwort koennte das Richtige sagen und
# das Falsche getan haben.
g = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'get', 'id': _item_id(r)},
token=ctx['werkbank_token'])
erwarte_erfolg(g)
erwarte(g.feld('item', 'project_slug') == PROJEKT,
'Das Item liegt in %r statt in %r'
% (g.feld('item', 'project_slug'), PROJEKT))
@test('roadmap', 'Meldung in ein unbekanntes Projekt wird abgewiesen')
def _(ctx):
"""
Die zweite Haelfte desselben Befunds. Ein Tippfehler im project_slug wurde
bis 2.8.0 klaglos angenommen und erzeugte dieselbe Sorte Waise wie
"default" - nur unter anderem Namen. Wer nicht weiss, wohin eine Meldung
gehoert, soll das erfahren statt geraten zu bekommen.
"""
r = ctx.client.post('/api/bugtracker/v1/report',
token=ctx['werkbank_token'],
body={
'project_slug': 'gibt-es-nicht-e2e',
'title': 'e2e: Meldung in ein erfundenes Projekt',
'evidence': 'Erwartet wird eine Absage, kein Item. '
'Dieser Text erfuellt die Begruendungspflicht.',
'environment': 'testing',
})
erwarte_fehler(r, http=400, code='unknown_project')
@test('roadmap', 'Liste ohne project_slug bleibt im eigenen Projekt')
def _(ctx):
"""
BEFUND #364 (und #176, dieselbe Sache): Ohne project_slug lieferten
manage?action=list, dc_search und dc_roadmap die Eintraege ALLER Projekte.
Die Werkzeugbeschreibung sagte das Gegenteil ("Ohne project_slug das
eigene Projekt"), was den Widerspruch lange verdeckt hat.
Grund war eine Voreinstellung auf 'all': Ein Werkbank-Token faellt beim
Lesen absichtlich nicht unter die Projektschranke - es soll die Landschaft
sehen duerfen. Damit war der Zwangsfilter leer und die Vorgabe griff.
Das wiegt schwer, weil CLAUDE.md dc_roadmap zum ersten Aufruf jeder
Sitzung macht: Der Agent bekam einen fremden Backlog vorgelegt, den er
nach derselben Regel nicht bearbeiten darf.
"""
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'limit': 100},
token=ctx['werkbank_token'])
erwarte_erfolg(r)
erwarte(r.feld('filters', 'project_slug') == PROJEKT,
'Gefiltert wurde auf %r statt auf das Heimatprojekt %r'
% (r.feld('filters', 'project_slug'), PROJEKT))
fremd = sorted({i.get('project_slug')
for i in (r.feld('items', default=[]) or [])
if i.get('project_slug') != PROJEKT})
erwarte(not fremd, 'Fremde Projekte in der Antwort: %s' % fremd)
@test('roadmap', 'Verengter Leseweg sagt, dass er verengt hat')
def _(ctx):
"""
BEFUND #131: Ein an Projekt X gebundenes Token, das nach Projekt Y fragt,
bekam beim SCHREIBEN eine klare Absage (403 project_forbidden) und beim
LESEN HTTP 200 - mit den Items von X und einem Umschlag, in dem
filters.project_slug bereits auf X umgeschrieben war. Die Antwort sah aus
wie eine gueltige Auskunft ueber Y.
Das ist keine Spitzfindigkeit: Die Prompt-Vorlage schickt jeden Agenten
vor jeder Meldung durch dc_search ("gibt es das schon?"). Auf "gibt es das
im Deploymentcenter schon?" kam eine leere, plausible Antwort - und das
Duplikat wurde angelegt. Einem Melder ist genau das beinahe passiert;
tatsaechlich waren es 71 Items.
Die Schranke bleibt, wo sie ist. Sie sagt jetzt nur, dass sie zugegangen
ist.
Es braucht ein gebundenes NICHT-Werkbank-Token: Ein dev_agent faellt beim
Lesen absichtlich nicht unter die Schranke und wird deshalb nie verengt.
"""
r = ctx.testctl('workbench_token', project_slug=PROJEKT,
name='e2e-laufzeit', owner_type='project',
scopes=['bugtracker:read'])
erwarte_erfolg(r)
token = r.feld('raw_token')
erwarte(token, 'Kein Token in der Antwort: %s' % r.kurz(300))
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': 'deploymentcenter',
'limit': 5},
token=token)
erwarte_erfolg(r)
erwarte(r.feld('filters', 'project_slug') == PROJEKT,
'Die Verengung selbst fehlt: gefiltert wurde auf %r'
% r.feld('filters', 'project_slug'))
erwarte(r.feld('scoped_to') == PROJEKT,
'scoped_to fehlt oder ist falsch: %r' % r.feld('scoped_to'))
erwarte('deploymentcenter' in (r.feld('scope_note') or ''),
'scope_note nennt das angefragte Projekt nicht: %r' % r.feld('scope_note'))
@test('roadmap', 'Ohne Verengung steht kein Hinweis im Umschlag')
def _(ctx):
"""
Gegenprobe. Der Hinweis darf nur auftauchen, wenn wirklich etwas anderes
geliefert wurde als gefragt - sonst ist er Rauschen und wird ignoriert,
gerade dann, wenn er einmal zaehlt.
"""
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': PROJEKT, 'limit': 5},
token=ctx['werkbank_token'])
erwarte_erfolg(r)
erwarte(r.feld('scoped_to') is None,
'scoped_to steht im Umschlag, obwohl nichts verengt wurde: %r'
% r.feld('scoped_to'))
@test('roadmap', 'Roadmap antwortet standardmaessig schmal')
def _(ctx):
"""
BEFUND #364, zweiter Teil: Die Roadmap lieferte je Eintrag den vollen
Datensatz mit rund fuenfzig Feldern - fuer 57 Eintraege etwa 137.000
Zeichen. Ausgerechnet der erste Aufruf einer Sitzung fuellte damit das
Kontextfenster, bevor die Arbeit begann.
Geprueft wird die Zusage, nicht die Zeichenzahl: compact liefert die
Uebersichtsfelder, fields=full weiterhin alles.
"""
schmal = ctx.client.get('/api/bugtracker/v1/roadmap.php',
query={'action': 'list', 'limit': 5},
token=ctx['werkbank_token'])
erwarte_erfolg(schmal)
erwarte(schmal.feld('fields') == 'compact',
'Vorgabe ist %r statt "compact"' % schmal.feld('fields'))
voll = ctx.client.get('/api/bugtracker/v1/roadmap.php',
query={'action': 'list', 'limit': 5, 'fields': 'full'},
token=ctx['werkbank_token'])
erwarte_erfolg(voll)
s_items = _liste(schmal, 'items')
v_items = _liste(voll, 'items')
if not s_items or not v_items:
ueberspringen('Keine Roadmap-Eintraege in %s vorhanden.' % PROJEKT)
erwarte(len(s_items[0]) < len(v_items[0]),
'Die schmale Sicht hat %d Felder, die volle %d - sie sollte weniger haben.'
% (len(s_items[0]), len(v_items[0])))
erwarte('title' in s_items[0] and 'id' in s_items[0],
'In der schmalen Sicht fehlen Titel oder ID: %s' % sorted(s_items[0]))
erwarte('stack_trace' not in s_items[0],
'Die schmale Sicht traegt weiterhin stack_trace mit.')
@test('roadmap', 'Abgleich uebersieht eine Markererwaehnung im Fliesstext') @test('roadmap', 'Abgleich uebersieht eine Markererwaehnung im Fliesstext')
def _(ctx): def _(ctx):
""" """
+63
View File
@@ -184,6 +184,69 @@ def _(ctx):
erwarte_erfolg(r) erwarte_erfolg(r)
@test('watchdog', 'Lebenszeichen traegt das Projekt seines Tokens')
def _(ctx):
"""
BEFUND #404: watchdog_monitors.project_slug gibt es seit Migration 014,
geschrieben hat es der Ping nie. Der Monitor entstand mit project_slug
NULL, und jeder daraus abgeleitete Supervisor-Befund fiel auf das
Scheinprojekt "default" zurueck.
Wirkung: dc_findings(project_slug="...") lieferte fuer eine laufende,
meldende Instanz null Befunde. Ein Agent, der dem Leitfaden folgt, schloss
daraus "nichts auffaellig" - waehrend ein deviation.self-Befund mit elf
Vorkommnissen bereitstand, nur unter einem Projekt, das es nicht gibt.
Die Zuordnung war die ganze Zeit bekannt: Das Ping-Token ist an ein
Projekt gebunden, und authorizeSource() liest die Bindung ohnehin - sie
wurde nur nicht weitergereicht.
Der bestehende Ping-Testfall benutzt das ungebundene Master-Token des
Testlaufs und konnte den Fehler deshalb nicht sehen.
"""
r = ctx.testctl('workbench_token', project_slug='myapp',
name='e2e-ping-gebunden',
scopes=['watchdog:ping', 'watchdog:read'])
erwarte_erfolg(r)
token = r.feld('raw_token')
erwarte(token, 'Kein Token in der Antwort: %s' % r.kurz(300))
r = ctx.client.post('/api/watchdog/v1/ping', token=token, body={
'source': 'e2e-gebunden',
'instance': MASCHINE,
'type': 'service',
'status': 'ok',
'interval': 60,
'message': 'Automatischer Testlauf: Projektbindung',
})
erwarte_erfolg(r)
erwarte(r.feld('monitor', 'project_slug') == 'myapp',
'Der Monitor traegt project_slug %r statt "myapp"'
% r.feld('monitor', 'project_slug'))
@test('watchdog', 'Ping ohne Bindung loescht ein bekanntes Projekt nicht')
def _(ctx):
"""
Die Gegenprobe. Nicht jeder Ping kommt mit einem projektgebundenen Token -
der Shared Key und die aelteren Agent-Tokens aus watchdog_agent_tokens
kennen kein Projekt. So einer darf eine einmal bekannte Zuordnung nicht
wieder auf NULL setzen, sonst haengt es vom Zufall ab, welcher Ping
zuletzt kam.
"""
r = ctx.client.post('/api/watchdog/v1/ping', body={
'source': 'e2e-gebunden',
'instance': MASCHINE,
'type': 'service',
'status': 'ok',
'interval': 60,
})
erwarte_erfolg(r)
erwarte(r.feld('monitor', 'project_slug') == 'myapp',
'Ein ungebundener Ping hat die Zuordnung ueberschrieben: %r'
% r.feld('monitor', 'project_slug'))
@test('watchdog', 'Lebenszeichen ohne Quelle abgewiesen') @test('watchdog', 'Lebenszeichen ohne Quelle abgewiesen')
def _(ctx): def _(ctx):
r = ctx.client.post('/api/watchdog/v1/ping', body={'instance': MASCHINE}) r = ctx.client.post('/api/watchdog/v1/ping', body={'instance': MASCHINE})
+16 -1
View File
@@ -13,6 +13,11 @@ from harness import (test, erwarte, erwarte_status, erwarte_erfolg,
PROJECT_DIR = Path(__file__).resolve().parents[2] PROJECT_DIR = Path(__file__).resolve().parents[2]
# Ein Projekt, das nach dem Zuruecksetzen wirklich in dc_projects steht
# (sql/schema.sql). Seit 2.8.1 weist der Meldeweg einen Slug ohne
# dc_projects-Zeile ab - ein ausgedachter Name taugt hier nicht mehr.
PROJEKT_FUER_MELDUNGEN = 'myapp'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Zustand # Zustand
@@ -243,8 +248,18 @@ def _(ctx):
@test('token', 'Sub-Token darf melden') @test('token', 'Sub-Token darf melden')
def _(ctx): def _(ctx):
# 'myapp' und nicht 'e2e-selftest': Seit 2.8.1 weist der Endpunkt ein
# project_slug ab, zu dem es keine dc_projects-Zeile gibt (400
# unknown_project). Vorher wurde ein solcher Slug klaglos angenommen und
# das Item mit project_id NULL abgelegt - unsichtbar in jeder
# Projektansicht, unerreichbar fuer jedes projektgebundene Token. Genau
# das war Bugtracker #382/#387.
#
# Dieser Testfall prueft die RECHTE eines Sub-Tokens, nicht die
# Projektanlage; der Slug war hier nie die Aussage. Die neue Schranke
# selbst wird in der Gruppe bugtracker geprueft.
r = ctx.client.post('/api/bugtracker/v1/report', token=ctx['sub_token'], body={ r = ctx.client.post('/api/bugtracker/v1/report', token=ctx['sub_token'], body={
'project_slug': 'e2e-selftest', 'project_slug': PROJEKT_FUER_MELDUNGEN,
'title': 'Meldung ueber Sub-Token', 'title': 'Meldung ueber Sub-Token',
'description': 'Erzeugt vom automatisierten Testlauf.', 'description': 'Erzeugt vom automatisierten Testlauf.',
'severity': 'low', 'severity': 'low',
+25 -3
View File
@@ -215,14 +215,35 @@ switch ($action) {
Http::fail(400, 'missing_project', 'project_slug wird gebraucht.'); Http::fail(400, 'missing_project', 'project_slug wird gebraucht.');
} }
// owner_type und scopes sind seit 2.8.1 waehlbar.
//
// Grund: Zwei Befunde lassen sich nur mit einem projektgebundenen
// Token ueberhaupt ausloesen, und zwar mit VERSCHIEDENEN Sorten davon.
//
// * #404 (Monitor ohne Projekt) braucht ein gebundenes Token mit
// watchdog:ping. Die feste Scope-Liste hier hatte nur
// watchdog:read - pingen konnte das Fixture also nicht.
// * #131 (Leseweg verengt still) tritt NUR bei einem gebundenen
// NICHT-Werkbank-Token auf: Ein dev_agent faellt beim Lesen
// absichtlich nicht unter die Schranke, wird also nie verengt.
//
// Beides war mit dem alten, fest verdrahteten Fixture nicht
// darstellbar - und beides ist deshalb jahrelang durchgerutscht.
$ownerType = (string)(Http::str('owner_type') ?? 'dev_agent');
$scopes = Http::input('scopes');
if (!is_array($scopes) || $scopes === []) {
$scopes = ['bugtracker:manage', 'supervisor:manage', 'watchdog:read', 'license:dev'];
}
$manager = new TokenManager($db); $manager = new TokenManager($db);
$token = $manager->createMasterToken( $token = $manager->createMasterToken(
(string)(Http::str('name') ?? 'Werkbank (Testlauf)'), (string)(Http::str('name') ?? 'Werkbank (Testlauf)'),
$slug, $slug,
null, null,
'dev_agent', $ownerType,
'testkit', 'testkit',
['bugtracker:manage', 'supervisor:manage', 'watchdog:read', 'license:dev'], array_values(array_map('strval', $scopes)),
'all' 'all'
); );
@@ -230,7 +251,8 @@ switch ($action) {
'token_id' => $token['token_id'], 'token_id' => $token['token_id'],
'raw_token' => $token['raw_token'], 'raw_token' => $token['raw_token'],
'project_slug' => $slug, 'project_slug' => $slug,
'owner_type' => 'dev_agent', 'owner_type' => $ownerType,
'scopes' => $scopes,
]); ]);
break; break;
} }