Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions frontend/e2e/alerts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,34 @@ test('Slack and Alertmanager targets retain their distinct fields', async ({ pag
kind === 'slack' ? 'Slack' : 'Alertmanager',
);
}
// Stored credentials come back masked: neither is prefilled, and both must be re-entered.
await rowAction(page, 'Test alertManager', 'Edit');
const sheet = page.getByRole('dialog', { name: 'Edit target', exact: true });
const username = sheet.getByLabel('Username', { exact: true });
const password = sheet.getByLabel('Password', { exact: true });
await expect(sheet.getByLabel('Target type')).toHaveValue('alertManager');
await expect(username).toHaveValue('');
await expect(password).toHaveValue('');
await expect(
sheet.getByText('Re-enter both username and password to keep basic authentication', {
exact: false,
}),
).toBeVisible();
await sheet.getByLabel('Endpoint URL').fill('https://example.com/api/v2/alerts');
await username.fill('********');
await password.fill('secret');
await sheet.getByRole('button', { name: 'Save target' }).click();
await expect(username).toHaveAccessibleDescription('Enter the real username, not the mask.');
await username.fill('svc');
await password.fill('');
await password.blur();
await expect(password).toHaveAccessibleDescription(
/Enter both username and password, or leave both empty\./,
);
await password.fill('new-secret');
await sheet.getByRole('button', { name: 'Save target' }).click();
await expect(sheet).toHaveCount(0);
await expect(namedRow(page, 'Test alertManager')).toContainText('Alertmanager');
});
test('validation errors describe their controls and block invalid thresholds, frequency and hold', async ({
page,
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/features/alerts/TargetSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,19 @@ export function TargetSheet({
)}
{draft.type === 'alertManager' && (
<>
{target && (
<p className="muted">
Stored credentials are masked. Re-enter both username and password to keep basic
authentication; leaving both empty removes it.
</p>
)}
<Input
label="Username"
autoComplete="off"
value={draft.username}
disabled={mutation.pending}
error={errors.username}
onBlur={() => shown.touch('username')}
onChange={(event) => update('username', event.target.value)}
/>
<Input
Expand All @@ -231,11 +239,7 @@ export function TargetSheet({
autoComplete="new-password"
value={draft.password}
disabled={mutation.pending}
hint={
target
? 'Re-enter the password to retain basic authentication, or clear both fields.'
: 'Username and password must both be set, or both empty.'
}
hint="Username and password must both be set, or both empty."
error={errors.password}
onBlur={() => shown.touch('password')}
onChange={(event) => update('password', event.target.value)}
Expand Down
66 changes: 52 additions & 14 deletions frontend/src/features/alerts/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,28 +443,65 @@ describe('list filtering, sorting and bounded type discovery', () => {
});
});
describe('target forms', () => {
const maskedAlertmanager = {
id: 'am',
name: 'AM',
type: 'webhook' as const,
endpoint: 'https://********',
username: '********',
password: '********',
};
it('recognizes masked Alertmanager, requires re-entry and builds top-level credentials', () => {
const target = {
id: 'am',
name: 'AM',
type: 'webhook' as const,
endpoint: 'https://********',
username: 'svc',
password: '********',
};
expect(targetType(target)).toBe('alertManager');
const form = targetDraft(target);
expect(form.endpoint).toBe('');
expect(form.password).toBe('');
expect(targetType(maskedAlertmanager)).toBe('alertManager');
expect(targetType({ ...maskedAlertmanager, username: null, password: null })).toBe(
'alertManager',
);
const form = targetDraft(maskedAlertmanager);
// Neither mask is prefilled, so it can never be resubmitted as a real credential.
expect([form.endpoint, form.username, form.password]).toEqual(['', '', '']);
expect(validateTarget(form)).toHaveProperty('endpoint');
form.endpoint = 'https://example.com/api/v2/alerts';
form.username = 'svc';
form.password = 'secret';
expect(buildTargetPayload(form)).toMatchObject({
expect(validateTarget(form)).toEqual({});
expect(buildTargetPayload(form)).toEqual({
name: 'AM',
type: 'alertManager',
endpoint: 'https://example.com/api/v2/alerts',
skipTlsCheck: false,
username: 'svc',
password: 'secret',
});
expect(buildTargetPayload(form)).not.toHaveProperty('auth');
});
it('removes Alertmanager auth when both credentials are left empty on edit', () => {
const form = {
...targetDraft(maskedAlertmanager),
endpoint: 'https://example.com/api/v2/alerts',
};
expect(validateTarget(form)).toEqual({});
const payload = buildTargetPayload(form);
expect(payload).not.toHaveProperty('username');
expect(payload).not.toHaveProperty('password');
expect(JSON.stringify(payload)).not.toContain('********');
});
it('requires the Alertmanager username and password as a pair and rejects masks', () => {
const form = {
...targetDraft(maskedAlertmanager),
endpoint: 'https://example.com/api/v2/alerts',
};
const pair = 'Enter both username and password, or leave both empty.';
expect(validateTarget({ ...form, username: 'svc' })).toEqual({ password: pair });
expect(validateTarget({ ...form, password: 'secret' })).toEqual({ password: pair });
expect(validateTarget({ ...form, username: '********', password: 'secret' })).toEqual({
username: 'Enter the real username, not the mask.',
});
expect(validateTarget({ ...form, username: 'svc', password: '********' })).toEqual({
password: 'Enter the real password, not the mask.',
});
expect(validateTarget({ ...form, username: '********', password: '********' })).toEqual({
username: 'Enter the real username, not the mask.',
password: 'Enter the real password, not the mask.',
});
});
it('omits TLS and auth fields for Slack', () => {
const form = {
Expand All @@ -487,6 +524,7 @@ describe('target forms', () => {
for (const headers of [
[{ id: 'a', key: 'Cookie', value: 'a' }],
[{ id: 'a', key: 'X-Key', value: '' }],
[{ id: 'a', key: 'X-Key', value: '********' }],
[
{ id: 'a', key: 'X-Key', value: 'a' },
{ id: 'b', key: 'x-key', value: 'b' },
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/features/alerts/targetHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { AlertTarget, AlertTargetRequest, AlertTargetType } from '../../lib/types';
import { createId } from '../../lib/ids';

/** The placeholder the server returns in place of stored target secrets. */
export const MASKED = '********';

export function targetType(target: AlertTarget): AlertTargetType {
return target.type === 'webhook' &&
!target.headers &&
Expand All @@ -25,7 +28,8 @@ export function targetDraft(target?: AlertTarget): TargetDraft {
type: target ? targetType(target) : 'webhook',
endpoint: '',
skipTls: target?.skipTlsCheck ?? false,
username: target?.username ?? '',
// Credentials come back masked, so both start empty and must be re-entered together.
username: '',
password: '',
headers: Object.keys(target?.headers ?? {}).map((key) => ({ id: createId(), key, value: '' })),
};
Expand Down Expand Up @@ -57,6 +61,8 @@ export function invalidHeader(
fields,
message: 'Each header needs a valid name and a non-empty value without line breaks.',
};
if (header.value === MASKED)
return { index, fields: ['value'], message: 'Enter the real header value, not the mask.' };
if (deniedHeaders.includes(name))
return {
index,
Expand All @@ -75,7 +81,7 @@ export function validateTarget(draft: TargetDraft): Partial<Record<keyof TargetD
if (
!['https:', 'http:'].includes(url.protocol) ||
!url.hostname ||
draft.endpoint.includes('********') ||
draft.endpoint.includes(MASKED) ||
url.username ||
url.password
)
Expand All @@ -89,8 +95,12 @@ export function validateTarget(draft: TargetDraft): Partial<Record<keyof TargetD
} catch {
errors.endpoint = 'Enter a complete HTTP or HTTPS endpoint URL.';
}
if (draft.type === 'alertManager' && Boolean(draft.username) !== Boolean(draft.password))
errors.password = 'Enter both username and password, or leave both empty.';
if (draft.type === 'alertManager') {
if (draft.username === MASKED) errors.username = 'Enter the real username, not the mask.';
if (draft.password === MASKED) errors.password = 'Enter the real password, not the mask.';
else if (Boolean(draft.username) !== Boolean(draft.password))
errors.password = 'Enter both username and password, or leave both empty.';
}
if (draft.type === 'webhook') {
const header = invalidHeader(draft.headers);
if (header) errors.headers = header.message;
Expand Down
63 changes: 53 additions & 10 deletions frontend/src/lib/demoAlerts.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { createDemoClient } from './demo';
import { alert, alertSummary, alertTargetStatus } from './alertsContract';
import { alert, alertSummary, alertTarget, alertTargetStatus } from './alertsContract';
import { alertDraft, buildAlertPayload } from '../features/alerts/helpers';
import { targetType } from '../features/alerts/targetHelpers';
import { targetDraft, targetType } from '../features/alerts/targetHelpers';
import { alertRequest } from './__fixtures__/alerts';

describe('demo alerts', () => {
Expand Down Expand Up @@ -61,25 +61,68 @@ describe('demo alerts', () => {
});
it('masks every target secret and recognizes the backend Alertmanager shape', async () => {
const client = createDemoClient();
const secrets = ['svc-user', 'secret-pass', 'secret-path', 'header-secret'];
const leaks = (value: unknown) =>
secrets.filter((secret) => JSON.stringify(value).includes(secret));
const target = await client.createAlertTarget({
name: 'AM',
type: 'alertManager',
endpoint: 'https://example.com/api/v2/alerts',
username: 'svc',
password: 'secret',
endpoint: 'https://example.com/secret-path/api/v2/alerts',
username: 'svc-user',
password: 'secret-pass',
skipTlsCheck: false,
});
expect(target).toMatchObject({
type: 'webhook',
endpoint: 'https://********',
username: '********',
password: '********',
});
expect(targetType(target)).toBe('alertManager');
expect(target.password).toBe('********');
expect(JSON.stringify(await client.listAlertTargets())).not.toContain('secret');
const fetched = (await client.getAlertTarget(target.id)).target;
expect(fetched).toEqual(target);
expect(alertTarget(fetched)).toBe(true);
expect(leaks([target, fetched, await client.listAlertTargets()])).toEqual([]);
// The edit form never carries masks forward; the demo uses the same form validation.
const form = targetDraft(fetched);
expect([form.username, form.password]).toEqual(['', '']);
for (const credentials of [
{ username: '********', password: 'secret-pass' },
{ username: 'svc-user', password: '********' },
])
await expect(
client.updateAlertTarget(target.id, {
name: 'AM',
type: 'alertManager',
endpoint: 'https://example.com/api/v2/alerts',
...credentials,
skipTlsCheck: false,
}),
).rejects.toMatchObject({ status: 400 });
const unauthenticated = await client.updateAlertTarget(target.id, {
name: 'AM',
type: 'alertManager',
endpoint: 'https://example.com/api/v2/alerts',
skipTlsCheck: false,
});
expect(unauthenticated).toMatchObject({ username: null, password: null });
expect(targetType(unauthenticated)).toBe('alertManager');
const updated = await client.updateAlertTarget(target.id, {
name: 'AM',
type: 'webhook',
endpoint: 'https://example.com/new-secret',
headers: { 'X-Key': 'secret' },
endpoint: 'https://example.com/secret-path',
headers: { 'X-Key': 'header-secret' },
skipTlsCheck: false,
});
expect(updated.headers).toEqual({ 'X-Key': '********' });
expect(updated).toMatchObject({
endpoint: 'https://********',
headers: { 'X-Key': '********' },
});
expect(updated).not.toHaveProperty('username');
expect(targetType(updated)).toBe('webhook');
expect(
leaks([updated, await client.getAlertTarget(target.id), await client.listAlertTargets()]),
).toEqual([]);
await client.deleteAlertTarget(target.id);
await expect(client.getAlertTarget(target.id)).rejects.toMatchObject({ status: 400 });
});
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/demoAlerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ export function createDemoAlerts(now = Date.now()): Pick<ParseableClient, Method
name: body.name,
type: 'webhook',
endpoint,
username: body.username ?? null,
// Like the server, configured basic auth masks both halves; absent auth stays null.
username: body.username ? '********' : null,
password: body.password ? '********' : null,
skipTlsCheck: body.skipTlsCheck,
};
Expand Down
Loading
Loading