From b5e64e940aebfe2bcce13e0098ca4fb36a735852 Mon Sep 17 00:00:00 2001 From: caballeto Date: Thu, 11 Jun 2026 14:23:24 +0200 Subject: [PATCH] fix(alert-channels): accept --config for all 20 channel types The ALERT_CHANNEL_CONFIG_SCHEMAS map (and the YAML CHANNEL_CONFIG_SCHEMAS map) covered only 7 of the 20 channel types, so --config for the other 13 (telegram, google_chat, pushover, mattermost, splunk_oncall, pushbullet, linear, incident_io, rootly, zapier, datadog, jira, gitlab) was rejected with "Unknown channelType" on the CLI path and silently skipped deep validation on the YAML path. Both maps are now derived from the generated OpenAPI Zod schemas (the source of truth), keyed by each variant's channelType literal, so they can never drift from the API again. Adds drift-guard tests asserting the maps cover every entry in CHANNEL_TYPES. Co-authored-by: Cursor --- src/lib/resources.ts | 43 ++++++++++++++++++++------ src/lib/yaml/zod-schemas.ts | 44 +++++++++++++++++++++------ test/lib/resources-validation.test.ts | 32 +++++++++++++++++-- test/yaml/zod-schemas.test.ts | 14 +++++++++ 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/lib/resources.ts b/src/lib/resources.ts index 786e1df..e51b47d 100644 --- a/src/lib/resources.ts +++ b/src/lib/resources.ts @@ -392,15 +392,40 @@ function parseAlertChannelConfigFlag(raw: string): object { } // Discriminated by `channelType` to match the API's AlertChannelConfig union. -const ALERT_CHANNEL_CONFIG_SCHEMAS: Record = { - discord: apiSchemas.DiscordChannelConfig, - email: apiSchemas.EmailChannelConfig, - opsgenie: apiSchemas.OpsGenieChannelConfig, - pagerduty: apiSchemas.PagerDutyChannelConfig, - slack: apiSchemas.SlackChannelConfig, - teams: apiSchemas.TeamsChannelConfig, - webhook: apiSchemas.WebhookChannelConfig, -} +// Derived from the generated OpenAPI Zod schemas (the source of truth) so this +// map can never drift from the API surface: every config variant is keyed by +// its own `channelType` literal, and a new channel type added to the spec shows +// up here automatically after `pnpm sync-schema` regenerates api-zod.generated. +// The drift-guard test asserts this covers every entry in CHANNEL_TYPES. +const CHANNEL_CONFIG_SCHEMA_LIST: z.AnyZodObject[] = [ + apiSchemas.DatadogChannelConfig, + apiSchemas.DiscordChannelConfig, + apiSchemas.EmailChannelConfig, + apiSchemas.GitLabChannelConfig, + apiSchemas.GoogleChatChannelConfig, + apiSchemas.IncidentIoChannelConfig, + apiSchemas.JiraChannelConfig, + apiSchemas.LinearChannelConfig, + apiSchemas.MattermostChannelConfig, + apiSchemas.OpsGenieChannelConfig, + apiSchemas.PagerDutyChannelConfig, + apiSchemas.PushbulletChannelConfig, + apiSchemas.PushoverChannelConfig, + apiSchemas.RootlyChannelConfig, + apiSchemas.SlackChannelConfig, + apiSchemas.SplunkOnCallChannelConfig, + apiSchemas.TeamsChannelConfig, + apiSchemas.TelegramChannelConfig, + apiSchemas.WebhookChannelConfig, + apiSchemas.ZapierChannelConfig, +] + +export const ALERT_CHANNEL_CONFIG_SCHEMAS: Record = Object.fromEntries( + CHANNEL_CONFIG_SCHEMA_LIST.map((schema) => [ + (schema.shape as {channelType: z.ZodLiteral}).channelType.value, + schema as z.ZodType, + ]), +) export const NOTIFICATION_POLICIES: FullResource = { name: 'notification policy', diff --git a/src/lib/yaml/zod-schemas.ts b/src/lib/yaml/zod-schemas.ts index 0143cb7..e496b85 100644 --- a/src/lib/yaml/zod-schemas.ts +++ b/src/lib/yaml/zod-schemas.ts @@ -91,16 +91,40 @@ export const ASSERTION_WIRE_TYPES = Object.keys(ASSERTION_CONFIG_SCHEMAS) export {ASSERTION_CONFIG_SCHEMAS} // ── Channel config schemas (imported from generated OpenAPI Zod) ───── - -const CHANNEL_CONFIG_SCHEMAS: Record = { - discord: apiSchemas.DiscordChannelConfig, - email: apiSchemas.EmailChannelConfig, - opsgenie: apiSchemas.OpsGenieChannelConfig, - pagerduty: apiSchemas.PagerDutyChannelConfig, - slack: apiSchemas.SlackChannelConfig, - teams: apiSchemas.TeamsChannelConfig, - webhook: apiSchemas.WebhookChannelConfig, -} +// Derived from the generated schemas (the source of truth), keyed by each +// variant's `channelType` literal, so every channel type the API supports is +// deeply validated in YAML. Previously this covered only 7 of 20 types and the +// rest fell through the `if (!configSchema) return` escape hatch unvalidated. +// The parity test asserts this covers every entry in CHANNEL_TYPES. +const CHANNEL_CONFIG_SCHEMA_LIST: z.AnyZodObject[] = [ + apiSchemas.DatadogChannelConfig, + apiSchemas.DiscordChannelConfig, + apiSchemas.EmailChannelConfig, + apiSchemas.GitLabChannelConfig, + apiSchemas.GoogleChatChannelConfig, + apiSchemas.IncidentIoChannelConfig, + apiSchemas.JiraChannelConfig, + apiSchemas.LinearChannelConfig, + apiSchemas.MattermostChannelConfig, + apiSchemas.OpsGenieChannelConfig, + apiSchemas.PagerDutyChannelConfig, + apiSchemas.PushbulletChannelConfig, + apiSchemas.PushoverChannelConfig, + apiSchemas.RootlyChannelConfig, + apiSchemas.SlackChannelConfig, + apiSchemas.SplunkOnCallChannelConfig, + apiSchemas.TeamsChannelConfig, + apiSchemas.TelegramChannelConfig, + apiSchemas.WebhookChannelConfig, + apiSchemas.ZapierChannelConfig, +] + +const CHANNEL_CONFIG_SCHEMAS: Record = Object.fromEntries( + CHANNEL_CONFIG_SCHEMA_LIST.map((schema) => [ + (schema.shape as {channelType: z.ZodLiteral}).channelType.value, + schema as z.ZodType, + ]), +) // ── Monitor config schemas (imported from generated OpenAPI Zod) ───── diff --git a/test/lib/resources-validation.test.ts b/test/lib/resources-validation.test.ts index 15bac4b..1445cd2 100644 --- a/test/lib/resources-validation.test.ts +++ b/test/lib/resources-validation.test.ts @@ -2,7 +2,8 @@ import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest' import {mkdtempSync, writeFileSync, rmSync} from 'node:fs' import {join} from 'node:path' import {tmpdir} from 'node:os' -import {ALERT_CHANNELS, MONITORS, STATUS_PAGES} from '../../src/lib/resources.js' +import {ALERT_CHANNELS, ALERT_CHANNEL_CONFIG_SCHEMAS, MONITORS, STATUS_PAGES} from '../../src/lib/resources.js' +import {CHANNEL_TYPES} from '../../src/lib/spec-facts.generated.js' describe('ALERT_CHANNELS bodyBuilder --config validation', () => { it('accepts valid slack config JSON', () => { @@ -35,8 +36,33 @@ describe('ALERT_CHANNELS bodyBuilder --config validation', () => { it('throws on unknown channelType', () => { expect(() => - ALERT_CHANNELS.bodyBuilder!({name: 'x', type: 'slack', config: '{"channelType":"telegram"}'}), - ).toThrow(/Unknown channelType "telegram"/) + ALERT_CHANNELS.bodyBuilder!({name: 'x', type: 'slack', config: '{"channelType":"carrier_pigeon"}'}), + ).toThrow(/Unknown channelType "carrier_pigeon"/) + }) + + it('accepts a valid telegram config (previously unmapped)', () => { + const body = ALERT_CHANNELS.bodyBuilder!({ + name: 'tg', + type: 'telegram', + config: JSON.stringify({channelType: 'telegram', botToken: 'abc123', chatId: '-1001234567890'}), + }) + expect(body.config).toMatchObject({channelType: 'telegram', botToken: 'abc123'}) + }) + + it.each(['telegram', 'google_chat', 'pushover', 'mattermost', 'splunk_oncall', 'pushbullet', 'linear', 'incident_io', 'rootly', 'zapier', 'datadog', 'jira', 'gitlab'])( + 'rejects an incomplete %s config with a field-level error (not "Unknown channelType")', + (channelType) => { + expect(() => + ALERT_CHANNELS.bodyBuilder!({name: 'x', type: channelType, config: JSON.stringify({channelType})}), + ).toThrow(new RegExp(`Invalid --config payload for channelType "${channelType}"`)) + }, + ) + + it('config schema map covers every channel type (drift guard)', () => { + const mapped = new Set(Object.keys(ALERT_CHANNEL_CONFIG_SCHEMAS)) + const missing = CHANNEL_TYPES.filter((t) => !mapped.has(t)) + expect(missing).toEqual([]) + expect(mapped.size).toBe(CHANNEL_TYPES.length) }) it('throws when payload does not match the channelType schema', () => { diff --git a/test/yaml/zod-schemas.test.ts b/test/yaml/zod-schemas.test.ts index 9de38cd..700811b 100644 --- a/test/yaml/zod-schemas.test.ts +++ b/test/yaml/zod-schemas.test.ts @@ -182,6 +182,20 @@ describe('DevhelmConfigSchema', () => { }) expect(result.success).toBe(false) }) + + // Drift guard: every channel type in the spec must be deeply validated. + // A config carrying only `channelType` (no required fields) must fail for + // every type — if a type were missing from CHANNEL_CONFIG_SCHEMAS it would + // slip through the `if (!configSchema) return` escape hatch and succeed. + it.each([..._ZOD_ENUMS.CHANNEL_TYPES])( + 'deeply validates %s config (no unmapped escape hatch)', + (channelType) => { + const result = DevhelmConfigSchema.safeParse({ + alertChannels: [{name: 'x', config: {channelType}}], + }) + expect(result.success).toBe(false) + }, + ) }) describe('notificationPolicies', () => {