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
43 changes: 34 additions & 9 deletions src/lib/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, z.ZodType> = {
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<string, z.ZodType> = Object.fromEntries(
CHANNEL_CONFIG_SCHEMA_LIST.map((schema) => [
(schema.shape as {channelType: z.ZodLiteral<string>}).channelType.value,
schema as z.ZodType,
]),
)

export const NOTIFICATION_POLICIES: FullResource<NotificationPolicyDto> = {
name: 'notification policy',
Expand Down
44 changes: 34 additions & 10 deletions src/lib/yaml/zod-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, z.ZodType> = {
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<string, z.ZodType> = Object.fromEntries(
CHANNEL_CONFIG_SCHEMA_LIST.map((schema) => [
(schema.shape as {channelType: z.ZodLiteral<string>}).channelType.value,
schema as z.ZodType,
]),
)

// ── Monitor config schemas (imported from generated OpenAPI Zod) ─────

Expand Down
32 changes: 29 additions & 3 deletions test/lib/resources-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
14 changes: 14 additions & 0 deletions test/yaml/zod-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading