From 80b9a74641c3bcc713457763e0fa3b5237c8c9a8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 27 May 2026 21:48:11 +0200 Subject: [PATCH] fix(ori): surface closeVotingRound failures explicitly and add bearer auth to ORI publication Closes #318 VotingService::closeVotingRound no longer swallows lifecycle-transition and ORI-publication failures silently. State-machine violations (\InvalidArgumentException) are re-thrown so the controller returns an explicit error. Infrastructure errors are promoted from WARNING to ERROR level in logs. ORI publication errors are attached to the returned round payload as 'oriPublicationError' so callers can surface them. Closes #320 OriPublicationService::publish now reads 'ori_bearer_secret' from IAppConfig and includes it as an Authorization: Bearer header when set. The key is added to SettingsService::CONFIG_KEYS so it can be saved from the admin UI. When the key is not configured the Authorization header is omitted (intentional: ORI endpoints that require auth will return 401, exposing the gap rather than silently failing with 200). --- lib/Service/OriPublicationService.php | 19 ++++++-- lib/Service/SettingsService.php | 1 + lib/Service/VotingService.php | 62 ++++++++++++++++++++------- 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/lib/Service/OriPublicationService.php b/lib/Service/OriPublicationService.php index e79fb9d7..a9e49855 100644 --- a/lib/Service/OriPublicationService.php +++ b/lib/Service/OriPublicationService.php @@ -157,14 +157,25 @@ public function publish(string $votingRoundId): void throw new \RuntimeException('JSON encoding of ORI payload failed: '.json_last_error_msg()); } + // #320: Attach the configurable ORI bearer token when set. + // The secret is stored in IAppConfig under key 'ori_bearer_secret'. + // When not configured the Authorization header is omitted (ORI endpoints + // that require auth will return 401, surfacing a configuration gap). + $headers = [ + 'Content-Type' => 'application/ld+json', + 'Accept' => 'application/json', + ]; + + $bearerSecret = $this->appConfig->getValueString(Application::APP_ID, 'ori_bearer_secret', ''); + if ($bearerSecret !== '') { + $headers['Authorization'] = 'Bearer '.$bearerSecret; + } + $client = $this->clientService->newClient(); $client->post( $endpoint, [ - 'headers' => [ - 'Content-Type' => 'application/ld+json', - 'Accept' => 'application/json', - ], + 'headers' => $headers, 'body' => $body, 'timeout' => 10, ] diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index fb9e558a..dfe2f327 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -52,6 +52,7 @@ class SettingsService private const CONFIG_KEYS = [ 'register', 'ori_endpoint', + 'ori_bearer_secret', 'email_voting_enabled', 'minutesSchema', 'decisionSchema', diff --git a/lib/Service/VotingService.php b/lib/Service/VotingService.php index bc4960df..758eff43 100644 --- a/lib/Service/VotingService.php +++ b/lib/Service/VotingService.php @@ -513,20 +513,26 @@ public function closeVotingRound(string $votingRoundId, bool $anonymise=false): $result = ($tally['result'] ?? 'invalid'); // Transition motion lifecycle based on result. + // #318: Re-throw \InvalidArgumentException (bad state-machine transition) so the + // caller learns the round was closed but motion lifecycle could not be updated. + // Transient/infrastructure errors are still logged-and-continued so a network hiccup + // does not leave the round un-closed; however they are logged at ERROR level so they + // surface in monitoring rather than silently disappearing. + $lifecycleError = null; if ($round !== null) { foreach (($round['relations'] ?? []) as $rel) { if (($rel['schema'] ?? '') === 'motion') { $motionId = ($rel['id'] ?? null); if ($motionId !== null) { - try { - // Only transition to defined terminal states via the guarded state machine. - $motionLifecycle = match ($result) { - 'adopted' => 'adopted', - 'rejected' => 'rejected', - default => null, - }; - - if ($motionLifecycle !== null) { + // Only transition to defined terminal states via the guarded state machine. + $motionLifecycle = match ($result) { + 'adopted' => 'adopted', + 'rejected' => 'rejected', + default => null, + }; + + if ($motionLifecycle !== null) { + try { $this->motionService->transitionLifecycle( objectId: $motionId, objectType: 'motion', @@ -545,10 +551,24 @@ public function closeVotingRound(string $votingRoundId, bool $anonymise=false): $motionTitle = (string) ($motion['title'] ?? $motionId); $this->createDossierFolder(motionId: $motionId, motionTitle: $motionTitle); } - } - } catch (\Throwable $e) { - $this->logger->warning('Decidesk: lifecycle transition after close failed', ['error' => $e->getMessage()]); - }//end try + } catch (\InvalidArgumentException $e) { + // State-machine violation: re-throw so the caller can surface it. + // #318: Previously swallowed silently. + throw new \RuntimeException( + 'Stemronde gesloten maar motie kon niet worden bijgewerkt: '.$e->getMessage(), + 0, + $e + ); + } catch (\Throwable $e) { + // Transient infrastructure failure: log at ERROR level and continue. + // #318: Previously logged at WARNING and lost in monitoring noise. + $this->logger->error( + 'Decidesk: lifecycle transition after close failed — round is closed but motion state may be stale', + ['votingRoundId' => $votingRoundId, 'motionId' => $motionId, 'error' => $e->getMessage()] + ); + $lifecycleError = $e->getMessage(); + }//end try + }//end if }//end if break; @@ -556,11 +576,23 @@ public function closeVotingRound(string $votingRoundId, bool $anonymise=false): }//end foreach }//end if - // Trigger ORI publication (fails silently if not configured). + // Trigger ORI publication. + // #318: Publication failures are now surfaced to callers when they indicate a real + // configuration or protocol error (not "endpoint not configured" — that is still + // swallowed, as `publish()` returns silently when no endpoint is set). + // Infrastructure/network errors are logged at ERROR level (was INFO) so they surface + // in monitoring. try { $this->oriPublicationService->publish($votingRoundId); } catch (\Throwable $e) { - $this->logger->info('Decidesk: ORI publication deferred', ['error' => $e->getMessage()]); + $this->logger->error( + 'Decidesk: ORI publication failed after round close', + ['votingRoundId' => $votingRoundId, 'error' => $e->getMessage()] + ); + // Attach ORI error to round data so caller can reflect it in the response. + if ($round !== null) { + $round['oriPublicationError'] = $e->getMessage(); + } } // Anonymise vote values if requested (sequence: tally → publish → anonymise).