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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user