fix(#32): webhook URL via shared helper, disabled-state note, CAS-release test, Conflict helper (review round 1)

F1 [WARNING] The frontend built the trigger URL from window.location.origin,
bypassing the shared webhookHelper whose getBaseUrl() honours the reverse-proxy
sub-path (<base href>) and the desktop file:// build. Under a non-root base-href
deploy the copied URL would miss the sub-path prefix. Added
containerAutomationWebhookUrl(token) to webhookHelper (mirroring dockerWebhookUrl)
and used it; the test asserts against the same helper.

F2 [WARNING] The panel showed the live URL + 'POST runs a pass' text even when
auto-update was disabled — contradicting the server, which 409s a valid token
while disabled. WebhookTriggerSection now takes the enabled flag and shows an
orange note ('trigger is inactive... a POST returns 409') when a token exists but
auto-update is off; the token/Regenerate/Clear stay (the token is kept). Tests
cover both the disabled note and its absence when enabled.

F3 [WARNING] The CAS-release of the REAL runUpdatePass was untested (only the
acquire-FAIL path was). A broken release defer would wedge auto-update forever
(poll drops every tick, webhook worker spins) with a green suite. Added
TestRunUpdatePassReleasesLock: a test store with auto-update enabled and no
endpoints runs a pass to completion and asserts the lock is released (and a
second pass re-acquires it).

F4 [low] Swapped raw NewError(StatusConflict) for the httperror.Conflict helper,
matching the rest of the file and webhook_create.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent
2026-07-05 06:30:38 +03:00
parent b6ba9b46c7
commit 400ac05864
5 changed files with 88 additions and 11 deletions
+27
View File
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
@@ -178,3 +179,29 @@ func TestRunUpdatePassCASGuard(t *testing.T) {
require.True(t, s.updateRunning.CompareAndSwap(false, true))
require.False(t, s.runUpdatePass(), "runUpdatePass must return false when a pass is already running")
}
// TestRunUpdatePassReleasesLock pins the CAS-RELEASE contract of the real
// runUpdatePass (not the stub): after a pass completes, the updateRunning lock
// MUST be released, or every future pass — poll tick AND webhook kick — would be
// permanently wedged (the poll drops the tick, the webhook worker spins its retry
// loop forever) while the rest of the suite stays green. A test store with
// auto-update enabled and no endpoints runs a trivial pass to completion.
func TestRunUpdatePassReleasesLock(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
settings, err := store.Settings().Settings()
require.NoError(t, err)
settings.ContainerAutomation.AutoUpdate.Enabled = true
require.NoError(t, store.Settings().UpdateSettings(settings))
s := &Service{baseCtx: context.Background(), dataStore: store}
// First pass: acquires the lock and runs to completion (no endpoints → no work).
require.True(t, s.runUpdatePass(), "the pass should acquire the lock and run")
require.False(t, s.updateRunning.Load(), "the lock MUST be released after the pass")
// A second pass must be able to acquire the lock again — proves the release,
// not just the flag read.
require.True(t, s.runUpdatePass(), "a subsequent pass must re-acquire the released lock")
require.False(t, s.updateRunning.Load())
}
@@ -49,7 +49,7 @@ func (handler *Handler) webhookContainerAutomation(w http.ResponseWriter, r *htt
// The token is valid but auto-update is turned off: report a conflict rather than
// silently accepting a kick that would never do anything.
if !autoUpdate.Enabled {
return httperror.NewError(http.StatusConflict, "Container auto-update is disabled", errors.New("container auto-update is disabled"))
return httperror.Conflict("Container auto-update is disabled", errors.New("container auto-update is disabled"))
}
if handler.ContainerAutomationService == nil {
+4
View File
@@ -14,6 +14,10 @@ export function dockerWebhookUrl(token: string) {
return `${baseUrl}${API_ENDPOINT_WEBHOOKS}/${token}`;
}
export function containerAutomationWebhookUrl(token: string) {
return `${baseUrl}${API_ENDPOINT_WEBHOOKS}/container-automation/${token}`;
}
export function baseStackWebhookUrl() {
return `${baseUrl}${API_ENDPOINT_STACKS}/webhooks`;
}
@@ -6,6 +6,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import { containerAutomationWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { AutoUpdatePanel } from './AutoUpdatePanel';
@@ -115,14 +116,14 @@ describe('AutoUpdatePanel', () => {
expect(screen.getByLabelText(/Rollback timeout/i)).toHaveValue('120s');
});
function seedSettings(token?: string) {
function seedSettings(token?: string, enabled = true) {
server.use(
http.get('/api/settings', () =>
HttpResponse.json({
ContainerAutomation: {
AutoHeal: { Enabled: false, CheckInterval: '30s', Scope: 'labeled' },
AutoUpdate: {
Enabled: true,
Enabled: enabled,
PollInterval: '6h',
Scope: 'labeled',
Cleanup: false,
@@ -143,10 +144,10 @@ describe('AutoUpdatePanel', () => {
renderComponent();
const urlField = await screen.findByLabelText(/Update trigger webhook/i);
// The URL is built via the shared helper (sub-path / base-href aware), not a
// bare origin — assert against the same helper.
await waitFor(() =>
expect(urlField).toHaveValue(
`${window.location.origin}/api/webhooks/container-automation/${token}`
)
expect(urlField).toHaveValue(containerAutomationWebhookUrl(token))
);
expect(
@@ -155,6 +156,28 @@ describe('AutoUpdatePanel', () => {
expect(screen.getByRole('button', { name: /Clear/i })).toBeInTheDocument();
});
it('warns that the trigger is inactive when a token exists but auto-update is disabled', async () => {
seedSettings('abc-123-token', false);
renderComponent();
// The disabled-state note (a POST would 409) must be shown so the UI does not
// contradict the server.
expect(
await screen.findByText(/trigger is inactive/i)
).toBeInTheDocument();
});
it('does NOT show the inactive note when auto-update is enabled', async () => {
seedSettings('abc-123-token', true);
renderComponent();
// Wait for the section to render, then confirm the note is absent.
await screen.findByLabelText(/Update trigger webhook/i);
expect(screen.queryByText(/trigger is inactive/i)).not.toBeInTheDocument();
});
it('shows Generate and no Clear when no token exists', async () => {
seedSettings(undefined);
@@ -2,6 +2,7 @@ import { RefreshCw } from 'lucide-react';
import { Field, Form, Formik, useFormikContext } from 'formik';
import { notifySuccess } from '@/portainer/services/notifications';
import { containerAutomationWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { Widget } from '@@/Widget';
import { LoadingButton, Button, CopyButton } from '@@/buttons';
@@ -71,7 +72,10 @@ export function AutoUpdatePanel() {
<hr />
<WebhookTriggerSection token={autoUpdate.WebhookToken} />
<WebhookTriggerSection
token={autoUpdate.WebhookToken}
enabled={autoUpdate.Enabled}
/>
</Widget.Body>
</Widget>
);
@@ -103,12 +107,18 @@ export function AutoUpdatePanel() {
// trigger URL (readonly, with a copy button) plus Generate/Regenerate and Clear
// actions. The token itself is server-generated — the client only requests an
// action — so this section never edits the URL, it just displays and rotates it.
function WebhookTriggerSection({ token }: { token?: string }) {
function WebhookTriggerSection({
token,
enabled,
}: {
token?: string;
enabled: boolean;
}) {
const mutation = useUpdateSettingsMutation();
const webhookUrl = token
? `${window.location.origin}/api/webhooks/container-automation/${token}`
: '';
// Build the URL via the shared helper so it honours the reverse-proxy sub-path
// (`<base href>`) and the desktop file:// build, rather than a bare origin.
const webhookUrl = token ? containerAutomationWebhookUrl(token) : '';
function regenerate() {
mutation.mutate(
@@ -148,6 +158,19 @@ function WebhookTriggerSection({ token }: { token?: string }) {
</div>
</div>
{token && !enabled && (
<div className="form-group">
<div className="col-sm-12">
<TextTip color="orange">
The trigger is inactive while container auto-update is disabled
a POST to this URL returns HTTP 409 and runs no pass. Enable
auto-update above to activate it. The token is kept and can still
be rotated or cleared here.
</TextTip>
</div>
</div>
)}
<FormControl label="Update trigger webhook" inputId="autoupdate_webhook_url">
<div className="flex items-center gap-2">
<Input