Repository: hyperjumptech/monika (ts)

Issue:
Failed to read assertion Error
**Describe the bug**
Getting some error message "Failed request assertion is not found" when probing.

**To Reproduce**
Steps to reproduce the behavior:
1. Use the following config.yaml
```yaml
probes:
  - id: 'http-1'
    name: 'mock-test'
    requests:
      - url: http://0.0.0.0:7001/v1/hello
    alerts:
      - assertion: response.status < 200 or response.status > 308
        message: HTTP Status is not 200
      - assertion: response.ime > 2000
        message: Too sloow
```

2. Start probing using: `npm start -- -c config.yaml`
3. Start your local mock server
4. Then disable/turn off the mock server

**Expected behavior**
1. Assertion exist, should have no problem finding it even when connection is refused

**Screenshots**
![image](https://github.com/user-attachments/assets/332a31a2-c5c7-499a-b651-eff2a7f59114)

**Desktop (please complete the following information):**

- OS: WIndows 11 and WSL (Ubuntu 24.04 LTS)
- Lateest monika from main branch

**Additional context**
Seems to appear when disconnected/ERCONNREFUSED error


Pull request description:
fix: failed request assertion not found
# Monika Pull Request (PR)

## What feature/issue does this PR add

This PR resolves #1330 

## How did you implement / how did you fix it

1. Move probe handling logic from monika.ts to config component
2. Add probe sanitization during config validation
3. Add tests for config sanitization
4. Make failed request assertion ID deterministic

## How to test

1. Create following config

```
probes:
  - id: 'http-1'
    name: 'status-401-test'
    requests:
      - url: https://httpbin.org/status/401
        method: PATCH
    alerts:
      - assertion: response.status < 200 or response.status > 308
        message: HTTP Status is not 200
      - assertion: response.ime > 2000
        message: Too sloow
```

2. `npm run start -- --config monika.yml`
3. Observe log `WARN: 2025-01-05T10:30:46.044Z 1 id:http-1 ERROR: ECONNREFUSED: Attempted to connect to a server, but the server declined the connection. Ensure the server is running and accepting connections., NOTIF: Service probably down`
4. Observe desktop notification "Probe is not accessible"


Patch:
diff --git a/src/commands/monika.ts b/src/commands/monika.ts
index 1f3ac72f..ede432b2 100644
--- a/src/commands/monika.ts
+++ b/src/commands/monika.ts
@@ -25,7 +25,6 @@
 import { Command, Errors } from '@oclif/core'
 import pEvent from 'p-event'
 import type { ValidatedConfig } from '../interfaces/config'
-import type { Probe } from '../interfaces/probe'
 
 import {
   getValidatedConfig,
@@ -33,7 +32,6 @@ import {
   initConfig,
 } from '../components/config'
 import { createConfig } from '../components/config/create'
-import { sortProbes } from '../components/config/sort'
 import { printAllLogs } from '../components/logger'
 import { flush } from '../components/logger/flush'
 import { closeLog } from '../components/logger/history'
@@ -43,10 +41,10 @@ import { sendMonikaStartMessage } from '../components/notification/start-message
 import { printSummary } from '../components/summary'
 import { getContext, setContext } from '../context'
 import events from '../events'
-import { type MonikaFlags, sanitizeFlags, flags } from '../flag'
+import { sanitizeFlags, flags } from '../flag'
 import { savePidFile } from '../jobs/summary-notification'
 import initLoaders from '../loaders'
-import { sanitizeProbe, startProbing } from '../looper'
+import { startProbing } from '../looper'
 import SymonClient from '../symon'
 import { getEventEmitter } from '../utils/events'
 import { log } from '../utils/pino'
@@ -57,6 +55,7 @@ import {
   close as closeSentry,
   flush as flushSentry,
 } from '@sentry/node'
+import { getProbes } from '../components/config/probe'
 
 const em = getEventEmitter()
 let symonClient: SymonClient
@@ -165,7 +164,7 @@ export default class Monika extends Command {
 
       for (;;) {
         const config = getValidatedConfig()
-        const probes = getProbes({ config, flags })
+        const probes = getProbes()
 
         // emit the sanitized probe
         em.emit(events.config.sanitized, probes)
@@ -238,19 +237,6 @@ async function logRunningInfo({ isVerbose, isSymonMode }: RunningInfoParams) {
   }
 }
 
-type GetProbesParams = {
-  config: ValidatedConfig
-  flags: MonikaFlags
-}
-
-function getProbes({ config, flags }: GetProbesParams): Probe[] {
-  const sortedProbes = sortProbes(config.probes, flags.id)
-
-  return sortedProbes.map((probe: Probe) =>
-    sanitizeProbe(isSymonModeFrom(flags), probe)
-  )
-}
-
 function deprecationHandler(config: ValidatedConfig): ValidatedConfig {
   const showDeprecateMsg: Record<'query', boolean> = {
     query: false,
diff --git a/src/components/config/sanitize.ts b/src/components/config/sanitize.ts
index e5604fc2..a54a0c72 100644
--- a/src/components/config/sanitize.ts
+++ b/src/components/config/sanitize.ts
@@ -4,14 +4,19 @@ import type {
   Config,
   ValidatedConfig,
 } from '../../interfaces/config'
+import { sanitizeProbe } from '../../looper'
+import { isSymonModeFrom } from '.'
+import { getContext } from '../../context'
 
 const DEFAULT_TLS_EXPIRY_REMINDER_DAYS = 30
 const DEFAULT_STATUS_NOTIFICATION = '0 6 * * *'
 
 export function sanitizeConfig(config: Config): ValidatedConfig {
-  const { certificate, notifications = [], version } = config
-  const sanitizedConfigWithoutVersion = {
+  const isSymonMode = isSymonModeFrom(getContext().flags)
+  const { certificate, notifications = [], version, probes } = config
+  const sanitizedConfigWithoutVersion: Omit<ValidatedConfig, 'version'> = {
     ...config,
+    probes: probes.map((probe) => sanitizeProbe(isSymonMode, probe)),
     certificate: sanitizeCertificate(certificate),
     notifications,
     'status-notification':
diff --git a/src/looper/index.ts b/src/looper/index.ts
index b2c04b35..6a2d9fed 100644
--- a/src/looper/index.ts
+++ b/src/looper/index.ts
@@ -79,10 +79,15 @@ export const FAILED_REQUEST_ASSERTION = {
 }
 
 function addFailedRequestAssertions(assertions: ProbeAlert[]) {
+  // create uuid seed from FAILED_REQUEST_ASSERTION
+  // to make the id deterministic, consistent, and testable
+  const idSeed = Buffer.from(JSON.stringify(FAILED_REQUEST_ASSERTION))
   return [
     ...assertions,
     {
-      id: uuid(),
+      id: uuid({
+        random: idSeed,
+      }),
       ...FAILED_REQUEST_ASSERTION,
     },
   ]


Test patch:
diff --git a/src/components/config/index.test.ts b/src/components/config/index.test.ts
index f2ce7a1d..56026472 100644
--- a/src/components/config/index.test.ts
+++ b/src/components/config/index.test.ts
@@ -308,3 +308,98 @@ describe('Setup Config', () => {
     ).not.undefined
   })
 })
+
+describe('sanitizeConfig', () => {
+  beforeEach(() => {
+    resetContext()
+  })
+
+  it('should sanitize probes in normal mode', () => {
+    // arrange
+    const config = {
+      probes: [
+        {
+          id: '1',
+          name: 'Test probe',
+          interval: 1000,
+          requests: [
+            {
+              url: 'https://example.com',
+              body: '',
+              followRedirects: 21,
+              timeout: 1000,
+            },
+          ],
+          alerts: [],
+        },
+      ],
+    }
+
+    // act
+    const result = sanitizeConfig(config)
+
+    // assert
+    expect(result.probes[0].alerts).to.have.lengthOf(1)
+    expect(result.probes[0].alerts[0]).to.include({
+      assertion: '',
+      message: 'Probe not accessible',
+    })
+  })
+
+  it('should sanitize probes in Symon mode', () => {
+    // arrange
+    setContext({
+      flags: sanitizeFlags({
+        symonKey: 'test-key',
+        symonUrl: 'https://example.com',
+      }),
+    })
+    const config = {
+      probes: [
+        {
+          id: '1',
+          name: 'Test probe',
+          interval: 30_000,
+          requests: [
+            {
+              url: 'https://example.com',
+              body: '',
+              followRedirects: 21,
+              timeout: 1000,
+            },
+          ],
+          alerts: [],
+        },
+      ],
+    }
+
+    // act
+    const result = sanitizeConfig(config)
+
+    // assert
+    expect(result.probes[0].alerts).to.have.lengthOf(0)
+    expect(result.probes[0].id).to.equal('1')
+    expect(result.probes[0].interval).to.equal(30_000)
+  })
+
+  it('should preserve existing config properties', () => {
+    // arrange
+    const config: Config = {
+      version: '1.0.0',
+      notifications: [{ id: 'test-1', type: 'smtp' }],
+      certificate: {
+        domains: ['example.com'],
+        reminder: 15,
+      },
+      probes: [],
+    }
+
+    // act
+    const result = sanitizeConfig(config)
+
+    // assert
+    expect(result.version).to.equal('1.0.0')
+    expect(result.notifications).to.deep.equal([{ id: 'test-1', type: 'smtp' }])
+    expect(result.certificate?.reminder).to.equal(15)
+  })
+})