Repository: hyperjumptech/monika (ts)

Issue:
Alert Assertion Validation Does not Working as Expected
**Describe the bug**
Monika crashes when the alert assertion value is invalid.

**To Reproduce**
Run Monika with the following configuration:

```yaml
probes:
  - id: '1'
    requests:
      - url: https://github.com
        alerts:
          - assertion: response.size >= 10000 ms
            message: Response size is {{ response.size }}, expecting less than 10000
```

**Expected behavior**
Monika gives a message to inform that alert assertion is invalid and stop. In Symon mode, Monika skips the probe that has invalid alert assertion and continues to run other probe.

**Screenshots**
![b8d8210f-4306-4244-939e-460753f26c88](https://github.com/hyperjumptech/monika/assets/15191978/88f056d2-fb1b-4996-b69c-4c690433339e)

**Desktop (please complete the following information):**

- OS: [e.g. Pop!_OS]
- Version [e.g. 22.04]



Pull request description:
Fix: Validation on Alert Assertion
# Monika Pull Request (PR)

## What feature/issue does this PR add?

Fix alert assertion validation. This will resolves #1177.

## How did you implement / how did you fix it?

- Add some tests.
- Compile alert assertion expression to the data with default value instead of empty array.
- Move manual validation into [Joi](https://github.com/hapijs/joi).

## How to test

### Standalone

Run with the following configuration

```yaml
probes:
  - id: "1"
    requests:
      - url: https://github.com
        alerts:
          - assertion: response.size >= 10000 ms
            message: Response size is {{ response.size }}, expecting less than 10000
```

### Symon

Use `response.size >= 10000 ms` as an alert assertion in a probe.

## Video

### Standalone

https://github.com/hyperjumptech/monika/assets/15191978/1689a58a-be59-41bf-89da-ad872e204204

### Symon mode

https://github.com/hyperjumptech/monika/assets/15191978/4425d36d-ced9-446e-9c09-03933b46e3ec


Patch:
diff --git a/src/components/config/validation/validator/probe.ts b/src/components/config/validation/validator/probe.ts
index 7bc09f44..28ba084b 100644
--- a/src/components/config/validation/validator/probe.ts
+++ b/src/components/config/validation/validator/probe.ts
@@ -30,30 +30,72 @@ import { getContext } from '../../../../context'
 import { FAILED_REQUEST_ASSERTION } from '../../../../looper'
 import { compileExpression } from '../../../../utils/expression-parser'
 import { isValidURL } from '../../../../utils/is-valid-url'
+import { log } from '../../../../utils/pino'
 import {
   DEFAULT_INCIDENT_THRESHOLD,
   DEFAULT_INTERVAL,
   DEFAULT_RECOVERY_THRESHOLD,
 } from './default-values'
 
+const ALERT_QUERY = 'status-not-2xx'
+const RESPONSE_TIME_PREFIX = 'response-time-greater-than-'
+
 export async function validateProbes(probes: Probe[]): Promise<Probe[]> {
   const alertSchema = joi.alternatives().try(
-    joi.string(),
-    joi.object({
-      assertion: joi
-        .string()
-        .allow('')
-        .default((parent) => {
-          if (!parent.query) {
-            return 'response.status < 200 or response.status > 299'
+    // Legacy alerts
+    joi.string().custom((alert) => {
+      if (alert === ALERT_QUERY) {
+        return {
+          id: uuid(),
+          assertion: 'response.status < 200 or response.status > 299',
+          message: 'HTTP status is {{ response.status }}, expecting 2xx',
+        }
+      }
+
+      if (alert.startsWith(RESPONSE_TIME_PREFIX)) {
+        const expectedTime = parseAlertStringTime(alert)
+
+        return {
+          id: uuid(),
+          assertion: `response.time > ${expectedTime}`,
+          message: `Response time is {{ response.time }}ms, expecting less than ${expectedTime}ms`,
+        }
+      }
+
+      throw new Error(`Probe alert format is invalid! (${alert})`)
+    }),
+    joi
+      .object({
+        assertion: joi
+          .string()
+          .allow('')
+          .default((parent) => {
+            if (!parent.query) {
+              return 'response.status < 200 or response.status > 299'
+            }
+
+            return parent.query
+          }),
+        id: joi.string().allow(''),
+        message: joi.string().allow(''),
+        query: joi.string().allow(''),
+      })
+      .custom((alert) => {
+        try {
+          isValidProbeAlert(alert)
+        } catch (error: unknown) {
+          if (isSymonModeFrom(getContext().flags)) {
+            log.error((error as Error)?.message)
+            return ''
           }
 
-          return parent.query
-        }),
-      id: joi.string().allow(''),
-      message: joi.string().allow(''),
-      query: joi.string().allow(''),
-    })
+          throw new Error(
+            `Probe alert format is invalid! (${JSON.stringify(alert, null, 1)})`
+          )
+        }
+
+        return alert
+      })
   )
   const mysqlSchema = joi.object({
     command: joi.string().allow(''),
@@ -74,211 +116,210 @@ export async function validateProbes(probes: Probe[]): Promise<Probe[]> {
       'array.min': 'Probes object does not exists or has length lower than 1!',
     })
     .items(
-      joi.object({
-        alerts: joi
-          .array()
-          .items(alertSchema)
-          .default((parent) => {
-            if (isSymonModeFrom(getContext().flags)) {
-              return []
-            }
-
-            const isHTTPProbe = Boolean(parent.requests)
-
-            if (!isHTTPProbe) {
-              return [{ id: uuid(), ...FAILED_REQUEST_ASSERTION }]
-            }
-
-            return [
-              {
-                id: uuid(),
-                assertion: 'response.status < 200 or response.status > 299',
-                message: 'HTTP Status is {{ response.status }}, expecting 2xx',
-              },
-              {
-                id: uuid(),
-                assertion: 'response.time > 2000',
-                message:
-                  'Response time is {{ response.time }}ms, expecting less than 2000ms',
-              },
-              { id: uuid(), ...FAILED_REQUEST_ASSERTION },
-            ]
-          }),
-        description: joi.string().allow(''),
-        id: joi.string().required(),
-        incidentThreshold: joi
-          .number()
-          .default(DEFAULT_INCIDENT_THRESHOLD)
-          .min(1),
-        recoveryThreshold: joi
-          .number()
-          .default(DEFAULT_RECOVERY_THRESHOLD)
-          .min(1),
-        interval: joi.number().default(DEFAULT_INTERVAL).min(1),
-        lastEvent: joi.object({
-          createdAt: joi.string().allow(''),
-          recoveredAt: joi.string().allow('', null),
-        }),
-        name: joi
-          .string()
-          .allow('')
-          .default((parent) => `monika_${parent.id}`),
-        mariadb: joi.array().items(mysqlSchema),
-        mongo: joi.array().items(
-          joi.alternatives([
-            joi.object({
-              alerts: joi.array().items(alertSchema),
-              uri: joi.string().required(),
-            }),
-            joi.object({
-              alerts: joi.array().items(alertSchema),
-              host: joi
-                .alternatives()
-                .try(
-                  joi.string().allow('').hostname(),
-                  joi.string().allow('').ip()
-                )
-                .required(),
-              password: joi.string().allow(''),
-              port: joi.number().default(27_017).min(0).max(65_536),
-              username: joi.string().allow(''),
+      joi
+        .object({
+          alerts: joi
+            .array()
+            .items(alertSchema)
+            .default((parent) => {
+              if (isSymonModeFrom(getContext().flags)) {
+                return []
+              }
+
+              const isHTTPProbe = Boolean(parent.requests)
+
+              if (!isHTTPProbe) {
+                return [{ id: uuid(), ...FAILED_REQUEST_ASSERTION }]
+              }
+
+              return [
+                {
+                  id: uuid(),
+                  assertion: 'response.status < 200 or response.status > 299',
+                  message:
+                    'HTTP Status is {{ response.status }}, expecting 2xx',
+                },
+                {
+                  id: uuid(),
+                  assertion: 'response.time > 2000',
+                  message:
+                    'Response time is {{ response.time }}ms, expecting less than 2000ms',
+                },
+                { id: uuid(), ...FAILED_REQUEST_ASSERTION },
+              ]
             }),
-          ])
-        ),
-        mysql: joi.array().items(mysqlSchema),
-        ping: joi.array().items(
-          joi.object({
-            alerts: joi.array().items(alertSchema),
-            uri: joi.string().required(),
-          })
-        ),
-        postgres: joi.array().items(
-          joi.alternatives([
+          description: joi.string().allow(''),
+          id: joi.string().required(),
+          incidentThreshold: joi
+            .number()
+            .default(DEFAULT_INCIDENT_THRESHOLD)
+            .min(1),
+          recoveryThreshold: joi
+            .number()
+            .default(DEFAULT_RECOVERY_THRESHOLD)
+            .min(1),
+          interval: joi.number().default(DEFAULT_INTERVAL).min(1),
+          lastEvent: joi.object({
+            createdAt: joi.string().allow(''),
+            recoveredAt: joi.string().allow('', null),
+          }),
+          name: joi
+            .string()
+            .allow('')
+            .default((parent) => `monika_${parent.id}`),
+          mariadb: joi.array().items(mysqlSchema),
+          mongo: joi.array().items(
+            joi.alternatives([
+              joi.object({
+                alerts: joi.array().items(alertSchema),
+                uri: joi.string().required(),
+              }),
+              joi.object({
+                alerts: joi.array().items(alertSchema),
+                host: joi
+                  .alternatives()
+                  .try(
+                    joi.string().allow('').hostname(),
+                    joi.string().allow('').ip()
+                  )
+                  .required(),
+                password: joi.string().allow(''),
+                port: joi.number().default(27_017).min(0).max(65_536),
+                username: joi.string().allow(''),
+              }),
+            ])
+          ),
+          mysql: joi.array().items(mysqlSchema),
+          ping: joi.array().items(
             joi.object({
               alerts: joi.array().items(alertSchema),
               uri: joi.string().required(),
-            }),
-            joi.object({
-              alerts: joi.array().items(alertSchema),
-              command: joi.string().allow(''),
-              data: joi
-                .alternatives()
-                .try(joi.string().allow(''), joi.number()),
-              database: joi.string().allow(''),
-              host: joi
-                .alternatives()
-                .try(
-                  joi.string().allow('').hostname(),
-                  joi.string().allow('').ip()
-                )
-                .required(),
-              password: joi.string().allow(''),
-              port: joi.number().default(5432),
-              username: joi.string().allow(''),
-            }),
-          ])
-        ),
-        redis: joi.array().items(
-          joi
-            .object({
-              alerts: joi.array().items(alertSchema),
-              command: joi.string().allow(''),
-              host: joi
-                .alternatives()
-                .try(
-                  joi.string().allow('').hostname(),
-                  joi.string().allow('').ip()
-                ),
-              password: joi.string().allow(''),
-              port: joi.number().min(0).max(65_536),
-              uri: joi.string().allow(''),
-              username: joi.string().allow(''),
             })
-            .xor('host', 'uri')
-            .and('host', 'port')
-        ),
-        requests: joi
-          .array()
-          .min(isSymonModeFrom(getContext().flags) ? 0 : 1)
-          .items(
-            joi.object({
-              alerts: joi.array().items(alertSchema),
-              allowUnauthorized: joi.bool(),
-              body: joi
-                .alternatives()
-                .try(
-                  joi.string().allow('', null),
-                  joi.object(),
-                  joi.array(),
-                  joi.number(),
-                  joi.bool()
-                ),
-              headers: joi.object().allow(null),
-              id: joi.string().allow(''),
-              interval: joi.number().min(1),
-              method: joi
-                .string()
-                .valid(
-                  'CONNECT',
-                  'GET',
-                  'POST',
-                  'PUT',
-                  'PATCH',
-                  'DELETE',
-                  'HEAD',
-                  'OPTIONS',
-                  'PURGE',
-                  'LINK',
-                  'TRACE',
-                  'UNLINK'
-                )
-                .default('GET')
-                .insensitive()
-                .label('Probe request method'),
-              ping: joi.bool(),
-              saveBody: joi.bool().default(false),
-              timeout: joi.number().default(10_000).min(1).allow(null),
-              url: joi
-                .string()
-                .custom((url) => {
-                  if (!isValidURL(url)) {
-                    throw new Error(
-                      `Probe request URL (${url}) should start with http:// or https://`
-                    )
-                  }
+          ),
+          postgres: joi.array().items(
+            joi.alternatives([
+              joi.object({
+                alerts: joi.array().items(alertSchema),
+                uri: joi.string().required(),
+              }),
+              joi.object({
+                alerts: joi.array().items(alertSchema),
+                command: joi.string().allow(''),
+                data: joi
+                  .alternatives()
+                  .try(joi.string().allow(''), joi.number()),
+                database: joi.string().allow(''),
+                host: joi
+                  .alternatives()
+                  .try(
+                    joi.string().allow('').hostname(),
+                    joi.string().allow('').ip()
+                  )
+                  .required(),
+                password: joi.string().allow(''),
+                port: joi.number().default(5432),
+                username: joi.string().allow(''),
+              }),
+            ])
+          ),
+          redis: joi.array().items(
+            joi
+              .object({
+                alerts: joi.array().items(alertSchema),
+                command: joi.string().allow(''),
+                host: joi
+                  .alternatives()
+                  .try(
+                    joi.string().allow('').hostname(),
+                    joi.string().allow('').ip()
+                  ),
+                password: joi.string().allow(''),
+                port: joi.number().min(0).max(65_536),
+                uri: joi.string().allow(''),
+                username: joi.string().allow(''),
+              })
+              .xor('host', 'uri')
+              .and('host', 'port')
+          ),
+          requests: joi
+            .array()
+            .min(isSymonModeFrom(getContext().flags) ? 0 : 1)
+            .items(
+              joi.object({
+                alerts: joi.array().items(alertSchema),
+                allowUnauthorized: joi.bool(),
+                body: joi
+                  .alternatives()
+                  .try(
+                    joi.string().allow('', null),
+                    joi.object(),
+                    joi.array(),
+                    joi.number(),
+                    joi.bool()
+                  ),
+                headers: joi.object().allow(null),
+                id: joi.string().allow(''),
+                interval: joi.number().min(1),
+                method: joi
+                  .string()
+                  .valid(
+                    'CONNECT',
+                    'GET',
+                    'POST',
+                    'PUT',
+                    'PATCH',
+                    'DELETE',
+                    'HEAD',
+                    'OPTIONS',
+                    'PURGE',
+                    'LINK',
+                    'TRACE',
+                    'UNLINK'
+                  )
+                  .default('GET')
+                  .insensitive()
+                  .label('Probe request method'),
+                ping: joi.bool(),
+                saveBody: joi.bool().default(false),
+                timeout: joi.number().default(10_000).min(1).allow(null),
+                url: joi
+                  .string()
+                  .custom((url) => {
+                    if (!isValidURL(url)) {
+                      throw new Error(
+                        `Probe request URL (${url}) should start with http:// or https://`
+                      )
+                    }
+
+                    return url
+                  })
+                  .label('Probe request URL')
+                  .required(),
+              })
+            )
+            .label('Probe requests'),
+          socket: joi.object({
+            alerts: joi.array().items(alertSchema),
+            data: joi.string().allow('', null),
+            host: joi.string().required(),
+            port: joi.number().required(),
+          }),
+        })
+        .custom((probe) => {
+          if (!isProbeRequestExists(probe)) {
+            throw new Error(
+              'Probe requests does not exists or has length lower than 1!'
+            )
+          }
 
-                  return url
-                })
-                .label('Probe request URL')
-                .required(),
-            })
-          )
-          .label('Probe requests'),
-        socket: joi.object({
-          alerts: joi.array().items(alertSchema),
-          data: joi.string().allow('', null),
-          host: joi.string().required(),
-          port: joi.number().required(),
-        }),
-      })
+          return probe
+        })
     )
 
   try {
-    const validatedProbes = await schema.validateAsync(probes, {
+    return await schema.validateAsync(probes, {
       stripUnknown: true,
     })
-
-    for (const probe of validatedProbes) {
-      if (!isProbeRequestExists(probe)) {
-        throw new Error(
-          'Probe requests does not exists or has length lower than 1!'
-        )
-      }
-
-      validateAlerts(combineAlerts(probe))
-    }
-
-    return transformDeprecatedAlerts(validatedProbes)
   } catch (error: any) {
     throw new Error(`Monika configuration is invalid. Probe: ${error?.message}`)
   }
@@ -297,39 +338,6 @@ function isProbeRequestExists(probe: Probe) {
   )
 }
 
-function combineAlerts(probe: Probe) {
-  const httpAlerts =
-    probe?.requests?.map(({ alerts }) => alerts).find(Boolean) || []
-  const mariadbAlerts =
-    probe?.mariadb?.map(({ alerts }) => alerts).find(Boolean) || []
-  const mongoAlerts =
-    probe?.mongo?.map(({ alerts }) => alerts).find(Boolean) || []
-  const mysqlAlerts =
-    probe?.mysql?.map(({ alerts }) => alerts).find(Boolean) || []
-  const pingAlerts =
-    probe?.ping?.map(({ alerts }) => alerts).find(Boolean) || []
-  const postgresAlerts =
-    probe?.postgres?.map(({ alerts }) => alerts).find(Boolean) || []
-  const redisAlerts =
-    probe?.redis?.map(({ alerts }) => alerts).find(Boolean) || []
-  const socketAlerts = probe?.socket?.alerts || []
-
-  return [
-    ...(probe?.alerts || []),
-    ...httpAlerts,
-    ...mariadbAlerts,
-    ...mongoAlerts,
-    ...mysqlAlerts,
-    ...pingAlerts,
-    ...postgresAlerts,
-    ...redisAlerts,
-    ...socketAlerts,
-  ]
-}
-
-const ALERT_QUERY = 'status-not-2xx'
-const RESPONSE_TIME_PREFIX = 'response-time-greater-than-'
-
 // parse string like "response-time-greater-than-200-ms" and return the time in ms
 function parseAlertStringTime(str: string): number {
   // match any string that ends with digits followed by unit 's' or 'ms'
@@ -347,69 +355,22 @@ function parseAlertStringTime(str: string): number {
   return number
 }
 
-function validateAlerts(alerts: (ProbeAlert | string)[]) {
-  for (const alert of alerts) {
-    try {
-      isValidProbeAlert(alert)
-    } catch {
-      throw new Error(`Probe alert format is invalid! (${alert})`)
-    }
-  }
-}
-
-function isValidProbeAlert(alert: ProbeAlert | string) {
-  if (typeof alert !== 'string') {
-    compileExpression(alert.assertion || alert.query || '')
-    return
-  }
-
-  // Legacy alerts
-  if (alert === ALERT_QUERY) {
+function isValidProbeAlert(alert: ProbeAlert) {
+  const expression = alert.assertion || alert.query
+  if (!expression) {
     return
   }
 
-  if (alert.startsWith(RESPONSE_TIME_PREFIX)) {
-    parseAlertStringTime(alert)
-    return
+  const data = {
+    response: {
+      size: 0,
+      status: 200,
+      time: 0,
+      body: '',
+      headers: {},
+    },
   }
+  const filter = compileExpression(expression, Object.keys(data))
 
-  throw new Error('Unknown alert')
-}
-
-function transformDeprecatedAlerts(probes: Probe[]) {
-  return probes.map((probe) => {
-    const newAlerts = []
-
-    if (!probe?.alerts) {
-      return probe
-    }
-
-    for (const alert of probe.alerts) {
-      if (typeof alert !== 'string') {
-        newAlerts.push(alert)
-        continue
-      }
-
-      if (alert === ALERT_QUERY) {
-        newAlerts.push({
-          id: uuid(),
-          assertion: 'response.status < 200 or response.status > 299',
-          message: 'HTTP status is {{ response.status }}, expecting 2xx',
-        })
-        continue
-      }
-
-      if ((alert as string).startsWith(RESPONSE_TIME_PREFIX)) {
-        const expectedTime = parseAlertStringTime(alert)
-
-        newAlerts.push({
-          id: uuid(),
-          assertion: `response.time > ${expectedTime}`,
-          message: `Response time is {{ response.time }}ms, expecting less than ${expectedTime}ms`,
-        })
-      }
-    }
-
-    return { ...probe, alerts: newAlerts }
-  })
+  filter(data)
 }


Test patch:
diff --git a/src/components/config/validation/validator/probe.test.ts b/src/components/config/validation/validator/probe.test.ts
index e801ec56..231604b0 100644
--- a/src/components/config/validation/validator/probe.test.ts
+++ b/src/components/config/validation/validator/probe.test.ts
@@ -28,6 +28,8 @@ import type { Probe } from '../../../../interfaces/probe'
 
 import { FAILED_REQUEST_ASSERTION } from '../../../../looper'
 import { validateProbes } from './probe'
+import { resetContext, setContext } from '../../../../../src/context'
+import type { MonikaFlags } from '../../../../../src/flag'
 
 describe('Probe validation', () => {
   describe('Probe sanitization', () => {
@@ -156,5 +158,60 @@ describe('Probe validation', () => {
       expect(result[0].alerts[0].assertion).eq('')
       expect(result[0].alerts[0].message).eq(FAILED_REQUEST_ASSERTION.message)
     })
+
+    it('should throws an error if alert assertion is invalid', () => {
+      // arrange
+      const probe = {
+        id: 'Example',
+        requests: [{ url: 'https://example.com' }],
+        alerts: [{ assertion: 'response.time > 1000 ms' }],
+      } as unknown as Probe
+
+      // act & assert
+      expect(validateProbes([probe])).to.eventually.throw()
+    })
+
+    it('should set the alerts empty if the probe if alert assertion is invalid in Symon mode', async () => {
+      // arrange
+      setContext({
+        flags: {
+          symonKey: 'bDF8j',
+          symonUrl: 'https://example.com',
+        } as MonikaFlags,
+      })
+      const probes = [
+        {
+          id: 'Example',
+          requests: [{ url: 'https://example.com' }],
+          alerts: [{ assertion: 'response.time > 1000 ms' }],
+        },
+        {
+          id: 'Example 2',
+          requests: [{ url: 'https://example.com' }],
+          alerts: [{ assertion: 'response.time > 1000' }],
+        },
+        {
+          id: 'Example 3',
+          requests: [{ url: 'https://example.com' }],
+          alerts: [{ assertion: 'response.status == 200' }],
+        },
+      ] as unknown as Probe[]
+
+      // act
+      const validatedProbes = await validateProbes(probes)
+
+      // assert
+      expect(
+        validatedProbes.find(({ id }) => id === 'Example')?.alerts
+      ).deep.eq([''])
+      expect(
+        validatedProbes.find(({ id }) => id === 'Example 2')?.alerts
+      ).deep.eq([{ assertion: 'response.time > 1000' }])
+      expect(
+        validatedProbes.find(({ id }) => id === 'Example 3')?.alerts
+      ).deep.eq([{ assertion: 'response.status == 200' }])
+
+      resetContext()
+    })
   })
 })