iec-builder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +111 -0
- package/.iec.yaml +5 -0
- package/CLAUDE.md +174 -0
- package/Dockerfile +34 -0
- package/README.md +84 -0
- package/catalog-info.yaml +11 -0
- package/dist/config/env.d.ts +219 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +89 -0
- package/dist/config/env.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +43 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +217 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/org-access.d.ts +28 -0
- package/dist/middleware/org-access.d.ts.map +1 -0
- package/dist/middleware/org-access.js +102 -0
- package/dist/middleware/org-access.js.map +1 -0
- package/dist/models/types.d.ts +254 -0
- package/dist/models/types.d.ts.map +1 -0
- package/dist/models/types.js +2 -0
- package/dist/models/types.js.map +1 -0
- package/dist/routes/ai.d.ts +2 -0
- package/dist/routes/ai.d.ts.map +1 -0
- package/dist/routes/ai.js +77 -0
- package/dist/routes/ai.js.map +1 -0
- package/dist/routes/audit.d.ts +2 -0
- package/dist/routes/audit.d.ts.map +1 -0
- package/dist/routes/audit.js +102 -0
- package/dist/routes/audit.js.map +1 -0
- package/dist/routes/builds.d.ts +2 -0
- package/dist/routes/builds.d.ts.map +1 -0
- package/dist/routes/builds.js +262 -0
- package/dist/routes/builds.js.map +1 -0
- package/dist/routes/cluster.d.ts +2 -0
- package/dist/routes/cluster.d.ts.map +1 -0
- package/dist/routes/cluster.js +181 -0
- package/dist/routes/cluster.js.map +1 -0
- package/dist/routes/config.d.ts +2 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +291 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/databases.d.ts +2 -0
- package/dist/routes/databases.d.ts.map +1 -0
- package/dist/routes/databases.js +161 -0
- package/dist/routes/databases.js.map +1 -0
- package/dist/routes/db-whitelist.d.ts +2 -0
- package/dist/routes/db-whitelist.d.ts.map +1 -0
- package/dist/routes/db-whitelist.js +148 -0
- package/dist/routes/db-whitelist.js.map +1 -0
- package/dist/routes/domains.d.ts +2 -0
- package/dist/routes/domains.d.ts.map +1 -0
- package/dist/routes/domains.js +449 -0
- package/dist/routes/domains.js.map +1 -0
- package/dist/routes/oauth.d.ts +2 -0
- package/dist/routes/oauth.d.ts.map +1 -0
- package/dist/routes/oauth.js +180 -0
- package/dist/routes/oauth.js.map +1 -0
- package/dist/routes/observability.d.ts +2 -0
- package/dist/routes/observability.d.ts.map +1 -0
- package/dist/routes/observability.js +167 -0
- package/dist/routes/observability.js.map +1 -0
- package/dist/routes/orgs.d.ts +2 -0
- package/dist/routes/orgs.d.ts.map +1 -0
- package/dist/routes/orgs.js +270 -0
- package/dist/routes/orgs.js.map +1 -0
- package/dist/routes/platform.d.ts +2 -0
- package/dist/routes/platform.d.ts.map +1 -0
- package/dist/routes/platform.js +107 -0
- package/dist/routes/platform.js.map +1 -0
- package/dist/routes/push.d.ts +2 -0
- package/dist/routes/push.d.ts.map +1 -0
- package/dist/routes/push.js +233 -0
- package/dist/routes/push.js.map +1 -0
- package/dist/routes/rotation.d.ts +3 -0
- package/dist/routes/rotation.d.ts.map +1 -0
- package/dist/routes/rotation.js +154 -0
- package/dist/routes/rotation.js.map +1 -0
- package/dist/routes/services.d.ts +2 -0
- package/dist/routes/services.d.ts.map +1 -0
- package/dist/routes/services.js +246 -0
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/storage.d.ts +2 -0
- package/dist/routes/storage.d.ts.map +1 -0
- package/dist/routes/storage.js +118 -0
- package/dist/routes/storage.js.map +1 -0
- package/dist/routes/users.d.ts +2 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +183 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/routes/versions.d.ts +2 -0
- package/dist/routes/versions.d.ts.map +1 -0
- package/dist/routes/versions.js +195 -0
- package/dist/routes/versions.js.map +1 -0
- package/dist/routes/webhooks.d.ts +2 -0
- package/dist/routes/webhooks.d.ts.map +1 -0
- package/dist/routes/webhooks.js +334 -0
- package/dist/routes/webhooks.js.map +1 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts +2 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts.map +1 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.js +482 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.js.map +1 -0
- package/dist/services/bio-client.d.ts +68 -0
- package/dist/services/bio-client.d.ts.map +1 -0
- package/dist/services/bio-client.js +110 -0
- package/dist/services/bio-client.js.map +1 -0
- package/dist/services/build-queue.d.ts +7 -0
- package/dist/services/build-queue.d.ts.map +1 -0
- package/dist/services/build-queue.js +114 -0
- package/dist/services/build-queue.js.map +1 -0
- package/dist/services/builder.d.ts +7 -0
- package/dist/services/builder.d.ts.map +1 -0
- package/dist/services/builder.js +1384 -0
- package/dist/services/builder.js.map +1 -0
- package/dist/services/catalog.d.ts +177 -0
- package/dist/services/catalog.d.ts.map +1 -0
- package/dist/services/catalog.js +805 -0
- package/dist/services/catalog.js.map +1 -0
- package/dist/services/catalog.test.d.ts +2 -0
- package/dist/services/catalog.test.d.ts.map +1 -0
- package/dist/services/catalog.test.js +467 -0
- package/dist/services/catalog.test.js.map +1 -0
- package/dist/services/cloudflare.d.ts +43 -0
- package/dist/services/cloudflare.d.ts.map +1 -0
- package/dist/services/cloudflare.js +182 -0
- package/dist/services/cloudflare.js.map +1 -0
- package/dist/services/config-validator.d.ts +28 -0
- package/dist/services/config-validator.d.ts.map +1 -0
- package/dist/services/config-validator.js +68 -0
- package/dist/services/config-validator.js.map +1 -0
- package/dist/services/config-validator.test.d.ts +2 -0
- package/dist/services/config-validator.test.d.ts.map +1 -0
- package/dist/services/config-validator.test.js +151 -0
- package/dist/services/config-validator.test.js.map +1 -0
- package/dist/services/crypto.d.ts +19 -0
- package/dist/services/crypto.d.ts.map +1 -0
- package/dist/services/crypto.js +63 -0
- package/dist/services/crypto.js.map +1 -0
- package/dist/services/database.d.ts +26 -0
- package/dist/services/database.d.ts.map +1 -0
- package/dist/services/database.js +100 -0
- package/dist/services/database.js.map +1 -0
- package/dist/services/db-credential-manager.d.ts +73 -0
- package/dist/services/db-credential-manager.d.ts.map +1 -0
- package/dist/services/db-credential-manager.js +342 -0
- package/dist/services/db-credential-manager.js.map +1 -0
- package/dist/services/db-provisioner.d.ts +57 -0
- package/dist/services/db-provisioner.d.ts.map +1 -0
- package/dist/services/db-provisioner.js +400 -0
- package/dist/services/db-provisioner.js.map +1 -0
- package/dist/services/db-provisioner.test.d.ts +2 -0
- package/dist/services/db-provisioner.test.d.ts.map +1 -0
- package/dist/services/db-provisioner.test.js +141 -0
- package/dist/services/db-provisioner.test.js.map +1 -0
- package/dist/services/db-whitelist.d.ts +58 -0
- package/dist/services/db-whitelist.d.ts.map +1 -0
- package/dist/services/db-whitelist.js +379 -0
- package/dist/services/db-whitelist.js.map +1 -0
- package/dist/services/dependency-resolver.d.ts +58 -0
- package/dist/services/dependency-resolver.d.ts.map +1 -0
- package/dist/services/dependency-resolver.js +180 -0
- package/dist/services/dependency-resolver.js.map +1 -0
- package/dist/services/dependency-resolver.test.d.ts +2 -0
- package/dist/services/dependency-resolver.test.d.ts.map +1 -0
- package/dist/services/dependency-resolver.test.js +195 -0
- package/dist/services/dependency-resolver.test.js.map +1 -0
- package/dist/services/deploy-gate.d.ts +19 -0
- package/dist/services/deploy-gate.d.ts.map +1 -0
- package/dist/services/deploy-gate.js +56 -0
- package/dist/services/deploy-gate.js.map +1 -0
- package/dist/services/deploy-gate.test.d.ts +2 -0
- package/dist/services/deploy-gate.test.d.ts.map +1 -0
- package/dist/services/deploy-gate.test.js +199 -0
- package/dist/services/deploy-gate.test.js.map +1 -0
- package/dist/services/dockerfile-generator.d.ts +31 -0
- package/dist/services/dockerfile-generator.d.ts.map +1 -0
- package/dist/services/dockerfile-generator.js +544 -0
- package/dist/services/dockerfile-generator.js.map +1 -0
- package/dist/services/dockerfile-generator.test.d.ts +2 -0
- package/dist/services/dockerfile-generator.test.d.ts.map +1 -0
- package/dist/services/dockerfile-generator.test.js +144 -0
- package/dist/services/dockerfile-generator.test.js.map +1 -0
- package/dist/services/forgejo.d.ts +58 -0
- package/dist/services/forgejo.d.ts.map +1 -0
- package/dist/services/forgejo.js +131 -0
- package/dist/services/forgejo.js.map +1 -0
- package/dist/services/koko.d.ts +153 -0
- package/dist/services/koko.d.ts.map +1 -0
- package/dist/services/koko.js +260 -0
- package/dist/services/koko.js.map +1 -0
- package/dist/services/kubernetes.d.ts +16 -0
- package/dist/services/kubernetes.d.ts.map +1 -0
- package/dist/services/kubernetes.js +102 -0
- package/dist/services/kubernetes.js.map +1 -0
- package/dist/services/oauth-provisioner.d.ts +30 -0
- package/dist/services/oauth-provisioner.d.ts.map +1 -0
- package/dist/services/oauth-provisioner.js +182 -0
- package/dist/services/oauth-provisioner.js.map +1 -0
- package/dist/services/oauth-provisioner.test.d.ts +2 -0
- package/dist/services/oauth-provisioner.test.d.ts.map +1 -0
- package/dist/services/oauth-provisioner.test.js +349 -0
- package/dist/services/oauth-provisioner.test.js.map +1 -0
- package/dist/services/pod-diagnostics.d.ts +11 -0
- package/dist/services/pod-diagnostics.d.ts.map +1 -0
- package/dist/services/pod-diagnostics.js +201 -0
- package/dist/services/pod-diagnostics.js.map +1 -0
- package/dist/services/rotation-scheduler.d.ts +2 -0
- package/dist/services/rotation-scheduler.d.ts.map +1 -0
- package/dist/services/rotation-scheduler.js +215 -0
- package/dist/services/rotation-scheduler.js.map +1 -0
- package/dist/services/storage-credential-manager.d.ts +43 -0
- package/dist/services/storage-credential-manager.d.ts.map +1 -0
- package/dist/services/storage-credential-manager.js +159 -0
- package/dist/services/storage-credential-manager.js.map +1 -0
- package/dist/services/storage-provisioner.d.ts +32 -0
- package/dist/services/storage-provisioner.d.ts.map +1 -0
- package/dist/services/storage-provisioner.js +136 -0
- package/dist/services/storage-provisioner.js.map +1 -0
- package/dist/services/storage.d.ts +65 -0
- package/dist/services/storage.d.ts.map +1 -0
- package/dist/services/storage.js +204 -0
- package/dist/services/storage.js.map +1 -0
- package/dist/services/troubleshooter.d.ts +22 -0
- package/dist/services/troubleshooter.d.ts.map +1 -0
- package/dist/services/troubleshooter.js +168 -0
- package/dist/services/troubleshooter.js.map +1 -0
- package/dist/services/vault-client.d.ts +114 -0
- package/dist/services/vault-client.d.ts.map +1 -0
- package/dist/services/vault-client.js +411 -0
- package/dist/services/vault-client.js.map +1 -0
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +6 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/response.d.ts +13 -0
- package/dist/utils/response.d.ts.map +1 -0
- package/dist/utils/response.js +12 -0
- package/dist/utils/response.js.map +1 -0
- package/docs/registry-migration.md +301 -0
- package/docs/registry-quickstart.md +169 -0
- package/ecosystem.config.cjs +14 -0
- package/findings.md +168 -0
- package/helm/default-service/Chart.yaml +6 -0
- package/helm/default-service/templates/deployment.yaml +97 -0
- package/helm/default-service/templates/ingress.yaml +43 -0
- package/helm/default-service/templates/service.yaml +17 -0
- package/helm/default-service/values.yaml +82 -0
- package/helm/services/iec-builder/Chart.yaml +6 -0
- package/helm/services/iec-builder/templates/_helpers.tpl +61 -0
- package/helm/services/iec-builder/templates/deployment.yaml +73 -0
- package/helm/services/iec-builder/templates/service.yaml +15 -0
- package/helm/services/iec-builder/templates/serviceaccount.yaml +12 -0
- package/helm/services/iec-builder/values.yaml +56 -0
- package/helm/vault-values.yaml +127 -0
- package/package.json +45 -0
- package/progress.md +156 -0
- package/scripts/.vault-init-keys.json +23 -0
- package/scripts/backfill-ownership.ts +113 -0
- package/scripts/finalize-mongo-auth.sh +212 -0
- package/scripts/setup-ipset.sh +107 -0
- package/scripts/setup-mongo-auth.sh +163 -0
- package/scripts/setup-neo4j-auth.sh +62 -0
- package/scripts/setup-redis-auth.sh +55 -0
- package/scripts/setup-registry-secret.sh +71 -0
- package/scripts/setup-vault.sh +308 -0
- package/src/config/env.ts +117 -0
- package/src/index.ts +153 -0
- package/src/middleware/auth.ts +294 -0
- package/src/middleware/org-access.ts +126 -0
- package/src/models/types.ts +288 -0
- package/src/routes/ai.ts +115 -0
- package/src/routes/audit.ts +121 -0
- package/src/routes/builds.ts +320 -0
- package/src/routes/cluster.ts +235 -0
- package/src/routes/config.ts +369 -0
- package/src/routes/databases.ts +201 -0
- package/src/routes/db-whitelist.ts +204 -0
- package/src/routes/domains.ts +547 -0
- package/src/routes/oauth.ts +195 -0
- package/src/routes/observability.ts +205 -0
- package/src/routes/orgs.ts +330 -0
- package/src/routes/platform.ts +134 -0
- package/src/routes/rotation.ts +191 -0
- package/src/routes/services.ts +290 -0
- package/src/routes/storage.ts +153 -0
- package/src/routes/users.ts +235 -0
- package/src/routes/webhooks.ts +384 -0
- package/src/services/__tests__/catalog-storage.test.ts +186 -0
- package/src/services/__tests__/deploy-pipeline.integration.test.ts +624 -0
- package/src/services/__tests__/pod-diagnostics.test.ts +332 -0
- package/src/services/__tests__/storage-credential-manager.test.ts +129 -0
- package/src/services/__tests__/storage-provisioner.test.ts +166 -0
- package/src/services/__tests__/troubleshooter.test.ts +329 -0
- package/src/services/bio-client.ts +189 -0
- package/src/services/build-queue.ts +137 -0
- package/src/services/builder.ts +1800 -0
- package/src/services/catalog.test.ts +1389 -0
- package/src/services/catalog.ts +1187 -0
- package/src/services/cloudflare.ts +259 -0
- package/src/services/config-validator.test.ts +190 -0
- package/src/services/config-validator.ts +108 -0
- package/src/services/crypto.ts +78 -0
- package/src/services/database.ts +122 -0
- package/src/services/db-credential-manager.test.ts +101 -0
- package/src/services/db-credential-manager.ts +447 -0
- package/src/services/db-provisioner.test.ts +602 -0
- package/src/services/db-provisioner.ts +589 -0
- package/src/services/db-whitelist.test.ts +671 -0
- package/src/services/db-whitelist.ts +496 -0
- package/src/services/dependency-resolver.test.ts +677 -0
- package/src/services/dependency-resolver.ts +319 -0
- package/src/services/deploy-gate.test.ts +247 -0
- package/src/services/deploy-gate.ts +75 -0
- package/src/services/dockerfile-generator.test.ts +401 -0
- package/src/services/dockerfile-generator.ts +606 -0
- package/src/services/forgejo.ts +212 -0
- package/src/services/koko.ts +492 -0
- package/src/services/kubernetes.ts +141 -0
- package/src/services/oauth-provisioner.test.ts +477 -0
- package/src/services/oauth-provisioner.ts +286 -0
- package/src/services/pod-diagnostics.ts +261 -0
- package/src/services/rotation-scheduler.ts +293 -0
- package/src/services/storage-credential-manager.ts +223 -0
- package/src/services/storage-provisioner.ts +216 -0
- package/src/services/storage.ts +274 -0
- package/src/services/troubleshooter.ts +208 -0
- package/src/services/vault-client.test.ts +272 -0
- package/src/services/vault-client.ts +587 -0
- package/src/utils/logger.ts +6 -0
- package/src/utils/response.ts +23 -0
- package/task_plan.md +171 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
mockInsertOne,
|
|
5
|
+
mockFindOne,
|
|
6
|
+
mockUpdateOne,
|
|
7
|
+
mockCountDocuments,
|
|
8
|
+
mockToArray,
|
|
9
|
+
mockSort,
|
|
10
|
+
mockFind,
|
|
11
|
+
mockEnsureMongoUser,
|
|
12
|
+
mockExecFileSync,
|
|
13
|
+
} = vi.hoisted(() => {
|
|
14
|
+
const mockInsertOne = vi.fn()
|
|
15
|
+
const mockFindOne = vi.fn()
|
|
16
|
+
const mockUpdateOne = vi.fn()
|
|
17
|
+
const mockCountDocuments = vi.fn(() => 0)
|
|
18
|
+
const mockToArray = vi.fn(() => [])
|
|
19
|
+
const mockSort = vi.fn(() => ({ toArray: mockToArray }))
|
|
20
|
+
const mockFind = vi.fn(() => ({ sort: mockSort, toArray: mockToArray }))
|
|
21
|
+
const mockEnsureMongoUser = vi.fn(() => true)
|
|
22
|
+
const mockExecFileSync = vi.fn()
|
|
23
|
+
return {
|
|
24
|
+
mockInsertOne, mockFindOne, mockUpdateOne, mockCountDocuments,
|
|
25
|
+
mockToArray, mockSort, mockFind, mockEnsureMongoUser, mockExecFileSync,
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
vi.mock('../config/env.js', () => ({
|
|
30
|
+
env: {
|
|
31
|
+
DB_MONGODB_HOST: '64.23.181.20',
|
|
32
|
+
DB_MONGODB_PUBLIC_HOST: '64.23.181.20',
|
|
33
|
+
DB_MONGODB_PORT: 27017,
|
|
34
|
+
DB_MONGODB_ADMIN_URI: 'mongodb://admin:pass@localhost:27017/admin',
|
|
35
|
+
},
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
vi.mock('../utils/logger.js', () => ({
|
|
39
|
+
logger: {
|
|
40
|
+
info: vi.fn(),
|
|
41
|
+
warn: vi.fn(),
|
|
42
|
+
error: vi.fn(),
|
|
43
|
+
},
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
vi.mock('./crypto.js', () => ({
|
|
47
|
+
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
|
48
|
+
decrypt: vi.fn((v: string) => v.replace('encrypted:', '')),
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
vi.mock('./database.js', () => ({
|
|
52
|
+
getDbWhitelistCollection: vi.fn(() => ({
|
|
53
|
+
insertOne: mockInsertOne,
|
|
54
|
+
findOne: mockFindOne,
|
|
55
|
+
find: mockFind,
|
|
56
|
+
updateOne: mockUpdateOne,
|
|
57
|
+
countDocuments: mockCountDocuments,
|
|
58
|
+
})),
|
|
59
|
+
getServicesCollection: vi.fn(() => ({
|
|
60
|
+
updateOne: vi.fn(),
|
|
61
|
+
})),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
vi.mock('./db-credential-manager.js', () => ({
|
|
65
|
+
buildMongoUsername: vi.fn((svc: string, env: string) => `svc_${svc}_${env}`),
|
|
66
|
+
generatePassword: vi.fn(() => 'test-password-abc123'),
|
|
67
|
+
ensureMongoUser: mockEnsureMongoUser,
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
vi.mock('child_process', () => ({
|
|
71
|
+
execFileSync: mockExecFileSync,
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
import {
|
|
75
|
+
validatePublicIp,
|
|
76
|
+
detectCallerIp,
|
|
77
|
+
isIpsetAvailable,
|
|
78
|
+
addIpToSet,
|
|
79
|
+
removeIpFromSet,
|
|
80
|
+
createWhitelistEntry,
|
|
81
|
+
revokeWhitelistEntry,
|
|
82
|
+
listWhitelistEntries,
|
|
83
|
+
listAllWhitelistEntries,
|
|
84
|
+
reconcileOnStartup,
|
|
85
|
+
} from './db-whitelist.js'
|
|
86
|
+
import { logger } from '../utils/logger.js'
|
|
87
|
+
import { decrypt } from './crypto.js'
|
|
88
|
+
import type { Service } from '../models/types.js'
|
|
89
|
+
|
|
90
|
+
function makeService(overrides: Partial<Service> = {}): Service {
|
|
91
|
+
return {
|
|
92
|
+
id: 'svc-001',
|
|
93
|
+
name: 'my-api',
|
|
94
|
+
repoUrl: 'https://git.insureco.io/insureco/my-api.git',
|
|
95
|
+
branch: 'main',
|
|
96
|
+
dockerfilePath: 'Dockerfile',
|
|
97
|
+
webhookSecret: 'secret',
|
|
98
|
+
org: 'insureco',
|
|
99
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
100
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
101
|
+
databaseCredentials: [
|
|
102
|
+
{
|
|
103
|
+
type: 'mongodb',
|
|
104
|
+
username: 'svc_my-api_prod',
|
|
105
|
+
passwordEncrypted: 'encrypted:prod-pass-123',
|
|
106
|
+
databaseName: 'my-api-prod',
|
|
107
|
+
environment: 'prod',
|
|
108
|
+
provisionedAt: '2026-01-01T00:00:00Z',
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
...overrides,
|
|
112
|
+
} as Service
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// Tests
|
|
117
|
+
// =============================================================================
|
|
118
|
+
|
|
119
|
+
describe('db-whitelist', () => {
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
vi.clearAllMocks()
|
|
122
|
+
// Default: ipset not available (dev mode)
|
|
123
|
+
mockExecFileSync.mockImplementation(() => {
|
|
124
|
+
throw new Error('command not found')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// validatePublicIp
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
describe('validatePublicIp', () => {
|
|
132
|
+
it('should accept valid public IPv4 addresses', () => {
|
|
133
|
+
expect(validatePublicIp('73.162.55.10')).toEqual({ valid: true })
|
|
134
|
+
expect(validatePublicIp('8.8.8.8')).toEqual({ valid: true })
|
|
135
|
+
expect(validatePublicIp('203.0.113.1')).toEqual({ valid: true })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('should reject private IPs (10.x.x.x)', () => {
|
|
139
|
+
const result = validatePublicIp('10.0.0.1')
|
|
140
|
+
expect(result.valid).toBe(false)
|
|
141
|
+
expect(result.reason).toContain('RFC 1918')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should reject private IPs (172.16-31.x.x)', () => {
|
|
145
|
+
expect(validatePublicIp('172.16.0.1').valid).toBe(false)
|
|
146
|
+
expect(validatePublicIp('172.20.0.1').valid).toBe(false)
|
|
147
|
+
expect(validatePublicIp('172.31.255.255').valid).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should reject private IPs (192.168.x.x)', () => {
|
|
151
|
+
const result = validatePublicIp('192.168.1.1')
|
|
152
|
+
expect(result.valid).toBe(false)
|
|
153
|
+
expect(result.reason).toContain('RFC 1918')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('should reject loopback (127.x.x.x)', () => {
|
|
157
|
+
const result = validatePublicIp('127.0.0.1')
|
|
158
|
+
expect(result.valid).toBe(false)
|
|
159
|
+
expect(result.reason).toContain('loopback')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should reject link-local (169.254.x.x)', () => {
|
|
163
|
+
const result = validatePublicIp('169.254.1.1')
|
|
164
|
+
expect(result.valid).toBe(false)
|
|
165
|
+
expect(result.reason).toContain('link-local')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should reject invalid format', () => {
|
|
169
|
+
expect(validatePublicIp('not-an-ip').valid).toBe(false)
|
|
170
|
+
expect(validatePublicIp('').valid).toBe(false)
|
|
171
|
+
expect(validatePublicIp('1.2.3').valid).toBe(false)
|
|
172
|
+
expect(validatePublicIp('1.2.3.4.5').valid).toBe(false)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should reject octets > 255', () => {
|
|
176
|
+
const result = validatePublicIp('256.1.1.1')
|
|
177
|
+
expect(result.valid).toBe(false)
|
|
178
|
+
expect(result.reason).toContain('out of range')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('should reject 0.x.x.x', () => {
|
|
182
|
+
const result = validatePublicIp('0.0.0.0')
|
|
183
|
+
expect(result.valid).toBe(false)
|
|
184
|
+
expect(result.reason).toContain('invalid')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// detectCallerIp
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
describe('detectCallerIp', () => {
|
|
192
|
+
it('should prefer CF-Connecting-IP header', () => {
|
|
193
|
+
const headers = {
|
|
194
|
+
'cf-connecting-ip': '73.162.55.10',
|
|
195
|
+
'x-forwarded-for': '98.45.0.1, 10.0.0.1',
|
|
196
|
+
}
|
|
197
|
+
expect(detectCallerIp(headers)).toBe('73.162.55.10')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should fall back to first X-Forwarded-For entry', () => {
|
|
201
|
+
const headers = { 'x-forwarded-for': '73.162.55.10, 10.0.0.1' }
|
|
202
|
+
expect(detectCallerIp(headers)).toBe('73.162.55.10')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should trim whitespace from IP', () => {
|
|
206
|
+
const headers = { 'cf-connecting-ip': ' 73.162.55.10 ' }
|
|
207
|
+
expect(detectCallerIp(headers)).toBe('73.162.55.10')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('should use fallback IP when no headers present', () => {
|
|
211
|
+
expect(detectCallerIp({}, '98.45.0.1')).toBe('98.45.0.1')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should return undefined when no IP available', () => {
|
|
215
|
+
expect(detectCallerIp({})).toBeUndefined()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('should ignore empty CF-Connecting-IP', () => {
|
|
219
|
+
const headers = { 'cf-connecting-ip': '', 'x-forwarded-for': '73.162.55.10' }
|
|
220
|
+
expect(detectCallerIp(headers)).toBe('73.162.55.10')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// isIpsetAvailable / addIpToSet / removeIpFromSet
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
describe('isIpsetAvailable', () => {
|
|
228
|
+
it('should return true when ipset command succeeds', () => {
|
|
229
|
+
mockExecFileSync.mockReturnValueOnce('ipset v7.15')
|
|
230
|
+
expect(isIpsetAvailable()).toBe(true)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should return false when ipset command fails', () => {
|
|
234
|
+
expect(isIpsetAvailable()).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('addIpToSet', () => {
|
|
239
|
+
it('should call execFileSync with correct args and return true', () => {
|
|
240
|
+
mockExecFileSync.mockReturnValueOnce('')
|
|
241
|
+
const result = addIpToSet('73.162.55.10', 28800)
|
|
242
|
+
expect(result).toBe(true)
|
|
243
|
+
expect(mockExecFileSync).toHaveBeenCalledWith(
|
|
244
|
+
'sudo',
|
|
245
|
+
['ipset', 'add', 'mongodb-access', '73.162.55.10', 'timeout', '28800', '-exist'],
|
|
246
|
+
{ encoding: 'utf8', stdio: 'pipe' }
|
|
247
|
+
)
|
|
248
|
+
expect(logger.info).toHaveBeenCalled()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should return false on failure', () => {
|
|
252
|
+
const result = addIpToSet('73.162.55.10', 28800)
|
|
253
|
+
expect(result).toBe(false)
|
|
254
|
+
expect(logger.error).toHaveBeenCalled()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('removeIpFromSet', () => {
|
|
259
|
+
it('should call execFileSync with correct args and return true', () => {
|
|
260
|
+
mockExecFileSync.mockReturnValueOnce('')
|
|
261
|
+
const result = removeIpFromSet('73.162.55.10')
|
|
262
|
+
expect(result).toBe(true)
|
|
263
|
+
expect(mockExecFileSync).toHaveBeenCalledWith(
|
|
264
|
+
'sudo',
|
|
265
|
+
['ipset', 'del', 'mongodb-access', '73.162.55.10', '-exist'],
|
|
266
|
+
{ encoding: 'utf8', stdio: 'pipe' }
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('should return false on failure', () => {
|
|
271
|
+
const result = removeIpFromSet('73.162.55.10')
|
|
272
|
+
expect(result).toBe(false)
|
|
273
|
+
expect(logger.error).toHaveBeenCalled()
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// createWhitelistEntry
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
describe('createWhitelistEntry', () => {
|
|
281
|
+
it('should create entry with readWrite access using existing credentials', async () => {
|
|
282
|
+
const service = makeService()
|
|
283
|
+
const result = await createWhitelistEntry(
|
|
284
|
+
service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io'
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
expect(result.ip).toBe('73.162.55.10')
|
|
288
|
+
expect(result.environment).toBe('prod')
|
|
289
|
+
expect(result.accessLevel).toBe('readWrite')
|
|
290
|
+
expect(result.connectionString).toContain('svc_my-api_prod')
|
|
291
|
+
expect(result.connectionString).toContain('prod-pass-123')
|
|
292
|
+
expect(result.connectionString).toContain('64.23.181.20:27017')
|
|
293
|
+
expect(result.connectionString).toContain('my-api-prod')
|
|
294
|
+
expect(result.connectionString).toContain('authSource=my-api-prod')
|
|
295
|
+
expect(result.id).toBeDefined()
|
|
296
|
+
expect(result.expiresAt).toBeDefined()
|
|
297
|
+
expect(mockInsertOne).toHaveBeenCalledTimes(1)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('should create entry with read access and provision _ro user', async () => {
|
|
301
|
+
const service = makeService()
|
|
302
|
+
const result = await createWhitelistEntry(
|
|
303
|
+
service, '73.162.55.10', 'prod', 'read', 8, 'user-1', 'dev@insureco.io'
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
expect(result.accessLevel).toBe('read')
|
|
307
|
+
expect(mockEnsureMongoUser).toHaveBeenCalledWith(
|
|
308
|
+
'my-api-prod', 'svc_my-api_prod_ro', 'test-password-abc123', 'read'
|
|
309
|
+
)
|
|
310
|
+
expect(result.connectionString).toContain('svc_my-api_prod_ro')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('should use existing _ro credential without re-provisioning', async () => {
|
|
314
|
+
const service = makeService({
|
|
315
|
+
databaseCredentials: [
|
|
316
|
+
{
|
|
317
|
+
type: 'mongodb',
|
|
318
|
+
username: 'svc_my-api_prod',
|
|
319
|
+
passwordEncrypted: 'encrypted:prod-pass-123',
|
|
320
|
+
databaseName: 'my-api-prod',
|
|
321
|
+
environment: 'prod',
|
|
322
|
+
provisionedAt: '2026-01-01T00:00:00Z',
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
type: 'mongodb',
|
|
326
|
+
username: 'svc_my-api_prod_ro',
|
|
327
|
+
passwordEncrypted: 'encrypted:ro-pass-456',
|
|
328
|
+
databaseName: 'my-api-prod',
|
|
329
|
+
environment: 'prod',
|
|
330
|
+
provisionedAt: '2026-01-01T00:00:00Z',
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const result = await createWhitelistEntry(
|
|
336
|
+
service, '73.162.55.10', 'prod', 'read', 8, 'user-1', 'dev@insureco.io'
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
expect(mockEnsureMongoUser).not.toHaveBeenCalled()
|
|
340
|
+
expect(result.connectionString).toContain('svc_my-api_prod_ro')
|
|
341
|
+
expect(result.connectionString).toContain('ro-pass-456')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('should clamp TTL to minimum 1 hour', async () => {
|
|
345
|
+
const service = makeService()
|
|
346
|
+
const result = await createWhitelistEntry(
|
|
347
|
+
service, '73.162.55.10', 'prod', 'readWrite', 0, 'user-1', 'dev@insureco.io'
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
const diffMs = new Date(result.expiresAt).getTime() - Date.now()
|
|
351
|
+
const diffHours = diffMs / 3600000
|
|
352
|
+
expect(diffHours).toBeGreaterThan(0.9)
|
|
353
|
+
expect(diffHours).toBeLessThan(1.2)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('should clamp TTL to maximum 24 hours', async () => {
|
|
357
|
+
const service = makeService()
|
|
358
|
+
const result = await createWhitelistEntry(
|
|
359
|
+
service, '73.162.55.10', 'prod', 'readWrite', 48, 'user-1', 'dev@insureco.io'
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
const diffMs = new Date(result.expiresAt).getTime() - Date.now()
|
|
363
|
+
const diffHours = diffMs / 3600000
|
|
364
|
+
expect(diffHours).toBeGreaterThan(23.8)
|
|
365
|
+
expect(diffHours).toBeLessThan(24.2)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('should throw when service has no MongoDB database configured', async () => {
|
|
369
|
+
const service = makeService({ databaseCredentials: [] })
|
|
370
|
+
await expect(
|
|
371
|
+
createWhitelistEntry(service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io')
|
|
372
|
+
).rejects.toThrow('has no MongoDB database configured')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('should throw when readWrite credentials not available for environment', async () => {
|
|
376
|
+
// Has prod _ro cred (db name resolves) but no prod readWrite cred,
|
|
377
|
+
// AND lazy provisioning fails
|
|
378
|
+
mockEnsureMongoUser.mockResolvedValueOnce(false)
|
|
379
|
+
const service = makeService({
|
|
380
|
+
databaseCredentials: [{
|
|
381
|
+
type: 'mongodb',
|
|
382
|
+
username: 'svc_my-api_prod_ro',
|
|
383
|
+
passwordEncrypted: 'encrypted:ro-pass',
|
|
384
|
+
databaseName: 'my-api-prod',
|
|
385
|
+
environment: 'prod',
|
|
386
|
+
provisionedAt: '2026-01-01T00:00:00Z',
|
|
387
|
+
}],
|
|
388
|
+
})
|
|
389
|
+
await expect(
|
|
390
|
+
createWhitelistEntry(service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io')
|
|
391
|
+
).rejects.toThrow('No readWrite credentials available')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('should throw when read provisioning fails', async () => {
|
|
395
|
+
mockEnsureMongoUser.mockResolvedValueOnce(false)
|
|
396
|
+
const service = makeService()
|
|
397
|
+
await expect(
|
|
398
|
+
createWhitelistEntry(service, '73.162.55.10', 'prod', 'read', 8, 'user-1', 'dev@insureco.io')
|
|
399
|
+
).rejects.toThrow('No read credentials available')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('should skip ipset when unavailable (dev mode)', async () => {
|
|
403
|
+
const service = makeService()
|
|
404
|
+
await createWhitelistEntry(
|
|
405
|
+
service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io'
|
|
406
|
+
)
|
|
407
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ipset not available'))
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('should add IP to ipset when available', async () => {
|
|
411
|
+
mockExecFileSync.mockReturnValue('')
|
|
412
|
+
const service = makeService()
|
|
413
|
+
await createWhitelistEntry(
|
|
414
|
+
service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io'
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
expect(mockExecFileSync).toHaveBeenCalledWith(
|
|
418
|
+
'sudo',
|
|
419
|
+
expect.arrayContaining(['ipset', 'add', 'mongodb-access', '73.162.55.10']),
|
|
420
|
+
expect.any(Object)
|
|
421
|
+
)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('should throw when ipset add fails', async () => {
|
|
425
|
+
mockExecFileSync
|
|
426
|
+
.mockReturnValueOnce('ipset v7.15')
|
|
427
|
+
.mockImplementationOnce(() => { throw new Error('ipset add failed') })
|
|
428
|
+
|
|
429
|
+
const service = makeService()
|
|
430
|
+
await expect(
|
|
431
|
+
createWhitelistEntry(service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io')
|
|
432
|
+
).rejects.toThrow('Failed to add IP to firewall whitelist')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('should insert audit record with correct fields', async () => {
|
|
436
|
+
const service = makeService()
|
|
437
|
+
await createWhitelistEntry(
|
|
438
|
+
service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io'
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
expect(mockInsertOne).toHaveBeenCalledTimes(1)
|
|
442
|
+
const doc = mockInsertOne.mock.calls[0][0]
|
|
443
|
+
expect(doc.serviceId).toBe('svc-001')
|
|
444
|
+
expect(doc.serviceName).toBe('my-api')
|
|
445
|
+
expect(doc.orgId).toBe('insureco')
|
|
446
|
+
expect(doc.ip).toBe('73.162.55.10')
|
|
447
|
+
expect(doc.environment).toBe('prod')
|
|
448
|
+
expect(doc.accessLevel).toBe('readWrite')
|
|
449
|
+
expect(doc.requestedBy).toBe('user-1')
|
|
450
|
+
expect(doc.requestedByEmail).toBe('dev@insureco.io')
|
|
451
|
+
expect(doc.status).toBe('active')
|
|
452
|
+
expect(doc.expiresAt).toBeInstanceOf(Date)
|
|
453
|
+
expect(typeof doc.createdAt).toBe('string')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('should resolve db name from catalogSpec when available', async () => {
|
|
457
|
+
const service = {
|
|
458
|
+
...makeService(),
|
|
459
|
+
catalogSpec: { databases: [{ type: 'mongodb', name: 'custom-db' }] },
|
|
460
|
+
} as unknown as Service
|
|
461
|
+
|
|
462
|
+
const result = await createWhitelistEntry(
|
|
463
|
+
service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io'
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
expect(result.connectionString).toContain('custom-db')
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('should fall back to databaseCredentials for db name', async () => {
|
|
470
|
+
const service = makeService()
|
|
471
|
+
const result = await createWhitelistEntry(
|
|
472
|
+
service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io'
|
|
473
|
+
)
|
|
474
|
+
expect(result.connectionString).toContain('my-api-prod')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('should URL-encode special characters in credentials', async () => {
|
|
478
|
+
const service = makeService({
|
|
479
|
+
databaseCredentials: [{
|
|
480
|
+
type: 'mongodb',
|
|
481
|
+
username: 'svc_my-api_prod',
|
|
482
|
+
passwordEncrypted: 'encrypted:p@ss/w0rd?&=',
|
|
483
|
+
databaseName: 'my-api-prod',
|
|
484
|
+
environment: 'prod',
|
|
485
|
+
provisionedAt: '2026-01-01T00:00:00Z',
|
|
486
|
+
}],
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const result = await createWhitelistEntry(
|
|
490
|
+
service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io'
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
expect(result.connectionString).toContain(encodeURIComponent('p@ss/w0rd?&='))
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('should return null and log error when decrypt fails for readWrite', async () => {
|
|
497
|
+
vi.mocked(decrypt).mockImplementationOnce(() => { throw new Error('decrypt failed') })
|
|
498
|
+
// Lazy provisioning also fails — no fallback
|
|
499
|
+
mockEnsureMongoUser.mockResolvedValueOnce(false)
|
|
500
|
+
const service = makeService()
|
|
501
|
+
await expect(
|
|
502
|
+
createWhitelistEntry(service, '73.162.55.10', 'prod', 'readWrite', 8, 'user-1', 'dev@insureco.io')
|
|
503
|
+
).rejects.toThrow('No readWrite credentials available')
|
|
504
|
+
expect(logger.error).toHaveBeenCalled()
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// revokeWhitelistEntry
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
describe('revokeWhitelistEntry', () => {
|
|
512
|
+
it('should revoke active entry and mark as revoked', async () => {
|
|
513
|
+
mockFindOne.mockResolvedValueOnce({
|
|
514
|
+
id: 'entry-1', ip: '73.162.55.10', serviceName: 'my-api',
|
|
515
|
+
orgId: 'insureco', status: 'active',
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
const result = await revokeWhitelistEntry('entry-1', 'admin@insureco.io')
|
|
519
|
+
|
|
520
|
+
expect(result).not.toBeNull()
|
|
521
|
+
expect(result!.status).toBe('revoked')
|
|
522
|
+
expect(result!.revokedBy).toBe('admin@insureco.io')
|
|
523
|
+
expect(result!.revokedAt).toBeDefined()
|
|
524
|
+
expect(mockUpdateOne).toHaveBeenCalledWith(
|
|
525
|
+
{ id: 'entry-1' },
|
|
526
|
+
{ $set: expect.objectContaining({ status: 'revoked', revokedBy: 'admin@insureco.io' }) }
|
|
527
|
+
)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('should return null when entry not found', async () => {
|
|
531
|
+
mockFindOne.mockResolvedValueOnce(null)
|
|
532
|
+
const result = await revokeWhitelistEntry('nonexistent', 'admin@insureco.io')
|
|
533
|
+
expect(result).toBeNull()
|
|
534
|
+
expect(mockUpdateOne).not.toHaveBeenCalled()
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('should enforce org scope when orgId provided', async () => {
|
|
538
|
+
mockFindOne.mockResolvedValueOnce(null)
|
|
539
|
+
await revokeWhitelistEntry('entry-1', 'admin@insureco.io', 'insureco')
|
|
540
|
+
expect(mockFindOne).toHaveBeenCalledWith(
|
|
541
|
+
expect.objectContaining({ id: 'entry-1', status: 'active', orgId: 'insureco' })
|
|
542
|
+
)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('should skip org filter when orgId not provided', async () => {
|
|
546
|
+
mockFindOne.mockResolvedValueOnce(null)
|
|
547
|
+
await revokeWhitelistEntry('entry-1', 'admin@insureco.io')
|
|
548
|
+
const filter = mockFindOne.mock.calls[0][0]
|
|
549
|
+
expect(filter).not.toHaveProperty('orgId')
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('should remove IP from ipset only when no other active entries use it', async () => {
|
|
553
|
+
mockExecFileSync.mockReturnValue('')
|
|
554
|
+
mockCountDocuments.mockResolvedValueOnce(0)
|
|
555
|
+
mockFindOne.mockResolvedValueOnce({ id: 'entry-1', ip: '73.162.55.10', status: 'active' })
|
|
556
|
+
|
|
557
|
+
await revokeWhitelistEntry('entry-1', 'admin@insureco.io')
|
|
558
|
+
|
|
559
|
+
expect(mockExecFileSync).toHaveBeenCalledWith(
|
|
560
|
+
'sudo',
|
|
561
|
+
expect.arrayContaining(['ipset', 'del', 'mongodb-access', '73.162.55.10']),
|
|
562
|
+
expect.any(Object)
|
|
563
|
+
)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('should NOT remove IP from ipset when other active entries exist', async () => {
|
|
567
|
+
mockExecFileSync.mockReturnValue('')
|
|
568
|
+
mockCountDocuments.mockResolvedValueOnce(2)
|
|
569
|
+
mockFindOne.mockResolvedValueOnce({ id: 'entry-1', ip: '73.162.55.10', status: 'active' })
|
|
570
|
+
|
|
571
|
+
await revokeWhitelistEntry('entry-1', 'admin@insureco.io')
|
|
572
|
+
|
|
573
|
+
const delCalls = mockExecFileSync.mock.calls.filter(c => c[1]?.includes?.('del'))
|
|
574
|
+
expect(delCalls).toHaveLength(0)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('should skip ipset when unavailable but still mark as revoked', async () => {
|
|
578
|
+
mockFindOne.mockResolvedValueOnce({ id: 'entry-1', ip: '73.162.55.10', status: 'active' })
|
|
579
|
+
|
|
580
|
+
await revokeWhitelistEntry('entry-1', 'admin@insureco.io')
|
|
581
|
+
|
|
582
|
+
expect(mockUpdateOne).toHaveBeenCalled()
|
|
583
|
+
expect(mockCountDocuments).not.toHaveBeenCalled()
|
|
584
|
+
})
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// listWhitelistEntries / listAllWhitelistEntries
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
describe('listWhitelistEntries', () => {
|
|
591
|
+
it('should filter by serviceName and active status', async () => {
|
|
592
|
+
await listWhitelistEntries('my-api')
|
|
593
|
+
expect(mockFind).toHaveBeenCalledWith({ serviceName: 'my-api', status: 'active' })
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('should add environment filter when provided', async () => {
|
|
597
|
+
await listWhitelistEntries('my-api', 'prod')
|
|
598
|
+
expect(mockFind).toHaveBeenCalledWith({ serviceName: 'my-api', status: 'active', environment: 'prod' })
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('should sort by createdAt descending', async () => {
|
|
602
|
+
await listWhitelistEntries('my-api')
|
|
603
|
+
expect(mockSort).toHaveBeenCalledWith({ createdAt: -1 })
|
|
604
|
+
})
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
describe('listAllWhitelistEntries', () => {
|
|
608
|
+
it('should filter only active entries', async () => {
|
|
609
|
+
await listAllWhitelistEntries()
|
|
610
|
+
expect(mockFind).toHaveBeenCalledWith({ status: 'active' })
|
|
611
|
+
})
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// reconcileOnStartup
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
describe('reconcileOnStartup', () => {
|
|
618
|
+
it('should skip when ipset not available', async () => {
|
|
619
|
+
await reconcileOnStartup()
|
|
620
|
+
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('ipset not available'))
|
|
621
|
+
expect(mockFind).not.toHaveBeenCalled()
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
it('should re-add active entries with remaining TTL', async () => {
|
|
625
|
+
mockExecFileSync.mockReturnValue('')
|
|
626
|
+
const futureDate = new Date(Date.now() + 3600000)
|
|
627
|
+
mockToArray.mockResolvedValueOnce([
|
|
628
|
+
{ ip: '73.162.55.10', expiresAt: futureDate, status: 'active' },
|
|
629
|
+
{ ip: '98.45.0.1', expiresAt: futureDate, status: 'active' },
|
|
630
|
+
])
|
|
631
|
+
|
|
632
|
+
await reconcileOnStartup()
|
|
633
|
+
|
|
634
|
+
const addCalls = mockExecFileSync.mock.calls.filter(c => c[1]?.includes?.('add'))
|
|
635
|
+
expect(addCalls).toHaveLength(2)
|
|
636
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
637
|
+
expect.objectContaining({ reconciled: 2, expired: 0, total: 2 }),
|
|
638
|
+
expect.any(String)
|
|
639
|
+
)
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('should skip expired entries', async () => {
|
|
643
|
+
mockExecFileSync.mockReturnValue('')
|
|
644
|
+
mockToArray.mockResolvedValueOnce([
|
|
645
|
+
{ ip: '73.162.55.10', expiresAt: new Date(Date.now() - 3600000), status: 'active' },
|
|
646
|
+
{ ip: '98.45.0.1', expiresAt: new Date(Date.now() + 3600000), status: 'active' },
|
|
647
|
+
])
|
|
648
|
+
|
|
649
|
+
await reconcileOnStartup()
|
|
650
|
+
|
|
651
|
+
const addCalls = mockExecFileSync.mock.calls.filter(c => c[1]?.includes?.('add'))
|
|
652
|
+
expect(addCalls).toHaveLength(1)
|
|
653
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
654
|
+
expect.objectContaining({ reconciled: 1, expired: 1, total: 2 }),
|
|
655
|
+
expect.any(String)
|
|
656
|
+
)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('should handle empty active entries', async () => {
|
|
660
|
+
mockExecFileSync.mockReturnValue('')
|
|
661
|
+
mockToArray.mockResolvedValueOnce([])
|
|
662
|
+
|
|
663
|
+
await reconcileOnStartup()
|
|
664
|
+
|
|
665
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
666
|
+
expect.objectContaining({ reconciled: 0, expired: 0, total: 0 }),
|
|
667
|
+
expect.any(String)
|
|
668
|
+
)
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
})
|