From 460fb91ad2d7020f07aab242ea953cc285566d5d Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:10:50 -0400 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=85=20test(e2e):=20align=20pinned-dis?= =?UTF-8?q?play=20and=20maturity-clock=20expectations=20with=20#566?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display-honesty batch changed two surfaces these specs asserted: - #406: the one-clock maturity panel collapsed the countdown into a single '{countdown} · unlocks {date}' line, replacing 'Lifts at'. - #498: insight-only cards read 'Current' (pinned left the Update column) and the insight-kind badge was retired; pinned-ness is the persistent pin glyph on the Tag cell, asserted in both table and cards. Fixture now sets tagPinGated, the backend gate verdict the glyph keys on. Verified locally: 4 passed against the QA env. --- e2e/playwright/v16-modes-pins.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/e2e/playwright/v16-modes-pins.spec.ts b/e2e/playwright/v16-modes-pins.spec.ts index be1c8aadf..3ba97632e 100644 --- a/e2e/playwright/v16-modes-pins.spec.ts +++ b/e2e/playwright/v16-modes-pins.spec.ts @@ -158,11 +158,11 @@ test.describe('v1.6 update modes, scheduling, and pinned tags', () => { await expect(condition).toContainText( 'The candidate must remain unchanged until the stabilization period ends.', ); - await expect(condition).toContainText(/6m\s*·\s*Lifts at /); + await expect(condition).toContainText(/6m\s*·\s*unlocks /); await expect(panel.locator('[data-test="update-status-manual-cta"]')).toBeEnabled(); await page.clock.fastForward(60_000); - await expect(condition).toContainText(/5m\s*·\s*Lifts at /); + await expect(condition).toContainText(/5m\s*·\s*unlocks /); }); test('#498 renders pinned current-to-newer tags as information in table and cards', async ({ @@ -173,6 +173,7 @@ test.describe('v1.6 update modes, scheduling, and pinned tags', () => { container.displayName = 'Immich ML (Pinned)'; container.tagFamily = 'strict'; container.tagPinned = true; + container.tagPinGated = true; container.tagPinInfo = true; container.updateAvailable = false; container.updateKind = { @@ -215,14 +216,15 @@ test.describe('v1.6 update modes, scheduling, and pinned tags', () => { await expect(tableFlow).toContainText('v3.0.2-openvino'); const tableText = (await tableFlow.innerText()).replace(/\s+/g, ' '); expect(tableText.indexOf('v2.7.5-openvino')).toBeLessThan(tableText.indexOf('v3.0.2-openvino')); + await expect(row.locator('[data-test="container-tag-pinned-glyph"]')).toBeVisible(); await expect(row.getByRole('button', { name: /^Update$/ })).toHaveCount(0); await page.getByRole('button', { name: 'Cards view' }).click(); const card = page.locator('[data-test="dd-card"]').filter({ hasText: 'Immich ML (Pinned)' }); await expect(card).toBeVisible(); await expect(card).toContainText(/v2\.7\.5-openvino\s*→\s*v3\.0\.2-openvino/); - await expect(card.locator('[data-test="container-card-update-state"]')).toHaveText('Pinned'); - await expect(card.locator('[data-test="update-insight-kind-badge"]')).toHaveText('Major'); + await expect(card.locator('[data-test="container-card-update-state"]')).toHaveText('Current'); + await expect(card.locator('[data-test="container-tag-pinned-glyph"]')).toBeVisible(); await expect(card.getByRole('button', { name: /^Update$/ })).toHaveCount(0); }); }); From 13acfffca63d52cc902251746544de5dcd241095 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:33:24 -0400 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20fix(runtime):=20close=20v1.6?= =?UTF-8?q?=20identity=20and=20lifecycle=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/AgentClient.test.ts | 59 +++++++++++++++++++ app/agent/AgentClient.ts | 38 +++++++++++- app/model/container.ts | 17 ++++-- app/registries/BaseRegistry.test.ts | 21 +++++++ app/registries/BaseRegistry.ts | 20 +++++-- app/store/container.test.ts | 48 +++++++++++++++ app/watchers/providers/docker/Docker.ts | 4 +- .../docker/digest-cache-lifecycle.test.ts | 25 ++++++++ .../docker/digest-cache-lifecycle.ts | 24 +++++--- 9 files changed, 235 insertions(+), 21 deletions(-) diff --git a/app/agent/AgentClient.test.ts b/app/agent/AgentClient.test.ts index 169b03ac8..58301b906 100644 --- a/app/agent/AgentClient.test.ts +++ b/app/agent/AgentClient.test.ts @@ -1558,6 +1558,41 @@ describe('AgentClient', () => { expect((client as any).reconnectTimer).toBeNull(); }); + test('should not reconnect when an established SSE stream errors after stop', async () => { + const stream = new EventEmitter(); + axios.mockRejectedValue(new Error('unexpected reconnect')); + axios.mockResolvedValueOnce({ data: stream }); + const reconnectSpy = vi.spyOn(client, 'scheduleReconnect'); + + client.startSse(); + await vi.advanceTimersByTimeAsync(0); + client.stop(); + stream.emit('error', new Error('late connection error')); + await vi.advanceTimersByTimeAsync(1_000); + + expect(reconnectSpy).not.toHaveBeenCalled(); + expect(axios).toHaveBeenCalledTimes(1); + expect((client as any).reconnectTimer).toBeNull(); + }); + + test('should destroy an established SSE stream and ignore later data after stop', async () => { + const stream = new EventEmitter(); + const destroy = vi.fn(); + Object.assign(stream, { destroy }); + axios.mockResolvedValueOnce({ data: stream }); + const handleSpy = vi.spyOn(client, 'handleEvent').mockResolvedValue(undefined); + + client.startSse(); + await vi.advanceTimersByTimeAsync(0); + client.stop(); + stream.emit('data', Buffer.from('data: {"type":"dd:ack","data":{"version":"late"}}\n\n')); + await Promise.resolve(); + await Promise.resolve(); + + expect(destroy).toHaveBeenCalledOnce(); + expect(handleSpy).not.toHaveBeenCalled(); + }); + test('should clear an armed stableConnectionTimer', async () => { const stream = new EventEmitter(); axios.mockResolvedValue({ data: stream }); @@ -6658,6 +6693,30 @@ describe('AgentClient', () => { }); }); + test('does not flag replacementExpected for the same name under a different watcher', () => { + storeContainer.getContainers.mockReturnValue([ + { + id: 'old-id', + name: 'nginx', + watcher: 'watcher-a', + agent: 'test-agent', + }, + ]); + + (client as any).pruneOldContainers([ + { + id: 'new-id', + name: 'nginx', + watcher: 'watcher-b', + }, + ]); + + expect(storeContainer.deleteContainer).toHaveBeenCalledWith('old-id'); + expect(storeContainer.deleteContainer).not.toHaveBeenCalledWith('old-id', { + replacementExpected: true, + }); + }); + // A genuinely removed container must stay unflagged, otherwise Hass keeps its discovery // state topic alive forever (Hass.ts reads replacementExpected off the removed event). test('does not flag replacementExpected when the container is genuinely gone', () => { diff --git a/app/agent/AgentClient.ts b/app/agent/AgentClient.ts index da58219cd..f04ff3f4c 100644 --- a/app/agent/AgentClient.ts +++ b/app/agent/AgentClient.ts @@ -30,6 +30,7 @@ import { type Container, type ContainerReport, clearDetectedUpdateState, + deriveContainerIdentityKey, } from '../model/container.js'; import { type ActiveContainerUpdateOperationStatus, @@ -220,6 +221,7 @@ export class AgentClient { private reconnectTimer: NodeJS.Timeout | null; private reconnectAttempts: number; private stableConnectionTimer: NodeJS.Timeout | null; + private activeSseStream: (NodeJS.EventEmitter & { destroy?: () => void }) | undefined; private stopped: boolean; private hasConnectedOnce: boolean; private readonly pendingFreshStateAfterRemoteUpdate: Set; @@ -252,6 +254,7 @@ export class AgentClient { this.reconnectTimer = null; this.reconnectAttempts = 0; this.stableConnectionTimer = null; + this.activeSseStream = undefined; this.stopped = false; this.hasConnectedOnce = false; this.pendingFreshStateAfterRemoteUpdate = new Set(); @@ -436,8 +439,20 @@ export class AgentClient { // removal. Flagging it lets the store retain the user's updatePolicy for the incoming doc // (and, as before, lets Hass keep the state topic alive across the swap). A name that is // genuinely gone stays unflagged so its HA discovery topics are still cleaned up. - const newContainerNames = new Set( + const newContainerIdentityKeys = new Set( newContainers + .map((container) => + deriveContainerIdentityKey({ + ...container, + agent: this.name, + watcher: container.watcher || watcher, + }), + ) + .filter((key): key is string => key !== undefined), + ); + const newUnscopedContainerNames = new Set( + newContainers + .filter((container) => !container.watcher && !watcher) .map((container) => container.name) .filter((name): name is string => typeof name === 'string' && name !== ''), ); @@ -445,7 +460,11 @@ export class AgentClient { containersToRemove.forEach((c) => { this.log.info(`Pruning container ${c.name} (removed on Agent)`); this.pendingFreshStateAfterRemoteUpdate.delete(c.id); - if (typeof c.name === 'string' && newContainerNames.has(c.name)) { + const identityKey = deriveContainerIdentityKey(c); + const replacementExpected = identityKey + ? newContainerIdentityKeys.has(identityKey) + : typeof c.name === 'string' && newUnscopedContainerNames.has(c.name); + if (replacementExpected) { storeContainer.deleteContainer(c.id, { replacementExpected: true }); return; } @@ -761,6 +780,9 @@ export class AgentClient { stop() { this.stopped = true; + const activeSseStream = this.activeSseStream; + this.activeSseStream = undefined; + activeSseStream?.destroy?.(); this.clearStableConnectionTimer(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); @@ -857,6 +879,9 @@ export class AgentClient { let sseProcessing = Promise.resolve(); stream.on('data', (chunk: Buffer) => { + if (this.stopped || this.activeSseStream !== stream) { + return; + } const decodedChunk = decoder.write(chunk); if (!decodedChunk) { return; @@ -872,10 +897,18 @@ export class AgentClient { }); }); stream.on('error', (e: Error) => { + if (this.activeSseStream !== stream) { + return; + } + this.activeSseStream = undefined; this.log.error(`SSE Connection failed: ${e.message}`); this.scheduleReconnect(); }); stream.on('end', () => { + if (this.activeSseStream !== stream) { + return; + } + this.activeSseStream = undefined; this.log.warn('SSE stream ended. Reconnecting...'); this.scheduleReconnect(); }); @@ -907,6 +940,7 @@ export class AgentClient { this.stableConnectionTimer = null; this.reconnectAttempts = 0; }, SSE_STABLE_CONNECTION_MS); + this.activeSseStream = response.data; this.attachStreamHandlers(response.data); }) .catch((error: unknown) => { diff --git a/app/model/container.ts b/app/model/container.ts index cdc7e854b..cf59795e0 100644 --- a/app/model/container.ts +++ b/app/model/container.ts @@ -912,9 +912,11 @@ function hasResultChanged( /** * Check whether the update candidate's identity changed, i.e. the tag or * digest a recheck would actually promote. Unlike hasResultChanged, this - * ignores display-only metadata (suggestedTag, created) that can wobble - * between scans without the candidate itself changing, so callers that gate - * the maturity clock restart on this don't falsely reset it. + * ignores display-only metadata (suggestedTag and, when a digest is present, + * created) that can wobble between scans without the candidate itself + * changing. For legacy manifests without a digest, created is the only + * available immutable candidate discriminator and must participate in the + * identity. * @param currentResult * @param otherResult * @returns {boolean} @@ -923,7 +925,14 @@ export function hasCandidateIdentityChanged( currentResult: Container['result'], otherResult: Container['result'], ): boolean { - return currentResult?.tag !== otherResult?.tag || currentResult?.digest !== otherResult?.digest; + if (currentResult?.tag !== otherResult?.tag || currentResult?.digest !== otherResult?.digest) { + return true; + } + return ( + currentResult?.digest === undefined && + otherResult?.digest === undefined && + currentResult?.created !== otherResult?.created + ); } function resultChangedFunction(this: Container, otherContainer: Container | undefined) { diff --git a/app/registries/BaseRegistry.test.ts b/app/registries/BaseRegistry.test.ts index 6019dae34..a5505755d 100644 --- a/app/registries/BaseRegistry.test.ts +++ b/app/registries/BaseRegistry.test.ts @@ -1414,6 +1414,27 @@ test('startDigestCachePollCycle should clear previous tag-list cache entries', a expect(superGetTagsSpy).toHaveBeenCalledTimes(2); }); +test('ending an older poll cycle should not disable a newer overlapping cycle', async () => { + const superGetTagsSpy = vi + .spyOn(Registry.prototype, 'getTags') + .mockResolvedValue(['2.0.0', '1.0.0']); + const image = { + name: 'library/postgres', + tag: { value: '16' }, + registry: { url: 'docker.io' }, + }; + + const olderCycle = baseRegistry.startDigestCachePollCycle(); + const newerCycle = baseRegistry.startDigestCachePollCycle(); + baseRegistry.endDigestCachePollCycle(olderCycle); + + await baseRegistry.getTags(image); + await baseRegistry.getTags(image); + + expect(superGetTagsSpy).toHaveBeenCalledTimes(1); + baseRegistry.endDigestCachePollCycle(newerCycle); +}); + test('stale tag lookups cannot overwrite a newer poll cycle cache', async () => { let resolveStale: (tags: string[]) => void = () => {}; let resolveFresh: (tags: string[]) => void = () => {}; diff --git a/app/registries/BaseRegistry.ts b/app/registries/BaseRegistry.ts index 066d798fa..2c834168e 100644 --- a/app/registries/BaseRegistry.ts +++ b/app/registries/BaseRegistry.ts @@ -193,6 +193,7 @@ class BaseRegistry< this.tagListCacheInFlight.clear(); this.digestCacheHits = 0; this.digestCacheMisses = 0; + return this.digestCachePollCycleGeneration; } override async getTags( @@ -235,9 +236,20 @@ class BaseRegistry< } } - public endDigestCachePollCycle() { + public endDigestCachePollCycle(expectedGeneration?: number) { const totalRequests = this.digestCacheHits + this.digestCacheMisses; const hitRate = totalRequests === 0 ? 0 : (this.digestCacheHits / totalRequests) * 100; + const stats = { + hits: this.digestCacheHits, + misses: this.digestCacheMisses, + hitRate, + }; + if ( + expectedGeneration !== undefined && + expectedGeneration !== this.digestCachePollCycleGeneration + ) { + return stats; + } if (this.log && typeof this.log.debug === 'function') { this.log.debug( `${this.getId()} digest cache hit rate ${hitRate.toFixed(2)}% (${this.digestCacheHits} hits, ${this.digestCacheMisses} misses)`, @@ -249,11 +261,7 @@ class BaseRegistry< this.digestManifestCacheInFlight.clear(); this.tagListCache.clear(); this.tagListCacheInFlight.clear(); - return { - hits: this.digestCacheHits, - misses: this.digestCacheMisses, - hitRate, - }; + return stats; } /** diff --git a/app/store/container.test.ts b/app/store/container.test.ts index c96b52da1..b12ba73d3 100644 --- a/app/store/container.test.ts +++ b/app/store/container.test.ts @@ -1267,6 +1267,54 @@ test('updateContainer should preserve updateDetectedAt when a recheck only chang expect(updated.updateDetectedAt).toBe(existingDetectedAt); }); +test('updateContainer should reset updateDetectedAt when a created-only candidate changes', () => { + vi.useFakeTimers(); + try { + const frozenNow = new Date('2026-06-03T12:00:00.000Z'); + vi.setSystemTime(frozenNow); + const existingDetectedAt = '2026-06-01T12:00:00.000Z'; + const existingFixture = createContainerFixture(); + const existingContainer = { + data: { + ...existingFixture, + result: { + tag: 'version', + created: '2026-06-01T00:00:00.000Z', + }, + updateDetectedAt: existingDetectedAt, + }, + }; + const collection = { + findOne: () => existingContainer, + insert: () => {}, + chain: () => ({ + find: () => ({ + remove: () => ({}), + }), + }), + }; + const nextFixture = createContainerFixture(); + const containerToSave = { + ...nextFixture, + result: { + tag: 'version', + created: '2026-06-02T00:00:00.000Z', + }, + updateDetectedAt: existingDetectedAt, + }; + + container.createCollections({ + getCollection: () => collection, + addCollection: () => null, + }); + const updated = container.updateContainer(containerToSave); + + expect(updated.updateDetectedAt).toBe(frozenNow.toISOString()); + } finally { + vi.useRealTimers(); + } +}); + test('updateContainer should reset updateDetectedAt when the candidate tag genuinely changes (#565)', async () => { const existingDetectedAt = '2026-02-20T09:15:00.000Z'; const existingFixture = createContainerFixture(); diff --git a/app/watchers/providers/docker/Docker.ts b/app/watchers/providers/docker/Docker.ts index 0bec4f04f..3e95c88fd 100644 --- a/app/watchers/providers/docker/Docker.ts +++ b/app/watchers/providers/docker/Docker.ts @@ -1086,7 +1086,7 @@ class Docker extends Watcher { let containers: Container[] = []; let containerEnumerationFailed = false; const enumerationDiagnostics: { enrichmentErrors: number } = { enrichmentErrors: 0 }; - startDigestCachePollCycleForRegistries(); + const digestCachePollCycle = startDigestCachePollCycleForRegistries(); // Dispatch event to notify start watching event.emitWatcherStart(this); @@ -1159,7 +1159,7 @@ class Docker extends Watcher { } return containerReports; } finally { - endDigestCachePollCycleForRegistries(); + endDigestCachePollCycleForRegistries(digestCachePollCycle); // Dispatch event to notify stop watching event.emitWatcherStop(this); this.lastRunAt = new Date().toISOString(); diff --git a/app/watchers/providers/docker/digest-cache-lifecycle.test.ts b/app/watchers/providers/docker/digest-cache-lifecycle.test.ts index 6add33b42..b73816481 100644 --- a/app/watchers/providers/docker/digest-cache-lifecycle.test.ts +++ b/app/watchers/providers/docker/digest-cache-lifecycle.test.ts @@ -52,6 +52,31 @@ describe('digest-cache-lifecycle', () => { }); describe('endDigestCachePollCycleForRegistries', () => { + test('passes each registry the handle returned when its cycle started', () => { + const startA = vi.fn().mockReturnValue(11); + const startB = vi.fn().mockReturnValue(22); + const endA = vi.fn(); + const endB = vi.fn(); + mockGetState.mockReturnValue({ + registry: { + a: { + startDigestCachePollCycle: startA, + endDigestCachePollCycle: endA, + }, + b: { + startDigestCachePollCycle: startB, + endDigestCachePollCycle: endB, + }, + }, + } as never); + + const cycle = startDigestCachePollCycleForRegistries(); + endDigestCachePollCycleForRegistries(cycle); + + expect(endA).toHaveBeenCalledWith(11); + expect(endB).toHaveBeenCalledWith(22); + }); + test('calls endDigestCachePollCycle on each registry that supports it', () => { const endA = vi.fn(); const endB = vi.fn(); diff --git a/app/watchers/providers/docker/digest-cache-lifecycle.ts b/app/watchers/providers/docker/digest-cache-lifecycle.ts index 909e1f5ef..1479430f2 100644 --- a/app/watchers/providers/docker/digest-cache-lifecycle.ts +++ b/app/watchers/providers/docker/digest-cache-lifecycle.ts @@ -1,24 +1,34 @@ import * as registry from '../../../registry/index.js'; interface DigestCachePollCycleAwareRegistry { - startDigestCachePollCycle?: () => void; - endDigestCachePollCycle?: () => void; + startDigestCachePollCycle?: () => unknown; + endDigestCachePollCycle?: (handle?: unknown) => void; } +export type DigestCachePollCycle = Map; + function getRegistries() { return registry.getState().registry; } export function startDigestCachePollCycleForRegistries() { const registries = Object.values(getRegistries()) as DigestCachePollCycleAwareRegistry[]; + const cycle: DigestCachePollCycle = new Map(); for (const provider of registries) { - provider.startDigestCachePollCycle?.(); + if (provider.startDigestCachePollCycle) { + cycle.set(provider, provider.startDigestCachePollCycle()); + } } + return cycle; } -export function endDigestCachePollCycleForRegistries() { - const registries = Object.values(getRegistries()) as DigestCachePollCycleAwareRegistry[]; - for (const provider of registries) { - provider.endDigestCachePollCycle?.(); +export function endDigestCachePollCycleForRegistries(cycle?: DigestCachePollCycle) { + const entries = cycle + ? cycle.entries() + : (Object.values(getRegistries()) as DigestCachePollCycleAwareRegistry[]).map( + (provider) => [provider, undefined] as const, + ); + for (const [provider, handle] of entries) { + provider.endDigestCachePollCycle?.(handle); } } From ef11704f5f465d3d236ced88d64821cf778d0c6e Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:33:38 -0400 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20fix(ui):=20harden=20preview?= =?UTF-8?q?=20and=20demo=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/tests/ci-verify-workflow.test.ts | 16 + .github/workflows/ci-verify.yml | 24 ++ app/api/openapi.test.ts | 4 + app/api/openapi/schemas.test.ts | 28 ++ app/api/openapi/schemas.ts | 66 +++- app/api/preview-errors.test.ts | 4 +- app/api/preview-errors.ts | 8 +- app/api/preview.test.ts | 8 +- apps/demo/package-lock.json | 364 +++++++++++++++++- apps/demo/package.json | 4 +- apps/demo/src/mocks/data/notifications.ts | 76 +++- apps/demo/src/mocks/handlers.contract.test.ts | 94 +++++ apps/demo/src/mocks/handlers/notifications.ts | 47 ++- apps/demo/src/mocks/handlers/settings.ts | 17 +- ui/src/components/DataTableColumnPicker.vue | 5 +- ui/src/locales/en/containerComponents.json | 4 + ui/src/services/preview.ts | 14 +- ui/src/views/NotificationsView.vue | 59 ++- .../views/containers/useContainerPreview.ts | 64 ++- .../components/DataTableColumnPicker.spec.ts | 25 +- ui/tests/services/preview.spec.ts | 46 ++- ui/tests/views/NotificationsView.spec.ts | 62 +++ .../containers/useContainerActions.spec.ts | 144 ++++++- 23 files changed, 1098 insertions(+), 85 deletions(-) create mode 100644 apps/demo/src/mocks/handlers.contract.test.ts diff --git a/.github/tests/ci-verify-workflow.test.ts b/.github/tests/ci-verify-workflow.test.ts index a5b511d8e..4f7f6d583 100644 --- a/.github/tests/ci-verify-workflow.test.ts +++ b/.github/tests/ci-verify-workflow.test.ts @@ -83,6 +83,22 @@ test('workflow tests are wired outside the app coverage suite', () => { }); }); +test('demo mock contracts and production build are first-class CI gates', () => { + expect(getTestJobStep('Install demo dependencies')).toMatchObject({ + with: { + command: 'cd apps/demo && npm ci --ignore-scripts', + }, + }); + expect(getTestJobStep('Run demo contract and security tests')).toMatchObject({ + run: 'npm test', + 'working-directory': 'apps/demo', + }); + expect(getWorkflowStep('build', 'Build demo')).toMatchObject({ + run: 'npm run build', + 'working-directory': 'apps/demo', + }); +}); + test('secret scanning gates full history and the tracked working tree', () => { const workflow = loadWorkflow(); const job = workflow.jobs?.secrets; diff --git a/.github/workflows/ci-verify.yml b/.github/workflows/ci-verify.yml index 62178dc69..90b277f40 100644 --- a/.github/workflows/ci-verify.yml +++ b/.github/workflows/ci-verify.yml @@ -456,6 +456,18 @@ jobs: - name: Run workflow tests run: npm run test:workflows + - name: Install demo dependencies + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd apps/demo && npm ci --ignore-scripts + + - name: Run demo contract and security tests + run: npm test + working-directory: apps/demo + - name: Install ui dependencies uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: @@ -583,6 +595,18 @@ jobs: run: npm run build working-directory: ui + - name: Install demo dependencies + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd apps/demo && npm ci --ignore-scripts + + - name: Build demo + run: npm run build + working-directory: apps/demo + - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/app/api/openapi.test.ts b/app/api/openapi.test.ts index 4a0b621e2..526637116 100644 --- a/app/api/openapi.test.ts +++ b/app/api/openapi.test.ts @@ -306,7 +306,11 @@ describe('OpenAPI document', () => { ]), }, action: { + required: ['code', 'href'], properties: { + code: { + enum: ['open-registry-settings', 'open-trigger-settings'], + }, href: { enum: ['/registries', '/triggers'] }, }, }, diff --git a/app/api/openapi/schemas.test.ts b/app/api/openapi/schemas.test.ts index 0fdc889d0..e98e2bbbf 100644 --- a/app/api/openapi/schemas.test.ts +++ b/app/api/openapi/schemas.test.ts @@ -55,3 +55,31 @@ describe('scanner runtime OpenAPI schemas', () => { }); }); }); + +describe('container update-eligibility OpenAPI schemas', () => { + test('documents the eligibility verdict, blockers, and maturity clock details', () => { + expect(openApiSchemas.ContainerResource.properties).toMatchObject({ + tagPinGated: { type: 'boolean' }, + updateEligibility: { $ref: '#/components/schemas/UpdateEligibility' }, + }); + expect(openApiSchemas.UpdateEligibility).toMatchObject({ + type: 'object', + required: ['eligible', 'blockers', 'evaluatedAt'], + properties: { + eligible: { type: 'boolean' }, + blockers: { + type: 'array', + items: { $ref: '#/components/schemas/UpdateBlocker' }, + }, + evaluatedAt: { type: 'string', format: 'date-time' }, + }, + }); + expect(openApiSchemas.UpdateBlocker.properties.details).toMatchObject({ + properties: { + clockSource: { type: 'string', enum: ['publishedAt', 'detectedAt'] }, + clockStartAt: { type: 'string', format: 'date-time' }, + remainingMs: { type: 'number', minimum: 0 }, + }, + }); + }); +}); diff --git a/app/api/openapi/schemas.ts b/app/api/openapi/schemas.ts index b41aeb63c..2a63e77b9 100644 --- a/app/api/openapi/schemas.ts +++ b/app/api/openapi/schemas.ts @@ -39,10 +39,13 @@ export const openApiSchemas = { action: { type: 'object', properties: { - label: { type: 'string' }, + code: { + type: 'string', + enum: ['open-registry-settings', 'open-trigger-settings'], + }, href: { type: 'string', enum: ['/registries', '/triggers'] }, }, - required: ['label', 'href'], + required: ['code', 'href'], additionalProperties: false, }, }, @@ -583,6 +586,63 @@ export const openApiSchemas = { }, additionalProperties: false, }, + UpdateBlocker: { + type: 'object', + properties: { + reason: { + type: 'string', + enum: [ + 'no-update-available', + 'rollback-container', + 'active-operation', + 'security-scan-blocked', + 'last-update-rolled-back', + 'snoozed', + 'skip-tag', + 'skip-digest', + 'maturity-not-reached', + 'threshold-not-reached', + 'trigger-excluded', + 'trigger-not-included', + 'agent-mismatch', + 'no-update-trigger-configured', + 'self-update-unavailable', + 'maintenance-window-closed', + ], + }, + severity: { type: 'string', enum: ['hard', 'soft'] }, + message: { type: 'string' }, + actionable: { type: 'boolean' }, + actionHint: { type: 'string' }, + liftableAt: { type: 'string', format: 'date-time' }, + details: { + type: 'object', + properties: { + clockSource: { type: 'string', enum: ['publishedAt', 'detectedAt'] }, + clockStartAt: { type: 'string', format: 'date-time' }, + minAgeDays: { type: 'integer', minimum: 1 }, + remainingMs: { type: 'number', minimum: 0 }, + policySource: { type: 'string' }, + }, + additionalProperties: true, + }, + }, + required: ['reason', 'severity', 'message', 'actionable'], + additionalProperties: false, + }, + UpdateEligibility: { + type: 'object', + properties: { + eligible: { type: 'boolean' }, + blockers: { + type: 'array', + items: { $ref: '#/components/schemas/UpdateBlocker' }, + }, + evaluatedAt: { type: 'string', format: 'date-time' }, + }, + required: ['eligible', 'blockers', 'evaluatedAt'], + additionalProperties: false, + }, ContainerResource: { type: 'object', properties: { @@ -593,6 +653,8 @@ export const openApiSchemas = { agent: { type: 'string' }, identityKey: { type: 'string' }, updateAvailable: { type: 'boolean' }, + tagPinGated: { type: 'boolean' }, + updateEligibility: { $ref: '#/components/schemas/UpdateEligibility' }, image: { ...genericObjectSchema }, updatePolicy: { $ref: '#/components/schemas/ContainerUpdatePolicy' }, updatePolicyDeclarative: { diff --git a/app/api/preview-errors.test.ts b/app/api/preview-errors.test.ts index 3a9313304..a9cfe4ee5 100644 --- a/app/api/preview-errors.test.ts +++ b/app/api/preview-errors.test.ts @@ -17,7 +17,7 @@ function container(options: { registry?: unknown; imageName?: unknown } = {}): C describe('preview errors', () => { test('exports the safe trigger-settings action', () => { - expect(TRIGGER_ACTION).toEqual({ label: 'Open trigger settings', href: '/triggers' }); + expect(TRIGGER_ACTION).toEqual({ code: 'open-trigger-settings', href: '/triggers' }); }); test.each([ @@ -34,7 +34,7 @@ describe('preview errors', () => { code: 'registry-auth-failed', message: `Authentication failed for ghcr.io: ${status} ${label}`, details: { reason: 'denied', registry: 'ghcr.io' }, - action: { label: 'Open registry settings', href: '/registries' }, + action: { code: 'open-registry-settings', href: '/registries' }, }, }); }); diff --git a/app/api/preview-errors.ts b/app/api/preview-errors.ts index 5b5a940dc..2dbe72a7a 100644 --- a/app/api/preview-errors.ts +++ b/app/api/preview-errors.ts @@ -13,8 +13,10 @@ export type PreviewErrorCode = | 'registry-network-error' | 'registry-not-found'; +export type PreviewErrorActionCode = 'open-registry-settings' | 'open-trigger-settings'; + export interface PreviewErrorAction { - label: string; + code: PreviewErrorActionCode; href: '/registries' | '/triggers'; } @@ -45,12 +47,12 @@ const NETWORK_ERROR_CODES = new Set([ ]); const REGISTRY_ACTION: PreviewErrorAction = { - label: 'Open registry settings', + code: 'open-registry-settings', href: '/registries', }; export const TRIGGER_ACTION: PreviewErrorAction = { - label: 'Open trigger settings', + code: 'open-trigger-settings', href: '/triggers', }; diff --git a/app/api/preview.test.ts b/app/api/preview.test.ts index 0ce6bda23..6e9d9e34b 100644 --- a/app/api/preview.test.ts +++ b/app/api/preview.test.ts @@ -76,7 +76,7 @@ describe('Preview Router', () => { code: 'no-trigger-configured', message: 'No action trigger configured for this container', action: { - label: 'Open trigger settings', + code: 'open-trigger-settings', href: '/triggers', }, }); @@ -139,7 +139,7 @@ describe('Preview Router', () => { message: 'Authentication failed for ghcr.io: 401 Unauthorized', details: { reason: 'request failed: 401 Unauthorized', registry: 'ghcr.io' }, action: { - label: 'Open registry settings', + code: 'open-registry-settings', href: '/registries', }, }); @@ -173,7 +173,7 @@ describe('Preview Router', () => { registry: 'ghcr.io', }, action: { - label: 'Open registry settings', + code: 'open-registry-settings', href: '/registries', }, }); @@ -207,7 +207,7 @@ describe('Preview Router', () => { registry: 'registry.example', }, action: { - label: 'Open registry settings', + code: 'open-registry-settings', href: '/registries', }, }); diff --git a/apps/demo/package-lock.json b/apps/demo/package-lock.json index ca32c85ba..b4ad08615 100644 --- a/apps/demo/package-lock.json +++ b/apps/demo/package-lock.json @@ -31,7 +31,8 @@ "@types/node": "25.9.4", "@vitejs/plugin-vue": "6.0.7", "typescript": "5.9.3", - "vite": "7.3.6" + "vite": "7.3.6", + "vitest": "4.1.9" } }, "node_modules/@babel/generator": { @@ -1182,6 +1183,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", @@ -1532,6 +1540,24 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1586,6 +1612,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue-macros/common": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", @@ -1776,6 +1925,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -1818,6 +1977,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1897,6 +2066,13 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1958,6 +2134,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2015,6 +2198,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -2581,6 +2774,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -2763,6 +2970,13 @@ "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2784,6 +2998,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2793,6 +3014,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -2857,6 +3085,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2873,6 +3118,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.27", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", @@ -3058,6 +3313,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vue": { "version": "3.5.39", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", @@ -3134,6 +3479,23 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/apps/demo/package.json b/apps/demo/package.json index eb92a6476..3543b4759 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "vitest run src/mocks/handlers.contract.test.ts --config vite.config.ts --maxWorkers=1 --fileParallelism=false && npm run test:security", "test:security": "node --test tests/security/*.test.js" }, "dependencies": { @@ -33,7 +34,8 @@ "@types/node": "25.9.4", "@vitejs/plugin-vue": "6.0.7", "typescript": "5.9.3", - "vite": "7.3.6" + "vite": "7.3.6", + "vitest": "4.1.9" }, "overrides": { "postcss": "8.5.16", diff --git a/apps/demo/src/mocks/data/notifications.ts b/apps/demo/src/mocks/data/notifications.ts index 6dc0a17c1..136cef688 100644 --- a/apps/demo/src/mocks/data/notifications.ts +++ b/apps/demo/src/mocks/data/notifications.ts @@ -1,40 +1,76 @@ +import type { NotificationRule } from '@/services/notification'; import type { NotificationOutboxEntry } from '@/services/notification-outbox'; -export const notificationRules = [ +export const notificationRules: NotificationRule[] = [ { - id: 'rule-1', - name: 'All Updates', - description: 'Notify on all container updates', + id: 'update-available', + name: 'Update Available', + description: 'When a container has a new version', enabled: true, triggers: ['slack.homelab', 'discord.updates'], + bellEnabled: true, + bellThreshold: 'all', + templates: {}, }, { - id: 'rule-2', - name: 'Security Alerts', - description: 'Notify on critical/high CVEs', + id: 'update-applied', + name: 'Update Applied', + description: 'After a container is successfully updated', + enabled: true, + triggers: ['slack.homelab'], + bellEnabled: true, + bellThreshold: 'all', + templates: {}, + }, + { + id: 'update-failed', + name: 'Update Failed', + description: 'When an update fails or is rolled back', + enabled: true, + triggers: ['slack.homelab', 'smtp.email'], + bellEnabled: true, + bellThreshold: 'all', + templates: {}, + }, + { + id: 'security-alert', + name: 'Security Alert', + description: 'Critical/High vulnerability detected', enabled: true, triggers: ['slack.homelab', 'smtp.email'], + bellEnabled: true, + bellThreshold: 'all', + templates: {}, }, { - id: 'rule-3', - name: 'Major Updates Only', - description: 'Notify only on major version updates', + id: 'agent-disconnect', + name: 'Agent Disconnected', + description: 'When a remote agent loses connection', enabled: false, - triggers: ['http.webhook'], + triggers: ['discord.updates'], + bellEnabled: true, + bellThreshold: 'all', + templates: {}, }, { - id: 'rule-4', - name: 'Infra Stack', - description: 'Notify on infrastructure container updates', - enabled: true, + id: 'agent-reconnect', + name: 'Agent Reconnected', + description: 'When a remote agent reconnects after losing connection', + enabled: false, triggers: ['discord.updates'], + bellEnabled: false, + bellThreshold: 'all', + templates: {}, }, { - id: 'rule-5', - name: 'Media Stack', - description: 'Notify on media container updates', - enabled: true, - triggers: ['slack.homelab'], + id: 'container-unhealthy', + name: 'Container Unhealthy', + description: "When a container's Docker health check enters the unhealthy state", + enabled: false, + triggers: ['http.webhook'], + bellEnabled: false, + bellThreshold: 'all', + templates: {}, }, ]; diff --git a/apps/demo/src/mocks/handlers.contract.test.ts b/apps/demo/src/mocks/handlers.contract.test.ts new file mode 100644 index 000000000..bfe4f4ecc --- /dev/null +++ b/apps/demo/src/mocks/handlers.contract.test.ts @@ -0,0 +1,94 @@ +import { setupServer } from 'msw/node'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { notificationHandlers } from './handlers/notifications'; +import { settingsHandlers } from './handlers/settings'; + +const server = setupServer(...settingsHandlers, ...notificationHandlers); + +beforeAll(() => { + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: new URL('http://localhost'), + }); + server.listen({ onUnhandledRequest: 'error' }); +}); +afterAll(() => server.close()); + +async function readJson(response: Response) { + expect(response.ok).toBe(true); + return response.json(); +} + +describe('demo mock contracts', () => { + test('settings expose updateMode and persist PATCH updates', async () => { + const initial = await readJson(await fetch('http://localhost/api/v1/settings')); + expect(initial).toEqual({ internetlessMode: false, updateMode: 'manual' }); + + const updated = await readJson( + await fetch('http://localhost/api/v1/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updateMode: 'auto' }), + }), + ); + expect(updated).toEqual({ internetlessMode: false, updateMode: 'auto' }); + + const persisted = await readJson(await fetch('http://localhost/api/v1/settings')); + expect(persisted.updateMode).toBe('auto'); + }); + + test('notification rules use canonical ids and persist bell/template updates', async () => { + const initial = await readJson(await fetch('http://localhost/api/v1/notifications')); + const updateAvailable = initial.data.find( + (rule: { id: string }) => rule.id === 'update-available', + ); + expect(updateAvailable).toMatchObject({ + id: 'update-available', + bellEnabled: true, + bellThreshold: 'all', + templates: {}, + }); + + const templates = { + 'slack.homelab': { + simpleTitle: 'Custom ${container.name}', + }, + }; + const updated = await readJson( + await fetch('http://localhost/api/v1/notifications/update-available', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bellEnabled: false, bellThreshold: 'major', templates }), + }), + ); + expect(updated).toMatchObject({ + bellEnabled: false, + bellThreshold: 'major', + templates, + }); + + const persisted = await readJson(await fetch('http://localhost/api/v1/notifications')); + expect( + persisted.data.find((rule: { id: string }) => rule.id === 'update-available'), + ).toMatchObject(updated); + }); + + test('notification template preview returns every required rendered field', async () => { + const preview = await readJson( + await fetch('http://localhost/api/v1/notifications/update-available/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + triggerId: 'slack.homelab', + templates: { simpleTitle: 'Preview ${container.name}' }, + }), + }), + ); + + expect(preview).toEqual({ + simpleTitle: 'Preview Grafana', + simpleBody: expect.any(String), + batchTitle: expect.any(String), + }); + }); +}); diff --git a/apps/demo/src/mocks/handlers/notifications.ts b/apps/demo/src/mocks/handlers/notifications.ts index b858ca596..1959a0952 100644 --- a/apps/demo/src/mocks/handlers/notifications.ts +++ b/apps/demo/src/mocks/handlers/notifications.ts @@ -1,4 +1,5 @@ import { HttpResponse, http } from 'msw'; +import type { NotificationRuleUpdate, NotificationTemplateOverride } from '@/services/notification'; import type { NotificationOutboxEntry, NotificationOutboxEntryStatus, @@ -33,6 +34,13 @@ function outboxResponse(status: NotificationOutboxEntryStatus) { }; } +function renderPreviewTemplate(value: string, fallback: string): string { + return (value || fallback) + .replaceAll('${container.name}', 'Grafana') + .replaceAll('${container.result.tag}', '12.1.0') + .replaceAll('${containers.length}', '3'); +} + export const notificationHandlers = [ http.get('/api/v1/notifications', () => HttpResponse.json({ data: notificationRules })), @@ -84,10 +92,43 @@ export const notificationHandlers = [ return new HttpResponse(null, { status: 204 }); }), + http.post('/api/v1/notifications/:id/preview', async ({ params, request }) => { + const rule = notificationRules.find((candidate) => candidate.id === params.id); + if (!rule) { + return HttpResponse.json({ error: 'Notification rule not found' }, { status: 404 }); + } + const body = (await request.json()) as { + triggerId?: unknown; + templates?: NotificationTemplateOverride; + }; + if (typeof body.triggerId !== 'string' || body.triggerId.trim() === '') { + return HttpResponse.json({ error: 'triggerId is required' }, { status: 400 }); + } + const templates = body.templates ?? {}; + return HttpResponse.json({ + simpleTitle: renderPreviewTemplate( + templates.simpleTitle ?? '', + 'Update available for ${container.name}', + ), + simpleBody: renderPreviewTemplate( + templates.simpleBody ?? '', + '${container.name} can update to ${container.result.tag}.', + ), + batchTitle: renderPreviewTemplate( + templates.batchTitle ?? '', + '${containers.length} container updates are available', + ), + }); + }), + http.patch('/api/v1/notifications/:id', async ({ params, request }) => { - const rule = notificationRules.find((r) => r.id === params.id); + const rule = notificationRules.find((candidate) => candidate.id === params.id); if (!rule) return new HttpResponse(null, { status: 404 }); - const body = (await request.json()) as Record; - return HttpResponse.json({ ...rule, ...body }); + const body = (await request.json()) as NotificationRuleUpdate; + Object.assign(rule, body, { + ...(body.triggers ? { triggers: [...body.triggers] } : {}), + ...(body.templates ? { templates: structuredClone(body.templates) } : {}), + }); + return HttpResponse.json(rule); }), ]; diff --git a/apps/demo/src/mocks/handlers/settings.ts b/apps/demo/src/mocks/handlers/settings.ts index 2d26e0541..084a5acaf 100644 --- a/apps/demo/src/mocks/handlers/settings.ts +++ b/apps/demo/src/mocks/handlers/settings.ts @@ -1,11 +1,24 @@ import { HttpResponse, http } from 'msw'; -const settings = { internetlessMode: false }; +const settings = { internetlessMode: false, updateMode: 'manual' as 'notify' | 'manual' | 'auto' }; export const settingsHandlers = [ http.get('/api/v1/settings', () => HttpResponse.json(settings)), - http.patch('/api/v1/settings', () => HttpResponse.json(settings)), + http.patch('/api/v1/settings', async ({ request }) => { + const body = (await request.json()) as Record; + if (typeof body.internetlessMode === 'boolean') { + settings.internetlessMode = body.internetlessMode; + } + if ( + body.updateMode === 'notify' || + body.updateMode === 'manual' || + body.updateMode === 'auto' + ) { + settings.updateMode = body.updateMode; + } + return HttpResponse.json(settings); + }), http.delete('/api/v1/icons/cache', () => HttpResponse.json({ cleared: 12 })), ]; diff --git a/ui/src/components/DataTableColumnPicker.vue b/ui/src/components/DataTableColumnPicker.vue index 8bf7039cc..d374b9eff 100644 --- a/ui/src/components/DataTableColumnPicker.vue +++ b/ui/src/components/DataTableColumnPicker.vue @@ -146,12 +146,11 @@ onUnmounted(() => { size="sm" variant="secondary" :class="showPicker ? 'dd-text dd-bg-elevated' : ''" - :tooltip="t('sharedComponents.columnPicker.toggleTooltip')" + :tooltip="hiddenCount > 0 ? badgeTooltip : t('sharedComponents.columnPicker.toggleTooltip')" @click.stop="togglePicker($event)" /> + class="absolute -top-1 -end-1 pointer-events-none text-3xs font-bold px-1 dd-rounded dd-text-muted dd-bg-elevated leading-tight"> +{{ hiddenCount }} diff --git a/ui/src/locales/en/containerComponents.json b/ui/src/locales/en/containerComponents.json index 433d2f5d3..8685ebf94 100644 --- a/ui/src/locales/en/containerComponents.json +++ b/ui/src/locales/en/containerComponents.json @@ -560,6 +560,10 @@ "justNow": "Detected just now" }, "preview": { + "actions": { + "openRegistrySettings": "Open registry settings", + "openTriggerSettings": "Open trigger settings" + }, "toasts": { "failedTitle": "Preview failed", "failedDetail": "Failed to generate update preview" diff --git a/ui/src/services/preview.ts b/ui/src/services/preview.ts index 90d89fa13..3a1d7f8a3 100644 --- a/ui/src/services/preview.ts +++ b/ui/src/services/preview.ts @@ -11,7 +11,7 @@ export interface ContainerPreviewPayload extends Record { } export interface PreviewErrorAction { - label: string; + code: 'open-registry-settings' | 'open-trigger-settings'; href: '/registries' | '/triggers'; } @@ -245,13 +245,19 @@ export function normalizePreviewPayload(payload: unknown): ContainerPreviewPaylo function normalizePreviewErrorAction(value: unknown): PreviewErrorAction | undefined { const action = asRecord(value); + if (action?.href !== '/registries' && action?.href !== '/triggers') { + return undefined; + } + const expectedCode = + action.href === '/registries' ? 'open-registry-settings' : 'open-trigger-settings'; if ( - typeof action?.label !== 'string' || - (action.href !== '/registries' && action.href !== '/triggers') + (action.code === undefined && + (typeof action.label !== 'string' || action.label.trim().length === 0)) || + (action.code !== undefined && action.code !== expectedCode) ) { return undefined; } - return { label: action.label, href: action.href }; + return { code: expectedCode, href: action.href }; } async function buildPreviewRequestError(response: Response): Promise { diff --git a/ui/src/views/NotificationsView.vue b/ui/src/views/NotificationsView.vue index c90877f44..3bf533900 100644 --- a/ui/src/views/NotificationsView.vue +++ b/ui/src/views/NotificationsView.vue @@ -74,6 +74,16 @@ const detailTemplateTriggerId = ref(''); const templatePreview = ref(null); const templatePreviewError = ref(''); const templatePreviewLoading = ref(false); +let templatePreviewGeneration = 0; + +function invalidateTemplatePreview() { + templatePreviewGeneration += 1; + templatePreview.value = null; + templatePreviewError.value = ''; + templatePreviewLoading.value = false; +} + +watch(detailTemplateTriggerId, invalidateTemplatePreview); function triggerTypeBadge(type: string) { if (type === 'slack') @@ -298,8 +308,7 @@ function syncDetailDraftFromRule() { triggersSorted.value[0]?.id ?? ''; } - templatePreview.value = null; - templatePreviewError.value = ''; + invalidateTemplatePreview(); } function openDetail(rule: NotificationRule) { @@ -398,32 +407,44 @@ function setTemplateField(field: keyof NotificationTemplateOverride, value: stri else template[field] = value; if (Object.keys(template).length === 0) delete detailTemplates.value[triggerId]; else detailTemplates.value[triggerId] = template; - templatePreview.value = null; - templatePreviewError.value = ''; + invalidateTemplatePreview(); } function resetTemplate() { const triggerId = detailTemplateTriggerId.value; if (!triggerId) return; delete detailTemplates.value[triggerId]; - templatePreview.value = null; - templatePreviewError.value = ''; + invalidateTemplatePreview(); } async function previewSelectedTemplate() { if (!selectedRule.value || !detailTemplateTriggerId.value || templatePreviewLoading.value) return; + const ruleId = selectedRule.value.id; + const triggerId = detailTemplateTriggerId.value; + const requestGeneration = ++templatePreviewGeneration; templatePreviewLoading.value = true; templatePreviewError.value = ''; try { - templatePreview.value = await previewNotificationTemplates( - selectedRule.value.id, - detailTemplateTriggerId.value, - currentTemplate(), - ); + const preview = await previewNotificationTemplates(ruleId, triggerId, currentTemplate()); + if ( + requestGeneration === templatePreviewGeneration && + selectedRule.value?.id === ruleId && + detailTemplateTriggerId.value === triggerId + ) { + templatePreview.value = preview; + } } catch (e: unknown) { - templatePreviewError.value = errorMessage(e, t('notificationsView.detail.previewError')); + if ( + requestGeneration === templatePreviewGeneration && + selectedRule.value?.id === ruleId && + detailTemplateTriggerId.value === triggerId + ) { + templatePreviewError.value = errorMessage(e, t('notificationsView.detail.previewError')); + } } finally { - templatePreviewLoading.value = false; + if (requestGeneration === templatePreviewGeneration) { + templatePreviewLoading.value = false; + } } } @@ -803,11 +824,13 @@ onMounted(async () => { {{ t('notificationsView.detail.resetTemplate') }} -
{{ templatePreviewError }}
-
-
{{ t('notificationsView.detail.simpleTitle') }}: {{ templatePreview.simpleTitle }}
-
{{ t('notificationsView.detail.simpleBody') }}: {{ templatePreview.simpleBody }}
-
{{ t('notificationsView.detail.batchTitle') }}: {{ templatePreview.batchTitle }}
+
+
{{ templatePreviewError }}
+
+
{{ t('notificationsView.detail.simpleTitle') }}: {{ templatePreview.simpleTitle }}
+
{{ t('notificationsView.detail.simpleBody') }}: {{ templatePreview.simpleBody }}
+
{{ t('notificationsView.detail.batchTitle') }}: {{ templatePreview.batchTitle }}
+
diff --git a/ui/src/views/containers/useContainerPreview.ts b/ui/src/views/containers/useContainerPreview.ts index bb129ce30..6ab61f7c0 100644 --- a/ui/src/views/containers/useContainerPreview.ts +++ b/ui/src/views/containers/useContainerPreview.ts @@ -13,6 +13,8 @@ interface UseContainerPreviewInput { selectedContainerId: Readonly>; } +type PreviewErrorActionPresentation = PreviewErrorAction & { label: string }; + function buildDetailComposePreview( preview: ContainerPreviewPayload | null, ): ContainerComposePreview | null { @@ -63,22 +65,27 @@ function buildDetailComposePreview( } async function runContainerPreviewState(args: { - containerId: string | undefined; + containerId: string; previewLoading: Ref; previewError: Ref; - previewErrorAction: Ref; + previewErrorAction: Ref; detailPreview: Ref; t: (key: string) => string; + isCurrentRequest: () => boolean; }) { - if (!args.containerId || args.previewLoading.value) { - return; - } args.previewLoading.value = true; args.previewError.value = null; args.previewErrorAction.value = null; try { - args.detailPreview.value = await previewContainer(args.containerId); + const preview = await previewContainer(args.containerId); + if (!args.isCurrentRequest()) { + return; + } + args.detailPreview.value = preview; } catch (e: unknown) { + if (!args.isCurrentRequest()) { + return; + } args.detailPreview.value = null; const msg = errorMessage(e, args.t('containerComponents.preview.toasts.failedDetail')); args.previewError.value = msg; @@ -87,18 +94,28 @@ async function runContainerPreviewState(args: { if ( action && typeof action === 'object' && - 'label' in action && - typeof action.label === 'string' && + 'code' in action && 'href' in action && - (action.href === '/registries' || action.href === '/triggers') + ((action.code === 'open-registry-settings' && action.href === '/registries') || + (action.code === 'open-trigger-settings' && action.href === '/triggers')) ) { - args.previewErrorAction.value = { label: action.label, href: action.href }; + const labelKey = + action.code === 'open-registry-settings' + ? 'containerComponents.preview.actions.openRegistrySettings' + : 'containerComponents.preview.actions.openTriggerSettings'; + args.previewErrorAction.value = { + code: action.code, + label: args.t(labelKey), + href: action.href, + }; } } const toast = useToast(); toast.error(args.t('containerComponents.preview.toasts.failedTitle'), msg); } finally { - args.previewLoading.value = false; + if (args.isCurrentRequest()) { + args.previewLoading.value = false; + } } } @@ -110,23 +127,44 @@ export function useContainerPreview(input: UseContainerPreviewInput) { ); const previewLoading = ref(false); const previewError = ref(null); - const previewErrorAction = ref(null); + const previewErrorAction = ref(null); + let previewRequestGeneration = 0; + let previewRequestContainerId: string | undefined; function resetPreview() { + previewRequestGeneration += 1; + previewRequestContainerId = undefined; + previewLoading.value = false; detailPreview.value = null; previewError.value = null; previewErrorAction.value = null; } async function runContainerPreview() { + const containerId = input.selectedContainerId.value; + if ( + !containerId || + (previewLoading.value && + (previewRequestContainerId === undefined || previewRequestContainerId === containerId)) + ) { + return; + } + const requestGeneration = ++previewRequestGeneration; + previewRequestContainerId = containerId; await runContainerPreviewState({ - containerId: input.selectedContainerId.value, + containerId, previewLoading, previewError, previewErrorAction, detailPreview, t, + isCurrentRequest: () => + requestGeneration === previewRequestGeneration && + input.selectedContainerId.value === containerId, }); + if (requestGeneration === previewRequestGeneration) { + previewRequestContainerId = undefined; + } } return { diff --git a/ui/tests/components/DataTableColumnPicker.spec.ts b/ui/tests/components/DataTableColumnPicker.spec.ts index 08422b3cb..cb3cf220d 100644 --- a/ui/tests/components/DataTableColumnPicker.spec.ts +++ b/ui/tests/components/DataTableColumnPicker.spec.ts @@ -223,8 +223,7 @@ describe('DataTableColumnPicker', () => { expect( panel()!.querySelector('[data-test="column-picker-auto-hidden-annotation"]'), ).toBeNull(); - const badge = w.find('.absolute'); - expect(badge.attributes('title')).toBe('1 columns hidden'); + expect(w.get('button').attributes('title')).toBe('1 columns hidden'); }); it('annotates a checked column that responsive sizing is currently auto-hiding', async () => { @@ -258,16 +257,30 @@ describe('DataTableColumnPicker', () => { it('names every checked-but-auto-hidden column in the badge tooltip instead of the generic count', () => { const w = factory({ autoHiddenKeys: ['status', 'containers'] }); - const badge = w.find('.absolute'); - expect(badge.attributes('title')).toBe('Status, Containers hidden to fit your screen.'); + expect(w.get('button').attributes('title')).toBe( + 'Status, Containers hidden to fit your screen.', + ); + }); + + it('exposes the auto-hidden column names from the focusable picker trigger', async () => { + const w = factory({ autoHiddenKeys: ['status', 'containers'] }); + const trigger = w.get('button'); + + await trigger.trigger('focus'); + await nextTick(); + + expect(trigger.attributes('aria-label')).toBe( + 'Status, Containers hidden to fit your screen.', + ); + const tooltip = document.body.querySelector('.dd-tooltip-visible'); + expect(tooltip?.textContent).toBe('Status, Containers hidden to fit your screen.'); }); it('does not double count a column that is both user-hidden and auto-hidden, and keeps the generic tooltip', () => { const w = factory({ hiddenKeys: ['status'], autoHiddenKeys: ['status'] }); expect(w.text()).toContain('+1'); expect(w.text()).not.toContain('+2'); - const badge = w.find('.absolute'); - expect(badge.attributes('title')).toBe('1 columns hidden'); + expect(w.get('button').attributes('title')).toBe('1 columns hidden'); }); }); }); diff --git a/ui/tests/services/preview.spec.ts b/ui/tests/services/preview.spec.ts index 788e53945..6789ac13f 100644 --- a/ui/tests/services/preview.spec.ts +++ b/ui/tests/services/preview.spec.ts @@ -31,7 +31,7 @@ describe('preview service', () => { code: 'registry-auth-failed', message: 'Authentication failed for ghcr.io: 401 Unauthorized', details: { registry: 'ghcr.io' }, - action: { label: 'Open registry settings', href: '/registries' }, + action: { code: 'open-registry-settings', href: '/registries' }, }), }); @@ -43,7 +43,49 @@ describe('preview service', () => { message: 'Authentication failed for ghcr.io: 401 Unauthorized', status: 401, details: { registry: 'ghcr.io' }, - action: { label: 'Open registry settings', href: '/registries' }, + action: { code: 'open-registry-settings', href: '/registries' }, + }); + }); + + it('normalizes the legacy label-only action from an older server to a stable action code', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: () => + Promise.resolve({ + code: 'registry-auth-failed', + message: 'Authentication failed', + action: { label: 'Open registry settings', href: '/registries' }, + }), + }); + + const failure = await previewContainer('bad-id').catch((error) => error); + + expect(failure.action).toEqual({ + code: 'open-registry-settings', + href: '/registries', + }); + }); + + it('normalizes a legacy trigger action to the trigger-settings code', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: () => + Promise.resolve({ + code: 'trigger-config-invalid', + message: 'Trigger configuration is incomplete', + action: { label: 'Open trigger settings', href: '/triggers' }, + }), + }); + + const failure = await previewContainer('bad-id').catch((error) => error); + + expect(failure.action).toEqual({ + code: 'open-trigger-settings', + href: '/triggers', }); }); diff --git a/ui/tests/views/NotificationsView.spec.ts b/ui/tests/views/NotificationsView.spec.ts index ee54f6e5e..ac0433ad9 100644 --- a/ui/tests/views/NotificationsView.spec.ts +++ b/ui/tests/views/NotificationsView.spec.ts @@ -331,6 +331,68 @@ describe('NotificationsView', () => { }); }); + it('invalidates an in-flight template preview when the selected trigger changes', async () => { + let resolveSlackPreview: (value: { + simpleTitle: string; + simpleBody: string; + batchTitle: string; + }) => void = () => {}; + mockPreviewNotificationTemplates + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSlackPreview = resolve; + }), + ) + .mockResolvedValueOnce({ + simpleTitle: 'Docker preview', + simpleBody: 'Docker body', + batchTitle: 'Docker batch', + }); + mockGetAllNotificationRules.mockResolvedValue([ + makeRule({ + id: 'security-alert', + triggers: ['trigger:slack-alerts', 'trigger:discord-alerts'], + }), + ]); + mockGetAllTriggers.mockResolvedValue([ + { id: 'trigger:slack-alerts', name: 'Slack Alerts', type: 'slack' }, + { id: 'trigger:discord-alerts', name: 'Discord Alerts', type: 'discord' }, + ]); + const wrapper = await mountNotificationsView(); + await wrapper.find('.row-click-first').trigger('click'); + await flushPromises(); + + await wrapper.get('button[aria-label="Preview notification template"]').trigger('click'); + const triggerSelect = wrapper.get('select[aria-label="Template trigger"]'); + expect((triggerSelect.element as HTMLSelectElement).value).toBe('trigger:discord-alerts'); + await triggerSelect.setValue('trigger:slack-alerts'); + await nextTick(); + expect((triggerSelect.element as HTMLSelectElement).value).toBe('trigger:slack-alerts'); + const previewButton = wrapper.get('button[aria-label="Preview notification template"]'); + expect(previewButton.attributes('disabled')).toBeUndefined(); + await previewButton.trigger('click'); + await flushPromises(); + resolveSlackPreview({ + simpleTitle: 'Stale Slack preview', + simpleBody: 'Stale Slack body', + batchTitle: 'Stale Slack batch', + }); + await flushPromises(); + + expect(mockPreviewNotificationTemplates).toHaveBeenNthCalledWith( + 2, + 'security-alert', + 'trigger:slack-alerts', + {}, + ); + expect(wrapper.text()).toContain('Docker preview'); + expect(wrapper.text()).not.toContain('Stale Slack preview'); + const liveRegion = wrapper.get('[aria-live="polite"]'); + expect(liveRegion.attributes('role')).toBe('status'); + expect(liveRegion.text()).toContain('Docker preview'); + }); + it('clears a rendered template preview when resetting the trigger template', async () => { const wrapper = await mountNotificationsView(); await wrapper.find('.row-click-first').trigger('click'); diff --git a/ui/tests/views/containers/useContainerActions.spec.ts b/ui/tests/views/containers/useContainerActions.spec.ts index 298e80bf9..fd95303e0 100644 --- a/ui/tests/views/containers/useContainerActions.spec.ts +++ b/ui/tests/views/containers/useContainerActions.spec.ts @@ -19,6 +19,7 @@ import { prunePendingActionsState, useContainerActions, } from '@/views/containers/useContainerActions'; +import { useContainerPreview } from '@/views/containers/useContainerPreview'; const mocks = vi.hoisted(() => ({ toastSuccess: vi.fn(), @@ -216,6 +217,33 @@ async function mountActionsHarness( return state; } +async function mountPreviewHarness(initialContainerId?: string) { + let state: + | { + selectedContainerId: Ref; + composable: ReturnType; + } + | undefined; + + const Harness = defineComponent({ + setup() { + const selectedContainerId = ref(initialContainerId); + const composable = useContainerPreview({ selectedContainerId }); + state = { selectedContainerId, composable }; + return () => h('div'); + }, + }); + + const wrapper = mount(Harness); + mountedWrappers.push(wrapper); + await flushPromises(); + + if (!state) { + throw new Error('Preview harness did not initialize'); + } + return state; +} + describe('useContainerActions', () => { beforeEach(() => { setI18nLocale('en'); @@ -2249,7 +2277,7 @@ describe('useContainerActions', () => { mocks.previewContainer.mockRejectedValueOnce( Object.assign(new Error('Authentication failed for ghcr.io: 401 Unauthorized'), { code: 'registry-auth-failed', - action: { label: 'Open registry settings', href: '/registries' }, + action: { code: 'open-registry-settings', href: '/registries' }, }), ); await composable.runContainerPreview(); @@ -2259,6 +2287,7 @@ describe('useContainerActions', () => { 'Authentication failed for ghcr.io: 401 Unauthorized', ); expect(composable.previewErrorAction.value).toEqual({ + code: 'open-registry-settings', label: 'Open registry settings', href: '/registries', }); @@ -2271,6 +2300,8 @@ describe('useContainerActions', () => { null, 'invalid', { href: '/registries' }, + { code: 'open-registry-settings' }, + { code: 'open-registry-settings', href: '/triggers' }, { label: 42, href: '/registries' }, { label: 'Open settings' }, { label: 'Unsafe', href: 'https://attacker.example' }, @@ -2281,6 +2312,117 @@ describe('useContainerActions', () => { await composable.runContainerPreview(); expect(composable.previewErrorAction.value).toBeNull(); } + + mocks.previewContainer.mockRejectedValueOnce( + Object.assign(new Error('Trigger configuration is incomplete'), { + action: { code: 'open-trigger-settings', href: '/triggers' }, + }), + ); + await composable.runContainerPreview(); + expect(composable.previewErrorAction.value).toEqual({ + code: 'open-trigger-settings', + label: 'Open trigger settings', + href: '/triggers', + }); + }); + + it('deduplicates the same preview while allowing a newer container request', async () => { + let resolveWebPreview: (value: { currentImage: string }) => void = () => {}; + mocks.previewContainer + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWebPreview = resolve; + }), + ) + .mockResolvedValueOnce({ currentImage: 'api:2.0' }); + const { composable, selectedContainerId } = await mountPreviewHarness('container-1'); + + const webPreview = composable.runContainerPreview(); + const duplicatePreview = composable.runContainerPreview(); + expect(mocks.previewContainer).toHaveBeenCalledTimes(1); + + selectedContainerId.value = 'container-2'; + const apiPreview = composable.runContainerPreview(); + await apiPreview; + resolveWebPreview({ currentImage: 'web:1.0' }); + await Promise.all([webPreview, duplicatePreview]); + + expect(mocks.previewContainer).toHaveBeenNthCalledWith(2, 'container-2'); + expect(composable.detailPreview.value).toEqual({ currentImage: 'api:2.0' }); + expect(composable.previewLoading.value).toBe(false); + }); + + it('ignores a preview failure after the request is invalidated', async () => { + let rejectWebPreview: (error: Error) => void = () => {}; + mocks.previewContainer.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectWebPreview = reject; + }), + ); + const { composable } = await mountPreviewHarness('container-1'); + + const webPreview = composable.runContainerPreview(); + composable.resetPreview(); + rejectWebPreview(new Error('late preview failure')); + await webPreview; + + expect(composable.previewError.value).toBeNull(); + expect(composable.previewErrorAction.value).toBeNull(); + expect(mocks.toastError).not.toHaveBeenCalled(); + expect(composable.previewLoading.value).toBe(false); + }); + + it('ignores a preview when selection changes without starting another request', async () => { + let resolveWebPreview: (value: { currentImage: string }) => void = () => {}; + mocks.previewContainer.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWebPreview = resolve; + }), + ); + const { composable, selectedContainerId } = await mountPreviewHarness('container-1'); + + const webPreview = composable.runContainerPreview(); + selectedContainerId.value = 'container-2'; + resolveWebPreview({ currentImage: 'web:1.0' }); + await webPreview; + + expect(composable.detailPreview.value).toBeNull(); + expect(composable.previewLoading.value).toBe(true); + }); + + it('keeps a slow preview response from replacing the newly selected container preview', async () => { + const web = makeContainer({ id: 'container-1', name: 'web' }); + const api = makeContainer({ id: 'container-2', name: 'api' }); + let resolveWebPreview: (value: { currentImage: string }) => void = () => {}; + mocks.previewContainer + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWebPreview = resolve; + }), + ) + .mockResolvedValueOnce({ currentImage: 'api:2.0' }); + const { composable, selectedContainer, selectedContainerId } = await mountActionsHarness({ + selectedContainer: web, + selectedContainerId: web.id, + }); + + const webPreview = composable.runContainerPreview(); + selectedContainer.value = api; + selectedContainerId.value = api.id; + await nextTick(); + const apiPreview = composable.runContainerPreview(); + await apiPreview; + resolveWebPreview({ currentImage: 'web:1.0' }); + await webPreview; + + expect(mocks.previewContainer).toHaveBeenNthCalledWith(1, web.id); + expect(mocks.previewContainer).toHaveBeenNthCalledWith(2, api.id); + expect(composable.detailPreview.value).toEqual({ currentImage: 'api:2.0' }); + expect(composable.previewLoading.value).toBe(false); }); it('covers rollback guard and failure/latest-backup branches', async () => { From 596a7442e92a276fa36b448ad594dcf3a0fc8e1b Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:33:51 -0400 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=94=92=20security(release):=20promote?= =?UTF-8?q?=20the=20exact=20soaked=20candidate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/release-cut-retry-workflow.test.ts | 90 +++- .github/workflows/release-cut.yml | 383 ++++++++++++++---- 2 files changed, 387 insertions(+), 86 deletions(-) diff --git a/.github/tests/release-cut-retry-workflow.test.ts b/.github/tests/release-cut-retry-workflow.test.ts index c9ccef406..cf4320923 100644 --- a/.github/tests/release-cut-retry-workflow.test.ts +++ b/.github/tests/release-cut-retry-workflow.test.ts @@ -20,7 +20,9 @@ const transientRetryStepNames = [ 'Retry manifest publish on transient registry failure', 'Verify container image signatures', 'Sign release artifact', - 'Create GitHub Release and upload signed assets', + 'Prepare draft GitHub Release and upload signed assets', + 'Publish release image tags', + 'Publish GitHub Release', ]; function loadReleaseSteps(): WorkflowStep[] { @@ -130,8 +132,8 @@ test('release-cut delegates image tags and labels to docker metadata-action', () "type=raw,value=latest,enable=${{ steps.tag.outputs.is_prerelease == 'false' }}", ]); - expect(getStep('Build and push')?.with).toMatchObject({ - tags: '${{ steps.meta.outputs.tags }}', + expect(getStep('Build and push staging image')?.with).toMatchObject({ + tags: '${{ steps.staging_meta.outputs.tags }}', labels: '${{ steps.meta.outputs.labels }}', }); expect(getStep('Compute image tags')).toBeUndefined(); @@ -142,6 +144,88 @@ test('release-cut delegates image tags and labels to docker metadata-action', () expect(shellTagComputations).toStrictEqual([]); }); +test('release-cut requires an exact, seven-day-old RC candidate for GA promotion', () => { + const workflow = loadWorkflow(workflowPath); + const inputs = workflow.on?.workflow_dispatch?.inputs; + const sourceStep = getStep('Resolve release source and validate GA candidate'); + + expect(inputs).toMatchObject({ + candidate_tag: { + required: false, + type: 'string', + }, + candidate_digest: { + required: false, + type: 'string', + }, + }); + expect(sourceStep?.id).toBe('source'); + expect(sourceStep?.run).toContain('publishedAt'); + expect(sourceStep?.run).toContain('isPrerelease'); + expect(sourceStep?.run).toContain('604800'); + expect(sourceStep?.run).toContain('^{commit}'); + expect(sourceStep?.run).toContain('^sha256:[0-9a-f]{64}$'); + expect(sourceStep?.run).toContain('source_sha='); + expect(sourceStep?.run).toContain('image_digest='); +}); + +test('release-cut validates the promoted digest in every registry', () => { + const validationStep = getStep('Validate GA candidate digest in every registry'); + + expect(validationStep?.if).toContain("steps.tag.outputs.is_prerelease == 'false'"); + expect(validationStep?.run).toContain('ghcr.io/${GHCR_REPO}:${CANDIDATE_TAG#v}'); + expect(validationStep?.run).toContain('docker.io/codeswhat/drydock:${CANDIDATE_TAG#v}'); + expect(validationStep?.run).toContain('quay.io/codeswhat/drydock:${CANDIDATE_TAG#v}'); + expect(validationStep?.run).toContain('raw_manifest'); + expect(validationStep?.run).toContain('computed_digest'); + expect(validationStep?.run).toContain('CANDIDATE_DIGEST'); +}); + +test('release-cut builds prereleases under staging tags and promotes final tags by digest', () => { + const stagingMetadata = getStep('Docker staging metadata'); + const buildStep = getStep('Build and push staging image'); + const publishStep = getStep('Publish release image tags'); + + expect(stagingMetadata).toMatchObject({ + id: 'staging_meta', + uses: metadataAction, + }); + expect(stagingMetadata?.with?.tags).toContain( + 'release-staging-${{ github.run_id }}-${{ github.run_attempt }}', + ); + expect(buildStep?.if).toContain("steps.tag.outputs.is_prerelease == 'true'"); + expect(buildStep?.with?.tags).toBe('${{ steps.staging_meta.outputs.tags }}'); + expect(publishStep?.env).toMatchObject({ + DIGEST: '${{ steps.digest.outputs.value }}', + TAGS: '${{ steps.meta.outputs.tags }}', + }); + expect(publishStep?.with?.command).toContain('docker buildx imagetools create'); + expect(publishStep?.with?.command).toContain('${source_ref}'); + expect(publishStep?.with?.command).toContain('${DIGEST}'); +}); + +test('release-cut prepares a draft before its recoverable public finalization sequence', () => { + const steps = loadReleaseSteps(); + const indexOf = (name: string) => steps.findIndex((step) => step.name === name); + const draftStep = getStep('Prepare draft GitHub Release and upload signed assets'); + const tagStep = getStep('Push release tag'); + const publishStep = getStep('Publish GitHub Release'); + + expect(draftStep?.with?.command).toContain('--draft'); + expect(draftStep?.with?.command).toContain('--target "${SOURCE_SHA}"'); + expect(tagStep?.env).toMatchObject({ + SOURCE_SHA: '${{ steps.source.outputs.source_sha }}', + }); + expect(tagStep?.run).toContain('"${SOURCE_SHA}"'); + expect(publishStep?.with?.command).toContain('gh release edit "${RELEASE_TAG}" --draft=false'); + + expect(indexOf('Prepare draft GitHub Release and upload signed assets')).toBeLessThan( + indexOf('Publish release image tags'), + ); + expect(indexOf('Publish release image tags')).toBeLessThan(indexOf('Push release tag')); + expect(indexOf('Push release tag')).toBeLessThan(indexOf('Publish GitHub Release')); +}); + test('release-cut generates the container SBOM with a pinned Trivy image', () => { const sbomStep = getStep('Generate container SBOM'); diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index ebd4163df..77045adc8 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,10 +1,12 @@ name: "🏷️ Release: Cut" run-name: "🏷️ Release: Cut — manual by ${{ github.actor }}" -# Tag-last release pipeline. Everything that can fail (CI check, Docker build, -# multi-registry push, cosign signing, SLSA attestation, artifact sign) runs -# BEFORE the git tag is pushed. The tag is a receipt that the release -# succeeded end-to-end — if you see the tag, the image exists. +# Recoverable release pipeline. Build output is first published under +# run-scoped staging tags, signed, attested, and attached to a draft release. +# User-facing image tags, the git tag, and the GitHub release are finalized in +# that order by idempotent steps. A retry can safely finish a partial cut. +# GA releases never rebuild: they promote an exact seven-day-old RC digest and +# tag the exact RC source commit. # # BEFORE DISPATCHING (local pre-cut step — cannot run in CI): # npm run release:precheck # e.g. npm run release:precheck v1.6.0 @@ -21,6 +23,14 @@ on: description: "Release tag to cut, e.g. v1.5.0-rc.17 or v1.5.0. Tag base must match package.json version and the tag must not already exist." required: true type: string + candidate_tag: + description: "GA only: exact prerelease tag to promote, e.g. v1.6.0-rc.3. Must be at least seven full days old." + required: false + type: string + candidate_digest: + description: "GA only: exact sha256 digest published by candidate_tag in GHCR, Docker Hub, and Quay." + required: false + type: string permissions: {} @@ -84,32 +94,6 @@ jobs: echo "Target SHA: ${sha}" echo "Repository (lowercase): ${repo_lower}" - - name: Wait for successful branch CI on target SHA - uses: ./.github/actions/wait-for-successful-branch-ci - with: - github-token: ${{ github.token }} - workflow-file: ${{ env.CI_VERIFY_WORKFLOW_FILE }} - target-sha: ${{ steps.target.outputs.sha }} - # 90-min budget at 5-min cadence. CI Verify runs 20-30m typically; - # pathological CodeQL/mutation overlap can push to ~60m. Polling - # more often than every 5m is wasteful because CI jobs only change - # state on completion, not mid-flight. - max-attempts: '18' - sleep-seconds: '300' - - - name: Wait for successful E2E Playwright on target SHA - uses: ./.github/actions/wait-for-successful-branch-ci - with: - github-token: ${{ github.token }} - workflow-file: ${{ env.E2E_PLAYWRIGHT_WORKFLOW_FILE }} - target-sha: ${{ steps.target.outputs.sha }} - # Playwright moved to its own workflow so its failures don't - # poison ci-verify's Check Suite for Scorecard. Releases still - # gate on it — runs ~10-15m typically, share the same 90-min - # budget shape. - max-attempts: '18' - sleep-seconds: '300' - - name: Resolve release tag from input id: next env: @@ -122,11 +106,6 @@ jobs: exit 1 fi - if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then - echo "::error::Tag already exists: ${RELEASE_TAG}. Pick the next unused tag and re-dispatch." - exit 1 - fi - next_version="${RELEASE_TAG#v}" if [[ "${RELEASE_TAG}" == *-* ]]; then release_level="prerelease" @@ -154,6 +133,100 @@ jobs: echo "${key}=${value}" >> "$GITHUB_OUTPUT" done <<< "${output}" + - name: Resolve release source and validate GA candidate + id: source + env: + CANDIDATE_DIGEST: ${{ inputs.candidate_digest }} + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + GH_TOKEN: ${{ github.token }} + IS_PRERELEASE: ${{ steps.tag.outputs.is_prerelease }} + RELEASE_TAG: ${{ steps.next.outputs.release_tag }} + TARGET_SHA: ${{ steps.target.outputs.sha }} + run: | + set -euo pipefail + + source_sha="${TARGET_SHA}" + image_digest="" + + if [ "${IS_PRERELEASE}" = "true" ]; then + if [ -n "${CANDIDATE_TAG}" ] || [ -n "${CANDIDATE_DIGEST}" ]; then + echo "::error::candidate_tag and candidate_digest are GA-only inputs. Leave both empty for ${RELEASE_TAG}." + exit 1 + fi + else + if ! [[ "${CANDIDATE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "::error::GA releases require candidate_tag in exact vX.Y.Z-rc.N form." + exit 1 + fi + if ! [[ "${CANDIDATE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::GA releases require candidate_digest in exact sha256:<64 lowercase hex> form." + exit 1 + fi + if [ "${CANDIDATE_TAG%-rc.*}" != "${RELEASE_TAG}" ]; then + echo "::error::Candidate ${CANDIDATE_TAG} does not belong to GA release ${RELEASE_TAG}." + exit 1 + fi + if ! git rev-parse -q --verify "refs/tags/${CANDIDATE_TAG}^{commit}" >/dev/null; then + echo "::error::Candidate tag ${CANDIDATE_TAG} does not exist in the fetched repository." + exit 1 + fi + + release_json="$(gh release view "${CANDIDATE_TAG}" --repo "${GITHUB_REPOSITORY}" --json isPrerelease,publishedAt,tagName)" + if [ "$(jq -r '.isPrerelease' <<< "${release_json}")" != "true" ]; then + echo "::error::Candidate ${CANDIDATE_TAG} is not a published prerelease." + exit 1 + fi + published_at="$(jq -er '.publishedAt' <<< "${release_json}")" + published_epoch="$(date -u -d "${published_at}" +%s)" + age_seconds="$(( $(date -u +%s) - published_epoch ))" + if [ "${age_seconds}" -lt 604800 ]; then + echo "::error::Candidate ${CANDIDATE_TAG} is only ${age_seconds}s old; GA promotion requires seven full days (604800s)." + exit 1 + fi + + source_sha="$(git rev-parse "${CANDIDATE_TAG}^{commit}")" + image_digest="${CANDIDATE_DIGEST}" + fi + + if existing_sha="$(git rev-parse -q --verify "refs/tags/${RELEASE_TAG}^{commit}" 2>/dev/null)"; then + if [ "${existing_sha}" != "${source_sha}" ]; then + echo "::error::Existing ${RELEASE_TAG} tag points to ${existing_sha}, expected ${source_sha}." + exit 1 + fi + echo "Existing release tag matches the requested source; continuing recoverable finalization." + fi + + { + echo "source_sha=${source_sha}" + echo "image_digest=${image_digest}" + } >> "$GITHUB_OUTPUT" + + - name: Checkout exact release source + env: + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: | + set -euo pipefail + git checkout --detach "${SOURCE_SHA}" + test "$(git rev-parse HEAD)" = "${SOURCE_SHA}" + + - name: Wait for successful branch CI on release source SHA + uses: ./.github/actions/wait-for-successful-branch-ci + with: + github-token: ${{ github.token }} + workflow-file: ${{ env.CI_VERIFY_WORKFLOW_FILE }} + target-sha: ${{ steps.source.outputs.source_sha }} + max-attempts: '18' + sleep-seconds: '300' + + - name: Wait for successful E2E Playwright on release source SHA + uses: ./.github/actions/wait-for-successful-branch-ci + with: + github-token: ${{ github.token }} + workflow-file: ${{ env.E2E_PLAYWRIGHT_WORKFLOW_FILE }} + target-sha: ${{ steps.source.outputs.source_sha }} + max-attempts: '18' + sleep-seconds: '300' + - name: Assert tag version matches package versions env: BASE_VERSION: ${{ steps.tag.outputs.base_version }} @@ -296,15 +369,55 @@ jobs: type=semver,pattern={{major}},value=${{ steps.next.outputs.release_tag }},enable=${{ steps.tag.outputs.is_prerelease == 'false' }} type=raw,value=latest,enable=${{ steps.tag.outputs.is_prerelease == 'false' }} - - name: Build and push + - name: Docker staging metadata + id: staging_meta + if: steps.tag.outputs.is_prerelease == 'true' + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: | + ghcr.io/${{ steps.target.outputs.repo_lower }} + docker.io/codeswhat/drydock + quay.io/codeswhat/drydock + flavor: | + latest=false + tags: | + type=raw,value=release-staging-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Validate GA candidate digest in every registry + if: steps.tag.outputs.is_prerelease == 'false' + env: + CANDIDATE_DIGEST: ${{ steps.source.outputs.image_digest }} + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + GHCR_REPO: ${{ steps.target.outputs.repo_lower }} + run: | + set -euo pipefail + candidate_refs=( + "ghcr.io/${GHCR_REPO}:${CANDIDATE_TAG#v}" + "docker.io/codeswhat/drydock:${CANDIDATE_TAG#v}" + "quay.io/codeswhat/drydock:${CANDIDATE_TAG#v}" + ) + + for candidate_ref in "${candidate_refs[@]}"; do + raw_manifest="$(mktemp)" + docker buildx imagetools inspect --raw "${candidate_ref}" > "${raw_manifest}" + computed_digest="sha256:$(sha256sum "${raw_manifest}" | cut -d ' ' -f 1)" + rm -f "${raw_manifest}" + if [ "${computed_digest}" != "${CANDIDATE_DIGEST}" ]; then + echo "::error::${candidate_ref} resolves to ${computed_digest}, expected exact candidate ${CANDIDATE_DIGEST}." + exit 1 + fi + done + + - name: Build and push staging image id: build + if: steps.tag.outputs.is_prerelease == 'true' continue-on-error: true # allow manifest retry path on transient push failures uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: true platforms: ${{ env.DOCKER_PLATFORMS }} - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.staging_meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: DD_VERSION=${{ steps.next.outputs.next_version }} sbom: true @@ -314,10 +427,10 @@ jobs: - name: Retry manifest publish on transient registry failure id: manifest-retry - if: steps.build.outcome == 'failure' + if: steps.tag.outputs.is_prerelease == 'true' && steps.build.outcome == 'failure' env: BUILD_DIGEST: ${{ steps.build.outputs.digest }} - TAGS: ${{ steps.meta.outputs.tags }} + TAGS: ${{ steps.staging_meta.outputs.tags }} GHCR_REPO: ${{ steps.target.outputs.repo_lower }} uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: @@ -361,21 +474,48 @@ jobs: - name: Resolve image digest id: digest - if: steps.build.outcome == 'success' || steps.manifest-retry.outcome == 'success' env: + CANDIDATE_DIGEST: ${{ steps.source.outputs.image_digest }} RETRY_DIGEST: ${{ steps.manifest-retry.outputs.digest }} BUILD_DIGEST: ${{ steps.build.outputs.digest }} - run: echo "value=${RETRY_DIGEST:-$BUILD_DIGEST}" >> "$GITHUB_OUTPUT" + run: | + set -euo pipefail + digest="${CANDIDATE_DIGEST:-${RETRY_DIGEST:-${BUILD_DIGEST:-}}}" + if ! [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Release source did not produce a valid image digest." + exit 1 + fi + echo "value=${digest}" >> "$GITHUB_OUTPUT" + + - name: Resolve image source references + id: image_refs + env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + GHCR_REPO: ${{ steps.target.outputs.repo_lower }} + IS_PRERELEASE: ${{ steps.tag.outputs.is_prerelease }} + STAGING_TAGS: ${{ steps.staging_meta.outputs.tags }} + run: | + set -euo pipefail + { + echo 'tags<> "$GITHUB_OUTPUT" - name: Install cosign - if: steps.build.outcome == 'success' || steps.manifest-retry.outcome == 'success' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Sign container images - if: steps.digest.outputs.value + if: steps.tag.outputs.is_prerelease == 'true' env: DIGEST: ${{ steps.digest.outputs.value }} - TAGS: ${{ steps.meta.outputs.tags }} + TAGS: ${{ steps.image_refs.outputs.tags }} run: | set -euo pipefail images=() @@ -385,10 +525,9 @@ jobs: cosign sign --yes "${images[@]}" - name: Verify container image signatures - if: steps.digest.outputs.value env: DIGEST: ${{ steps.digest.outputs.value }} - TAGS: ${{ steps.meta.outputs.tags }} + TAGS: ${{ steps.image_refs.outputs.tags }} uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 10 @@ -423,7 +562,7 @@ jobs: - name: Attest container build provenance id: attest_container_image - if: steps.digest.outputs.value + if: steps.tag.outputs.is_prerelease == 'true' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ghcr.io/${{ steps.target.outputs.repo_lower }} @@ -431,23 +570,21 @@ jobs: push-to-registry: true - name: Verify container build provenance attestation - if: steps.digest.outputs.value env: DIGEST: ${{ steps.digest.outputs.value }} GHCR_REPO: ${{ steps.target.outputs.repo_lower }} GH_TOKEN: ${{ github.token }} - TARGET_SHA: ${{ steps.target.outputs.sha }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} run: | set -euo pipefail gh attestation verify "oci://ghcr.io/${GHCR_REPO}@${DIGEST}" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release-cut.yml" \ --source-ref refs/heads/main \ - --source-digest "${TARGET_SHA}" \ + --source-digest "${SOURCE_SHA}" \ --bundle-from-oci >/dev/null - name: Generate container SBOM - if: steps.digest.outputs.value env: DIGEST: ${{ steps.digest.outputs.value }} GHCR_REPO: ${{ steps.target.outputs.repo_lower }} @@ -473,7 +610,7 @@ jobs: - name: Attest container SBOM id: attest_container_sbom - if: steps.digest.outputs.value + if: steps.tag.outputs.is_prerelease == 'true' uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-name: ghcr.io/${{ steps.target.outputs.repo_lower }} @@ -483,31 +620,30 @@ jobs: push-to-registry: true - name: Verify container SBOM attestation - if: steps.digest.outputs.value env: DIGEST: ${{ steps.digest.outputs.value }} GHCR_REPO: ${{ steps.target.outputs.repo_lower }} GH_TOKEN: ${{ github.token }} - TARGET_SHA: ${{ steps.target.outputs.sha }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} run: | set -euo pipefail gh attestation verify "oci://ghcr.io/${GHCR_REPO}@${DIGEST}" \ --repo "${GITHUB_REPOSITORY}" \ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release-cut.yml" \ --source-ref refs/heads/main \ - --source-digest "${TARGET_SHA}" \ + --source-digest "${SOURCE_SHA}" \ --predicate-type https://spdx.dev/Document/v2.3 \ --bundle-from-oci >/dev/null - name: Build release artifact env: RELEASE_TAG: ${{ steps.next.outputs.release_tag }} - TARGET_SHA: ${{ steps.target.outputs.sha }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} run: | set -euo pipefail mkdir -p dist artifact="dist/drydock-${RELEASE_TAG}.tar.gz" - git archive --format=tar.gz --prefix="drydock-${RELEASE_TAG}/" --output="${artifact}" "${TARGET_SHA}" + git archive --format=tar.gz --prefix="drydock-${RELEASE_TAG}/" --output="${artifact}" "${SOURCE_SHA}" sha256sum "${artifact}" > "${artifact}.sha256" - name: Sign release artifact @@ -644,32 +780,13 @@ jobs: echo "path=${notes_path}" >> "$GITHUB_OUTPUT" - # ──────────────────────────────────────────────────────────────────── - # POINT OF NO RETURN - # Everything above this line can fail without leaking a tag or release - # to users. The tag push below is the public commitment that this - # release exists; it runs ONLY after image push, signing, attestation, - # and artifact generation have all succeeded. - # ──────────────────────────────────────────────────────────────────── - - - name: Push release tag - env: - RELEASE_TAG: ${{ steps.next.outputs.release_tag }} - TARGET_SHA: ${{ steps.target.outputs.sha }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git tag -a "${RELEASE_TAG}" "${TARGET_SHA}" -m "release: ${RELEASE_TAG}" - git push origin "${RELEASE_TAG}" - - - name: Create GitHub Release and upload signed assets + - name: Prepare draft GitHub Release and upload signed assets env: GH_TOKEN: ${{ github.token }} IS_PRERELEASE: ${{ steps.tag.outputs.is_prerelease }} RELEASE_TAG: ${{ steps.next.outputs.release_tag }} RELEASE_NOTES_PATH: ${{ steps.release_notes.outputs.path }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 5 @@ -682,10 +799,25 @@ jobs: prerelease_args=(--prerelease) fi - if ! gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then - gh release create "${RELEASE_TAG}" --title "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_PATH}" "${prerelease_args[@]}" + if gh release view "${RELEASE_TAG}" --json isDraft >/tmp/drydock-release.json 2>/dev/null; then + if [ "$(jq -r '.isDraft' /tmp/drydock-release.json)" != "true" ]; then + echo "::error::Release ${RELEASE_TAG} is already public; refusing to alter it." + exit 1 + fi + else + gh release create "${RELEASE_TAG}" \ + --draft \ + --target "${SOURCE_SHA}" \ + --title "${RELEASE_TAG}" \ + --notes-file "${RELEASE_NOTES_PATH}" \ + "${prerelease_args[@]}" fi - gh release edit "${RELEASE_TAG}" --title "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_PATH}" "${prerelease_args[@]}" + + gh release edit "${RELEASE_TAG}" \ + --draft \ + --title "${RELEASE_TAG}" \ + --notes-file "${RELEASE_NOTES_PATH}" \ + "${prerelease_args[@]}" gh release upload "${RELEASE_TAG}" \ "dist/drydock-${RELEASE_TAG}.tar.gz" \ "dist/drydock-${RELEASE_TAG}.tar.gz.sha256" \ @@ -696,9 +828,94 @@ jobs: "dist/drydock-${RELEASE_TAG}.image.spdx.json" \ --clobber + # ──────────────────────────────────────────────────────────────────── + # PUBLIC FINALIZATION + # All expensive and failure-prone preparation is complete. The remaining + # operations are ordered and idempotent: publish exact-digest image tags, + # push/verify the git tag, then publish the prepared draft release. If a + # service fails between steps, re-dispatching resumes from verified state. + # ──────────────────────────────────────────────────────────────────── + + - name: Publish release image tags + env: + DIGEST: ${{ steps.digest.outputs.value }} + SOURCE_REFS: ${{ steps.image_refs.outputs.tags }} + TAGS: ${{ steps.meta.outputs.tags }} + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 5 + command: | + set -euo pipefail + while IFS= read -r tag; do + [ -z "${tag}" ] && continue + target_repository="${tag%:*}" + source_ref="" + while IFS= read -r candidate; do + [ -z "${candidate}" ] && continue + if [ "${candidate%:*}" = "${target_repository}" ] && \ + docker buildx imagetools inspect "${candidate}@${DIGEST}" >/dev/null 2>&1; then + source_ref="${candidate%@*}@${DIGEST}" + break + fi + done <<< "${SOURCE_REFS}" + if [ -z "${source_ref}" ]; then + echo "::error::${target_repository} does not expose exact source digest ${DIGEST}." + exit 1 + fi + + docker buildx imagetools create --tag "${tag}" "${source_ref}" >/dev/null + raw_manifest="$(mktemp)" + docker buildx imagetools inspect --raw "${tag}" > "${raw_manifest}" + published_digest="sha256:$(sha256sum "${raw_manifest}" | cut -d ' ' -f 1)" + rm -f "${raw_manifest}" + if [ "${published_digest}" != "${DIGEST}" ]; then + echo "::error::Published ${tag} resolved to ${published_digest}, expected ${DIGEST}." + exit 1 + fi + done <<< "${TAGS}" + + - name: Push release tag + env: + RELEASE_TAG: ${{ steps.next.outputs.release_tag }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if remote_sha="$(git ls-remote --exit-code origin "refs/tags/${RELEASE_TAG}^{}" | awk '{print $1}')"; then + if [ "${remote_sha}" != "${SOURCE_SHA}" ]; then + echo "::error::Remote tag ${RELEASE_TAG} points to ${remote_sha}, expected ${SOURCE_SHA}." + exit 1 + fi + echo "Remote tag already points to the exact release source." + else + git tag -a -f "${RELEASE_TAG}" "${SOURCE_SHA}" -m "release: ${RELEASE_TAG}" + git push origin "refs/tags/${RELEASE_TAG}" + fi + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.next.outputs.release_tag }} + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 5 + command: | + set -euo pipefail + gh release edit "${RELEASE_TAG}" --draft=false + if [ "$(gh release view "${RELEASE_TAG}" --json isDraft --jq '.isDraft')" != "false" ]; then + echo "::error::Release ${RELEASE_TAG} is still a draft after publish." + exit 1 + fi + - name: Release summary env: - TARGET_SHA: ${{ steps.target.outputs.sha }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} RELEASE_LEVEL: ${{ steps.next.outputs.release_level }} NEXT_VERSION: ${{ steps.next.outputs.next_version }} RELEASE_TAG: ${{ steps.next.outputs.release_tag }} @@ -706,7 +923,7 @@ jobs: run: | { echo "### Release Summary" - echo "- Target SHA: \`${TARGET_SHA}\`" + echo "- Release source SHA: \`${SOURCE_SHA}\`" echo "- Bump level: \`${RELEASE_LEVEL}\`" echo "- Version: \`${NEXT_VERSION}\`" echo "- Tag: \`${RELEASE_TAG}\`" From d88fd0399d249209b83327f9635756bc2d349ed4 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:33:55 -0400 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=93=9D=20docs(release):=20pin=20authe?= =?UTF-8?q?ntication=20and=20support=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- DEPRECATIONS.md | 2 +- README.md | 4 +- SECURITY.md | 11 +++-- .../configuration/authentications/index.mdx | 10 ++-- scripts/release-docs-identity.test.mjs | 49 +++++++++++++++++-- 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d047cc374..f156bf716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Anonymous access now fails closed on upgrades, not just fresh installs.** An instance with no authentication configured — or with anonymous auth enabled but unconfirmed — fails closed: the container runs, but every API request is rejected with `401` and `/health` reports `503`, instead of logging a warning and serving an open dashboard. If you run an intentionally open instance, set `DD_ANONYMOUS_AUTH_CONFIRM=true`; otherwise configure `DD_AUTH_BASIC__USER`/`_HASH` before upgrading. +- **Anonymous access now fails closed on upgrades, not just fresh installs.** An instance with no authentication configured — or with anonymous auth enabled but unconfirmed — starts and fails closed: protected API requests are rejected with `401`, auth discovery/status remains public, `/health` reports `503`, and the SPA shell may load without access to protected application data. This replaces the previous warning plus open dashboard. If you run an intentionally open instance, set `DD_ANONYMOUS_AUTH_CONFIRM=true`; otherwise configure `DD_AUTH_BASIC__USER`/`_HASH` before upgrading. - **HTTP notification trigger hardened against SSRF.** Hostnames are re-resolved on every request through a guarded DNS lookup that blocks cloud metadata and link-local targets (override with `allowmetadata=true`), and redirects can no longer escape into blocked address space via cross-host or DNS-rebinding hops. diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index c54bde37f..b987e846f 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -271,7 +271,7 @@ Before rc.35, the HTTP trigger sent requests to any syntactically valid URL, inc | **Removed in** | v1.6.0-rc.3 (immediate — security fix, no grace period) | | **Affects** | Upgrading instances (an existing `/store/dd.json`) with no authentication configured, or with anonymous auth enabled but not explicitly confirmed | -Before rc.3, an upgrading instance with no auth strategy configured was let through with anonymous access and a startup warning — the same grandfather path drydock used before v1.4.0 enforced authentication on fresh installs. rc.3 removes it: `app/authentications/providers/anonymous/Anonymous.ts` now throws at startup for an upgrade exactly as it already did for a fresh install, refusing to serve rather than warning and continuing. Because the warn-and-serve behavior was itself the exposure — an open dashboard reachable without credentials — it could not be kept alive behind a deprecation window. +Before rc.3, an upgrading instance with no auth strategy configured was let through with anonymous access and a startup warning — the same grandfather path drydock used before v1.4.0 enforced authentication on fresh installs. rc.3 removes it: `app/authentications/providers/anonymous/Anonymous.ts` now rejects unconfirmed anonymous registration for upgrades exactly as it already did for fresh installs. The registry fallback catches that registration error, so the process continues running while protected API requests are rejected with `401`, auth discovery/status remains public, `/health` returns `503`, and the SPA shell cannot read protected application data. Because the warn-and-serve behavior was itself the exposure — an open dashboard reachable without credentials — it could not be kept alive behind a deprecation window. **Migration:** Set `DD_AUTH_BASIC__USER`/`_HASH` (or configure OIDC) before upgrading, or set `DD_ANONYMOUS_AUTH_CONFIRM=true` to explicitly keep the instance anonymous. See [Authentication](https://getdrydock.com/docs/configuration/authentications). diff --git a/README.md b/README.md index 023734275..fdda2f040 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ > [!WARNING] -> **Updating to 1.6.0-rc.3 or later?** More security-hardening fixes land with no grace period. An instance with no authentication configured — or with anonymous auth enabled but unconfirmed — now **fails closed** on upgrade, exactly like a fresh install — the container runs, but every API request returns `401`; set `DD_ANONYMOUS_AUTH_CONFIRM=true` or configure `DD_AUTH_BASIC_*`/OIDC before upgrading. The session cookie is renamed `connect.sid` → `drydock.sid`, signing every existing user out once. HTTP notification triggers (plus the Hass webhook and registry icon fetches) now resolve hostnames through a guarded DNS lookup that blocks cloud-metadata/link-local targets and never follow redirects — set `allowmetadata=true` on a specific `DD_NOTIFICATION_HTTP_*` trigger if you legitimately need one. See **[DEPRECATIONS.md](DEPRECATIONS.md#enforced-security-changes-no-deprecation-window)** for full migration guidance. +> **Updating to 1.6.0-rc.3 or later?** More security-hardening fixes land with no grace period. An instance with no authentication configured — or with anonymous auth enabled but unconfirmed — now **fails closed** on upgrade, exactly like a fresh install: the container runs; protected API requests return `401`; authentication discovery/status routes remain public; and `/health` returns `503`. The SPA shell may still load, but it cannot read protected application data. Set `DD_ANONYMOUS_AUTH_CONFIRM=true` or configure `DD_AUTH_BASIC_*`/OIDC before upgrading. The session cookie is renamed `connect.sid` → `drydock.sid`, signing every existing user out once. HTTP notification triggers (plus the Hass webhook and registry icon fetches) now resolve hostnames through a guarded DNS lookup that blocks cloud-metadata/link-local targets and never follow redirects — set `allowmetadata=true` on a specific `DD_NOTIFICATION_HTTP_*` trigger if you legitimately need one. See **[DEPRECATIONS.md](DEPRECATIONS.md#enforced-security-changes-no-deprecation-window)** for full migration guidance.

📑 Contents

@@ -167,7 +167,7 @@ docker run -d \ > > Drydock v1.6 accepts only argon2id Basic auth hashes. Legacy `{SHA}`, `$apr1$`/`$1$`, `crypt`, and plain-text hashes are rejected; regenerate them before upgrading. > Authentication is **required by default**. See the [auth docs](https://getdrydock.com/docs/configuration/authentications) for OIDC, anonymous access, and other options. -> Anonymous access must be explicitly confirmed with `DD_ANONYMOUS_AUTH_CONFIRM=true` on new and upgraded instances alike — without it, an instance with no auth configured (or unconfirmed anonymous auth) fails closed and rejects every API request with `401`. +> Anonymous access must be explicitly confirmed with `DD_ANONYMOUS_AUTH_CONFIRM=true` on new and upgraded instances alike. Without it, an instance with no auth configured (or unconfirmed anonymous auth) starts fail-closed: protected API requests return `401`, public authentication discovery/status routes remain available, and `/health` returns `503`. The image includes `trivy` and `cosign` binaries for local vulnerability scanning and image verification. diff --git a/SECURITY.md b/SECURITY.md index 84e685668..a19c47d41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,10 +2,13 @@ ## Supported Versions -| Version | Supported | -| ------- | ------------------ | -| latest | :white_check_mark: | -| < latest | :x: | +| Version | Supported | +| --- | --- | +| Latest stable release | :white_check_mark: | +| Latest release candidate on the active train | :white_check_mark: | +| Older stable or prerelease versions | :x: | + +Security fixes for an active prerelease train are delivered in its next release candidate and carried into the next stable release. Older release candidates are not patched; upgrade to the newest candidate before reporting or validating a fix. Release candidates are pre-GA test builds and are not recommended as a substitute for the latest stable release in production. ## Reporting a Vulnerability diff --git a/content/docs/current/configuration/authentications/index.mdx b/content/docs/current/configuration/authentications/index.mdx index 099363d2a..d76e74ce1 100644 --- a/content/docs/current/configuration/authentications/index.mdx +++ b/content/docs/current/configuration/authentications/index.mdx @@ -5,11 +5,11 @@ description: "drydock requires authentication by default on fresh installs." import { Callout } from 'fumadocs-ui/components/callout'; -Since v1.4.0, drydock **requires authentication on fresh installs**. If no auth strategy is configured and no explicit anonymous opt-in is present, all API calls return `401 Unauthorized`. +Since v1.4.0, drydock **requires authentication on fresh installs**. If no auth strategy is configured and no explicit anonymous opt-in is present, protected API requests return `401 Unauthorized`; public auth discovery/status routes remain available so the login surface can explain the problem. You can enable 1 or multiple authentication strategies using `DD_AUTH_*` env vars. -Authentication protects all API routes and UI views unless anonymous access is enabled. Fresh installs and upgrades alike must opt in with `DD_ANONYMOUS_AUTH_CONFIRM=true` — without it, an instance with no auth configured fails closed: the container starts, but every API call returns `401`, so the dashboard cannot get past login. +Authentication protects protected API routes and UI data unless anonymous access is enabled. Fresh installs and upgrades alike must opt in with `DD_ANONYMOUS_AUTH_CONFIRM=true` — without it, an instance with no auth configured starts fail-closed. Protected API requests return `401`, auth discovery/status remains public, and `/health` returns `503`. The SPA shell may still load, but it cannot read protected application data. For OIDC setups, session-cookie policy is controlled by `DD_SERVER_COOKIE_SAMESITE` in [server configuration](/docs/configuration/server). Default `lax` works for most IdP callback flows. ## Login lockout controls @@ -51,16 +51,16 @@ environment: | Scenario | Result | | --- | --- | -| Fresh install, no auth configured, no `DD_ANONYMOUS_AUTH_CONFIRM` | Fails closed — all API calls return `401` | +| Fresh install, no auth configured, no `DD_ANONYMOUS_AUTH_CONFIRM` | Starts fail-closed — protected API requests return `401`; auth discovery/status stays public; `/health` returns `503` | | Fresh install, `DD_ANONYMOUS_AUTH_CONFIRM=true` | Anonymous access allowed | -| Upgrade, no auth configured, no `DD_ANONYMOUS_AUTH_CONFIRM` | Fails closed — all API calls return `401` | +| Upgrade, no auth configured, no `DD_ANONYMOUS_AUTH_CONFIRM` | Starts fail-closed — protected API requests return `401`; auth discovery/status stays public; `/health` returns `503` | | Upgrade, `DD_ANONYMOUS_AUTH_CONFIRM=true` | Anonymous access allowed | Drydock detects an upgrade by the presence of an existing store file (`/store/dd.json`). If the file exists, the install is treated as an upgrade; if it does not, it is treated as a fresh install. ### Upgrading -Upgrades fail closed exactly like fresh installs — there is no more warn-and-continue grace period. If an upgrading instance has no authentication configured (or anonymous auth is enabled but not explicitly confirmed), drydock fails closed — the container runs, but every API request is rejected with `401` and `/health` reports `503` — instead of logging a warning and serving an open dashboard. +Upgrades fail closed exactly like fresh installs — there is no more warn-and-continue grace period. If an upgrading instance has no authentication configured (or anonymous auth is enabled but not explicitly confirmed), the container runs while protected API requests return `401`, auth discovery/status remains public, and `/health` returns `503`. The SPA shell may still load, but it cannot read protected application data. This replaces the previous warning plus open dashboard. To restore access, either: diff --git a/scripts/release-docs-identity.test.mjs b/scripts/release-docs-identity.test.mjs index 824d9035d..164cd2a1a 100644 --- a/scripts/release-docs-identity.test.mjs +++ b/scripts/release-docs-identity.test.mjs @@ -336,16 +336,19 @@ test('current and archived docs describe destructive and recovery behavior accur // v1.6 removed the warn-and-serve grandfather path: upgrades fail closed like fresh installs. assert.match( authentications, - /Authentication protects all API routes and UI views unless anonymous access is enabled\. Fresh installs and upgrades alike must opt in with `DD_ANONYMOUS_AUTH_CONFIRM=true`/u, + /Authentication protects protected API routes and UI data unless anonymous access is enabled\. Fresh installs and upgrades alike must opt in with `DD_ANONYMOUS_AUTH_CONFIRM=true`/u, ); - // Fail-closed means 401-everything from a running container, not a refused start: - // Anonymous's registration throw is caught by the registry fallback and the API - // serves with zero passport strategies (app/registry/index.ts, app/api/auth.ts). assert.match( authentications, - /Upgrade, no auth configured, no `DD_ANONYMOUS_AUTH_CONFIRM` \| Fails closed — all API calls return `401`/u, + /Upgrade, no auth configured, no `DD_ANONYMOUS_AUTH_CONFIRM` \| Starts fail-closed — protected API requests return `401`; auth discovery\/status stays public; `\/health` returns `503`/u, + ); + assert.match( + authentications, + /The SPA shell may still load, but it cannot read protected application data/u, ); assert.doesNotMatch(authentications, /refuses to start/u); + assert.doesNotMatch(authentications, /all API calls return `401`/u); + assert.doesNotMatch(authentications, /every API (call|request)/u); assert.doesNotMatch(authentications, /retain anonymous access with a startup warning/u); } else { // The 1.5.x archive documents the grandfather path that line actually shipped with. @@ -378,6 +381,42 @@ test('current and archived docs describe destructive and recovery behavior accur } }); +test('v1.6 fail-closed upgrade docs preserve public auth routes and health semantics', () => { + const readme = read('README.md'); + const changelog = read('CHANGELOG.md'); + const deprecations = read('DEPRECATIONS.md'); + + for (const document of [readme, changelog, deprecations]) { + assert.match(document, /protected API requests? (?:are rejected with|return) `401`/u); + assert.match( + document, + /auth(?:entication)? discovery\/status (?:routes )?remain(?:s)? public/u, + ); + assert.match(document, /`\/health` (?:reports|returns) `503`/u); + assert.doesNotMatch( + document, + /every API (?:call|request)(?: is rejected with| returns)? `401`/u, + ); + assert.doesNotMatch(document, /all API calls return `401`/u); + } + + assert.match( + readme, + /The SPA shell may still load, but it cannot read protected application data/u, + ); + assert.match(deprecations, /the process continues running/u); + assert.doesNotMatch(deprecations, /refusing to serve/u); +}); + +test('security policy distinguishes stable and prerelease support', () => { + const security = read('SECURITY.md'); + + assert.match(security, /Latest stable release/u); + assert.match(security, /Latest release candidate on the active train/u); + assert.match(security, /Older release candidates are not patched/u); + assert.doesNotMatch(security, /\| latest\s+\|/u); +}); + test('current and archived release examples use consistent tags, filenames, dates, and anchors', () => { for (const root of DOC_ROOTS) { const verification = read(`${root}/guides/verifying-releases/index.mdx`); From f379d3d9e5c37018cb3ddb5e1cb25ceb584e5fcb Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:00:51 -0400 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20fix(v1.6):=20address=20remed?= =?UTF-8?q?iation=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/release-cut-retry-workflow.test.ts | 40 ++++++++- .github/workflows/release-cut.yml | 23 ++--- app/agent/AgentClient.test.ts | 16 ++++ app/agent/AgentClient.ts | 3 + apps/demo/src/mocks/handlers.contract.test.ts | 36 ++++++++ apps/demo/src/mocks/handlers/json.ts | 13 +++ apps/demo/src/mocks/handlers/notifications.ts | 89 +++++++++++++++++-- apps/demo/src/mocks/handlers/settings.ts | 6 +- e2e/playwright/v16-modes-pins.spec.ts | 1 + scripts/release-docs-identity.test.mjs | 13 ++- 10 files changed, 209 insertions(+), 31 deletions(-) create mode 100644 apps/demo/src/mocks/handlers/json.ts diff --git a/.github/tests/release-cut-retry-workflow.test.ts b/.github/tests/release-cut-retry-workflow.test.ts index cf4320923..705baf082 100644 --- a/.github/tests/release-cut-retry-workflow.test.ts +++ b/.github/tests/release-cut-retry-workflow.test.ts @@ -120,8 +120,8 @@ test('release-cut delegates image tags and labels to docker metadata-action', () }); expect(blockLines(metadataStep?.with?.images)).toStrictEqual([ 'ghcr.io/${{ steps.target.outputs.repo_lower }}', - 'docker.io/codeswhat/drydock', - 'quay.io/codeswhat/drydock', + '${{ env.DOCKERHUB_REPO }}', + '${{ env.QUAY_REPO }}', ]); expect(metadataStep?.with?.flavor?.trim()).toBe('latest=false'); expect(blockLines(metadataStep?.with?.tags)).toStrictEqual([ @@ -144,6 +144,38 @@ test('release-cut delegates image tags and labels to docker metadata-action', () expect(shellTagComputations).toStrictEqual([]); }); +test('release-cut defines external registry repositories once at job scope', () => { + const workflow = loadWorkflow(workflowPath); + const releaseJob = workflow.jobs?.release; + + expect(releaseJob?.env).toMatchObject({ + DOCKERHUB_REPO: 'docker.io/codeswhat/drydock', + QUAY_REPO: 'quay.io/codeswhat/drydock', + }); + expect(blockLines(getStep('Docker metadata')?.with?.images)).toContain( + '${{ env.DOCKERHUB_REPO }}', + ); + expect(blockLines(getStep('Docker metadata')?.with?.images)).toContain('${{ env.QUAY_REPO }}'); + expect(getStep('Validate GA candidate digest in every registry')?.run).toContain( + '${DOCKERHUB_REPO}:${CANDIDATE_TAG#v}', + ); + expect(getStep('Validate GA candidate digest in every registry')?.run).toContain( + '${QUAY_REPO}:${CANDIDATE_TAG#v}', + ); + expect(getStep('Retry manifest publish on transient registry failure')?.with?.command).toContain( + '${DOCKERHUB_REPO}@${BUILD_DIGEST}', + ); + expect(getStep('Retry manifest publish on transient registry failure')?.with?.command).toContain( + '${QUAY_REPO}@${BUILD_DIGEST}', + ); + expect(getStep('Resolve image source references')?.run).toContain( + '${DOCKERHUB_REPO}:${CANDIDATE_TAG#v}', + ); + expect(getStep('Resolve image source references')?.run).toContain( + '${QUAY_REPO}:${CANDIDATE_TAG#v}', + ); +}); + test('release-cut requires an exact, seven-day-old RC candidate for GA promotion', () => { const workflow = loadWorkflow(workflowPath); const inputs = workflow.on?.workflow_dispatch?.inputs; @@ -174,8 +206,8 @@ test('release-cut validates the promoted digest in every registry', () => { expect(validationStep?.if).toContain("steps.tag.outputs.is_prerelease == 'false'"); expect(validationStep?.run).toContain('ghcr.io/${GHCR_REPO}:${CANDIDATE_TAG#v}'); - expect(validationStep?.run).toContain('docker.io/codeswhat/drydock:${CANDIDATE_TAG#v}'); - expect(validationStep?.run).toContain('quay.io/codeswhat/drydock:${CANDIDATE_TAG#v}'); + expect(validationStep?.run).toContain('${DOCKERHUB_REPO}:${CANDIDATE_TAG#v}'); + expect(validationStep?.run).toContain('${QUAY_REPO}:${CANDIDATE_TAG#v}'); expect(validationStep?.run).toContain('raw_manifest'); expect(validationStep?.run).toContain('computed_digest'); expect(validationStep?.run).toContain('CANDIDATE_DIGEST'); diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 77045adc8..3beaf01df 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -49,6 +49,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 120 # Multi-arch build, signing, attestations, release upload, and CI-success polling budget environment: release-publish + env: + DOCKERHUB_REPO: docker.io/codeswhat/drydock + QUAY_REPO: quay.io/codeswhat/drydock permissions: contents: write # git push tag, gh release create packages: write # ghcr push @@ -358,8 +361,8 @@ jobs: with: images: | ghcr.io/${{ steps.target.outputs.repo_lower }} - docker.io/codeswhat/drydock - quay.io/codeswhat/drydock + ${{ env.DOCKERHUB_REPO }} + ${{ env.QUAY_REPO }} flavor: | latest=false tags: | @@ -376,8 +379,8 @@ jobs: with: images: | ghcr.io/${{ steps.target.outputs.repo_lower }} - docker.io/codeswhat/drydock - quay.io/codeswhat/drydock + ${{ env.DOCKERHUB_REPO }} + ${{ env.QUAY_REPO }} flavor: | latest=false tags: | @@ -393,8 +396,8 @@ jobs: set -euo pipefail candidate_refs=( "ghcr.io/${GHCR_REPO}:${CANDIDATE_TAG#v}" - "docker.io/codeswhat/drydock:${CANDIDATE_TAG#v}" - "quay.io/codeswhat/drydock:${CANDIDATE_TAG#v}" + "${DOCKERHUB_REPO}:${CANDIDATE_TAG#v}" + "${QUAY_REPO}:${CANDIDATE_TAG#v}" ) for candidate_ref in "${candidate_refs[@]}"; do @@ -447,8 +450,8 @@ jobs: source_candidates=( "ghcr.io/${GHCR_REPO}@${BUILD_DIGEST}" - "docker.io/codeswhat/drydock@${BUILD_DIGEST}" - "quay.io/codeswhat/drydock@${BUILD_DIGEST}" + "${DOCKERHUB_REPO}@${BUILD_DIGEST}" + "${QUAY_REPO}@${BUILD_DIGEST}" ) source_ref="" @@ -502,8 +505,8 @@ jobs: printf '%s\n' "${STAGING_TAGS}" else echo "ghcr.io/${GHCR_REPO}:${CANDIDATE_TAG#v}" - echo "docker.io/codeswhat/drydock:${CANDIDATE_TAG#v}" - echo "quay.io/codeswhat/drydock:${CANDIDATE_TAG#v}" + echo "${DOCKERHUB_REPO}:${CANDIDATE_TAG#v}" + echo "${QUAY_REPO}:${CANDIDATE_TAG#v}" fi echo 'DRYDOCK_IMAGE_REFS' } >> "$GITHUB_OUTPUT" diff --git a/app/agent/AgentClient.test.ts b/app/agent/AgentClient.test.ts index 58301b906..4c91039c0 100644 --- a/app/agent/AgentClient.test.ts +++ b/app/agent/AgentClient.test.ts @@ -1593,6 +1593,22 @@ describe('AgentClient', () => { expect(handleSpy).not.toHaveBeenCalled(); }); + test('should discard SSE data queued immediately before stop', async () => { + const stream = new EventEmitter(); + Object.assign(stream, { destroy: vi.fn() }); + axios.mockResolvedValueOnce({ data: stream }); + const handleSpy = vi.spyOn(client, 'handleEvent').mockResolvedValue(undefined); + + client.startSse(); + await vi.advanceTimersByTimeAsync(0); + stream.emit('data', Buffer.from('data: {"type":"dd:ack","data":{"version":"queued"}}\n\n')); + client.stop(); + await Promise.resolve(); + await Promise.resolve(); + + expect(handleSpy).not.toHaveBeenCalled(); + }); + test('should clear an armed stableConnectionTimer', async () => { const stream = new EventEmitter(); axios.mockResolvedValue({ data: stream }); diff --git a/app/agent/AgentClient.ts b/app/agent/AgentClient.ts index f04ff3f4c..80e9999af 100644 --- a/app/agent/AgentClient.ts +++ b/app/agent/AgentClient.ts @@ -889,6 +889,9 @@ export class AgentClient { sseProcessing = sseProcessing .then(async () => { + if (this.stopped || this.activeSseStream !== stream) { + return; + } buffer += decodedChunk; buffer = await this.processSseBuffer(buffer); }) diff --git a/apps/demo/src/mocks/handlers.contract.test.ts b/apps/demo/src/mocks/handlers.contract.test.ts index bfe4f4ecc..3110a7e29 100644 --- a/apps/demo/src/mocks/handlers.contract.test.ts +++ b/apps/demo/src/mocks/handlers.contract.test.ts @@ -20,6 +20,42 @@ async function readJson(response: Response) { } describe('demo mock contracts', () => { + test('rejects malformed and non-object JSON request bodies', async () => { + const requests = [ + ['PATCH', 'http://localhost/api/v1/settings'], + ['PATCH', 'http://localhost/api/v1/notifications/update-available'], + ['POST', 'http://localhost/api/v1/notifications/update-available/preview'], + ] as const; + + for (const [method, url] of requests) { + for (const body of ['null', '[]', '{']) { + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body, + }); + + expect(response.status, `${method} ${url} should reject ${body}`).toBe(400); + } + } + }); + + test('notification updates cannot overwrite identity or add arbitrary fields', async () => { + const updated = await readJson( + await fetch('http://localhost/api/v1/notifications/update-available', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: 'rewritten-id', + arbitrary: 'injected', + }), + }), + ); + + expect(updated).toMatchObject({ id: 'update-available' }); + expect(updated).not.toHaveProperty('arbitrary'); + }); + test('settings expose updateMode and persist PATCH updates', async () => { const initial = await readJson(await fetch('http://localhost/api/v1/settings')); expect(initial).toEqual({ internetlessMode: false, updateMode: 'manual' }); diff --git a/apps/demo/src/mocks/handlers/json.ts b/apps/demo/src/mocks/handlers/json.ts new file mode 100644 index 000000000..d1d13dc57 --- /dev/null +++ b/apps/demo/src/mocks/handlers/json.ts @@ -0,0 +1,13 @@ +export async function readJsonRecord( + request: Request, +): Promise | undefined> { + try { + const value: unknown = await request.json(); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record; + } catch { + return undefined; + } +} diff --git a/apps/demo/src/mocks/handlers/notifications.ts b/apps/demo/src/mocks/handlers/notifications.ts index 1959a0952..c4f6fe07f 100644 --- a/apps/demo/src/mocks/handlers/notifications.ts +++ b/apps/demo/src/mocks/handlers/notifications.ts @@ -5,12 +5,75 @@ import type { NotificationOutboxEntryStatus, } from '@/services/notification-outbox'; import { notificationOutboxEntries, notificationRules } from '../data/notifications'; +import { readJsonRecord } from './json'; const validOutboxStatuses = new Set([ 'pending', 'delivered', 'dead-letter', ]); +const validBellThresholds = new Set(['all', 'major', 'minor', 'patch']); +const templateFields = new Set(['simpleTitle', 'simpleBody', 'batchTitle']); + +function isTemplateOverride(value: unknown): value is NotificationTemplateOverride { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.entries(value).every( + ([key, fieldValue]) => templateFields.has(key) && typeof fieldValue === 'string', + ) + ); +} + +function isTemplateOverrides( + value: unknown, +): value is NonNullable { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value).every(isTemplateOverride) + ); +} + +function parseNotificationRuleUpdate( + body: Record, +): NotificationRuleUpdate | undefined { + const update: NotificationRuleUpdate = {}; + + if ('enabled' in body) { + if (typeof body.enabled !== 'boolean') return undefined; + update.enabled = body.enabled; + } + if ('triggers' in body) { + if ( + !Array.isArray(body.triggers) || + !body.triggers.every((value) => typeof value === 'string') + ) { + return undefined; + } + update.triggers = body.triggers; + } + if ('bellEnabled' in body) { + if (typeof body.bellEnabled !== 'boolean') return undefined; + update.bellEnabled = body.bellEnabled; + } + if ('bellThreshold' in body) { + if (typeof body.bellThreshold !== 'string' || !validBellThresholds.has(body.bellThreshold)) { + return undefined; + } + update.bellThreshold = body.bellThreshold as NonNullable< + NotificationRuleUpdate['bellThreshold'] + >; + } + if ('templates' in body) { + if (!isTemplateOverrides(body.templates)) return undefined; + update.templates = body.templates; + } + + return update; +} function isOutboxStatus(status: string | null): status is NotificationOutboxEntryStatus { return status !== null && validOutboxStatuses.has(status as NotificationOutboxEntryStatus); @@ -97,13 +160,16 @@ export const notificationHandlers = [ if (!rule) { return HttpResponse.json({ error: 'Notification rule not found' }, { status: 404 }); } - const body = (await request.json()) as { - triggerId?: unknown; - templates?: NotificationTemplateOverride; - }; + const body = await readJsonRecord(request); + if (!body) { + return HttpResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 }); + } if (typeof body.triggerId !== 'string' || body.triggerId.trim() === '') { return HttpResponse.json({ error: 'triggerId is required' }, { status: 400 }); } + if (body.templates !== undefined && !isTemplateOverride(body.templates)) { + return HttpResponse.json({ error: 'templates must be a template object' }, { status: 400 }); + } const templates = body.templates ?? {}; return HttpResponse.json({ simpleTitle: renderPreviewTemplate( @@ -124,10 +190,17 @@ export const notificationHandlers = [ http.patch('/api/v1/notifications/:id', async ({ params, request }) => { const rule = notificationRules.find((candidate) => candidate.id === params.id); if (!rule) return new HttpResponse(null, { status: 404 }); - const body = (await request.json()) as NotificationRuleUpdate; - Object.assign(rule, body, { - ...(body.triggers ? { triggers: [...body.triggers] } : {}), - ...(body.templates ? { templates: structuredClone(body.templates) } : {}), + const body = await readJsonRecord(request); + if (!body) { + return HttpResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 }); + } + const update = parseNotificationRuleUpdate(body); + if (!update) { + return HttpResponse.json({ error: 'Invalid notification update' }, { status: 400 }); + } + Object.assign(rule, update, { + ...(update.triggers ? { triggers: [...update.triggers] } : {}), + ...(update.templates ? { templates: structuredClone(update.templates) } : {}), }); return HttpResponse.json(rule); }), diff --git a/apps/demo/src/mocks/handlers/settings.ts b/apps/demo/src/mocks/handlers/settings.ts index 084a5acaf..bb8e3e034 100644 --- a/apps/demo/src/mocks/handlers/settings.ts +++ b/apps/demo/src/mocks/handlers/settings.ts @@ -1,4 +1,5 @@ import { HttpResponse, http } from 'msw'; +import { readJsonRecord } from './json'; const settings = { internetlessMode: false, updateMode: 'manual' as 'notify' | 'manual' | 'auto' }; @@ -6,7 +7,10 @@ export const settingsHandlers = [ http.get('/api/v1/settings', () => HttpResponse.json(settings)), http.patch('/api/v1/settings', async ({ request }) => { - const body = (await request.json()) as Record; + const body = await readJsonRecord(request); + if (!body) { + return HttpResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 }); + } if (typeof body.internetlessMode === 'boolean') { settings.internetlessMode = body.internetlessMode; } diff --git a/e2e/playwright/v16-modes-pins.spec.ts b/e2e/playwright/v16-modes-pins.spec.ts index 3ba97632e..e767310f9 100644 --- a/e2e/playwright/v16-modes-pins.spec.ts +++ b/e2e/playwright/v16-modes-pins.spec.ts @@ -20,6 +20,7 @@ interface ContainerFixture { }; result?: Record; tagFamily?: string; + tagPinGated?: boolean; tagPinned?: boolean; tagPinInfo?: boolean; updateAvailable?: boolean; diff --git a/scripts/release-docs-identity.test.mjs b/scripts/release-docs-identity.test.mjs index 164cd2a1a..2198557c1 100644 --- a/scripts/release-docs-identity.test.mjs +++ b/scripts/release-docs-identity.test.mjs @@ -6,6 +6,8 @@ const RC_VERSION = '1.6.0-rc.3'; const RC_DATE = '2026-07-21'; const RC_DISPLAY_DATE = 'July 21, 2026'; const DOC_ROOTS = ['content/docs/current', 'content/docs/v1.5']; +const BROAD_401_CLAIM = + /(?:all|every) API (?:call|request)s?(?: (?:is|are) rejected with| returns?) `401`/iu; function read(path) { return readFileSync(path, 'utf8'); @@ -347,8 +349,7 @@ test('current and archived docs describe destructive and recovery behavior accur /The SPA shell may still load, but it cannot read protected application data/u, ); assert.doesNotMatch(authentications, /refuses to start/u); - assert.doesNotMatch(authentications, /all API calls return `401`/u); - assert.doesNotMatch(authentications, /every API (call|request)/u); + assert.doesNotMatch(authentications, BROAD_401_CLAIM); assert.doesNotMatch(authentications, /retain anonymous access with a startup warning/u); } else { // The 1.5.x archive documents the grandfather path that line actually shipped with. @@ -393,11 +394,7 @@ test('v1.6 fail-closed upgrade docs preserve public auth routes and health seman /auth(?:entication)? discovery\/status (?:routes )?remain(?:s)? public/u, ); assert.match(document, /`\/health` (?:reports|returns) `503`/u); - assert.doesNotMatch( - document, - /every API (?:call|request)(?: is rejected with| returns)? `401`/u, - ); - assert.doesNotMatch(document, /all API calls return `401`/u); + assert.doesNotMatch(document, BROAD_401_CLAIM); } assert.match( @@ -414,7 +411,7 @@ test('security policy distinguishes stable and prerelease support', () => { assert.match(security, /Latest stable release/u); assert.match(security, /Latest release candidate on the active train/u); assert.match(security, /Older release candidates are not patched/u); - assert.doesNotMatch(security, /\| latest\s+\|/u); + assert.doesNotMatch(security, /\|\s*latest\s*\|/iu); }); test('current and archived release examples use consistent tags, filenames, dates, and anchors', () => { From c20eceb9337fb41bbf71f67be707fb6e61a15aac Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:11:59 -0400 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A7=AA=20test(v1.6):=20close=20review?= =?UTF-8?q?=20evidence=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/tests/release-cut-retry-workflow.test.ts | 3 +++ apps/demo/src/mocks/handlers.contract.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/.github/tests/release-cut-retry-workflow.test.ts b/.github/tests/release-cut-retry-workflow.test.ts index 705baf082..b224171f7 100644 --- a/.github/tests/release-cut-retry-workflow.test.ts +++ b/.github/tests/release-cut-retry-workflow.test.ts @@ -152,6 +152,9 @@ test('release-cut defines external registry repositories once at job scope', () DOCKERHUB_REPO: 'docker.io/codeswhat/drydock', QUAY_REPO: 'quay.io/codeswhat/drydock', }); + const releaseStepText = JSON.stringify(loadReleaseSteps()); + expect(releaseStepText).not.toContain('docker.io/codeswhat/drydock'); + expect(releaseStepText).not.toContain('quay.io/codeswhat/drydock'); expect(blockLines(getStep('Docker metadata')?.with?.images)).toContain( '${{ env.DOCKERHUB_REPO }}', ); diff --git a/apps/demo/src/mocks/handlers.contract.test.ts b/apps/demo/src/mocks/handlers.contract.test.ts index 3110a7e29..43bf9c068 100644 --- a/apps/demo/src/mocks/handlers.contract.test.ts +++ b/apps/demo/src/mocks/handlers.contract.test.ts @@ -54,6 +54,14 @@ describe('demo mock contracts', () => { expect(updated).toMatchObject({ id: 'update-available' }); expect(updated).not.toHaveProperty('arbitrary'); + + const persisted = await readJson(await fetch('http://localhost/api/v1/notifications')); + const persistedRule = persisted.data.find( + (rule: { id: string }) => rule.id === 'update-available', + ); + expect(persistedRule).toMatchObject({ id: 'update-available' }); + expect(persistedRule).not.toHaveProperty('arbitrary'); + expect(persisted.data.some((rule: { id: string }) => rule.id === 'rewritten-id')).toBe(false); }); test('settings expose updateMode and persist PATCH updates', async () => {