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', () => {