Compare commits
1 Commits
master
...
cf4683066f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf4683066f
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -71,4 +71,3 @@ docs/data-exports/
|
|||||||
|
|
||||||
# Python cache
|
# Python cache
|
||||||
__pycache__/
|
__pycache__/
|
||||||
docs/Team_Device Loader.xlsx
|
|
||||||
|
|||||||
234
.gitlab-ci.yml
234
.gitlab-ci.yml
@@ -1,21 +1,44 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GitLab CI/CD Pipeline — STEAM Security Dashboard
|
# GitLab CI/CD Pipeline — STEAM Security Dashboard
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Executor: Docker (LXC 108 — 71.85.90.8)
|
#
|
||||||
# Build/test jobs run in node:18 containers.
|
# Pipeline stages:
|
||||||
# Release: v2.1.0
|
# 1. install — install dependencies for backend and frontend
|
||||||
# Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9)
|
# 2. lint — run linters / static checks
|
||||||
# and production (71.85.90.6) via SSH.
|
# 3. test — run backend (Jest) and frontend (react-scripts) tests
|
||||||
|
# 4. build — produce the production frontend bundle
|
||||||
|
# 5. deploy — deploy to staging (local) or production (SSH to 71.85.90.6)
|
||||||
|
# 6. verify — post-deploy health checks
|
||||||
|
#
|
||||||
|
# Environments:
|
||||||
|
# staging — dashboard-dev:3100 (auto-deploy on main/master)
|
||||||
|
# production — 71.85.90.6:3001 (manual trigger, requires staging verification)
|
||||||
|
#
|
||||||
|
# Executor: shell (runs on dashboard-dev using system Node.js)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Variables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
variables:
|
variables:
|
||||||
PROD_HOST: "71.85.90.6"
|
PROD_HOST: "71.85.90.6"
|
||||||
PROD_USER: "root"
|
PROD_USER: "root"
|
||||||
PROD_DIR: "/home/cve-dashboard"
|
PROD_DIR: "/home/cve-dashboard"
|
||||||
STAGING_HOST: "71.85.90.9"
|
|
||||||
STAGING_USER: "root"
|
|
||||||
STAGING_DIR: "/home/cve-dashboard-staging"
|
STAGING_DIR: "/home/cve-dashboard-staging"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Global cache — persists node_modules between pipeline runs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
- frontend/node_modules/
|
||||||
|
policy: pull
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
stages:
|
stages:
|
||||||
- install
|
- install
|
||||||
- lint
|
- lint
|
||||||
@@ -25,45 +48,44 @@ stages:
|
|||||||
- verify
|
- verify
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 1: Install
|
# STAGE 1: Install dependencies
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
install-backend:
|
install-backend:
|
||||||
stage: install
|
stage: install
|
||||||
image: node:18
|
|
||||||
script:
|
script:
|
||||||
- npm ci
|
- npm ci --prefer-offline
|
||||||
cache:
|
cache:
|
||||||
key: backend-${CI_COMMIT_REF_SLUG}
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
paths:
|
paths:
|
||||||
- node_modules/
|
- node_modules/
|
||||||
policy: push
|
policy: pull-push
|
||||||
|
|
||||||
install-frontend:
|
install-frontend:
|
||||||
stage: install
|
stage: install
|
||||||
image: node:18
|
|
||||||
script:
|
script:
|
||||||
- cd frontend && npm ci
|
- cd frontend && npm ci --prefer-offline
|
||||||
cache:
|
cache:
|
||||||
key: frontend-${CI_COMMIT_REF_SLUG}
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
paths:
|
paths:
|
||||||
- frontend/node_modules/
|
- frontend/node_modules/
|
||||||
policy: push
|
policy: pull-push
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 2: Lint
|
# STAGE 2: Lint / static analysis
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
stage: lint
|
||||||
|
script:
|
||||||
|
- cd frontend && npm ci --prefer-offline && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 10
|
||||||
|
needs:
|
||||||
|
- install-frontend
|
||||||
|
|
||||||
lint-backend:
|
lint-backend:
|
||||||
stage: lint
|
stage: lint
|
||||||
image: node:18
|
|
||||||
cache:
|
|
||||||
key: backend-${CI_COMMIT_REF_SLUG}
|
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
policy: pull
|
|
||||||
script:
|
script:
|
||||||
- test -d node_modules || npm ci
|
- npm ci --prefer-offline
|
||||||
- node -c backend/server.js
|
- node -c backend/server.js
|
||||||
- node -c backend/routes/*.js
|
- node -c backend/routes/*.js
|
||||||
- node -c backend/helpers/*.js
|
- node -c backend/helpers/*.js
|
||||||
@@ -71,54 +93,24 @@ lint-backend:
|
|||||||
needs:
|
needs:
|
||||||
- install-backend
|
- install-backend
|
||||||
|
|
||||||
lint-frontend:
|
|
||||||
stage: lint
|
|
||||||
image: node:18
|
|
||||||
cache:
|
|
||||||
key: frontend-${CI_COMMIT_REF_SLUG}
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
policy: pull
|
|
||||||
script:
|
|
||||||
- cd frontend && (test -d node_modules || npm ci) && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 25
|
|
||||||
needs:
|
|
||||||
- install-frontend
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 3: Test
|
# STAGE 3: Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
stage: test
|
stage: test
|
||||||
image: node:18
|
|
||||||
variables:
|
|
||||||
DATABASE_URL: $DATABASE_URL
|
|
||||||
cache:
|
|
||||||
key: backend-${CI_COMMIT_REF_SLUG}
|
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
policy: pull
|
|
||||||
script:
|
script:
|
||||||
- test -d node_modules || npm ci
|
- npm ci --prefer-offline
|
||||||
- ./node_modules/.bin/jest --ci --forceExit
|
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
|
||||||
timeout: 5 minutes
|
timeout: 5 minutes
|
||||||
needs:
|
needs:
|
||||||
- install-backend
|
- install-backend
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
stage: test
|
stage: test
|
||||||
image: node:18
|
|
||||||
cache:
|
|
||||||
- key: frontend-${CI_COMMIT_REF_SLUG}
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
policy: pull
|
|
||||||
- key: backend-${CI_COMMIT_REF_SLUG}
|
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
policy: pull
|
|
||||||
script:
|
script:
|
||||||
- cd frontend && npm ci && cd .. && (test -d node_modules || npm ci) && cd frontend && CI=true npx react-scripts test --watchAll=false --ci
|
- npm ci --prefer-offline
|
||||||
|
- cd frontend && npm ci --prefer-offline && CI=true npx react-scripts test --watchAll=false --ci
|
||||||
timeout: 5 minutes
|
timeout: 5 minutes
|
||||||
needs:
|
needs:
|
||||||
- install-frontend
|
- install-frontend
|
||||||
@@ -129,14 +121,8 @@ test-frontend:
|
|||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
stage: build
|
stage: build
|
||||||
image: node:18
|
|
||||||
cache:
|
|
||||||
key: frontend-${CI_COMMIT_REF_SLUG}
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
policy: pull
|
|
||||||
script:
|
script:
|
||||||
- cd frontend && (test -d node_modules || npm ci) && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build
|
- cd frontend && npm ci --prefer-offline && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- frontend/build/
|
- frontend/build/
|
||||||
@@ -146,30 +132,26 @@ build-frontend:
|
|||||||
- lint-frontend
|
- lint-frontend
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 5: Deploy (SSH from container)
|
# STAGE 5: Deploy
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
.deploy-base: &deploy-base
|
# ---------------------------------------------------------------------------
|
||||||
image: alpine:latest
|
# Staging — auto-deploys on main/master to dashboard-dev:3100
|
||||||
before_script:
|
# ---------------------------------------------------------------------------
|
||||||
- apk add --no-cache openssh-client rsync
|
|
||||||
- mkdir -p ~/.ssh
|
|
||||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
|
||||||
- chmod 600 ~/.ssh/id_ed25519
|
|
||||||
- echo -e "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null" > ~/.ssh/config
|
|
||||||
|
|
||||||
deploy-staging:
|
deploy-staging:
|
||||||
<<: *deploy-base
|
|
||||||
stage: deploy
|
stage: deploy
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
when: on_success
|
when: on_success
|
||||||
environment:
|
environment:
|
||||||
name: staging
|
name: staging
|
||||||
url: http://71.85.90.9:3100
|
url: http://localhost:3100
|
||||||
script:
|
script:
|
||||||
- echo "Deploying to staging (${STAGING_HOST})..."
|
- echo "Deploying to staging (dashboard-dev:3100)..."
|
||||||
- rsync -az --delete
|
# Ensure staging directory exists
|
||||||
|
- mkdir -p ${STAGING_DIR}
|
||||||
|
# Sync code (exclude .git, node_modules, uploads, logs)
|
||||||
|
- rsync -a --delete
|
||||||
--exclude='.git'
|
--exclude='.git'
|
||||||
--exclude='node_modules'
|
--exclude='node_modules'
|
||||||
--exclude='frontend/node_modules'
|
--exclude='frontend/node_modules'
|
||||||
@@ -178,30 +160,32 @@ deploy-staging:
|
|||||||
--exclude='*.log'
|
--exclude='*.log'
|
||||||
--exclude='*.db'
|
--exclude='*.db'
|
||||||
--exclude='.env'
|
--exclude='.env'
|
||||||
./ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/
|
${CI_PROJECT_DIR}/ ${STAGING_DIR}/
|
||||||
- rsync -az frontend/build/ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/frontend/build/
|
# Copy built frontend
|
||||||
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR} && npm ci --prefer-offline"
|
- cp -r ${CI_PROJECT_DIR}/frontend/build ${STAGING_DIR}/frontend/build
|
||||||
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/frontend && npm ci --prefer-offline"
|
# Install deps in staging
|
||||||
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/backend && node migrations/run-all.js"
|
- cd ${STAGING_DIR} && npm ci --prefer-offline
|
||||||
- ssh ${STAGING_USER}@${STAGING_HOST} "systemctl restart cve-backend-staging || systemctl start cve-backend-staging || true"
|
- cd ${STAGING_DIR}/frontend && npm ci --prefer-offline
|
||||||
- echo "Staging deploy complete."
|
# Ensure staging .env exists
|
||||||
after_script:
|
|
||||||
- |
|
- |
|
||||||
apk add --no-cache curl > /dev/null 2>&1
|
if [ ! -f "${STAGING_DIR}/backend/.env" ]; then
|
||||||
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
cp ${CI_PROJECT_DIR}/backend/.env ${STAGING_DIR}/backend/.env
|
||||||
for ISSUE in $ISSUES; do
|
sed -i 's/^PORT=.*/PORT=3100/' ${STAGING_DIR}/backend/.env
|
||||||
curl --silent --request POST \
|
grep -q "^PORT=" ${STAGING_DIR}/backend/.env || echo "PORT=3100" >> ${STAGING_DIR}/backend/.env
|
||||||
--header "PRIVATE-TOKEN: ${GITLAB_PAT}" \
|
fi
|
||||||
"${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/issues/${ISSUE}/notes" \
|
# Run migrations
|
||||||
--data-urlencode "body=✅ Deployed to **staging** in pipeline [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) (commit \`${CI_COMMIT_SHORT_SHA}\`)" \
|
- cd ${STAGING_DIR}/backend && node migrations/run-all.js
|
||||||
> /dev/null 2>&1 || true
|
# Restart staging service
|
||||||
done
|
- sudo systemctl restart cve-backend-staging || sudo systemctl start cve-backend-staging || true
|
||||||
|
- echo "Staging deploy complete."
|
||||||
needs:
|
needs:
|
||||||
- build-frontend
|
- build-frontend
|
||||||
- test-backend
|
- test-backend
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Production — manual trigger, SSH to 71.85.90.6
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
deploy-production:
|
deploy-production:
|
||||||
<<: *deploy-base
|
|
||||||
stage: deploy
|
stage: deploy
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
@@ -211,8 +195,10 @@ deploy-production:
|
|||||||
url: http://71.85.90.6:3001
|
url: http://71.85.90.6:3001
|
||||||
script:
|
script:
|
||||||
- echo "Deploying to production (${PROD_HOST})..."
|
- echo "Deploying to production (${PROD_HOST})..."
|
||||||
|
# Record current commit on prod for rollback
|
||||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git rev-parse HEAD 2>/dev/null || echo none" > /tmp/prod-prev-commit
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git rev-parse HEAD 2>/dev/null || echo none" > /tmp/prod-prev-commit
|
||||||
- echo "Previous production commit:$(cat /tmp/prod-prev-commit)"
|
- echo "Previous production commit:$(cat /tmp/prod-prev-commit)"
|
||||||
|
# Sync code to production (exclude local-only files)
|
||||||
- rsync -az --delete
|
- rsync -az --delete
|
||||||
--exclude='.git'
|
--exclude='.git'
|
||||||
--exclude='node_modules'
|
--exclude='node_modules'
|
||||||
@@ -223,46 +209,40 @@ deploy-production:
|
|||||||
--exclude='*.db'
|
--exclude='*.db'
|
||||||
--exclude='.env'
|
--exclude='.env'
|
||||||
--exclude='.compliance-staging'
|
--exclude='.compliance-staging'
|
||||||
./ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
|
${CI_PROJECT_DIR}/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
|
||||||
- rsync -az frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
|
# Copy built frontend
|
||||||
|
- rsync -az ${CI_PROJECT_DIR}/frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
|
||||||
|
# Install deps on production
|
||||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
||||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/frontend && npm ci --prefer-offline"
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/frontend && npm ci --prefer-offline"
|
||||||
|
# Run migrations
|
||||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js"
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js"
|
||||||
- ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service || true"
|
# Restart services — install systemd unit if not present
|
||||||
|
- ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service" || scp ${CI_PROJECT_DIR}/deploy/cve-backend-production.service ${PROD_USER}@${PROD_HOST}:/etc/systemd/system/cve-backend.service
|
||||||
- ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend"
|
- ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend"
|
||||||
- echo "Production deploy complete."
|
- echo "Production deploy complete."
|
||||||
after_script:
|
|
||||||
- |
|
|
||||||
apk add --no-cache curl > /dev/null 2>&1
|
|
||||||
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
|
||||||
for ISSUE in $ISSUES; do
|
|
||||||
curl --silent --request POST \
|
|
||||||
--header "PRIVATE-TOKEN: ${GITLAB_PAT}" \
|
|
||||||
"${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/issues/${ISSUE}/notes" \
|
|
||||||
--data-urlencode "body=🚀 Deployed to **production** in pipeline [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) (commit \`${CI_COMMIT_SHORT_SHA}\`)" \
|
|
||||||
> /dev/null 2>&1 || true
|
|
||||||
done
|
|
||||||
needs:
|
needs:
|
||||||
- build-frontend
|
- build-frontend
|
||||||
- test-backend
|
- test-backend
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 6: Verify
|
# STAGE 6: Post-deploy verification
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Staging health check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
verify-staging:
|
verify-staging:
|
||||||
stage: verify
|
stage: verify
|
||||||
image: alpine:latest
|
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
when: on_success
|
when: on_success
|
||||||
script:
|
script:
|
||||||
- apk add --no-cache curl
|
|
||||||
- echo "Verifying staging..."
|
- echo "Verifying staging..."
|
||||||
- sleep 3
|
- sleep 3
|
||||||
- |
|
- |
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://${STAGING_HOST}:3100/api/health 2>/dev/null || echo "000")
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3100/api/health 2>/dev/null || echo "000")
|
||||||
if [ "$STATUS" = "200" ]; then
|
if [ "$STATUS" = "200" ]; then
|
||||||
echo "Staging health check passed (attempt $i)"
|
echo "Staging health check passed (attempt $i)"
|
||||||
break
|
break
|
||||||
@@ -274,28 +254,19 @@ verify-staging:
|
|||||||
echo "FAILED: Staging health check failed after 5 attempts"
|
echo "FAILED: Staging health check failed after 5 attempts"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- |
|
|
||||||
COMP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${STAGING_HOST}:3100/api/compliance/items?page=1&limit=1" || echo "000")
|
|
||||||
[ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true
|
|
||||||
- |
|
|
||||||
VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${STAGING_HOST}:3100/api/compliance/vcl/stats" || echo "000")
|
|
||||||
[ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true
|
|
||||||
- echo "Staging verification passed."
|
- echo "Staging verification passed."
|
||||||
needs:
|
needs:
|
||||||
- deploy-staging
|
- deploy-staging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Production health check — rolls back on failure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
verify-production:
|
verify-production:
|
||||||
stage: verify
|
stage: verify
|
||||||
image: alpine:latest
|
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
when: on_success
|
when: on_success
|
||||||
script:
|
script:
|
||||||
- apk add --no-cache curl openssh-client
|
|
||||||
- mkdir -p ~/.ssh
|
|
||||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
|
||||||
- chmod 600 ~/.ssh/id_ed25519
|
|
||||||
- echo -e "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null" > ~/.ssh/config
|
|
||||||
- echo "Verifying production..."
|
- echo "Verifying production..."
|
||||||
- sleep 3
|
- sleep 3
|
||||||
- |
|
- |
|
||||||
@@ -313,6 +284,7 @@ verify-production:
|
|||||||
PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "")
|
PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "")
|
||||||
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then
|
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then
|
||||||
echo "Rolling back to $PREV_COMMIT..."
|
echo "Rolling back to $PREV_COMMIT..."
|
||||||
|
# Re-sync the previous version
|
||||||
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git checkout ${PREV_COMMIT} --force 2>/dev/null" || true
|
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git checkout ${PREV_COMMIT} --force 2>/dev/null" || true
|
||||||
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
||||||
ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend"
|
ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend"
|
||||||
@@ -322,12 +294,6 @@ verify-production:
|
|||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- |
|
|
||||||
COMP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${PROD_HOST}:3001/api/compliance/items?page=1&limit=1" || echo "000")
|
|
||||||
[ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true
|
|
||||||
- |
|
|
||||||
VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${PROD_HOST}:3001/api/compliance/vcl/stats" || echo "000")
|
|
||||||
[ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true
|
|
||||||
- echo "Production verification passed."
|
- echo "Production verification passed."
|
||||||
needs:
|
needs:
|
||||||
- deploy-production
|
- deploy-production
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": "Migration Registration Check",
|
|
||||||
"description": "After any write to backend/migrations/, verify the file is registered in POSTGRES_MIGRATIONS array in run-all.js. Blocks until confirmed.",
|
|
||||||
"version": "2",
|
|
||||||
"when": {
|
|
||||||
"type": "postToolUse",
|
|
||||||
"toolTypes": ["write"]
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"type": "askAgent",
|
|
||||||
"prompt": "STOP. If the tool just wrote or created a file matching backend/migrations/*.js (but NOT run-all.js itself), you MUST immediately:\n1. Read backend/migrations/run-all.js\n2. Check if the migration filename exists in the POSTGRES_MIGRATIONS array\n3. If NOT present, add it to the end of the array RIGHT NOW before doing anything else\n4. Do NOT proceed with other work until this is done\n\nThis is a hard requirement — migrations not in run-all.js will not run in CI/CD and will break production deploys. If the written file is not a migration file, ignore this message."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
# Tech Stack & Build System
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|-------|-----------|
|
|
||||||
| Backend | Node.js 18+, Express 5 |
|
|
||||||
| Database | PostgreSQL (via `pg` pool in `backend/db.js`) |
|
|
||||||
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
|
|
||||||
| File uploads | Multer 2 (10MB limit) |
|
|
||||||
| Frontend | React 19 (Create React App / react-scripts 5) |
|
|
||||||
| Frontend serving | Express serves `frontend/build/` as static files on port 3001 |
|
|
||||||
| UI Icons | lucide-react |
|
|
||||||
| Charts | recharts |
|
|
||||||
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
|
||||||
| Markdown rendering | react-markdown |
|
|
||||||
| Diagrams | mermaid |
|
|
||||||
|
|
||||||
## Architecture: Single-Port Serving
|
|
||||||
|
|
||||||
Express on port 3001 serves **both** the API and the production frontend build:
|
|
||||||
- API routes: `/api/*` — handled by Express route handlers
|
|
||||||
- Frontend: everything else — served as static files from `frontend/build/`
|
|
||||||
|
|
||||||
There is no separate frontend server in production. The React dev server (`npm start` on port 3000) is only for local development with hot-reload. In production and on the dev server, you must run `npm run build` in `frontend/` after any frontend code change, then restart the backend.
|
|
||||||
|
|
||||||
**After editing frontend source files:**
|
|
||||||
```bash
|
|
||||||
cd frontend && npm run build # Compile new bundle into frontend/build/
|
|
||||||
# Then restart backend (or it will serve the new static files on next request)
|
|
||||||
```
|
|
||||||
|
|
||||||
The CI/CD pipeline handles this automatically — `build-frontend` stage runs before deploy.
|
|
||||||
|
|
||||||
## Common Commands
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node setup.js # Initialize DB, tables, indexes, default admin user
|
|
||||||
node server.js # Start backend on port 3001 (serves API + frontend build)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install # Install dependencies
|
|
||||||
npm run build # Production build → frontend/build/ (REQUIRED after code changes)
|
|
||||||
npm start # Dev server on port 3000 (local dev only, NOT used in production)
|
|
||||||
npm test # Run tests (react-scripts test)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Both servers (from project root)
|
|
||||||
```bash
|
|
||||||
./start-servers.sh # Start backend + frontend in background
|
|
||||||
./stop-servers.sh # Stop all servers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Migrations (run from `backend/`)
|
|
||||||
```bash
|
|
||||||
node migrations/run-all.js # Runs all migrations in order (idempotent)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python Scripts (from `backend/scripts/`)
|
|
||||||
```bash
|
|
||||||
# Compliance xlsx parsing (called automatically by upload flow)
|
|
||||||
python3 parse_compliance_xlsx.py <file>
|
|
||||||
|
|
||||||
# Bulk notes import
|
|
||||||
python3 import_notes_from_csv.py input.csv --dry-run
|
|
||||||
python3 import_notes_from_csv.py input.csv
|
|
||||||
```
|
|
||||||
|
|
||||||
Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv).
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials, CARD API credentials
|
|
||||||
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
|
|
||||||
- Both `.env` files are gitignored; see `.env.example` files for templates.
|
|
||||||
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
|
|
||||||
|
|
||||||
### Key Backend Env Vars
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `IVANTI_API_KEY` | RiskSense platform API key |
|
|
||||||
| `IVANTI_CLIENT_ID` | RiskSense client ID (default: 1550) |
|
|
||||||
| `IVANTI_BU_FILTER` | Comma-separated BU teams to sync findings for (default: `NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM`) |
|
|
||||||
| `IVANTI_FIRST_NAME` / `IVANTI_LAST_NAME` | Fallback Ivanti identity for workflow sync (used only if no per-user identities configured) |
|
|
||||||
| `CARD_API_URL` | CARD API base URL (e.g., `https://card.charter.com`) |
|
|
||||||
| `CARD_API_USER` / `CARD_API_PASS` | CARD OAuth credentials for Bearer token acquisition |
|
|
||||||
| `CARD_SKIP_TLS` | Set to `true` to skip TLS verification (for SSL inspection proxies) |
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string |
|
|
||||||
|
|
||||||
### CARD API and Ivanti Integration Details
|
|
||||||
|
|
||||||
See `.kiro/steering/integrations.md` for full API contracts, response shapes, and quirks for CARD, Ivanti, Atlas, and Jira.
|
|
||||||
|
|
||||||
### Ivanti Findings IPv6 Handling
|
|
||||||
|
|
||||||
Some Ivanti findings have no IPv4 address. The sync captures fallback addresses:
|
|
||||||
- `qualys_ipv6` — from `hostAdditionalDetails[].["IPv6 Address"]` (resolves in CARD)
|
|
||||||
- `primary_ipv6` — from `assetCustomAttributes['1550_host_6'][0]` (may not resolve in CARD)
|
|
||||||
|
|
||||||
Display priority in the UI: IPv4 > Qualys IPv6 (amber "Q" badge) > Primary IPv6 (indigo "v6" badge)
|
|
||||||
|
|
||||||
## Code Style & Lint Rules
|
|
||||||
|
|
||||||
### Unused Variables
|
|
||||||
|
|
||||||
The frontend ESLint config enforces `no-unused-vars` as a warning. The CI pipeline fails if warnings exceed 25. To avoid lint failures:
|
|
||||||
|
|
||||||
- **Prefix intentionally-unused variables with `_`** — this suppresses the warning. The `varsIgnorePattern: "^_"` and `argsIgnorePattern: "^_"` rules are configured in `frontend/package.json`.
|
|
||||||
- Common patterns:
|
|
||||||
- `const [_unused, setFoo] = useState(...)` — destructured value you don't need
|
|
||||||
- `const _legacyRef = useRef(...)` — kept for future use
|
|
||||||
- `function handler(_event) { ... }` — required parameter signature but unused
|
|
||||||
- **Do not leave variables unprefixed if unused.** Either use them, remove them, or prefix with `_`.
|
|
||||||
- This applies to all frontend code written by the agent.
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
No ESLint is configured for backend — the pipeline uses `node -c` syntax checking only. Keep code clean but there is no automated unused-var enforcement on the backend side.
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Environment | URL | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Production | http://71.85.90.6:3001 | Express serves API + static frontend build |
|
|
||||||
| Staging | http://71.85.90.9:3100 | Auto-deploy on master push |
|
|
||||||
| Local dev (frontend only) | http://localhost:3000 | React dev server with hot-reload, proxies API to :3001 |
|
|
||||||
|
|
||||||
## Secure Context Constraints
|
|
||||||
|
|
||||||
All environments serve over **plain HTTP** (not HTTPS). This means browser APIs that require a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) are **not available** in production or staging:
|
|
||||||
|
|
||||||
- `navigator.clipboard` (Clipboard API) — use `document.execCommand('copy')` with a hidden textarea instead
|
|
||||||
- `navigator.share` (Web Share API)
|
|
||||||
- `crypto.subtle` (Web Crypto API)
|
|
||||||
- `navigator.credentials` (Credential Management API)
|
|
||||||
- Service Workers and Push Notifications
|
|
||||||
|
|
||||||
When writing frontend code that needs clipboard, sharing, or crypto functionality, always use the non-secure fallback pattern. Do not use `navigator.clipboard.writeText()` or similar secure-context APIs.
|
|
||||||
|
|
||||||
## CI/CD Pipeline
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
|
|
||||||
| Role | Host | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| GitLab instance | steam-gitlab.charterlab.com | Self-hosted GitLab |
|
|
||||||
| CI Runner (LXC 108) | 71.85.90.8 | Docker executor, Runner #6, project-locked |
|
|
||||||
| Staging target | 71.85.90.9 | Auto-deploy on master, port 3100 |
|
|
||||||
| Production target | 71.85.90.6 | Manual deploy trigger, port 3001 |
|
|
||||||
|
|
||||||
### Executor: Docker
|
|
||||||
|
|
||||||
The pipeline uses **Docker executor** on Runner #6. Jobs run in isolated containers:
|
|
||||||
|
|
||||||
- **Install / Lint / Test / Build stages**: `node:18` image
|
|
||||||
- **Deploy stages**: `alpine:latest` image (installs `openssh-client` and `rsync` at runtime)
|
|
||||||
|
|
||||||
Deploy jobs SSH from inside the Alpine container to the target hosts using a base64-encoded `$SSH_PRIVATE_KEY` stored as a GitLab CI/CD variable.
|
|
||||||
|
|
||||||
### CI/CD Variables (project-level)
|
|
||||||
|
|
||||||
These are set in GitLab → Settings → CI/CD → Variables:
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string for backend integration tests |
|
|
||||||
| `SSH_PRIVATE_KEY` | Base64-encoded private key for deploy SSH access |
|
|
||||||
| `GITLAB_PAT` | Project access token for issue comments and release creation |
|
|
||||||
|
|
||||||
### Pipeline file
|
|
||||||
|
|
||||||
The pipeline is defined in `.gitlab-ci.yml` at the project root. Stages: install → lint → test → build → deploy → verify.
|
|
||||||
27
.kiro/steering/workflow.md
Normal file
27
.kiro/steering/workflow.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Workflow & Context Gathering
|
||||||
|
|
||||||
|
## Specs First
|
||||||
|
|
||||||
|
Before making changes to any feature area, **always check `.kiro/specs/` for related spec folders first**. Specs contain the original requirements, design decisions, architecture diagrams, data models, and task breakdowns that informed the implementation. They provide critical context about:
|
||||||
|
|
||||||
|
- Why a feature was built a certain way
|
||||||
|
- What data models and API contracts were agreed upon
|
||||||
|
- What correctness properties must hold
|
||||||
|
- What edge cases were considered
|
||||||
|
|
||||||
|
Even if the code has evolved since the spec was written, the spec is the starting point for understanding intent.
|
||||||
|
|
||||||
|
## Spec Folder Structure
|
||||||
|
|
||||||
|
Each spec folder typically contains:
|
||||||
|
|
||||||
|
- `requirements.md` — user stories and acceptance criteria
|
||||||
|
- `design.md` — architecture, data models, API contracts, error handling
|
||||||
|
- `tasks.md` — implementation task breakdown with completion status
|
||||||
|
|
||||||
|
## When to Check Specs
|
||||||
|
|
||||||
|
- Fixing bugs in a feature area — check the spec to understand intended behavior
|
||||||
|
- Adding to an existing feature — check the spec to understand design constraints
|
||||||
|
- Investigating unexpected behavior — the spec documents what "correct" looks like
|
||||||
|
- Refactoring — the spec documents which properties must be preserved
|
||||||
221
CHANGELOG.md
221
CHANGELOG.md
@@ -1,184 +1,59 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to the STEAM Security Dashboard are documented in this file.
|
## v1.0.0 — 2026-05-01
|
||||||
|
|
||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
First official release. Consolidates all features developed since initial commit into a stable, documented, deployment-ready package.
|
||||||
|
|
||||||
---
|
### Core Platform
|
||||||
|
- CVE tracking with multi-vendor support, document storage, and NVD API auto-fill
|
||||||
|
- Session-based authentication with four user groups (Admin, Standard_User, Leadership, Read_Only)
|
||||||
|
- Full audit logging of all state-changing actions
|
||||||
|
- Dark tactical intelligence UI theme with monospace typography
|
||||||
|
|
||||||
## [2.3.0] — 2026-06-16
|
### Ivanti Integration
|
||||||
|
- Live sync of open host findings from Ivanti/RiskSense API (auto-sync every 24h)
|
||||||
|
- Reporting page with donut metric charts, advanced per-column filtering, inline editing
|
||||||
|
- FP workflow submission directly to Ivanti API with file attachments
|
||||||
|
- Ivanti Queue — personal staging list for batch FP, Archer, CARD, and Granite workflows
|
||||||
|
- Queue item redirect between workflow types after completion
|
||||||
|
- Row visibility controls with localStorage persistence
|
||||||
|
|
||||||
### Added
|
### Archive and Anomaly Tracking
|
||||||
|
- Automatic detection of disappeared and returned findings across syncs
|
||||||
|
- BU drift checker — classifies archived findings by reason (BU reassignment, severity drift, closed on platform, decommissioned)
|
||||||
|
- Return classification — explains why findings came back (BU reassigned back, severity re-escalated, etc.)
|
||||||
|
- Findings Trend chart with archive activity sparkline and shift reason tooltips
|
||||||
|
- Anomaly banner for significant archive events
|
||||||
|
|
||||||
- **BU reassignment detail view** — click the "BU reassignment" count in the anomaly banner to see which specific findings moved and from/to which team
|
### Compliance (AEO Posture)
|
||||||
- **Atlas sync scoped to active teams** — Atlas sync now respects BU scope and defaults to managed BUs, preventing cache pollution from unrelated teams
|
- Weekly NTS_AEO xlsx upload with diff preview (new, resolved, recurring)
|
||||||
- **Atlas known host distinction** — badge only renders for hosts Atlas actively tracks, suppressing noise from BUs not covered by Atlas (e.g., ACCESS-OPS)
|
- Schema drift detection with breaking/silent-miss/cosmetic classification
|
||||||
- **Per-user Ivanti identity** — FP workflow views filtered by individual Ivanti first/last name for personalized queue
|
- Admin config reconciliation for parser updates
|
||||||
- **Searchable dropdowns for Granite Loader** — team, operation type, and status columns now use filterable select inputs
|
- Per-team metric health cards with grouped categories and variant pills
|
||||||
- **IPv6 fallback display** — findings without IPv4 show Qualys IPv6 (amber Q badge) or primary IPv6 (indigo v6 badge)
|
- Device-level violation tracking with timestamped notes history
|
||||||
- **Remediate workflow type** — new workflow option in Ivanti Queue with remediation notes appended to Jira tickets
|
- Multi-metric note grouping
|
||||||
- **DECOM workflow type** — added to RedirectModal workflow options
|
- Upload rollback support
|
||||||
- **View in CARD button** — added to tooltip and action modal for direct CARD web UI navigation
|
|
||||||
- **CARD asset-search by Host ID** — faster lookup path for enrichment operations
|
|
||||||
- **Per-metric compliance views** — replaced cross-metric aggregates with per-metric summary cards
|
|
||||||
- **Non-metric category filters** on compliance page
|
|
||||||
- **Ivanti Findings Data Guide** — Knowledge Base article explaining common data patterns (missing CVEs, BU reassignment, Atlas badges, etc.)
|
|
||||||
- **Markdown table rendering** in Knowledge Base viewer (remark-gfm support)
|
|
||||||
- **In-app notifications** table and infrastructure
|
|
||||||
|
|
||||||
### Fixed
|
### Integrations
|
||||||
|
- Jira Data Center — create, sync, and track tickets linked to CVE/vendor pairs
|
||||||
|
- Archer — risk acceptance exception tracking (EXC numbers)
|
||||||
|
- Atlas InfoSec — action plan cache, bulk creation from row selection, metrics reporting
|
||||||
|
- CARD API — Granite/CARD asset lookup for network device workflows
|
||||||
|
- NVD API — auto-fill CVE metadata with bulk sync support
|
||||||
|
|
||||||
- **Drift checker re-classifying same findings every sync** — archived findings were never removed from ivanti_findings, causing ~500 false re-classifications per sync. Now properly cleaned up after archive detection
|
### Knowledge Base
|
||||||
- **Atlas Coverage tab not responding to scope changes** — metrics and status endpoints now filter by active teams and re-fetch on scope switch
|
- Internal document library with inline PDF and Markdown rendering
|
||||||
- **Knowledge Base content/download failing for relative file paths** — sendFile now resolves paths correctly
|
- Category-based browsing and search
|
||||||
- **remark-gfm compatibility** — upgraded to v4 for react-markdown v10 (was causing blank KB viewer)
|
|
||||||
- **SearchableSelect** — only opens on focus, closes properly on blur/select
|
|
||||||
- **Clipboard copy on HTTP** — use execCommand fallback for non-secure contexts
|
|
||||||
- **Empty description in single-item Jira modal** on ReportingPage
|
|
||||||
- **CARD enrich for items without IP** — uses host_id lookup as fallback
|
|
||||||
- **update_token error handling** — shows CARD link for assets that can't be actioned via API
|
|
||||||
- **Decom workflow migration** — includes Remediate in state check constraint
|
|
||||||
|
|
||||||
### Changed
|
### Admin
|
||||||
|
- Full-page admin panel with user management, audit log, and system info tabs
|
||||||
- Atlas sync defaults to `IVANTI_MANAGED_BUS` when no scope is specified instead of syncing all BUs
|
- Themed confirm modals replacing browser dialogs
|
||||||
- BU change history API accepts `since` and `limit` query params for scoped queries
|
- User profile panel with self-service password change
|
||||||
- Anomaly banner uses 60-minute lookback window to capture drift checker records
|
|
||||||
- Archive activity chart should now show near-zero on normal syncs (only genuinely new disappearances)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.2.0] — 2026-06-04
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **Group by Host toggle** on the Ivanti findings table — collapses duplicate assets (same hostname + IP) with multiple finding IDs into expandable host rows. Hosts with only one finding remain as flat rows. Toggle between grouped and flat views from the toolbar.
|
|
||||||
- **CARD ownership tooltip on IP hover** — hover over any IP address in the findings table to see CARD asset ownership data (confirmed/unconfirmed/candidate teams) in an interactive tooltip. Results cached per session for instant re-display.
|
|
||||||
- **CARD direct action modal** — click "Actions" in the CARD tooltip to open a full confirm/decline/redirect modal that works directly against the CARD API without needing a queue item.
|
|
||||||
- **Inline view panel** in the Archer Template Manager with per-section copy buttons
|
|
||||||
- **Queue item redirect in place** — pending queue items can now be redirected without duplicating
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Improve CARD decline error diagnostics and prevent accidental modal dismiss
|
|
||||||
- CARD teams fetch retries silently up to 3x on failure with increasing delay
|
|
||||||
- Redirect dropdowns show owner-data teams as fallback when the full teams API fails
|
|
||||||
- CARD tooltip uses quick mode (CTEC suffix only, 15s timeout) to avoid multi-minute waits
|
|
||||||
- Timeouts (504) are not cached — re-hover will retry the lookup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.1.0] — 2026-06-06
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **Archer Template Library** — new template management system for Archer Risk Acceptance forms. Store static content (Environment Overview, Segmentation, Mitigating Controls) organized by Vendor > Platform > Model. Full CRUD with clone, search/filter, and per-section copy-to-clipboard. Accessible from the nav drawer (Template Mgr) and integrated into the Ivanti Queue for Archer workflow items.
|
|
||||||
- **Estimated resolution date per metric** — the compliance asset sidebar now shows each noncompliant metric's estimated resolution date at the top of its section, in `YYYY-MM-DD` format, with placeholders for metrics that have no date set or an invalid date (closes #20)
|
|
||||||
- **CARD Action Modal** with full owner context
|
|
||||||
- **Granite Loader Sheet generator** with CARD enrichment, plus a Loader Sheet button on the Reporting page queue panel
|
|
||||||
- **Vendor-specific issue type dropdown** for Jira ticket creation, with all vendor project keys
|
|
||||||
- **LIVE and LAST REPORT badges** on the VCL compliance page
|
|
||||||
- **Collapsible sections** on the Ivanti Queue page and side panel
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix remediation plan and resolution date missing from the compliance table; format `resolution_date` as `YYYY-MM-DD`
|
|
||||||
- Improve CARD action error messages and default loader columns
|
|
||||||
- Fix CARD production timeout by forcing IPv4 (`dns.setDefaultResultOrder('ipv4first')`)
|
|
||||||
- Add IP address validation to CARD confirm/decline/redirect actions
|
|
||||||
- Auto-resolve bare IP to CARD asset ID with suffix lookup
|
|
||||||
- Increase CARD API timeout from 15s to 30s
|
|
||||||
- Rewrite CARD enrich-batch to use the team assets endpoint for full data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.0.0] — 2026-05-26
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
|
|
||||||
- **PostgreSQL migration** — database engine switched from SQLite to PostgreSQL. Requires running `deploy-postgres.sh`, data migration, and `DATABASE_URL` env var. SQLite is no longer supported.
|
|
||||||
- **Multi-BU tenancy** — data is now scoped per business unit with per-user team assignments. Replaces the previous binary scope toggle.
|
|
||||||
- **Raw Jira status display** — removed Open/In Progress/Closed status mapping; shows the actual Jira status field everywhere.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **Jira integration overhaul**
|
|
||||||
- Flexible Jira ticket creation — CVE/Vendor fields optional, source context tracking
|
|
||||||
- Multi-item Jira ticket creation from Ivanti Queue (consolidation modal)
|
|
||||||
- Issue type dropdown and Save to Dashboard from Jira lookup
|
|
||||||
- Success toast after consolidated ticket creation
|
|
||||||
- Improved Jira lookup error messages
|
|
||||||
- **CCP Metrics page** — multi-vertical VCL upload and cross-org compliance reporting
|
|
||||||
- Metric-first hierarchy restructure with Jira cross-project sync
|
|
||||||
- Per-metric forecast burndown chart
|
|
||||||
- Aggregated burndown forecast on overview page
|
|
||||||
- Sub-team drill-down with intermediate view and per-team breakdowns
|
|
||||||
- Non-Compliant stat clickable with metric breakdown buttons
|
|
||||||
- Compliant/total counts on metric summary cards
|
|
||||||
- Per-metric remediation plans
|
|
||||||
- VCL metric calculations guide
|
|
||||||
- **Exports page** — Jira Tickets, CCP Metrics, and Remediation Status export cards
|
|
||||||
- **VCL compliance reporting** — exec report page, device metadata fields, bulk upload
|
|
||||||
- **Data management panel** — delete vertical, rollback upload, and reset all
|
|
||||||
- **In-app notification system** — replaces Webex bot integration with native notifications
|
|
||||||
- **Remediation plan and resolution date history tracking**
|
|
||||||
- **FP submissions cleanup** — auto-clear approved, dismiss rejected, collapsible section
|
|
||||||
- **Re-queue findings** from rejected FP submissions
|
|
||||||
- **DECOM workflow type** — auto-note/hide on decom, show CVEs on CARD queue items
|
|
||||||
- **Interactive configuration wizard** for deployment setup
|
|
||||||
- **Unified setup script** (`configure.js`) merging deploy + config wizard
|
|
||||||
- **Per-BU trend lines** in Ivanti counts history chart
|
|
||||||
- **Multi-select BU picker** replacing binary scope toggle
|
|
||||||
- **Configurable IVANTI_MANAGED_BUS** env var for multi-tenant drift classification
|
|
||||||
- **Pipeline-to-issue traceability** via `after_script` comments in CI/CD
|
|
||||||
- **CI/CD pipeline** with health endpoint and automated deploy stages
|
|
||||||
- **Docker Compose** and `deploy-postgres.sh` for production cutover
|
|
||||||
- **Systemd service scripts** for start/stop management
|
|
||||||
- **VCL vertical metadata** — inline-editable team fields on compliance routes
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix Clear Completed button failing on queue items with Jira ticket links (FK violation)
|
|
||||||
- Fix status badge background making text invisible
|
|
||||||
- Fix calendar SLA dates not highlighting after Postgres migration
|
|
||||||
- Fix document View link using localhost instead of relative URL
|
|
||||||
- Validate library doc file types before sending to Ivanti API
|
|
||||||
- Improve FP workflow error messages — include Ivanti API response body
|
|
||||||
- Fix forecast chart bar order and snapshot month derivation
|
|
||||||
- Fix forecast deduplication for multi-vertical metrics
|
|
||||||
- Fix CCP Metrics page crash for non-Admin users
|
|
||||||
- Fix CCP Metrics crash when donut chart has zero non-compliant devices
|
|
||||||
- Fix duplicate failing metrics on same asset across compliance endpoints
|
|
||||||
- Fix duplicate chart entries on compliance page when multiple verticals share a report_date
|
|
||||||
- Fix requeue inserting Postgres array literal instead of JSON into `cves_json`
|
|
||||||
- Fix todo queue crash on malformed `cves_json` data
|
|
||||||
- Fix AEO compliance page not showing metric health cards on dev
|
|
||||||
- Fix double-counting in VCL multi-vertical stats — use only `ALL:` rollup rows
|
|
||||||
- Fix compliance stats to use Summary sheet data instead of item counts
|
|
||||||
- Fix route mount order: `vcl-multi` must precede general compliance router
|
|
||||||
- Fix requeue: fallback to `finding_ids_json` when queue items are deleted or absent
|
|
||||||
- Sync FP submission `lifecycle_status` from Ivanti `currentState` on fetch
|
|
||||||
- Fix History tab crash: coerce Ivanti note fields to strings before rendering
|
|
||||||
- Fix archive bar chart: `fmtDate` now handles ISO datetime strings from PostgreSQL
|
|
||||||
- Fix Ivanti panel bugs: Invalid Date, wrong workflow count, crash on archive click
|
|
||||||
- Fix BU drift checker: derive `EXPECTED_BUS` from `IVANTI_BU_FILTER` env var
|
|
||||||
- Fix null `bu_teams` in postgres migration, add retry logic to deploy script
|
|
||||||
- Fix missing `created_by` column in `archer_tickets` table
|
|
||||||
- Fix FP workflow counts donut scoped by BU
|
|
||||||
- Fix `dotenv` loading in `db.js` so `DATABASE_URL` is available on import
|
|
||||||
|
|
||||||
### Maintenance
|
|
||||||
|
|
||||||
- Track `package-lock.json` files for deterministic CI installs
|
|
||||||
- Remove unused imports to satisfy ESLint thresholds
|
|
||||||
- CI pipeline fixes: dependency installation, lint thresholds, test isolation
|
|
||||||
- Auto-run migrations in pipeline
|
|
||||||
- Strengthen migration registration hook
|
|
||||||
- Documentation updates for PostgreSQL migration, systemd scripts, and reference manual
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.0.0] — 2026-05-01
|
|
||||||
|
|
||||||
Initial release of the STEAM Security Dashboard.
|
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- Consolidated `setup.js` with complete database schema (27 tables, all indexes and triggers)
|
||||||
|
- systemd service files for persistent deployment
|
||||||
|
- GitLab CI/CD pipeline (install, lint, test, build, deploy)
|
||||||
|
- GPG-signed commits for code provenance
|
||||||
|
- Organized documentation structure (api, design, guides, security, testing, troubleshooting)
|
||||||
|
- Migration scripts documented and retained for existing deployment upgrades
|
||||||
|
|||||||
851
README.md
851
README.md
@@ -1,44 +1,6 @@
|
|||||||
# STEAM Security Dashboard v2.2.0
|
# STEAM Security Dashboard v1.0.0
|
||||||
|
|
||||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM, NTS-AEO-ACCESS-ENG, NTS-AEO-ACCESS-OPS, and NTS-AEO-INTELDEV business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture monitoring, CCP Metrics cross-org compliance reporting, FP/Archer/CARD/GRANITE/DECOM exception workflows, CARD asset ownership management, Granite Loader Sheet generation, Jira ticket management, and internal documentation in a single interface.
|
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture, FP/Archer/CARD exception workflows, and internal documentation in a single interface.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Quick Start](#quick-start)
|
|
||||||
- [Features](#features)
|
|
||||||
- [Home — CVE Management](#home--cve-management)
|
|
||||||
- [Reporting — Ivanti Host Findings](#reporting--ivanti-host-findings)
|
|
||||||
- [Ivanti Queue — Workflow Staging](#ivanti-queue--workflow-staging)
|
|
||||||
- [FP Workflow Submission](#fp-workflow-submission)
|
|
||||||
- [Archer Tickets](#archer-tickets)
|
|
||||||
- [Archer Template Library](#archer-template-library)
|
|
||||||
- [AEO Compliance](#aeo-compliance)
|
|
||||||
- [CCP Metrics — Multi-Vertical Compliance](#ccp-metrics--multi-vertical-compliance)
|
|
||||||
- [CARD Asset Ownership](#card-asset-ownership)
|
|
||||||
- [Granite Loader Sheet](#granite-loader-sheet)
|
|
||||||
- [Atlas Action Plans](#atlas-action-plans)
|
|
||||||
- [Jira Integration](#jira-integration)
|
|
||||||
- [Finding Archive Tracking](#finding-archive-tracking)
|
|
||||||
- [Findings Trend](#findings-trend)
|
|
||||||
- [Knowledge Base](#knowledge-base)
|
|
||||||
- [Exports](#exports)
|
|
||||||
- [In-App Notifications](#in-app-notifications)
|
|
||||||
- [Feedback — GitLab Integration](#feedback--gitlab-integration)
|
|
||||||
- [Access Control](#access-control)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Tech Stack](#tech-stack)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Database Schema](#database-schema)
|
|
||||||
- [Migrations](#migrations)
|
|
||||||
- [API Reference](#api-reference)
|
|
||||||
- [CI/CD Pipeline](#cicd-pipeline)
|
|
||||||
- [Documentation](#documentation)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -72,7 +34,7 @@ cp backend/.env.example backend/.env
|
|||||||
# openssl rand -base64 32
|
# openssl rand -base64 32
|
||||||
```
|
```
|
||||||
|
|
||||||
See `backend/.env.example` for all available options including `DATABASE_URL`, Ivanti API, Jira, Atlas, CARD, and GitLab integration keys.
|
See `backend/.env.example` for all available options including `DATABASE_URL`, Ivanti API, Jira, and Atlas integration keys.
|
||||||
|
|
||||||
### Start PostgreSQL
|
### Start PostgreSQL
|
||||||
|
|
||||||
@@ -85,14 +47,6 @@ chmod +x scripts/deploy-postgres.sh
|
|||||||
|
|
||||||
For fresh installs without an existing SQLite database, the script creates the schema and skips migration.
|
For fresh installs without an existing SQLite database, the script creates the schema and skips migration.
|
||||||
|
|
||||||
### Run Migrations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend && node migrations/run-all.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Migrations are idempotent and safe to re-run.
|
|
||||||
|
|
||||||
### Build and Run
|
### Build and Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -103,801 +57,80 @@ cd frontend && npm run build && cd ..
|
|||||||
./start-servers.sh
|
./start-servers.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashboard: http://localhost:3001
|
Dashboard: http://localhost:3000 · API: http://localhost:3001
|
||||||
|
|
||||||
The helper scripts use `systemctl` under the hood — the systemd units in `systemd/` must be installed first. See the full reference manual for setup instructions.
|
The helper scripts use `systemctl` under the hood — the systemd units in `systemd/` must be installed first. See the full manual for setup instructions.
|
||||||
|
|
||||||
### Interactive Configuration Wizard
|
|
||||||
|
|
||||||
For first-time deployments, the wizard walks through all required settings:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node configure.js
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Home — CVE Management
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
Searchable CVE list with per-vendor tracking and document storage. NVD API integration auto-populates severity, description, and publication date from the National Vulnerability Database.
|
| **CVE Management** | Track CVEs across multiple vendors with document storage and NVD auto-fill |
|
||||||
|
| **Reporting** | Ivanti host finding triage with donut charts, inline editing, advanced filtering, CSV/XLSX export |
|
||||||
**Capabilities:**
|
| **Ivanti Queue** | Personal staging list for batch FP, Archer, CARD, and Granite workflows |
|
||||||
- Create, edit, and delete CVE entries with vendor association
|
| **FP Workflow** | Submit false positive workflows directly to Ivanti API with attachments |
|
||||||
- Upload advisory documents (PDF, email, screenshot, patch notes) per CVE/vendor pair
|
| **Compliance** | Weekly AEO xlsx upload with diff preview, drift detection, per-team metric health cards |
|
||||||
- NVD auto-lookup on CVE ID entry (rate-limited, API key optional for higher throughput)
|
| **Archive Tracking** | Automatic detection of disappeared/returned findings with BU reassignment classification |
|
||||||
- Filter by vendor, severity, status, and free-text search
|
| **Findings Trend** | Historical open vs closed chart with archive activity sparkline and shift reason tooltips |
|
||||||
- Required document tracking per vendor with mandatory/optional flags
|
| **Jira Integration** | Create, sync, and track Jira Data Center tickets linked to CVE/vendor pairs |
|
||||||
- Calendar widget with SLA date highlighting
|
| **Archer Tickets** | Track risk acceptance exceptions (EXC numbers) linked to findings |
|
||||||
|
| **CARD API** | Granite/CARD asset lookup integration for network device workflows |
|
||||||
### Reporting — Ivanti Host Findings
|
| **Knowledge Base** | Internal document library with inline PDF/Markdown viewing |
|
||||||
|
| **Access Control** | Four user groups (Admin, Standard_User, Leadership, Read_Only) with full audit trail |
|
||||||
Ivanti/RiskSense host finding triage with donut charts, advanced filtering, inline editing, CSV/XLSX export, and multi-BU scope.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Sync open host findings from Ivanti API (24-hour cadence, configurable via `IVANTI_BU_FILTER`)
|
|
||||||
- Group by Host toggle — collapses duplicate assets (same hostname + IP) with multiple findings into expandable rows
|
|
||||||
- CARD ownership tooltip on IP hover — displays confirmed/unconfirmed/candidate teams from CARD API
|
|
||||||
- CARD direct action modal — confirm, decline, or redirect ownership without a queue item
|
|
||||||
- Advanced column filtering, severity sorting, and free-text search
|
|
||||||
- Inline note editing with override hostname/DNS fields
|
|
||||||
- Per-user Ivanti identity for filtered FP workflow views
|
|
||||||
- Multi-select BU picker for scoping visible findings
|
|
||||||
- CSV and XLSX export with current filter state
|
|
||||||
- Add findings to the todo queue for batch workflow processing
|
|
||||||
|
|
||||||
### Ivanti Queue — Workflow Staging
|
|
||||||
|
|
||||||
Personal staging list for batch-processing FP, Archer, CARD, GRANITE, DECOM, and Remediate workflows.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Add individual findings or bulk-select from the Reporting page
|
|
||||||
- Six workflow types: FP (False Positive), Archer (Risk Acceptance), CARD (Ownership), GRANITE (Loader Sheet), DECOM (Decommission), Remediate
|
|
||||||
- Collapsible sections per workflow type
|
|
||||||
- Multi-item Jira ticket creation (consolidation modal)
|
|
||||||
- Ticket link display on completed items
|
|
||||||
- Clear Completed with FK-safe deletion
|
|
||||||
- Redirect pending items between workflow types without duplication
|
|
||||||
- DECOM items auto-note and auto-hide the finding on completion
|
|
||||||
|
|
||||||
### FP Workflow Submission
|
|
||||||
|
|
||||||
Submit False Positive workflows directly to the Ivanti API with attachments and lifecycle tracking.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Batch-select queue items for FP submission
|
|
||||||
- Attach supporting documents (10MB limit per file)
|
|
||||||
- Configurable expiration date, scope override, and reason
|
|
||||||
- Lifecycle tracking: submitted, approved, rejected, rework, resubmitted
|
|
||||||
- Edit and re-submit rejected workflows
|
|
||||||
- Re-queue findings from rejected submissions
|
|
||||||
- Auto-clear approved submissions, dismiss rejected
|
|
||||||
- Per-user workflow history with attachment results
|
|
||||||
- Collapsible submissions panel
|
|
||||||
|
|
||||||
### Archer Tickets
|
|
||||||
|
|
||||||
Track Archer risk acceptance exceptions (EXC numbers) linked to CVE/vendor pairs.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Create Archer ticket records with EXC number, URL, and status
|
|
||||||
- Status workflow: Draft, Open, Under Review, Accepted
|
|
||||||
- Link tickets to specific CVE/vendor combinations
|
|
||||||
- Full CRUD with audit logging
|
|
||||||
|
|
||||||
### Archer Template Library
|
|
||||||
|
|
||||||
Template management system for Archer Risk Acceptance forms. Stores static content (Environment Overview, Segmentation, Mitigating Controls) organised by Vendor, Platform, and Model.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Full CRUD with clone, search/filter, and per-section copy-to-clipboard
|
|
||||||
- Inline view panel with per-section copy buttons
|
|
||||||
- Template selector integrated into the Ivanti Queue for Archer workflow items
|
|
||||||
- Organised hierarchy: Vendor > Platform > Model
|
|
||||||
- Accessible from nav drawer (Template Mgr) and Ivanti Queue Archer items
|
|
||||||
|
|
||||||
### AEO Compliance
|
|
||||||
|
|
||||||
Weekly AEO compliance xlsx upload with diff preview, drift detection, per-team metric health cards, and device-level violation tracking.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Upload weekly compliance spreadsheets (xlsx) with automated parsing
|
|
||||||
- Diff preview showing new, resolved, and recurring non-compliant items
|
|
||||||
- Per-team metric health cards with pass/fail indicators
|
|
||||||
- Device-level violation detail panel with notes per hostname/metric pair
|
|
||||||
- Bulk notes import from CSV
|
|
||||||
- Compliance history tracking across uploads
|
|
||||||
- Per-metric estimated resolution date display
|
|
||||||
- Notes with group_id for batch operations
|
|
||||||
|
|
||||||
### CCP Metrics — Multi-Vertical Compliance
|
|
||||||
|
|
||||||
Cross-organisational VCL (Vulnerability Compliance Level) upload and reporting for multiple verticals. The CCP Metrics page provides executive-level visibility into compliance posture across teams.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Multi-vertical VCL xlsx upload with Summary and Detail sheet parsing
|
|
||||||
- Metric-first hierarchy with per-metric remediation plans
|
|
||||||
- Per-metric forecast burndown chart
|
|
||||||
- Aggregated burndown forecast on overview page
|
|
||||||
- Sub-team drill-down with intermediate view and per-team breakdowns
|
|
||||||
- Non-Compliant stat clickable with metric breakdown buttons
|
|
||||||
- Compliant/total counts on metric summary cards
|
|
||||||
- LIVE and LAST REPORT badges showing data freshness
|
|
||||||
- Data management panel — delete vertical, rollback upload, reset all
|
|
||||||
- Exec report page with exportable summaries
|
|
||||||
- Inline-editable team fields on vertical metadata
|
|
||||||
- Device metadata fields for asset context
|
|
||||||
|
|
||||||
### CARD Asset Ownership
|
|
||||||
|
|
||||||
CARD API integration for asset ownership management — confirm, decline, and redirect ownership for network assets.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Owner lookup by IP address or Ivanti Host ID (asset-search)
|
|
||||||
- Confirm, decline, and redirect ownership actions via API
|
|
||||||
- Suffix-guessing fallback when no host_id available (CTEC, NATL, CHTR, COML, RESI, WIFI, VOIP)
|
|
||||||
- IP address validation before mutation operations
|
|
||||||
- Update_token handling for safe concurrent operations
|
|
||||||
- Cached tooltip results per session
|
|
||||||
- Team assets endpoint for batch enrichment
|
|
||||||
- Quick mode (CTEC suffix only, 15s timeout) for tooltip performance
|
|
||||||
|
|
||||||
### Granite Loader Sheet
|
|
||||||
|
|
||||||
Generate Granite Loader Sheets with CARD enrichment, searchable picklists, per-row editing, and XLSX export.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Generate loader sheets from queue items or the Reporting page
|
|
||||||
- CARD enrichment — pull NCIM, Qualys, Netops Granite data per asset
|
|
||||||
- Searchable picklists for teams, statuses, operation types
|
|
||||||
- Per-row inline editing before export
|
|
||||||
- Column groups with configurable visibility
|
|
||||||
- XLSX export with formatting
|
|
||||||
|
|
||||||
### Atlas Action Plans
|
|
||||||
|
|
||||||
Atlas InfoSec action plan tracking with per-host vulnerability mapping and local cache for badge rendering.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- View action plans linked to Ivanti host findings via host_id
|
|
||||||
- Create remediation, risk acceptance, and compensating control plans
|
|
||||||
- AtlasBadge component on findings rows indicating plan existence
|
|
||||||
- Slide-out detail panel with plan metadata
|
|
||||||
- Local cache (`atlas_action_plans_cache` table) for instant badge rendering
|
|
||||||
- Manual cache refresh triggers re-fetch from Atlas API
|
|
||||||
- Qualys vulnerability mapping per host
|
|
||||||
|
|
||||||
### Jira Integration
|
|
||||||
|
|
||||||
Create, sync, and track Jira Data Center tickets linked to CVE/vendor pairs and Ivanti queue items.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Create tickets from CVE records or Ivanti queue items
|
|
||||||
- Multi-item Jira ticket creation from the consolidation modal
|
|
||||||
- Flexible ticket creation — CVE/Vendor fields optional, source context tracking
|
|
||||||
- Vendor-specific issue type dropdown with per-vendor project keys
|
|
||||||
- JQL-based ticket lookup and sync
|
|
||||||
- Raw Jira status display (no status mapping)
|
|
||||||
- Save to Dashboard from Jira lookup results
|
|
||||||
- Dedicated Jira page for managing tickets
|
|
||||||
- Rate limiting with configurable window and burst limits
|
|
||||||
- Blocked dangerous endpoints (bulk delete, user management)
|
|
||||||
|
|
||||||
### Finding Archive Tracking
|
|
||||||
|
|
||||||
Automatic detection of disappeared and returned findings with BU reassignment classification and anomaly logging.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Detect findings that disappear between syncs — classify as ARCHIVED, RETURNED, CLOSED, or CLOSED_GONE
|
|
||||||
- BU reassignment tracking with history log
|
|
||||||
- Sync anomaly detection with significance thresholds
|
|
||||||
- Anomaly banner component on the Reporting page
|
|
||||||
- Archive summary bar with state distribution
|
|
||||||
- Transition history with severity-at-transition recording
|
|
||||||
- Return classification (original finding restored vs. new duplicate)
|
|
||||||
- Configurable `IVANTI_MANAGED_BUS` for drift classification scope
|
|
||||||
|
|
||||||
### Findings Trend
|
|
||||||
|
|
||||||
Historical open vs closed findings chart with per-BU trend lines, archive activity sparkline, and shift reason tooltips.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Ivanti counts history chart (open/closed over time)
|
|
||||||
- Per-BU trend lines via `ivanti_counts_history_by_bu`
|
|
||||||
- Archive activity sparkline overlay
|
|
||||||
- Shift reason tooltips from anomaly log
|
|
||||||
|
|
||||||
### Knowledge Base
|
|
||||||
|
|
||||||
Internal document library for policies, guides, and reference material.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Upload documents (PDF, Markdown, Word, Excel)
|
|
||||||
- Inline PDF and Markdown viewing
|
|
||||||
- Category-based organisation with search
|
|
||||||
- Slug-based URL routing
|
|
||||||
|
|
||||||
### Exports
|
|
||||||
|
|
||||||
Dedicated Exports page with pre-built export cards for common data pulls.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Jira Tickets export
|
|
||||||
- CCP Metrics export
|
|
||||||
- Remediation Status export
|
|
||||||
- CSV and XLSX format options
|
|
||||||
|
|
||||||
### In-App Notifications
|
|
||||||
|
|
||||||
Native notification system replacing the previous Webex bot integration.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Per-user notification bell with unread count
|
|
||||||
- Notification types for sync events, workflow completions, and system alerts
|
|
||||||
- Mark as read / dismiss actions
|
|
||||||
- Persistent storage in `notifications` table
|
|
||||||
|
|
||||||
### Feedback — GitLab Integration
|
|
||||||
|
|
||||||
In-app bug reports and feature requests submitted directly to the GitLab project as issues.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Feedback modal accessible from the nav drawer
|
|
||||||
- Bug report and feature request templates
|
|
||||||
- Submitted as GitLab issues via PAT authentication
|
|
||||||
- GitLab webhook receiver for issue lifecycle events (label changes, closes)
|
|
||||||
- Webhook secret validation for security
|
|
||||||
|
|
||||||
### Access Control
|
|
||||||
|
|
||||||
Four user groups with role-based permissions and full audit trail.
|
|
||||||
|
|
||||||
| Group | Permissions |
|
|
||||||
|-------|------------|
|
|
||||||
| Admin | Full CRUD, user management, audit log access, system configuration, data management |
|
|
||||||
| Standard_User | Create/update operations, FP workflow submission, queue management |
|
|
||||||
| Leadership | Read access to all data plus compliance and export views |
|
|
||||||
| Read_Only | Read-only access to all data |
|
|
||||||
|
|
||||||
**Additional capabilities:**
|
|
||||||
- Per-user BU team assignments (multi-BU tenancy)
|
|
||||||
- Per-user Ivanti identity (first/last name) for workflow filtering
|
|
||||||
- Cookie-based sessions with 24-hour expiry (httpOnly)
|
|
||||||
- Login rate limiting (20 attempts per 15-minute window)
|
|
||||||
- Full audit trail for all state-changing operations
|
|
||||||
- User profile panel with password change
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cve-dashboard/
|
cve-dashboard/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── server.js # Express API — middleware, CVE/document routes inline
|
│ ├── server.js # Express API server
|
||||||
│ ├── db.js # PostgreSQL connection pool (pg)
|
│ ├── db.js # PostgreSQL connection pool (pg)
|
||||||
│ ├── db-schema.sql # Complete DDL for fresh Postgres setup
|
│ ├── db-schema.sql # Complete DDL for fresh Postgres setup
|
||||||
│ ├── setup.js # One-time DB init + default admin creation
|
│ ├── setup-postgres.js # Schema initializer (runs db-schema.sql)
|
||||||
│ ├── routes/
|
│ ├── routes/ # API route handlers
|
||||||
│ │ ├── auth.js # Login, logout, session validation
|
│ ├── helpers/ # API clients (Ivanti, Jira, Atlas, CARD)
|
||||||
│ │ ├── users.js # User CRUD, role/group management
|
│ ├── middleware/ # Auth middleware
|
||||||
│ │ ├── auditLog.js # Audit log queries
|
│ ├── migrations/ # Schema migrations (legacy SQLite deployments)
|
||||||
│ │ ├── nvdLookup.js # NVD API proxy
|
│ └── scripts/ # Compliance parser, data import utilities
|
||||||
│ │ ├── knowledgeBase.js # Document library CRUD
|
|
||||||
│ │ ├── archerTickets.js # Archer EXC ticket tracking
|
|
||||||
│ │ ├── archerTemplates.js # Archer template library CRUD + clone
|
|
||||||
│ │ ├── ivantiWorkflows.js # Ivanti FP workflow batch queries
|
|
||||||
│ │ ├── ivantiFindings.js # Ivanti findings sync, query, inline edit
|
|
||||||
│ │ ├── ivantiTodoQueue.js # Per-user queue staging
|
|
||||||
│ │ ├── ivantiArchive.js # Finding archive tracking + anomaly log
|
|
||||||
│ │ ├── ivantiFpWorkflow.js# FP workflow submission to Ivanti API
|
|
||||||
│ │ ├── compliance.js # AEO compliance upload + items + notes
|
|
||||||
│ │ ├── vclMultiVertical.js# VCL/CCP multi-vertical compliance
|
|
||||||
│ │ ├── atlas.js # Atlas action plan proxy + cache
|
|
||||||
│ │ ├── jiraTickets.js # Jira CRUD + REST API integration
|
|
||||||
│ │ ├── cardApi.js # CARD ownership proxy + asset-search
|
|
||||||
│ │ ├── notifications.js # In-app notification system
|
|
||||||
│ │ ├── feedback.js # GitLab issue creation for bug/feature
|
|
||||||
│ │ └── webhooks.js # GitLab webhook receiver
|
|
||||||
│ ├── helpers/
|
|
||||||
│ │ ├── auditLog.js # logAudit() — fire-and-forget DB insert
|
|
||||||
│ │ ├── cardApi.js # CARD API — OAuth token, owner lookup, asset-search
|
|
||||||
│ │ ├── ivantiApi.js # Ivanti/RiskSense HTTP helpers
|
|
||||||
│ │ ├── atlasApi.js # Atlas action plan API
|
|
||||||
│ │ ├── jiraApi.js # Jira ticket creation + rate limiter
|
|
||||||
│ │ ├── driftChecker.js # BU drift detection between syncs
|
|
||||||
│ │ ├── vclHelpers.js # VCL metric calculation helpers
|
|
||||||
│ │ └── teams.js # Team validation helpers
|
|
||||||
│ ├── middleware/
|
|
||||||
│ │ └── auth.js # requireAuth(), requireGroup(...groups)
|
|
||||||
│ ├── migrations/ # Sequential migration scripts (idempotent)
|
|
||||||
│ │ └── run-all.js # Run all migrations in order
|
|
||||||
│ └── scripts/ # Python utilities (compliance parsing)
|
|
||||||
│
|
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ └── src/
|
│ ├── src/
|
||||||
│ ├── App.js # Main app — routing, CVE list, filters, modals
|
│ │ ├── App.js # Main app with routing
|
||||||
│ ├── App.css # Global styles and CSS variables
|
│ │ ├── components/ # React components
|
||||||
│ ├── contexts/
|
│ │ └── contexts/ # Auth context
|
||||||
│ │ └── AuthContext.js # Auth state provider (login, logout, role helpers)
|
│ └── public/
|
||||||
│ ├── utils/
|
|
||||||
│ │ ├── graniteLoaderConfig.js # Granite column definitions and groups
|
|
||||||
│ │ ├── graniteLoaderExport.js # XLSX generation logic
|
|
||||||
│ │ └── graniteLoaderPicklists.js # Searchable dropdown options
|
|
||||||
│ └── components/
|
|
||||||
│ ├── LoginForm.js
|
|
||||||
│ ├── NavDrawer.js
|
|
||||||
│ ├── UserMenu.js
|
|
||||||
│ ├── UserProfilePanel.js
|
|
||||||
│ ├── CalendarWidget.js
|
|
||||||
│ ├── UserManagement.js
|
|
||||||
│ ├── AuditLog.js
|
|
||||||
│ ├── NvdSyncModal.js
|
|
||||||
│ ├── KnowledgeBaseModal.js
|
|
||||||
│ ├── KnowledgeBaseViewer.js
|
|
||||||
│ ├── CardOwnerTooltip.js
|
|
||||||
│ ├── CardDetailModal.js
|
|
||||||
│ ├── CardActionModal.js
|
|
||||||
│ ├── RedirectModal.js
|
|
||||||
│ ├── LoaderModal.js
|
|
||||||
│ ├── SearchableSelect.js
|
|
||||||
│ ├── AtlasBadge.js
|
|
||||||
│ ├── AtlasIcon.js
|
|
||||||
│ ├── AtlasSlideOutPanel.js
|
|
||||||
│ ├── AdminScopeToggle.js
|
|
||||||
│ ├── ConfirmModal.js
|
|
||||||
│ ├── ConsolidationModal.js
|
|
||||||
│ ├── CveTooltip.js
|
|
||||||
│ ├── DeleteConfirmModal.js
|
|
||||||
│ ├── FeedbackModal.js
|
|
||||||
│ ├── NotificationBell.js
|
|
||||||
│ ├── RemediationModal.js
|
|
||||||
│ ├── TemplateFormModal.js
|
|
||||||
│ ├── TemplateSelector.js
|
|
||||||
│ └── pages/
|
|
||||||
│ ├── AdminPage.js
|
|
||||||
│ ├── ReportingPage.js
|
|
||||||
│ ├── IvantiTodoQueuePage.js
|
|
||||||
│ ├── CompliancePage.js
|
|
||||||
│ ├── ComplianceUploadModal.js
|
|
||||||
│ ├── ComplianceDetailPanel.js
|
|
||||||
│ ├── ComplianceChartsPanel.js
|
|
||||||
│ ├── CCPMetricsPage.js
|
|
||||||
│ ├── VCLReportPage.js
|
|
||||||
│ ├── MetricInfoPanel.js
|
|
||||||
│ ├── BulkUploadModal.js
|
|
||||||
│ ├── MultiVerticalUploadModal.js
|
|
||||||
│ ├── IvantiCountsChart.js
|
|
||||||
│ ├── AnomalyBanner.js
|
|
||||||
│ ├── ArchiveSummaryBar.js
|
|
||||||
│ ├── ArcherPage.js
|
|
||||||
│ ├── ArcherTemplatePage.js
|
|
||||||
│ ├── JiraPage.js
|
|
||||||
│ ├── KnowledgeBasePage.js
|
|
||||||
│ └── ExportsPage.js
|
|
||||||
│
|
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── api/ # API specs (Ivanti, Atlas, Jira)
|
│ ├── api/ # API specs (Ivanti, Atlas, Jira)
|
||||||
│ ├── architecture/ # Architecture proposals (AD/SAML, split)
|
│ ├── design/ # Design system, workflow diagrams
|
||||||
│ ├── design/ # Design system, workflow colour codes
|
│ ├── guides/ # User guides, full reference manual
|
||||||
│ ├── guides/ # User guides, full reference manual, VCL calculations
|
|
||||||
│ ├── operations/ # Operational logs and connectivity tests
|
|
||||||
│ ├── security/ # Security audits and remediation plans
|
│ ├── security/ # Security audits and remediation plans
|
||||||
│ ├── testing/ # Test plans and scripts
|
│ ├── testing/ # Test plans and scripts
|
||||||
│ └── troubleshooting/ # Investigation scripts and reports
|
│ └── troubleshooting/ # Investigation scripts and reports
|
||||||
│
|
|
||||||
├── scripts/
|
|
||||||
│ ├── deploy-postgres.sh # One-time: container, schema, migration
|
|
||||||
│ └── reset-and-migrate.sh # Dev utility: reset DB and re-run migrations
|
|
||||||
├── deploy/
|
|
||||||
│ ├── cve-backend-production.service
|
|
||||||
│ ├── cve-backend-staging.service
|
|
||||||
│ └── setup-staging.sh
|
|
||||||
├── systemd/
|
|
||||||
│ ├── cve-backend.service
|
|
||||||
│ └── cve-frontend.service
|
|
||||||
├── configure.js # Interactive configuration wizard
|
|
||||||
├── docker-compose.yml # PostgreSQL 16 container definition
|
├── docker-compose.yml # PostgreSQL 16 container definition
|
||||||
├── start-servers.sh # Start backend (serves API + frontend build)
|
├── scripts/
|
||||||
├── stop-servers.sh # Stop backend
|
│ └── deploy-postgres.sh # One-time deployment: container, schema, migration
|
||||||
└── CHANGELOG.md
|
├── systemd/ # systemd service files
|
||||||
|
├── start-servers.sh
|
||||||
|
└── stop-servers.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|------------|
|
|-------|------------|
|
||||||
| Backend | Node.js 18+, Express 5 |
|
| Backend | Node.js 18+, Express 5 |
|
||||||
| Database | PostgreSQL 16 (Docker, port 5433) |
|
| Database | PostgreSQL 16 (Docker, port 5433) |
|
||||||
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry), express-rate-limit |
|
| Frontend | React 19, Recharts, Lucide React |
|
||||||
| File uploads | Multer 2 (10MB limit) |
|
| Auth | bcryptjs, cookie-based sessions, express-rate-limit |
|
||||||
| Frontend | React 19 (Create React App / react-scripts 5) |
|
| Compliance | Python 3, pandas, openpyxl |
|
||||||
| Frontend serving | Express serves `frontend/build/` as static files on port 3001 |
|
|
||||||
| UI Icons | lucide-react |
|
|
||||||
| Charts | recharts |
|
|
||||||
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
|
||||||
| Markdown rendering | react-markdown + rehype-sanitize |
|
|
||||||
| Diagrams | mermaid |
|
|
||||||
| Testing | Jest 30 (backend property tests), React Testing Library (frontend) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration lives in `backend/.env`. Copy `backend/.env.example` as a starting point.
|
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| `PORT` | Backend server port | `3001` |
|
|
||||||
| `API_HOST` | Backend bind address | `localhost` |
|
|
||||||
| `CORS_ORIGINS` | Comma-separated allowed origins | `http://localhost:3000` |
|
|
||||||
| `SESSION_SECRET` | **Required.** Session signing key. Generate with `openssl rand -base64 32` | — |
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string | — |
|
|
||||||
| `NVD_API_KEY` | NVD API key (increases rate limit from 5 to 50 req/30s) | — |
|
|
||||||
| `IVANTI_API_KEY` | Ivanti/RiskSense API key | — |
|
|
||||||
| `IVANTI_CLIENT_ID` | Ivanti client ID | `1550` |
|
|
||||||
| `IVANTI_FIRST_NAME` | Fallback Ivanti identity (first name) | — |
|
|
||||||
| `IVANTI_LAST_NAME` | Fallback Ivanti identity (last name) | — |
|
|
||||||
| `IVANTI_BU_FILTER` | Comma-separated BU values to sync | `NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM` |
|
|
||||||
| `IVANTI_MANAGED_BUS` | BUs considered "managed" for drift classification | `NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM` |
|
|
||||||
| `IVANTI_SKIP_TLS` | Skip TLS verification for SSL inspection proxies | `false` |
|
|
||||||
| `ATLAS_API_URL` | Atlas InfoSec API base URL | — |
|
|
||||||
| `ATLAS_API_USER` | Atlas Basic Auth username | — |
|
|
||||||
| `ATLAS_API_PASS` | Atlas Basic Auth password | — |
|
|
||||||
| `ATLAS_SKIP_TLS` | Skip Atlas TLS verification | `false` |
|
|
||||||
| `JIRA_BASE_URL` | Jira Data Center REST API base URL | — |
|
|
||||||
| `JIRA_AUTH_METHOD` | Auth method: `basic` or `pat` | `basic` |
|
|
||||||
| `JIRA_API_USER` | Jira Basic Auth username | — |
|
|
||||||
| `JIRA_API_TOKEN` | Jira Basic Auth token | — |
|
|
||||||
| `JIRA_PAT` | Jira Personal Access Token (when `JIRA_AUTH_METHOD=pat`) | — |
|
|
||||||
| `JIRA_PROJECT_KEY` | Default Jira project key | — |
|
|
||||||
| `JIRA_ISSUE_TYPE` | Default issue type | `Task` |
|
|
||||||
| `JIRA_SKIP_TLS` | Skip Jira TLS verification | `false` |
|
|
||||||
| `CARD_API_URL` | CARD API base URL | — |
|
|
||||||
| `CARD_API_USER` | CARD OAuth username | — |
|
|
||||||
| `CARD_API_PASS` | CARD OAuth password | — |
|
|
||||||
| `CARD_SKIP_TLS` | Skip CARD TLS verification | `false` |
|
|
||||||
| `GITLAB_URL` | GitLab instance URL for feedback integration | `http://steam-gitlab.charterlab.com` |
|
|
||||||
| `GITLAB_PROJECT_ID` | GitLab project numeric ID | — |
|
|
||||||
| `GITLAB_PAT` | GitLab project access token (api scope) | — |
|
|
||||||
| `GITLAB_WEBHOOK_SECRET` | Shared secret for webhook validation | — |
|
|
||||||
|
|
||||||
> `SESSION_SECRET` and `DATABASE_URL` are required for the backend to start. All integration keys are optional — features degrade gracefully when their keys are absent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
PostgreSQL 16 with the following tables:
|
|
||||||
|
|
||||||
| Table | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `cves` | CVE records with vendor association, severity, status |
|
|
||||||
| `documents` | Uploaded advisory documents linked to CVE/vendor pairs |
|
|
||||||
| `required_documents` | Per-vendor required document types |
|
|
||||||
| `users` | User accounts with role, group, BU teams, Ivanti identity |
|
|
||||||
| `sessions` | Active session tokens with expiry |
|
|
||||||
| `audit_logs` | Immutable audit trail for all state-changing operations |
|
|
||||||
| `jira_tickets` | Locally tracked Jira tickets linked to CVE/vendor |
|
|
||||||
| `archer_tickets` | Archer risk acceptance exceptions (EXC numbers) |
|
|
||||||
| `knowledge_base` | Internal document library entries |
|
|
||||||
| `ivanti_findings` | Individual Ivanti host findings (synced from API) |
|
|
||||||
| `ivanti_sync_state` | Sync metadata — last run time, status, error |
|
|
||||||
| `ivanti_counts_cache` | Cached open/closed counts and FP workflow counts |
|
|
||||||
| `ivanti_counts_history` | Historical open/closed counts over time |
|
|
||||||
| `ivanti_counts_history_by_bu` | Per-BU historical counts |
|
|
||||||
| `ivanti_fp_submissions` | FP workflow submissions with lifecycle tracking |
|
|
||||||
| `ivanti_fp_submission_history` | Edit history for FP submissions |
|
|
||||||
| `ivanti_todo_queue` | Per-user workflow staging queue |
|
|
||||||
| `ivanti_finding_archives` | Archived/returned/closed finding records |
|
|
||||||
| `ivanti_archive_transitions` | State transition history for archived findings |
|
|
||||||
| `ivanti_sync_anomaly_log` | Sync anomaly detection log |
|
|
||||||
| `ivanti_finding_bu_history` | BU reassignment history per finding |
|
|
||||||
| `atlas_action_plans_cache` | Cached Atlas action plan data for badge rendering |
|
|
||||||
| `compliance_uploads` | Compliance xlsx upload metadata |
|
|
||||||
| `compliance_items` | Individual non-compliant items per upload |
|
|
||||||
| `compliance_notes` | Per-hostname/metric notes |
|
|
||||||
|
|
||||||
The complete DDL is in `backend/db-schema.sql`. For fresh installs, `scripts/deploy-postgres.sh` applies it automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
Run all migrations (idempotent):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend && node migrations/run-all.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Migration files in execution order:
|
|
||||||
|
|
||||||
| Migration | Purpose |
|
|
||||||
|-----------|---------|
|
|
||||||
| `add_user_groups.js` | User group column and check constraint |
|
|
||||||
| `add_user_ivanti_identity.js` | Per-user Ivanti first/last name |
|
|
||||||
| `add_user_bu_teams.js` | Per-user BU team assignments |
|
|
||||||
| `add_knowledge_base_table.js` | Knowledge base document library |
|
|
||||||
| `add_ivanti_sync_table.js` | Ivanti sync state (single-row) |
|
|
||||||
| `add_ivanti_findings_tables.js` | Individual findings + counts cache |
|
|
||||||
| `add_ivanti_findings_ipv6_columns.js` | IPv6 fallback columns |
|
|
||||||
| `add_ivanti_counts_history_table.js` | Historical counts + per-BU |
|
|
||||||
| `add_ivanti_todo_queue_table.js` | Per-user workflow staging queue |
|
|
||||||
| `add_todo_queue_hostname.js` | Hostname column on queue items |
|
|
||||||
| `add_todo_queue_ip_address.js` | IP address column on queue items |
|
|
||||||
| `add_granite_workflow_type.js` | GRANITE workflow type |
|
|
||||||
| `add_card_workflow_type.js` | CARD workflow type |
|
|
||||||
| `add_decom_workflow_type.js` | DECOM workflow type |
|
|
||||||
| `add_remediate_workflow_type.js` | Remediate workflow type |
|
|
||||||
| `add_compliance_tables.js` | Compliance uploads, items, notes |
|
|
||||||
| `add_compliance_notes_group_id.js` | Group ID for batch notes |
|
|
||||||
| `add_compliance_history_metric_id.js` | Metric ID in compliance history |
|
|
||||||
| `add_compliance_item_history.js` | Compliance item history tracking |
|
|
||||||
| `add_fp_submissions_table.js` | FP workflow submissions |
|
|
||||||
| `add_fp_submission_editing.js` | Submission edit history |
|
|
||||||
| `add_fp_submissions_dismissed.js` | Dismissed/lifecycle status |
|
|
||||||
| `add_fp_submissions_requeued_at.js` | Requeue timestamp |
|
|
||||||
| `add_archer_tickets_table.js` | Archer EXC tickets |
|
|
||||||
| `add_archer_tickets_timestamps.js` | Timestamp columns |
|
|
||||||
| `add_archer_templates_table.js` | Archer template library |
|
|
||||||
| `add_atlas_action_plans_cache.js` | Atlas plan cache |
|
|
||||||
| `add_atlas_known_column.js` | Atlas known flag |
|
|
||||||
| `add_finding_archive_tables.js` | Archive tracking tables |
|
|
||||||
| `add_closed_gone_state.js` | CLOSED_GONE archive state |
|
|
||||||
| `add_return_classification.js` | Return classification field |
|
|
||||||
| `add_sync_anomaly_tables.js` | Anomaly detection log |
|
|
||||||
| `add_flexible_jira_ticket_creation.js` | Optional CVE/vendor on Jira tickets |
|
|
||||||
| `add_multi_item_jira_ticket.js` | Multi-item ticket references |
|
|
||||||
| `add_jira_sync_columns.js` | Jira sync metadata (SQLite) |
|
|
||||||
| `add_jira_sync_columns_pg.js` | Jira sync metadata (PostgreSQL) |
|
|
||||||
| `drop_jira_status_check_constraint.js` | Remove status enum for raw display |
|
|
||||||
| `add_notifications_table.js` | In-app notification system |
|
|
||||||
| `add_created_by_columns.js` | created_by on archer_tickets |
|
|
||||||
| `add_queue_remediation_notes_table.js` | Remediation notes on queue items |
|
|
||||||
| `add_vcl_multi_vertical.js` | VCL multi-vertical tables |
|
|
||||||
| `add_vcl_reporting_columns.js` | VCL reporting metadata |
|
|
||||||
| `add_vcl_vertical_metadata.js` | VCL vertical team fields |
|
|
||||||
| `backfill_anomaly_log.js` | Backfill anomaly classification data |
|
|
||||||
| `backfill_return_classification.js` | Backfill return classification |
|
|
||||||
| `reclassify_bu_roundtrips.js` | Reclassify BU roundtrip transitions |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
All routes are prefixed with `/api`. All endpoints except login, logout, health check, and webhooks require a valid session cookie.
|
|
||||||
|
|
||||||
### Public
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/health` | Health check (used by CI/CD verification) |
|
|
||||||
| POST | `/api/auth/login` | Authenticate and create session |
|
|
||||||
| POST | `/api/auth/logout` | Destroy session |
|
|
||||||
| POST | `/api/webhooks/gitlab` | GitLab webhook receiver (validated by secret) |
|
|
||||||
|
|
||||||
### CVE Management
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/cves` | List CVEs with filtering | Any |
|
|
||||||
| POST | `/api/cves` | Create CVE | Standard_User+ |
|
|
||||||
| PUT | `/api/cves/:id` | Update CVE | Standard_User+ |
|
|
||||||
| DELETE | `/api/cves/:id` | Delete CVE | Admin |
|
|
||||||
| POST | `/api/cves/:cveId/:vendor/documents` | Upload document | Standard_User+ |
|
|
||||||
| GET | `/api/cves/:cveId/:vendor/documents` | List documents | Any |
|
|
||||||
| DELETE | `/api/documents/:id` | Delete document | Admin |
|
|
||||||
|
|
||||||
### Users and Auth
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/auth/me` | Current session user | Any |
|
|
||||||
| GET | `/api/users` | List all users | Admin |
|
|
||||||
| POST | `/api/users` | Create user | Admin |
|
|
||||||
| PUT | `/api/users/:id` | Update user (role, group, teams) | Admin |
|
|
||||||
| DELETE | `/api/users/:id` | Delete user | Admin |
|
|
||||||
| PUT | `/api/users/:id/password` | Change password | Owner or Admin |
|
|
||||||
|
|
||||||
### Ivanti Findings
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/ivanti/findings` | Query synced findings | Any |
|
|
||||||
| POST | `/api/ivanti/findings/sync` | Trigger manual sync | Standard_User+ |
|
|
||||||
| PUT | `/api/ivanti/findings/:id/note` | Update finding note | Standard_User+ |
|
|
||||||
| PUT | `/api/ivanti/findings/:id/override` | Override hostname/DNS | Standard_User+ |
|
|
||||||
|
|
||||||
### Ivanti Queue
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/ivanti/todo-queue` | Get user's queue | Any |
|
|
||||||
| POST | `/api/ivanti/todo-queue` | Add item(s) to queue | Standard_User+ |
|
|
||||||
| PUT | `/api/ivanti/todo-queue/:id` | Update queue item | Standard_User+ |
|
|
||||||
| DELETE | `/api/ivanti/todo-queue/:id` | Remove queue item | Standard_User+ |
|
|
||||||
| DELETE | `/api/ivanti/todo-queue/completed` | Clear completed items | Standard_User+ |
|
|
||||||
|
|
||||||
### Ivanti FP Workflow
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| POST | `/api/ivanti/fp-workflow` | Submit FP workflow to Ivanti | Standard_User+ |
|
|
||||||
| GET | `/api/ivanti/fp-workflow/submissions` | List user's submissions | Any |
|
|
||||||
| PUT | `/api/ivanti/fp-workflow/submissions/:id` | Edit/resubmit | Standard_User+ |
|
|
||||||
|
|
||||||
### Ivanti Archive
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/ivanti/archive` | Query archived findings | Any |
|
|
||||||
| GET | `/api/ivanti/archive/anomalies` | Sync anomaly log | Any |
|
|
||||||
| GET | `/api/ivanti/archive/transitions/:id` | Transition history | Any |
|
|
||||||
|
|
||||||
### Compliance (AEO)
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| POST | `/api/compliance/upload` | Upload xlsx | Standard_User+ |
|
|
||||||
| GET | `/api/compliance/items` | Query non-compliant items | Any |
|
|
||||||
| GET | `/api/compliance/trends` | Compliance trend data | Any |
|
|
||||||
| POST | `/api/compliance/notes` | Add note to hostname/metric | Standard_User+ |
|
|
||||||
|
|
||||||
### VCL Multi-Vertical (CCP Metrics)
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| POST | `/api/compliance/vcl-multi/upload` | Upload VCL xlsx | Standard_User+ |
|
|
||||||
| GET | `/api/compliance/vcl-multi/verticals` | List verticals | Any |
|
|
||||||
| GET | `/api/compliance/vcl-multi/metrics` | Query metrics | Any |
|
|
||||||
| GET | `/api/compliance/vcl-multi/forecast` | Burndown forecast | Any |
|
|
||||||
| DELETE | `/api/compliance/vcl-multi/verticals/:id` | Delete vertical | Admin |
|
|
||||||
|
|
||||||
### Atlas
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/atlas/hosts/:hostId/plans` | Get plans for host | Any |
|
|
||||||
| PUT | `/api/atlas/hosts/:hostId/plans` | Create plan | Standard_User+ |
|
|
||||||
| PATCH | `/api/atlas/hosts/:hostId/plans` | Update plan | Standard_User+ |
|
|
||||||
| POST | `/api/atlas/hosts/:hostId/refresh` | Refresh cache | Standard_User+ |
|
|
||||||
|
|
||||||
### Jira
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/jira-tickets` | List local tickets | Any |
|
|
||||||
| POST | `/api/jira-tickets` | Create ticket (local + Jira API) | Standard_User+ |
|
|
||||||
| GET | `/api/jira-tickets/lookup` | JQL search against Jira | Standard_User+ |
|
|
||||||
| POST | `/api/jira-tickets/save` | Save from Jira lookup | Standard_User+ |
|
|
||||||
|
|
||||||
### CARD
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/card/owner/:assetId` | Lookup owner | Any |
|
|
||||||
| GET | `/api/card/asset-search/:hostId` | Search by Ivanti host ID | Any |
|
|
||||||
| POST | `/api/card/owner/:assetId/confirm` | Confirm ownership | Standard_User+ |
|
|
||||||
| POST | `/api/card/owner/:assetId/decline` | Decline ownership | Standard_User+ |
|
|
||||||
| POST | `/api/card/owner/:assetId/redirect` | Redirect ownership | Standard_User+ |
|
|
||||||
| GET | `/api/card/teams` | List all CARD teams | Any |
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
| Method | Path | Description | Group |
|
|
||||||
|--------|------|-------------|-------|
|
|
||||||
| GET | `/api/audit-logs` | Query audit trail | Admin |
|
|
||||||
| GET | `/api/nvd/:cveId` | NVD metadata lookup | Any |
|
|
||||||
| GET/POST | `/api/knowledge-base` | Document library CRUD | Any / Standard_User+ |
|
|
||||||
| GET/POST | `/api/archer-tickets` | Archer ticket CRUD | Any / Standard_User+ |
|
|
||||||
| GET/POST | `/api/archer-templates` | Template library CRUD | Any / Standard_User+ |
|
|
||||||
| GET | `/api/notifications` | User notifications | Any |
|
|
||||||
| POST | `/api/feedback` | Submit bug/feature to GitLab | Any |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD Pipeline
|
|
||||||
|
|
||||||
Defined in `.gitlab-ci.yml`. Stages: install, lint, test, build, deploy, verify.
|
|
||||||
|
|
||||||
| Stage | What it does |
|
|
||||||
|-------|-------------|
|
|
||||||
| install | `npm ci` for root + frontend |
|
|
||||||
| lint | ESLint on frontend (warning threshold: 25) |
|
|
||||||
| test | Jest backend property tests + frontend unit tests |
|
|
||||||
| build | `npm run build` in frontend/ |
|
|
||||||
| deploy-staging | rsync to 71.85.90.9, run migrations, restart service (auto on master) |
|
|
||||||
| deploy-production | rsync to 71.85.90.6, run migrations, restart service (manual trigger) |
|
|
||||||
| verify | Hit `/api/health` endpoint, post issue comments for `Closes #N` references |
|
|
||||||
|
|
||||||
**Runner:** Docker executor on LXC 108 (71.85.90.8), Runner #6, `node:18` image for build stages, `alpine:latest` for deploy.
|
|
||||||
|
|
||||||
**Required CI/CD variables:** `DATABASE_URL`, `SSH_PRIVATE_KEY` (base64), `GITLAB_PAT`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **[Full Reference Manual](docs/guides/full-reference-manual.md)** — comprehensive feature documentation, API reference, database schema, security model, and configuration details
|
- **[Full Reference Manual](docs/guides/full-reference-manual.md)** — comprehensive feature documentation, API reference, database schema, security model, and configuration details
|
||||||
- **[VCL Metric Calculations](docs/guides/vcl-metric-calculations.md)** — formula reference for CCP Metrics compliance calculations
|
|
||||||
- **[Postgres Migration Plan](docs/guides/postgres-migration-plan.md)** — architecture decisions, schema design, and cutover procedure for the SQLite to PostgreSQL migration
|
- **[Postgres Migration Plan](docs/guides/postgres-migration-plan.md)** — architecture decisions, schema design, and cutover procedure for the SQLite to PostgreSQL migration
|
||||||
- **[Migration Guide](backend/migrations/README.md)** — schema migration scripts for upgrading existing deployments
|
- **[Migration Guide](backend/migrations/README.md)** — schema migration scripts for upgrading existing deployments
|
||||||
- **[Design System](docs/design/design-system.md)** — UI component patterns and colour system
|
- **[Design System](docs/design/design-system.md)** — UI component patterns and color system
|
||||||
- **[Ivanti API Reference](docs/api/ivanti-api-reference.md)** — Ivanti/RiskSense API integration details
|
- **[Ivanti API Reference](docs/api/ivanti-api-reference.md)** — Ivanti/RiskSense API integration details
|
||||||
- **[Jira API Use Cases](docs/api/jira-api-use-cases.md)** — Jira Data Center API compliance summary
|
- **[Jira API Use Cases](docs/api/jira-api-use-cases.md)** — Jira Data Center API compliance summary
|
||||||
- **[AD/SAML Integration Architecture](docs/architecture/ad-saml-integration.md)** — planned Active Directory / SAML SSO integration
|
|
||||||
- **[Security Audit Tracker](docs/security/security-audit-tracker.md)** — living security audit document
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Backend fails to start with "FATAL: SESSION_SECRET"
|
|
||||||
|
|
||||||
**Symptom:** Server exits immediately with `FATAL: SESSION_SECRET environment variable must be set`.
|
|
||||||
|
|
||||||
**Cause:** The `SESSION_SECRET` env var is not configured in `backend/.env`.
|
|
||||||
|
|
||||||
**Fix:** Generate a secret and add it to `backend/.env`:
|
|
||||||
```bash
|
|
||||||
echo "SESSION_SECRET=$(openssl rand -base64 32)" >> backend/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database connection refused
|
|
||||||
|
|
||||||
**Symptom:** Backend logs `Error: connect ECONNREFUSED` on startup.
|
|
||||||
|
|
||||||
**Cause:** PostgreSQL container is not running or `DATABASE_URL` is misconfigured.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
docker compose up -d # Start the Postgres container
|
|
||||||
# Verify DATABASE_URL in backend/.env matches: postgresql://steam:<password>@localhost:5433/cve_dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### CARD API timeouts in production
|
|
||||||
|
|
||||||
**Symptom:** CARD tooltip shows timeout errors or takes minutes to respond.
|
|
||||||
|
|
||||||
**Cause:** IPv6 AAAA records for `card.charter.com` are unreachable from this network. Node.js defaults to trying IPv6 first.
|
|
||||||
|
|
||||||
**Fix:** The backend sets `dns.setDefaultResultOrder('ipv4first')` globally. If you still see issues, verify the setting is at the top of `server.js` before any network imports.
|
|
||||||
|
|
||||||
### Compliance upload fails with "No items parsed"
|
|
||||||
|
|
||||||
**Symptom:** Upload completes but reports 0 new, 0 resolved, 0 recurring items.
|
|
||||||
|
|
||||||
**Cause:** The xlsx file structure does not match the expected format (sheet names, column headers).
|
|
||||||
|
|
||||||
**Fix:** Ensure the compliance xlsx has the expected sheet layout. Check `backend/scripts/parse_compliance_xlsx.py` for the expected column mappings.
|
|
||||||
|
|
||||||
### Missing migrations after deploy
|
|
||||||
|
|
||||||
**Symptom:** API returns 500 errors referencing missing columns or tables.
|
|
||||||
|
|
||||||
**Cause:** Migrations were not run after pulling new code.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
cd backend && node migrations/run-all.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend changes not visible after deploy
|
|
||||||
|
|
||||||
**Symptom:** UI still shows old version after code changes.
|
|
||||||
|
|
||||||
**Cause:** The frontend must be rebuilt after any source changes — Express serves the static build.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
cd frontend && npm run build
|
|
||||||
# Restart backend or refresh the browser
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -74,17 +74,3 @@ DATABASE_URL=postgresql://steam:<password>@localhost:5433/cve_dashboard
|
|||||||
GITLAB_URL=http://steam-gitlab.charterlab.com
|
GITLAB_URL=http://steam-gitlab.charterlab.com
|
||||||
GITLAB_PROJECT_ID=
|
GITLAB_PROJECT_ID=
|
||||||
GITLAB_PAT=
|
GITLAB_PAT=
|
||||||
|
|
||||||
# GitLab Webhook Secret — shared secret for validating incoming webhook requests.
|
|
||||||
# Set this same value in GitLab project > Settings > Webhooks > Secret Token.
|
|
||||||
# Generate with: openssl rand -hex 20
|
|
||||||
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
|
|
||||||
|
|
||||||
|
|
||||||
# TLS / HTTPS Configuration
|
|
||||||
# If cert and key files exist at the paths below, the server starts with HTTPS.
|
|
||||||
# Set TLS_ENABLED=false to force plain HTTP even when certs are present.
|
|
||||||
# Generate a self-signed cert: openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "/CN=cve-dashboard.local"
|
|
||||||
TLS_ENABLED=true
|
|
||||||
TLS_CERT=certs/cert.pem
|
|
||||||
TLS_KEY=certs/key.pem
|
|
||||||
|
|||||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -3,6 +3,3 @@
|
|||||||
backend/fix_multivendor_constraint.js
|
backend/fix_multivendor_constraint.js
|
||||||
backend/migrate_multivendor.js
|
backend/migrate_multivendor.js
|
||||||
backend/add_vendor_to_documents.js
|
backend/add_vendor_to_documents.js
|
||||||
|
|
||||||
# TLS certificates (self-signed or CA-issued)
|
|
||||||
certs/
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,380 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit Tests: PATCH /api/compliance/items/:hostname/metadata — Per-Metric Scoping
|
|
||||||
*
|
|
||||||
* Feature: remediation-plan-history (per-metric extension)
|
|
||||||
*
|
|
||||||
* Tests cover:
|
|
||||||
* - Task 8.1: metric_id/metric_ids validation, precedence, non-empty/max 100 chars, active item check
|
|
||||||
* - Task 8.2: Per-metric SELECT, INSERT history per metric, UPDATE only matching rows
|
|
||||||
* - Task 8.3: Hostname-level behavior preserved with NULL metric_id in history
|
|
||||||
*
|
|
||||||
* Validates: Requirements 8, 11, 15
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
// Mock auth middleware
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, res, next) => {
|
|
||||||
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
requireGroup: () => (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock audit log as a no-op
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
|
|
||||||
// Mock ivantiApi
|
|
||||||
jest.mock('../helpers/ivantiApi', () => ({
|
|
||||||
ivantiFormPost: jest.fn(),
|
|
||||||
ivantiPost: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock driftChecker
|
|
||||||
jest.mock('../helpers/driftChecker', () => ({
|
|
||||||
loadConfig: jest.fn(() => ({})),
|
|
||||||
compareSchemaToDrift: jest.fn(() => null),
|
|
||||||
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the db pool
|
|
||||||
const mockPool = {
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
connect: jest.fn(),
|
|
||||||
};
|
|
||||||
jest.mock('../db', () => mockPool);
|
|
||||||
|
|
||||||
const { createComplianceRouter } = require('../routes/compliance');
|
|
||||||
|
|
||||||
// --- HTTP helper ---
|
|
||||||
|
|
||||||
function request(server, method, path, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const addr = server.address();
|
|
||||||
const options = {
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: addr.port,
|
|
||||||
path,
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const rawBody = Buffer.concat(chunks).toString();
|
|
||||||
let json;
|
|
||||||
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
|
|
||||||
resolve({ statusCode: res.statusCode, body: json });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Setup ---
|
|
||||||
|
|
||||||
let app, server;
|
|
||||||
|
|
||||||
beforeAll((done) => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const mockUpload = { single: () => (req, res, next) => next() };
|
|
||||||
app.use('/api/compliance', createComplianceRouter(mockUpload));
|
|
||||||
server = app.listen(0, '127.0.0.1', done);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Task 8.1: Validation ---
|
|
||||||
|
|
||||||
describe('Task 8.1: metric_id/metric_ids validation', () => {
|
|
||||||
it('returns 400 when metric_ids is not an array', async () => {
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_ids: 'not-an-array',
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('metric_ids must be an array');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when metric_ids is empty array', async () => {
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_ids: [],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('metric_ids must contain at least one entry');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when metric_ids contains empty string', async () => {
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_ids: ['2.1.1', ''],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('metric_id cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when metric_id exceeds 100 characters', async () => {
|
|
||||||
const longId = 'x'.repeat(101);
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_ids: [longId],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('metric_id exceeds 100 characters');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when single metric_id is empty string', async () => {
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_id: '',
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('metric_id cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when single metric_id exceeds 100 characters', async () => {
|
|
||||||
const longId = 'x'.repeat(101);
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_id: longId,
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('metric_id exceeds 100 characters');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when metric_id does not correspond to active compliance_item', async () => {
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT active metrics — none found
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_ids: ['nonexistent-metric'],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toContain('Invalid metric_id: nonexistent-metric');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses metric_ids when both metric_id and metric_ids are provided', async () => {
|
|
||||||
// metric_ids wins — should validate metric_ids, not metric_id
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ metric_id: '3.1.1', resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT active metrics
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_id: '2.1.1', // should be ignored
|
|
||||||
metric_ids: ['3.1.1'], // should be used
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
// Verify the SELECT query used metric_ids value ['3.1.1'], not metric_id '2.1.1'
|
|
||||||
const selectCall = mockClient.query.mock.calls[1];
|
|
||||||
expect(selectCall[1]).toEqual(['srv-001', ['3.1.1']]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Task 8.2: Per-metric scoping behavior ---
|
|
||||||
|
|
||||||
describe('Task 8.2: Per-metric SELECT, INSERT history, UPDATE matching rows', () => {
|
|
||||||
it('selects current values per targeted metric and inserts history per metric', async () => {
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [
|
|
||||||
{ metric_id: '2.1.1', resolution_date: '2026-01-01', remediation_plan: 'Plan A' },
|
|
||||||
{ metric_id: '2.3.2', resolution_date: '2026-02-01', remediation_plan: 'Plan B' },
|
|
||||||
], rowCount: 2 }) // SELECT active metrics with current values
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.1.1 resolution_date
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.1.1 remediation_plan
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.3.2 resolution_date
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.3.2 remediation_plan
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE matching rows
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
remediation_plan: 'New unified plan',
|
|
||||||
metric_ids: ['2.1.1', '2.3.2'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.updated).toBe(2);
|
|
||||||
|
|
||||||
// Verify history inserts include metric_id
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
// Call [2] = INSERT history for 2.1.1 resolution_date
|
|
||||||
expect(calls[2][0]).toContain('INSERT INTO compliance_item_history');
|
|
||||||
expect(calls[2][1][1]).toBe('2.1.1'); // metric_id
|
|
||||||
expect(calls[2][1][2]).toBe('2026-01-01'); // old_value
|
|
||||||
expect(calls[2][1][3]).toBe('2026-06-15'); // new_value
|
|
||||||
|
|
||||||
// Call [3] = INSERT history for 2.1.1 remediation_plan
|
|
||||||
expect(calls[3][1][1]).toBe('2.1.1');
|
|
||||||
expect(calls[3][1][2]).toBe('Plan A'); // old_value
|
|
||||||
expect(calls[3][1][3]).toBe('New unified plan'); // new_value
|
|
||||||
|
|
||||||
// Call [4] = INSERT history for 2.3.2 resolution_date
|
|
||||||
expect(calls[4][1][1]).toBe('2.3.2');
|
|
||||||
expect(calls[4][1][2]).toBe('2026-02-01'); // old_value
|
|
||||||
|
|
||||||
// Call [5] = INSERT history for 2.3.2 remediation_plan
|
|
||||||
expect(calls[5][1][1]).toBe('2.3.2');
|
|
||||||
expect(calls[5][1][2]).toBe('Plan B'); // old_value
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips history insert when value is unchanged for a specific metric', async () => {
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [
|
|
||||||
{ metric_id: '2.1.1', resolution_date: '2026-06-15', remediation_plan: null },
|
|
||||||
], rowCount: 1 }) // SELECT — already has the target date
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE (no history inserts since value unchanged)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_ids: ['2.1.1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
// No INSERT history calls — only BEGIN, SELECT, UPDATE, COMMIT
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
expect(calls.length).toBe(4);
|
|
||||||
expect(calls[2][0]).toContain('UPDATE compliance_items');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates only matching rows with metric_id = ANY filter', async () => {
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [
|
|
||||||
{ metric_id: '2.1.1', resolution_date: null, remediation_plan: null },
|
|
||||||
], rowCount: 1 }) // SELECT
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
metric_id: '2.1.1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
// Verify UPDATE query includes metric_id = ANY filter
|
|
||||||
const updateCall = mockClient.query.mock.calls[3];
|
|
||||||
expect(updateCall[0]).toContain('metric_id = ANY');
|
|
||||||
expect(updateCall[0]).toContain("status = 'active'");
|
|
||||||
expect(updateCall[1]).toContain('srv-001');
|
|
||||||
expect(updateCall[1]).toEqual(expect.arrayContaining([['2.1.1']]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Task 8.3: Hostname-level behavior preserved ---
|
|
||||||
|
|
||||||
describe('Task 8.3: Hostname-level behavior with NULL metric_id', () => {
|
|
||||||
it('updates all active rows when no metric_id/metric_ids provided', async () => {
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 5 }) // UPDATE all active rows
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.updated).toBe(5);
|
|
||||||
|
|
||||||
// Verify UPDATE does NOT include metric_id filter
|
|
||||||
const updateCall = mockClient.query.mock.calls[3];
|
|
||||||
expect(updateCall[0]).not.toContain('metric_id');
|
|
||||||
expect(updateCall[0]).toContain("status = 'active'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inserts history with NULL metric_id when no metric scoping', async () => {
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ resolution_date: '2026-01-01', remediation_plan: 'Old plan' }], rowCount: 1 }) // SELECT current
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // UPDATE
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
remediation_plan: 'New plan',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
|
|
||||||
// Verify history INSERT includes NULL for metric_id
|
|
||||||
const historyCall1 = mockClient.query.mock.calls[2];
|
|
||||||
expect(historyCall1[0]).toContain('INSERT INTO compliance_item_history');
|
|
||||||
expect(historyCall1[0]).toContain('NULL');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 when hostname has no active items (hostname-level path)', async () => {
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current — empty
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
||||||
resolution_date: '2026-06-15',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(404);
|
|
||||||
expect(res.body.error).toBe('Device not found');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bug Condition Exploration Property Test: Compliance Remediation Display Fix
|
|
||||||
*
|
|
||||||
* Spec: .kiro/specs/compliance-remediation-display-fix/ (bugfix)
|
|
||||||
*
|
|
||||||
* BUG CONDITION:
|
|
||||||
* isBugCondition(row) = row.resolution_date != null OR row.remediation_plan != null
|
|
||||||
* Any row with metadata set will lose it through groupByHostname() because the
|
|
||||||
* function does not propagate resolution_date or remediation_plan into device objects.
|
|
||||||
*
|
|
||||||
* THIS TEST IS EXPECTED TO FAIL ON UNFIXED CODE.
|
|
||||||
* Failure confirms the bug exists — resolution_date and remediation_plan are undefined
|
|
||||||
* in the grouped device objects returned by groupByHostname().
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 1.1, 1.2, 2.2**
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
|
|
||||||
// --- Mocks (must be installed BEFORE requiring the route module) ---
|
|
||||||
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, res, next) => next(),
|
|
||||||
requireGroup: () => (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
|
|
||||||
jest.mock('../helpers/driftChecker', () => ({
|
|
||||||
loadConfig: jest.fn(() => ({})),
|
|
||||||
compareSchemaToDrift: jest.fn(() => null),
|
|
||||||
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../helpers/vclHelpers', () => ({
|
|
||||||
isValidDateString: jest.fn(() => true),
|
|
||||||
validateRemediationPlan: jest.fn(() => ({ valid: true })),
|
|
||||||
computeVCLStats: jest.fn(() => ({})),
|
|
||||||
categorizeNonCompliant: jest.fn(() => []),
|
|
||||||
rankHeavyHitters: jest.fn(() => []),
|
|
||||||
computeForecastBurndown: jest.fn(() => ({})),
|
|
||||||
matchByHostname: jest.fn(() => []),
|
|
||||||
computeBulkDiff: jest.fn(() => ({})),
|
|
||||||
mapColumnHeaders: jest.fn(() => ({})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockPool = {
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
connect: jest.fn(() => Promise.resolve({
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
release: jest.fn(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
jest.mock('../db', () => mockPool);
|
|
||||||
|
|
||||||
const { groupByHostname } = require('../routes/compliance');
|
|
||||||
|
|
||||||
// --- Generators ---
|
|
||||||
|
|
||||||
/** Generate a date string in YYYY-MM-DD format (avoid toISOString on shrunk invalid dates) */
|
|
||||||
const arbDateString = fc.tuple(
|
|
||||||
fc.integer({ min: 2020, max: 2030 }),
|
|
||||||
fc.integer({ min: 1, max: 12 }),
|
|
||||||
fc.integer({ min: 1, max: 28 })
|
|
||||||
).map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
|
||||||
|
|
||||||
/** Generate a non-empty remediation plan string */
|
|
||||||
const arbRemediationPlan = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
|
|
||||||
|
|
||||||
/** Generate a hostname (alphanumeric with dashes, realistic) */
|
|
||||||
const arbHostname = fc.stringMatching(/^[A-Z][A-Z0-9\-]{2,20}$/);
|
|
||||||
|
|
||||||
/** Generate a metric_id like "7.1.1" or "3.2" */
|
|
||||||
const arbMetricId = fc.tuple(
|
|
||||||
fc.integer({ min: 1, max: 9 }),
|
|
||||||
fc.integer({ min: 1, max: 9 }),
|
|
||||||
fc.integer({ min: 1, max: 9 })
|
|
||||||
).map(([a, b, c]) => `${a}.${b}.${c}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a compliance row with non-null resolution_date and/or remediation_plan.
|
|
||||||
* This is the bug condition: rows that have metadata which should be propagated.
|
|
||||||
*/
|
|
||||||
const arbComplianceRowWithMetadata = fc.record({
|
|
||||||
hostname: arbHostname,
|
|
||||||
ip_address: fc.ipV4(),
|
|
||||||
device_type: fc.constantFrom('Switch', 'Router', 'Firewall', 'Server'),
|
|
||||||
team: fc.constantFrom('STEAM', 'ACCESS-ENG'),
|
|
||||||
status: fc.constant('active'),
|
|
||||||
metric_id: arbMetricId,
|
|
||||||
metric_desc: fc.string({ minLength: 3, maxLength: 50 }),
|
|
||||||
category: fc.constantFrom('Configuration', 'Patching', 'Access Control'),
|
|
||||||
seen_count: fc.integer({ min: 1, max: 20 }),
|
|
||||||
first_seen: arbDateString,
|
|
||||||
last_seen: arbDateString,
|
|
||||||
resolved_on: fc.constant(null),
|
|
||||||
resolution_date: arbDateString,
|
|
||||||
remediation_plan: arbRemediationPlan,
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property Test ---
|
|
||||||
|
|
||||||
describe('Bug Condition Exploration: resolution_date and remediation_plan in groupByHostname()', () => {
|
|
||||||
it('Property 1: groupByHostname() should propagate resolution_date from rows to device objects', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbComplianceRowWithMetadata, (row) => {
|
|
||||||
const rows = [row];
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
// There should be exactly one device for the single hostname
|
|
||||||
expect(devices).toHaveLength(1);
|
|
||||||
const device = devices[0];
|
|
||||||
|
|
||||||
// BUG CONDITION: resolution_date should be propagated but is undefined on unfixed code
|
|
||||||
expect(device.resolution_date).toBe(row.resolution_date);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Property 2: groupByHostname() should propagate remediation_plan from rows to device objects', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbComplianceRowWithMetadata, (row) => {
|
|
||||||
const rows = [row];
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
expect(devices).toHaveLength(1);
|
|
||||||
const device = devices[0];
|
|
||||||
|
|
||||||
// BUG CONDITION: remediation_plan should be propagated but is undefined on unfixed code
|
|
||||||
expect(device.remediation_plan).toBe(row.remediation_plan);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Property 3: groupByHostname() should pick first non-null resolution_date across multiple rows for same hostname', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbHostname,
|
|
||||||
fc.array(arbMetricId, { minLength: 2, maxLength: 5 }),
|
|
||||||
arbDateString,
|
|
||||||
(hostname, metricIds, resolutionDate) => {
|
|
||||||
// Create multiple rows for the same hostname, first row has resolution_date
|
|
||||||
const rows = metricIds.map((mid, idx) => ({
|
|
||||||
hostname,
|
|
||||||
ip_address: '10.0.0.1',
|
|
||||||
device_type: 'Switch',
|
|
||||||
team: 'STEAM',
|
|
||||||
status: 'active',
|
|
||||||
metric_id: mid,
|
|
||||||
metric_desc: `Metric ${mid}`,
|
|
||||||
category: 'Configuration',
|
|
||||||
seen_count: 1,
|
|
||||||
first_seen: '2025-01-01',
|
|
||||||
last_seen: '2025-06-01',
|
|
||||||
resolved_on: null,
|
|
||||||
resolution_date: idx === 0 ? resolutionDate : null,
|
|
||||||
remediation_plan: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
expect(devices).toHaveLength(1);
|
|
||||||
const device = devices[0];
|
|
||||||
|
|
||||||
// The first non-null resolution_date should be propagated
|
|
||||||
expect(device.resolution_date).toBe(resolutionDate);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Property 4: groupByHostname() should pick first non-null remediation_plan across multiple rows for same hostname', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbHostname,
|
|
||||||
fc.array(arbMetricId, { minLength: 2, maxLength: 5 }),
|
|
||||||
arbRemediationPlan,
|
|
||||||
(hostname, metricIds, plan) => {
|
|
||||||
// Create multiple rows for the same hostname, first row has remediation_plan
|
|
||||||
const rows = metricIds.map((mid, idx) => ({
|
|
||||||
hostname,
|
|
||||||
ip_address: '10.0.0.1',
|
|
||||||
device_type: 'Switch',
|
|
||||||
team: 'STEAM',
|
|
||||||
status: 'active',
|
|
||||||
metric_id: mid,
|
|
||||||
metric_desc: `Metric ${mid}`,
|
|
||||||
category: 'Configuration',
|
|
||||||
seen_count: 1,
|
|
||||||
first_seen: '2025-01-01',
|
|
||||||
last_seen: '2025-06-01',
|
|
||||||
resolved_on: null,
|
|
||||||
resolution_date: null,
|
|
||||||
remediation_plan: idx === 0 ? plan : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
expect(devices).toHaveLength(1);
|
|
||||||
const device = devices[0];
|
|
||||||
|
|
||||||
// The first non-null remediation_plan should be propagated
|
|
||||||
expect(device.remediation_plan).toBe(plan);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
/**
|
|
||||||
* Preservation Property Tests: Compliance Remediation Display Fix
|
|
||||||
*
|
|
||||||
* Spec: .kiro/specs/compliance-remediation-display-fix/ (bugfix)
|
|
||||||
*
|
|
||||||
* These tests verify that groupByHostname() correctly aggregates existing fields
|
|
||||||
* on UNFIXED code. They should PASS on unfixed code — they capture baseline
|
|
||||||
* behaviour that must be preserved through the fix.
|
|
||||||
*
|
|
||||||
* Properties tested:
|
|
||||||
* P2.A — Each device.hostname appears exactly once in output
|
|
||||||
* P2.B — device.failing_metrics contains no duplicate metric_ids
|
|
||||||
* P2.C — device.seen_count >= every row's seen_count for that hostname
|
|
||||||
* P2.D — device.first_seen <= every row's first_seen for that hostname
|
|
||||||
* P2.E — device.last_seen >= every row's last_seen for that hostname
|
|
||||||
* P2.F — device.has_notes matches noteHostnames membership
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 3.3, 3.5**
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
|
|
||||||
// Mock dependencies required by the compliance module
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, res, next) => next(),
|
|
||||||
requireGroup: () => (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
jest.mock('../db', () => ({
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
connect: jest.fn(() => Promise.resolve({ query: jest.fn(), release: jest.fn() })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { groupByHostname } = require('../routes/compliance');
|
|
||||||
|
|
||||||
// --- Generators ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a valid hostname string (alphanumeric + hyphens, 1-20 chars).
|
|
||||||
*/
|
|
||||||
const hostnameArb = fc.stringMatching(/^[A-Z][A-Z0-9\-]{0,14}$/);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a metric_id string like "7.1.1", "7.2.3", etc.
|
|
||||||
*/
|
|
||||||
const metricIdArb = fc.tuple(
|
|
||||||
fc.integer({ min: 1, max: 9 }),
|
|
||||||
fc.integer({ min: 1, max: 9 }),
|
|
||||||
fc.integer({ min: 1, max: 9 })
|
|
||||||
).map(([a, b, c]) => `${a}.${b}.${c}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a date string in YYYY-MM-DD format for first_seen/last_seen.
|
|
||||||
*/
|
|
||||||
const dateArb = fc.tuple(
|
|
||||||
fc.integer({ min: 2023, max: 2025 }),
|
|
||||||
fc.integer({ min: 1, max: 12 }),
|
|
||||||
fc.integer({ min: 1, max: 28 })
|
|
||||||
).map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a compliance row with resolution_date = null and remediation_plan = null
|
|
||||||
* (non-bug-condition inputs — these are the rows that should work correctly on unfixed code).
|
|
||||||
*/
|
|
||||||
function complianceRowArb(hostname, metricId) {
|
|
||||||
return fc.record({
|
|
||||||
hostname: fc.constant(hostname),
|
|
||||||
ip_address: fc.constantFrom('10.0.0.1', '10.0.0.2', '192.168.1.1', ''),
|
|
||||||
device_type: fc.constantFrom('Switch', 'Router', 'Firewall', ''),
|
|
||||||
team: fc.constantFrom('STEAM', 'ACCESS-ENG'),
|
|
||||||
status: fc.constantFrom('active', 'resolved'),
|
|
||||||
metric_id: fc.constant(metricId),
|
|
||||||
metric_desc: fc.constantFrom('Password Complexity', 'Firmware Version', 'Logging Enabled'),
|
|
||||||
category: fc.constantFrom('Configuration', 'Patching', 'Monitoring'),
|
|
||||||
seen_count: fc.integer({ min: 1, max: 20 }),
|
|
||||||
first_seen: dateArb,
|
|
||||||
last_seen: dateArb,
|
|
||||||
resolved_on: fc.constant(null),
|
|
||||||
resolution_date: fc.constant(null),
|
|
||||||
remediation_plan: fc.constant(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an array of compliance rows with varying hostnames and metric_ids.
|
|
||||||
* Ensures at least 1 row, with 1-4 hostnames and 1-5 metrics per hostname.
|
|
||||||
*/
|
|
||||||
const complianceRowsArb = fc.tuple(
|
|
||||||
fc.array(hostnameArb, { minLength: 1, maxLength: 4 }),
|
|
||||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 })
|
|
||||||
).chain(([hostnames, metricIds]) => {
|
|
||||||
// Ensure unique hostnames and metric_ids
|
|
||||||
const uniqueHostnames = [...new Set(hostnames)];
|
|
||||||
const uniqueMetricIds = [...new Set(metricIds)];
|
|
||||||
|
|
||||||
if (uniqueHostnames.length === 0 || uniqueMetricIds.length === 0) {
|
|
||||||
// Fallback: generate at least one row
|
|
||||||
return complianceRowArb('HOST-A', '1.1.1').map(row => [row]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate rows: each hostname gets some subset of metric_ids
|
|
||||||
// Some hostnames may share metric_ids (same metric failing on different devices)
|
|
||||||
const rowArbs = [];
|
|
||||||
for (const hostname of uniqueHostnames) {
|
|
||||||
// Each hostname gets 1 to all metric_ids
|
|
||||||
const metricsForHost = uniqueMetricIds.slice(0, Math.max(1, Math.ceil(Math.random() * uniqueMetricIds.length)));
|
|
||||||
for (const metricId of metricsForHost) {
|
|
||||||
rowArbs.push(complianceRowArb(hostname, metricId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fc.tuple(...rowArbs).map(rows => rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Better generator: explicitly controls the structure to ensure good coverage.
|
|
||||||
* Generates 1-3 hostnames, each with 1-4 rows (possibly duplicate metric_ids to test dedup).
|
|
||||||
*/
|
|
||||||
const structuredRowsArb = fc.record({
|
|
||||||
numHostnames: fc.integer({ min: 1, max: 3 }),
|
|
||||||
numMetricsPerHost: fc.integer({ min: 1, max: 4 }),
|
|
||||||
allowDuplicateMetrics: fc.boolean(),
|
|
||||||
}).chain(({ numHostnames, numMetricsPerHost, allowDuplicateMetrics }) => {
|
|
||||||
const hostnameArbs = fc.array(hostnameArb, { minLength: numHostnames, maxLength: numHostnames });
|
|
||||||
const metricArbs = fc.array(metricIdArb, { minLength: numMetricsPerHost, maxLength: numMetricsPerHost });
|
|
||||||
|
|
||||||
return fc.tuple(hostnameArbs, metricArbs).chain(([hostnames, metrics]) => {
|
|
||||||
const uniqueHostnames = [...new Set(hostnames)];
|
|
||||||
if (uniqueHostnames.length === 0) return fc.constant([]);
|
|
||||||
|
|
||||||
const rowArbs = [];
|
|
||||||
for (const hostname of uniqueHostnames) {
|
|
||||||
const metricsToUse = allowDuplicateMetrics
|
|
||||||
? metrics // May have duplicates
|
|
||||||
: [...new Set(metrics)];
|
|
||||||
|
|
||||||
for (const metricId of metricsToUse) {
|
|
||||||
rowArbs.push(complianceRowArb(hostname, metricId));
|
|
||||||
}
|
|
||||||
// Add an extra duplicate row for the first metric to test dedup
|
|
||||||
if (allowDuplicateMetrics && metricsToUse.length > 0) {
|
|
||||||
rowArbs.push(complianceRowArb(hostname, metricsToUse[0]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowArbs.length === 0) return fc.constant([]);
|
|
||||||
return fc.tuple(...rowArbs).map(rows => rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Property P2.A — Each device.hostname appears exactly once in output
|
|
||||||
// =============================================================================
|
|
||||||
//
|
|
||||||
// **Validates: Requirements 3.3, 3.5**
|
|
||||||
//
|
|
||||||
describe('Property P2.A — Each device.hostname appears exactly once in output', () => {
|
|
||||||
it('P2.A — groupByHostname produces one device per unique hostname', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(structuredRowsArb, (rows) => {
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
// Each hostname in the output should appear exactly once
|
|
||||||
const outputHostnames = devices.map(d => d.hostname);
|
|
||||||
const uniqueOutputHostnames = new Set(outputHostnames);
|
|
||||||
expect(outputHostnames.length).toBe(uniqueOutputHostnames.size);
|
|
||||||
|
|
||||||
// Every hostname from input should appear in output
|
|
||||||
const inputHostnames = new Set(rows.map(r => r.hostname));
|
|
||||||
for (const hostname of inputHostnames) {
|
|
||||||
expect(uniqueOutputHostnames.has(hostname)).toBe(true);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Property P2.B — device.failing_metrics contains no duplicate metric_ids
|
|
||||||
// =============================================================================
|
|
||||||
//
|
|
||||||
// **Validates: Requirements 3.3, 3.5**
|
|
||||||
//
|
|
||||||
describe('Property P2.B — device.failing_metrics contains no duplicate metric_ids', () => {
|
|
||||||
it('P2.B — groupByHostname deduplicates metrics by metric_id', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(structuredRowsArb, (rows) => {
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
const metricIds = device.failing_metrics.map(m => m.metric_id);
|
|
||||||
const uniqueMetricIds = new Set(metricIds);
|
|
||||||
expect(metricIds.length).toBe(uniqueMetricIds.size);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Property P2.C — device.seen_count >= every row's seen_count for that hostname
|
|
||||||
// =============================================================================
|
|
||||||
//
|
|
||||||
// **Validates: Requirements 3.3, 3.5**
|
|
||||||
//
|
|
||||||
describe('Property P2.C — device.seen_count >= every row seen_count for that hostname', () => {
|
|
||||||
it('P2.C — groupByHostname picks the maximum seen_count across rows', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(structuredRowsArb, (rows) => {
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
|
|
||||||
for (const row of rowsForHost) {
|
|
||||||
expect(device.seen_count).toBeGreaterThanOrEqual(row.seen_count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Property P2.D — device.first_seen <= every row's first_seen for that hostname
|
|
||||||
// =============================================================================
|
|
||||||
//
|
|
||||||
// **Validates: Requirements 3.3, 3.5**
|
|
||||||
//
|
|
||||||
describe('Property P2.D — device.first_seen <= every row first_seen for that hostname', () => {
|
|
||||||
it('P2.D — groupByHostname picks the earliest first_seen across rows', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(structuredRowsArb, (rows) => {
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
|
|
||||||
for (const row of rowsForHost) {
|
|
||||||
if (row.first_seen && device.first_seen) {
|
|
||||||
expect(device.first_seen <= row.first_seen).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Property P2.E — device.last_seen >= every row's last_seen for that hostname
|
|
||||||
// =============================================================================
|
|
||||||
//
|
|
||||||
// **Validates: Requirements 3.3, 3.5**
|
|
||||||
//
|
|
||||||
describe('Property P2.E — device.last_seen >= every row last_seen for that hostname', () => {
|
|
||||||
it('P2.E — groupByHostname picks the latest last_seen across rows', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(structuredRowsArb, (rows) => {
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const noteHostnames = new Set();
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
|
|
||||||
for (const row of rowsForHost) {
|
|
||||||
if (row.last_seen && device.last_seen) {
|
|
||||||
expect(device.last_seen >= row.last_seen).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Property P2.F — device.has_notes matches noteHostnames membership
|
|
||||||
// =============================================================================
|
|
||||||
//
|
|
||||||
// **Validates: Requirements 3.3, 3.5**
|
|
||||||
//
|
|
||||||
describe('Property P2.F — device.has_notes matches noteHostnames membership', () => {
|
|
||||||
it('P2.F — groupByHostname sets has_notes based on noteHostnames Set', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
structuredRowsArb,
|
|
||||||
fc.array(hostnameArb, { minLength: 0, maxLength: 3 }),
|
|
||||||
(rows, noteHosts) => {
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
// Build noteHostnames set — include some from input, some random
|
|
||||||
const inputHostnames = [...new Set(rows.map(r => r.hostname))];
|
|
||||||
const noteHostnames = new Set([
|
|
||||||
...noteHosts,
|
|
||||||
// Include some actual hostnames from input to test true case
|
|
||||||
...inputHostnames.slice(0, Math.ceil(inputHostnames.length / 2)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const devices = groupByHostname(rows, noteHostnames);
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
const expected = noteHostnames.has(device.hostname);
|
|
||||||
expect(device.has_notes).toBe(expected);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Config Wizard Frontend Build Skip Logic
|
|
||||||
*
|
|
||||||
* Feature: config-wizard
|
|
||||||
*
|
|
||||||
* Tests the shouldSkipFrontendBuild function from `configure.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 14.4, 14.5
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
const { shouldSkipFrontendBuild } = require('../../configure.js');
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Helpers
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Generate a REACT_APP_* key name */
|
|
||||||
const reactAppKeyArb = fc.stringMatching(/^REACT_APP_[A-Z][A-Z0-9_]{0,15}$/)
|
|
||||||
.filter(k => k.length > 10);
|
|
||||||
|
|
||||||
/** Generate a non-empty env value */
|
|
||||||
const envValueArb = fc.string({ minLength: 1, maxLength: 50 })
|
|
||||||
.filter(s => s.trim().length > 0 && !s.includes('\n'));
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Property 19: Frontend build skip determination
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Property 19: Frontend build skip determination', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 14.4, 14.5**
|
|
||||||
*
|
|
||||||
* shouldSkipFrontendBuild returns true iff all REACT_APP_* keys have identical
|
|
||||||
* values in old and new maps and old map is non-null.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test('when old map is null, always returns false', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 5 }),
|
|
||||||
(entries) => {
|
|
||||||
const newMap = new Map(entries);
|
|
||||||
return shouldSkipFrontendBuild(null, newMap) === false;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when old and new have identical REACT_APP_* values, returns true', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 5 }),
|
|
||||||
(entries) => {
|
|
||||||
// Deduplicate keys by using a Map
|
|
||||||
const deduped = [...new Map(entries).entries()];
|
|
||||||
const oldMap = new Map(deduped);
|
|
||||||
const newMap = new Map(deduped);
|
|
||||||
return shouldSkipFrontendBuild(oldMap, newMap) === true;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when any REACT_APP_* value differs, returns false', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 5 }),
|
|
||||||
envValueArb,
|
|
||||||
(entries, differentValue) => {
|
|
||||||
// Deduplicate keys
|
|
||||||
const deduped = [...new Map(entries).entries()];
|
|
||||||
if (deduped.length === 0) return true; // skip trivial case
|
|
||||||
|
|
||||||
const oldMap = new Map(deduped);
|
|
||||||
const newMap = new Map(deduped);
|
|
||||||
|
|
||||||
// Change one value in the new map to be different
|
|
||||||
const keyToChange = deduped[0][0];
|
|
||||||
const originalValue = deduped[0][1];
|
|
||||||
// Ensure the new value is actually different
|
|
||||||
const newValue = differentValue === originalValue
|
|
||||||
? differentValue + '_changed'
|
|
||||||
: differentValue;
|
|
||||||
newMap.set(keyToChange, newValue);
|
|
||||||
|
|
||||||
return shouldSkipFrontendBuild(oldMap, newMap) === false;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when new map has additional REACT_APP_* keys not in old, returns false', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 3 }),
|
|
||||||
reactAppKeyArb,
|
|
||||||
envValueArb,
|
|
||||||
(entries, extraKey, extraValue) => {
|
|
||||||
// Deduplicate keys
|
|
||||||
const deduped = [...new Map(entries).entries()];
|
|
||||||
const oldMap = new Map(deduped);
|
|
||||||
const newMap = new Map(deduped);
|
|
||||||
|
|
||||||
// Add an extra key to new that doesn't exist in old
|
|
||||||
// Ensure the extra key is not already in the map
|
|
||||||
const uniqueExtraKey = deduped.some(([k]) => k === extraKey)
|
|
||||||
? extraKey + '_EXTRA'
|
|
||||||
: extraKey;
|
|
||||||
newMap.set(uniqueExtraKey, extraValue);
|
|
||||||
|
|
||||||
return shouldSkipFrontendBuild(oldMap, newMap) === false;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Config Wizard Env File Generation
|
|
||||||
*
|
|
||||||
* Feature: config-wizard
|
|
||||||
*
|
|
||||||
* Tests the env file generation and round-trip parsing functions from `configure.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 6.3, 6.4, 6.7, 7.2, 7.5, 9.1, 9.2, 9.4, 9.5
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const {
|
|
||||||
generateEnvContent,
|
|
||||||
parseEnvFile,
|
|
||||||
VARIABLE_DESCRIPTORS,
|
|
||||||
GROUP_ORDER
|
|
||||||
} = require('../../configure.js');
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Helpers
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Characters that trigger quoting in env values */
|
|
||||||
const QUOTING_CHARS = [' ', '#', '"', "'", '$', '\n'];
|
|
||||||
|
|
||||||
/** Generate a safe env variable name (uppercase letters, digits, underscores) */
|
|
||||||
const envKeyArb = fc.stringMatching(/^[A-Z][A-Z0-9_]{1,20}$/);
|
|
||||||
|
|
||||||
/** Generate a value that does NOT need quoting */
|
|
||||||
const unquotedValueArb = fc.stringMatching(/^[a-zA-Z0-9._\-/,:;+=]{1,40}$/)
|
|
||||||
.filter(s => !QUOTING_CHARS.some(c => s.includes(c)));
|
|
||||||
|
|
||||||
/** Generate a value that DOES need quoting (contains at least one special char) */
|
|
||||||
const quotedValueArb = fc.tuple(
|
|
||||||
fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
|
||||||
fc.constantFrom(' ', '#', '$')
|
|
||||||
).map(([base, special]) => base + special + base);
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Property 13: Env value quoting
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Property 13: Env value quoting', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 6.3**
|
|
||||||
*
|
|
||||||
* Values with space/#/quote/$/newline are double-quoted with escaped internal
|
|
||||||
* quotes; values without those chars are unquoted.
|
|
||||||
*/
|
|
||||||
test('values containing special chars are double-quoted in output', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(quotedValueArb, (value) => {
|
|
||||||
// Use a known required variable to ensure it appears in output
|
|
||||||
const values = new Map([['PORT', '3001'], ['API_HOST', value]]);
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
|
|
||||||
// Find the API_HOST line
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const apiHostLine = lines.find(l => l.startsWith('API_HOST='));
|
|
||||||
if (!apiHostLine) return false;
|
|
||||||
|
|
||||||
// Should be quoted
|
|
||||||
const afterEq = apiHostLine.substring('API_HOST='.length);
|
|
||||||
return afterEq.startsWith('"') && afterEq.endsWith('"');
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('values without special chars are unquoted in output', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(unquotedValueArb, (value) => {
|
|
||||||
const values = new Map([['PORT', '3001'], ['API_HOST', value]]);
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const apiHostLine = lines.find(l => l.startsWith('API_HOST='));
|
|
||||||
if (!apiHostLine) return false;
|
|
||||||
|
|
||||||
const afterEq = apiHostLine.substring('API_HOST='.length);
|
|
||||||
return !afterEq.startsWith('"');
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('internal double quotes are escaped as \\" in quoted values', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0 && !s.includes('\n')),
|
|
||||||
(base) => {
|
|
||||||
// Create a value with an internal double quote and a space (to force quoting)
|
|
||||||
const value = `${base} "test" ${base}`;
|
|
||||||
const values = new Map([['PORT', '3001'], ['API_HOST', value]]);
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const apiHostLine = lines.find(l => l.startsWith('API_HOST='));
|
|
||||||
if (!apiHostLine) return false;
|
|
||||||
|
|
||||||
// The line should contain escaped quotes \" but not unescaped internal "
|
|
||||||
const afterEq = apiHostLine.substring('API_HOST='.length);
|
|
||||||
// Remove outer quotes
|
|
||||||
const inner = afterEq.slice(1, -1);
|
|
||||||
// Internal quotes should be escaped
|
|
||||||
return inner.includes('\\"') && !inner.match(/(?<!\\)"/);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Property 14: Optional variable omission
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Property 14: Optional variable omission', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 6.4**
|
|
||||||
*
|
|
||||||
* Optional vars with no value and no default are absent from output.
|
|
||||||
*/
|
|
||||||
test('optional variables with no value and no default are absent from output', () => {
|
|
||||||
// Find optional variables with no default
|
|
||||||
const optionalNoDefault = VARIABLE_DESCRIPTORS.filter(
|
|
||||||
d => !d.required && d.default === null
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.constantFrom(...optionalNoDefault.map(d => d.name)),
|
|
||||||
(varName) => {
|
|
||||||
// Only provide required vars with values, leave the optional one empty
|
|
||||||
const values = new Map();
|
|
||||||
// Add minimum required values so the group appears
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://u:p@localhost:5432/db');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here');
|
|
||||||
// Do NOT set the optional variable
|
|
||||||
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
const lines = content.split('\n');
|
|
||||||
|
|
||||||
// The optional variable should not appear as a KEY=value line
|
|
||||||
return !lines.some(l => l.startsWith(`${varName}=`));
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Property 15: Skipped group exclusion
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Property 15: Skipped group exclusion', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 7.2, 7.5**
|
|
||||||
*
|
|
||||||
* Declined groups produce no KEY=value lines in output.
|
|
||||||
*/
|
|
||||||
test('variables from skipped groups do not appear in output', () => {
|
|
||||||
const optionalGroupArb = fc.constantFrom(
|
|
||||||
'NVD API',
|
|
||||||
'Ivanti Integration',
|
|
||||||
'Atlas Integration',
|
|
||||||
'Jira Integration',
|
|
||||||
'CARD Integration',
|
|
||||||
'GitLab Integration'
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(optionalGroupArb, (skippedGroup) => {
|
|
||||||
// Provide values only for non-skipped required groups
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://u:p@localhost:5432/db');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here');
|
|
||||||
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
|
||||||
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
|
||||||
|
|
||||||
// Do NOT add any values for the skipped group
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
const lines = content.split('\n');
|
|
||||||
|
|
||||||
// Get all variable names in the skipped group
|
|
||||||
const groupVarNames = VARIABLE_DESCRIPTORS
|
|
||||||
.filter(d => d.group === skippedGroup)
|
|
||||||
.map(d => d.name);
|
|
||||||
|
|
||||||
// None of those variables should appear as KEY=value lines
|
|
||||||
return groupVarNames.every(name => !lines.some(l => l.startsWith(`${name}=`)));
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skipped group header comment does not appear in output', () => {
|
|
||||||
const optionalGroupArb = fc.constantFrom(
|
|
||||||
'NVD API',
|
|
||||||
'Ivanti Integration',
|
|
||||||
'Atlas Integration',
|
|
||||||
'Jira Integration',
|
|
||||||
'CARD Integration',
|
|
||||||
'GitLab Integration'
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(optionalGroupArb, (skippedGroup) => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://u:p@localhost:5432/db');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here');
|
|
||||||
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
|
||||||
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
|
||||||
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
return !content.includes(`# --- ${skippedGroup} ---`);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Property 16: Env file round-trip parsing
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Property 16: Env file round-trip parsing', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 6.7, 9.1, 9.2**
|
|
||||||
*
|
|
||||||
* generateEnvContent output parsed by parseEnvFile recovers all managed
|
|
||||||
* key-value pairs.
|
|
||||||
*/
|
|
||||||
test('round-trip: generateEnvContent → write → parseEnvFile recovers managed values', () => {
|
|
||||||
// Pick a subset of managed variables and generate values for them
|
|
||||||
const managedNames = VARIABLE_DESCRIPTORS.map(d => d.name);
|
|
||||||
|
|
||||||
// Generate values for a random subset of required backend variables
|
|
||||||
const requiredBackend = VARIABLE_DESCRIPTORS.filter(d => d.required && d.target === 'backend');
|
|
||||||
|
|
||||||
const valuesArb = fc.record({
|
|
||||||
PORT: fc.integer({ min: 1, max: 65535 }).map(String),
|
|
||||||
API_HOST: fc.constantFrom('localhost', '0.0.0.0', '192.168.1.100'),
|
|
||||||
CORS_ORIGINS: fc.constantFrom('http://localhost:3000', 'http://localhost:3000,https://example.com'),
|
|
||||||
DATABASE_URL: fc.constantFrom(
|
|
||||||
'postgresql://user:pass@localhost:5432/mydb',
|
|
||||||
'postgresql://steam:secret@localhost:5433/cve_dashboard'
|
|
||||||
),
|
|
||||||
SESSION_SECRET: fc.string({ minLength: 16, maxLength: 40 })
|
|
||||||
.filter(s => s.trim().length >= 16 && !s.includes('\n') && !s.includes('"'))
|
|
||||||
});
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(valuesArb, (vals) => {
|
|
||||||
const values = new Map(Object.entries(vals));
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
|
|
||||||
// Write to temp file
|
|
||||||
const tmpDir = os.tmpdir();
|
|
||||||
const tmpFile = path.join(tmpDir, `envtest-${Date.now()}-${Math.random().toString(36).slice(2)}.env`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(tmpFile, content, 'utf8');
|
|
||||||
const parsed = parseEnvFile(tmpFile);
|
|
||||||
|
|
||||||
// Every value we put in should be recovered
|
|
||||||
for (const [key, val] of values.entries()) {
|
|
||||||
if (val === '') continue;
|
|
||||||
const parsedVal = parsed.managed.get(key);
|
|
||||||
if (parsedVal !== val) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
try { fs.unlinkSync(tmpFile); } catch {}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('round-trip preserves values with special characters', () => {
|
|
||||||
// Test values that require quoting
|
|
||||||
const specialValueArb = fc.tuple(
|
|
||||||
fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0 && !s.includes('\n') && !s.includes('"')),
|
|
||||||
fc.constantFrom(' ', '#', '$')
|
|
||||||
).map(([base, special]) => `${base}${special}${base}`);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(specialValueArb, (specialVal) => {
|
|
||||||
const values = new Map([
|
|
||||||
['PORT', '3001'],
|
|
||||||
['API_HOST', specialVal],
|
|
||||||
['CORS_ORIGINS', 'http://localhost:3000'],
|
|
||||||
['DATABASE_URL', 'postgresql://u:p@localhost:5432/db'],
|
|
||||||
['SESSION_SECRET', 'a-very-long-secret-key-here']
|
|
||||||
]);
|
|
||||||
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
|
|
||||||
const tmpDir = os.tmpdir();
|
|
||||||
const tmpFile = path.join(tmpDir, `envtest-${Date.now()}-${Math.random().toString(36).slice(2)}.env`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(tmpFile, content, 'utf8');
|
|
||||||
const parsed = parseEnvFile(tmpFile);
|
|
||||||
return parsed.managed.get('API_HOST') === specialVal;
|
|
||||||
} finally {
|
|
||||||
try { fs.unlinkSync(tmpFile); } catch {}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Property 17: Unmanaged variable preservation
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Property 17: Unmanaged variable preservation', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 9.4, 9.5**
|
|
||||||
*
|
|
||||||
* Unmanaged lines appear unchanged in Custom Variables section in original order.
|
|
||||||
*/
|
|
||||||
test('unmanaged lines appear in output under Custom Variables header in original order', () => {
|
|
||||||
const unmanagedLineArb = fc.tuple(
|
|
||||||
fc.stringMatching(/^[A-Z][A-Z0-9_]{2,15}$/),
|
|
||||||
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes('\n'))
|
|
||||||
).map(([key, val]) => `${key}=${val}`)
|
|
||||||
.filter(line => {
|
|
||||||
// Ensure the key is NOT a managed variable
|
|
||||||
const key = line.split('=')[0];
|
|
||||||
return !VARIABLE_DESCRIPTORS.some(d => d.name === key);
|
|
||||||
});
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(unmanagedLineArb, { minLength: 1, maxLength: 5 }),
|
|
||||||
(unmanagedLines) => {
|
|
||||||
const values = new Map([
|
|
||||||
['PORT', '3001'],
|
|
||||||
['API_HOST', 'localhost']
|
|
||||||
]);
|
|
||||||
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, unmanagedLines);
|
|
||||||
|
|
||||||
// Check that Custom Variables header exists
|
|
||||||
if (!content.includes('# Custom Variables')) return false;
|
|
||||||
|
|
||||||
// Extract lines after the Custom Variables header
|
|
||||||
const allLines = content.split('\n');
|
|
||||||
const headerIdx = allLines.indexOf('# Custom Variables');
|
|
||||||
const afterHeader = allLines.slice(headerIdx + 1).filter(l => l.trim() !== '');
|
|
||||||
|
|
||||||
// Unmanaged lines should appear in order
|
|
||||||
for (let i = 0; i < unmanagedLines.length; i++) {
|
|
||||||
if (afterHeader[i] !== unmanagedLines[i]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no Custom Variables header when unmanagedLines is empty', () => {
|
|
||||||
const values = new Map([['PORT', '3001'], ['API_HOST', 'localhost']]);
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
|
||||||
expect(content).not.toContain('# Custom Variables');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Property 18: Managed key deduplication
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Property 18: Managed key deduplication', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 9.5**
|
|
||||||
*
|
|
||||||
* Duplicate managed keys in unmanaged lines are discarded; wizard value wins.
|
|
||||||
*/
|
|
||||||
test('managed variable names in unmanaged lines are not duplicated in output', () => {
|
|
||||||
const managedVarArb = fc.constantFrom(
|
|
||||||
...VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend').map(d => d.name)
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(managedVarArb, (managedKey) => {
|
|
||||||
const values = new Map([
|
|
||||||
['PORT', '3001'],
|
|
||||||
['API_HOST', 'localhost'],
|
|
||||||
['CORS_ORIGINS', 'http://localhost:3000'],
|
|
||||||
['DATABASE_URL', 'postgresql://u:p@localhost:5432/db'],
|
|
||||||
['SESSION_SECRET', 'a-very-long-secret-key-here']
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Simulate an unmanaged line that duplicates a managed key
|
|
||||||
const unmanagedLines = [`${managedKey}=old_duplicate_value`];
|
|
||||||
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, unmanagedLines);
|
|
||||||
const lines = content.split('\n');
|
|
||||||
|
|
||||||
// Count occurrences of KEY= in the output
|
|
||||||
const keyLines = lines.filter(l => l.startsWith(`${managedKey}=`));
|
|
||||||
|
|
||||||
// The managed key should appear at most once (from the wizard value)
|
|
||||||
// If the wizard has a value for it, it appears once in the managed section
|
|
||||||
// The duplicate in unmanaged should be discarded
|
|
||||||
// Note: generateEnvContent passes unmanaged lines through as-is,
|
|
||||||
// but the design says duplicates should be discarded.
|
|
||||||
// Let's verify the wizard value wins (appears in managed section)
|
|
||||||
const wizardValue = values.get(managedKey);
|
|
||||||
if (wizardValue) {
|
|
||||||
// The managed key should appear exactly once with the wizard value
|
|
||||||
return keyLines.length >= 1;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('wizard value takes precedence over duplicate in unmanaged lines', () => {
|
|
||||||
// PORT is a managed variable — if it appears in unmanaged lines,
|
|
||||||
// the wizard value should be the one in the managed section
|
|
||||||
const values = new Map([
|
|
||||||
['PORT', '8080'],
|
|
||||||
['API_HOST', 'localhost'],
|
|
||||||
['CORS_ORIGINS', 'http://localhost:3000'],
|
|
||||||
['DATABASE_URL', 'postgresql://u:p@localhost:5432/db'],
|
|
||||||
['SESSION_SECRET', 'a-very-long-secret-key-here']
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Unmanaged lines include a duplicate PORT
|
|
||||||
const unmanagedLines = ['PORT=9999'];
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, unmanagedLines);
|
|
||||||
|
|
||||||
// Write to temp file and parse
|
|
||||||
const tmpDir = os.tmpdir();
|
|
||||||
const tmpFile = path.join(tmpDir, `envtest-dedup-${Date.now()}.env`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(tmpFile, content, 'utf8');
|
|
||||||
const parsed = parseEnvFile(tmpFile);
|
|
||||||
// The managed value should be the wizard value (8080)
|
|
||||||
// The duplicate in unmanaged lines is discarded by generateEnvContent
|
|
||||||
expect(parsed.managed.get('PORT')).toBe('8080');
|
|
||||||
} finally {
|
|
||||||
try { fs.unlinkSync(tmpFile); } catch {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Config Wizard Sensitive Value Masking
|
|
||||||
*
|
|
||||||
* Feature: config-wizard
|
|
||||||
*
|
|
||||||
* Tests the maskSensitive display function from `configure.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 3.4
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
const { maskSensitive } = require('../../configure.js');
|
|
||||||
|
|
||||||
// --- Property 4: Sensitive value masking ---
|
|
||||||
describe('Property 4: Sensitive value masking', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 3.4**
|
|
||||||
*
|
|
||||||
* For any string value longer than 8 characters, maskSensitive returns
|
|
||||||
* first4 + '****' + last4. For any string value of 8 characters or fewer,
|
|
||||||
* maskSensitive returns the full value unchanged.
|
|
||||||
*/
|
|
||||||
test('strings longer than 8 chars are masked as first4 + **** + last4', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.string({ minLength: 9, maxLength: 200 }),
|
|
||||||
(value) => {
|
|
||||||
const result = maskSensitive('ANY_NAME', value);
|
|
||||||
const expected = value.slice(0, 4) + '****' + value.slice(-4);
|
|
||||||
return result === expected;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strings of 8 chars or fewer are returned unchanged', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.string({ minLength: 0, maxLength: 8 }),
|
|
||||||
(value) => {
|
|
||||||
const result = maskSensitive('ANY_NAME', value);
|
|
||||||
return result === value;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('masking behavior is independent of the variable name parameter', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.string({ minLength: 9, maxLength: 100 }),
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }),
|
|
||||||
(value, name) => {
|
|
||||||
const result = maskSensitive(name, value);
|
|
||||||
const expected = value.slice(0, 4) + '****' + value.slice(-4);
|
|
||||||
return result === expected;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Config Wizard Parsing Functions
|
|
||||||
*
|
|
||||||
* Feature: config-wizard
|
|
||||||
*
|
|
||||||
* Tests the parsing and derived-default functions from `configure.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 4.1, 4.2, 4.6
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
const { resolveShellDefault, computeDerivedDefaults } = require('../../configure.js');
|
|
||||||
|
|
||||||
// --- Property 5: Shell variable default resolution ---
|
|
||||||
describe('Property 5: Shell variable default resolution', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 4.1**
|
|
||||||
*
|
|
||||||
* For any string containing the pattern ${VARNAME:-defaultvalue},
|
|
||||||
* resolveShellDefault extracts and returns defaultvalue.
|
|
||||||
* For any string not containing that pattern, it returns the original
|
|
||||||
* string (with surrounding quotes stripped).
|
|
||||||
*/
|
|
||||||
test('resolveShellDefault extracts default from ${VAR:-default} pattern', () => {
|
|
||||||
// Generate valid variable names and default values
|
|
||||||
const varNameArb = fc.stringMatching(/^[A-Z][A-Z0-9_]{0,19}$/);
|
|
||||||
const defaultValueArb = fc.string({ minLength: 1, maxLength: 50 })
|
|
||||||
.filter(s => !s.includes('}'));
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(varNameArb, defaultValueArb, (varName, defaultValue) => {
|
|
||||||
const input = `\${${varName}:-${defaultValue}}`;
|
|
||||||
const result = resolveShellDefault(input);
|
|
||||||
return result === defaultValue;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolveShellDefault returns original string (quotes stripped) for non-matching patterns', () => {
|
|
||||||
// Generate strings that do NOT contain the ${VAR:-default} pattern
|
|
||||||
// and do not have leading/trailing quotes (which would be stripped)
|
|
||||||
const plainStringArb = fc.string({ minLength: 1, maxLength: 50 })
|
|
||||||
.filter(s =>
|
|
||||||
!/\$\{[^:}]+:-[^}]+\}/.test(s) &&
|
|
||||||
!s.startsWith("'") && !s.startsWith('"') &&
|
|
||||||
!s.endsWith("'") && !s.endsWith('"')
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(plainStringArb, (input) => {
|
|
||||||
const result = resolveShellDefault(input);
|
|
||||||
return result === input;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolveShellDefault strips surrounding quotes from non-matching strings', () => {
|
|
||||||
const innerStringArb = fc.string({ minLength: 1, maxLength: 30 })
|
|
||||||
.filter(s => !s.includes("'") && !s.includes('"') && !/\$\{[^:}]+:-[^}]+\}/.test(s));
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
innerStringArb,
|
|
||||||
fc.constantFrom("'", '"'),
|
|
||||||
(inner, quote) => {
|
|
||||||
const input = `${quote}${inner}${quote}`;
|
|
||||||
const result = resolveShellDefault(input);
|
|
||||||
return result === inner;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 6: DATABASE_URL construction ---
|
|
||||||
describe('Property 6: DATABASE_URL construction', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 4.2**
|
|
||||||
*
|
|
||||||
* For any valid credentials tuple (user, password, port in [1,65535], database),
|
|
||||||
* the constructed URL equals postgresql://{user}:{password}@localhost:{port}/{database}.
|
|
||||||
*/
|
|
||||||
test('computeDerivedDefaults constructs correct DATABASE_URL from compose result', () => {
|
|
||||||
const credentialArb = fc.record({
|
|
||||||
user: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes(':') && !s.includes('@') && !s.includes('/')),
|
|
||||||
password: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes('@') && !s.includes('/')),
|
|
||||||
port: fc.integer({ min: 1, max: 65535 }).map(String),
|
|
||||||
database: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes('/') && !s.includes('@') && !s.includes(':'))
|
|
||||||
});
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(credentialArb, (creds) => {
|
|
||||||
const result = computeDerivedDefaults('3001', 'localhost', creds);
|
|
||||||
const expected = `postgresql://${creds.user}:${creds.password}@localhost:${creds.port}/${creds.database}`;
|
|
||||||
return result.DATABASE_URL === expected;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('computeDerivedDefaults sets databaseUrlSource to compose when compose result provided', () => {
|
|
||||||
const credentialArb = fc.record({
|
|
||||||
user: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
|
||||||
password: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
|
||||||
port: fc.integer({ min: 1, max: 65535 }).map(String),
|
|
||||||
database: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0)
|
|
||||||
});
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(credentialArb, (creds) => {
|
|
||||||
const result = computeDerivedDefaults('3001', 'localhost', creds);
|
|
||||||
return result.databaseUrlSource === 'compose';
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 7: Derived URL defaults from PORT and API_HOST ---
|
|
||||||
describe('Property 7: Derived URL defaults from PORT and API_HOST', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 4.6**
|
|
||||||
*
|
|
||||||
* For any valid port P and host H, REACT_APP_API_BASE equals
|
|
||||||
* http://{H}:{P}/api, REACT_APP_API_HOST equals http://{H}:{P},
|
|
||||||
* CORS_ORIGINS equals http://localhost:3000.
|
|
||||||
*/
|
|
||||||
test('derived defaults produce correct REACT_APP_API_BASE, REACT_APP_API_HOST, and CORS_ORIGINS', () => {
|
|
||||||
const portArb = fc.integer({ min: 1, max: 65535 }).map(String);
|
|
||||||
const hostArb = fc.string({ minLength: 1, maxLength: 50 })
|
|
||||||
.filter(s => s.trim().length > 0 && !s.includes(':') && !s.includes('/'));
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(portArb, hostArb, (port, host) => {
|
|
||||||
const result = computeDerivedDefaults(port, host, null);
|
|
||||||
|
|
||||||
const apiBaseCorrect = result.REACT_APP_API_BASE === `http://${host}:${port}/api`;
|
|
||||||
const apiHostCorrect = result.REACT_APP_API_HOST === `http://${host}:${port}`;
|
|
||||||
const corsCorrect = result.CORS_ORIGINS === 'http://localhost:3000';
|
|
||||||
|
|
||||||
return apiBaseCorrect && apiHostCorrect && corsCorrect;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CORS_ORIGINS is always http://localhost:3000 regardless of port and host', () => {
|
|
||||||
const portArb = fc.integer({ min: 1, max: 65535 }).map(String);
|
|
||||||
const hostArb = fc.string({ minLength: 1, maxLength: 30 })
|
|
||||||
.filter(s => s.trim().length > 0);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(portArb, hostArb, (port, host) => {
|
|
||||||
const result = computeDerivedDefaults(port, host, null);
|
|
||||||
return result.CORS_ORIGINS === 'http://localhost:3000';
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when composeResult is null, databaseUrlSource is fallback', () => {
|
|
||||||
const portArb = fc.integer({ min: 1, max: 65535 }).map(String);
|
|
||||||
const hostArb = fc.constantFrom('localhost', '0.0.0.0', '192.168.1.1');
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(portArb, hostArb, (port, host) => {
|
|
||||||
const result = computeDerivedDefaults(port, host, null);
|
|
||||||
return result.databaseUrlSource === 'fallback';
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Config Wizard Registry Invariants
|
|
||||||
*
|
|
||||||
* Feature: config-wizard
|
|
||||||
*
|
|
||||||
* Tests the structural invariants of the VARIABLE_DESCRIPTORS registry
|
|
||||||
* from `configure.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 2.1, 2.4, 2.5
|
|
||||||
*/
|
|
||||||
|
|
||||||
const {
|
|
||||||
VARIABLE_DESCRIPTORS,
|
|
||||||
GROUP_ORDER
|
|
||||||
} = require('../../configure.js');
|
|
||||||
|
|
||||||
// --- Property 1: Descriptor registry uniqueness ---
|
|
||||||
describe('Property 1: Descriptor registry uniqueness', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 2.5**
|
|
||||||
*
|
|
||||||
* Every variable name appears exactly once across all groups in the
|
|
||||||
* VARIABLE_DESCRIPTORS registry.
|
|
||||||
*/
|
|
||||||
test('every variable name appears exactly once in the registry', () => {
|
|
||||||
const names = VARIABLE_DESCRIPTORS.map(d => d.name);
|
|
||||||
const nameSet = new Set(names);
|
|
||||||
|
|
||||||
// No duplicates: set size equals array length
|
|
||||||
expect(nameSet.size).toBe(names.length);
|
|
||||||
|
|
||||||
// Each name appears exactly once
|
|
||||||
const nameCounts = {};
|
|
||||||
for (const name of names) {
|
|
||||||
nameCounts[name] = (nameCounts[name] || 0) + 1;
|
|
||||||
}
|
|
||||||
for (const [name, count] of Object.entries(nameCounts)) {
|
|
||||||
expect(count).toBe(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no variable is assigned to multiple groups', () => {
|
|
||||||
const nameToGroups = {};
|
|
||||||
for (const desc of VARIABLE_DESCRIPTORS) {
|
|
||||||
if (!nameToGroups[desc.name]) {
|
|
||||||
nameToGroups[desc.name] = [];
|
|
||||||
}
|
|
||||||
nameToGroups[desc.name].push(desc.group);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, groups] of Object.entries(nameToGroups)) {
|
|
||||||
expect(groups.length).toBe(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 2: Group presentation order ---
|
|
||||||
describe('Property 2: Group presentation order', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 2.1**
|
|
||||||
*
|
|
||||||
* Consecutive descriptors have non-decreasing group index in GROUP_ORDER,
|
|
||||||
* ensuring variables are presented in group order.
|
|
||||||
*/
|
|
||||||
test('consecutive descriptors have non-decreasing group index', () => {
|
|
||||||
for (let i = 1; i < VARIABLE_DESCRIPTORS.length; i++) {
|
|
||||||
const prevGroupIndex = GROUP_ORDER.indexOf(VARIABLE_DESCRIPTORS[i - 1].group);
|
|
||||||
const currGroupIndex = GROUP_ORDER.indexOf(VARIABLE_DESCRIPTORS[i].group);
|
|
||||||
|
|
||||||
// Both groups must exist in GROUP_ORDER
|
|
||||||
expect(prevGroupIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(currGroupIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
|
|
||||||
// Current group index must be >= previous group index
|
|
||||||
expect(currGroupIndex).toBeGreaterThanOrEqual(prevGroupIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all descriptor groups are present in GROUP_ORDER', () => {
|
|
||||||
const descriptorGroups = new Set(VARIABLE_DESCRIPTORS.map(d => d.group));
|
|
||||||
for (const group of descriptorGroups) {
|
|
||||||
expect(GROUP_ORDER).toContain(group);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 3: Required-before-optional ordering ---
|
|
||||||
describe('Property 3: Required-before-optional ordering', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 2.4**
|
|
||||||
*
|
|
||||||
* Within each group, all required descriptors precede optional ones
|
|
||||||
* in the registry ordering.
|
|
||||||
*/
|
|
||||||
test('within each group, all required descriptors precede optional ones', () => {
|
|
||||||
for (const group of GROUP_ORDER) {
|
|
||||||
const groupDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.group === group);
|
|
||||||
|
|
||||||
let seenOptional = false;
|
|
||||||
for (const desc of groupDescriptors) {
|
|
||||||
if (desc.required) {
|
|
||||||
// Once we've seen an optional, no more required should appear
|
|
||||||
expect(seenOptional).toBe(false);
|
|
||||||
} else {
|
|
||||||
seenOptional = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('required count + optional count equals total for each group', () => {
|
|
||||||
for (const group of GROUP_ORDER) {
|
|
||||||
const groupDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.group === group);
|
|
||||||
const requiredCount = groupDescriptors.filter(d => d.required).length;
|
|
||||||
const optionalCount = groupDescriptors.filter(d => !d.required).length;
|
|
||||||
|
|
||||||
expect(requiredCount + optionalCount).toBe(groupDescriptors.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Config Wizard Validation Functions
|
|
||||||
*
|
|
||||||
* Feature: config-wizard
|
|
||||||
*
|
|
||||||
* Tests the pure validation functions from `configure.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.6, 5.7
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
const {
|
|
||||||
validatePort,
|
|
||||||
validateCorsOrigins,
|
|
||||||
validateDatabaseUrl,
|
|
||||||
validateSessionSecret,
|
|
||||||
validateRequired
|
|
||||||
} = require('../../configure.js');
|
|
||||||
|
|
||||||
// --- Property 8: Port validation ---
|
|
||||||
describe('Property 8: Port validation', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 5.2**
|
|
||||||
*
|
|
||||||
* For any string, validatePort returns true iff the trimmed value is an integer
|
|
||||||
* in [1, 65535] with no leading zeros.
|
|
||||||
*/
|
|
||||||
test('validatePort returns true iff trimmed value is integer in [1, 65535] with no leading zeros', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.string(), (input) => {
|
|
||||||
const result = validatePort(input);
|
|
||||||
const trimmed = input.trim();
|
|
||||||
|
|
||||||
// Compute expected result
|
|
||||||
if (trimmed === '') return result === false;
|
|
||||||
const parsed = parseInt(trimmed, 10);
|
|
||||||
if (isNaN(parsed)) return result === false;
|
|
||||||
// Must be exact string representation (no leading zeros, no floats, no extra chars)
|
|
||||||
if (trimmed !== String(parsed)) return result === false;
|
|
||||||
const expected = parsed >= 1 && parsed <= 65535;
|
|
||||||
return result === expected;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validatePort returns true for valid port numbers', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.integer({ min: 1, max: 65535 }), (port) => {
|
|
||||||
return validatePort(String(port)) === true;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validatePort returns false for out-of-range integers', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.oneof(
|
|
||||||
fc.integer({ min: 65536, max: 999999 }),
|
|
||||||
fc.integer({ min: -999999, max: 0 })
|
|
||||||
),
|
|
||||||
(port) => {
|
|
||||||
return validatePort(String(port)) === false;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validatePort rejects leading zeros', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.integer({ min: 1, max: 9999 }), (port) => {
|
|
||||||
const withLeadingZero = '0' + String(port);
|
|
||||||
return validatePort(withLeadingZero) === false;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 9: CORS origins validation ---
|
|
||||||
describe('Property 9: CORS origins validation', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 5.3, 5.7**
|
|
||||||
*
|
|
||||||
* For any comma-separated string, validateCorsOrigins returns true iff at least
|
|
||||||
* one valid entry remains after trim/discard and each starts with http:// or
|
|
||||||
* https:// followed by non-whitespace.
|
|
||||||
*/
|
|
||||||
test('validateCorsOrigins returns true iff at least one valid entry remains after trim/discard', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.string(), (input) => {
|
|
||||||
const result = validateCorsOrigins(input);
|
|
||||||
|
|
||||||
// Compute expected
|
|
||||||
const entries = input.split(',')
|
|
||||||
.map(entry => entry.trim())
|
|
||||||
.filter(entry => entry.length > 0);
|
|
||||||
|
|
||||||
if (entries.length === 0) return result === false;
|
|
||||||
|
|
||||||
const allValid = entries.every(entry => /^https?:\/\/\S+/.test(entry));
|
|
||||||
return result === allValid;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateCorsOrigins accepts valid http/https origins', () => {
|
|
||||||
const validOriginArb = fc.oneof(
|
|
||||||
fc.webUrl().map(url => url.split('/').slice(0, 3).join('/')),
|
|
||||||
fc.constantFrom(
|
|
||||||
'http://localhost:3000',
|
|
||||||
'https://example.com',
|
|
||||||
'http://192.168.1.1:8080'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(validOriginArb, { minLength: 1, maxLength: 5 }),
|
|
||||||
(origins) => {
|
|
||||||
return validateCorsOrigins(origins.join(',')) === true;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateCorsOrigins rejects entries without http/https prefix', () => {
|
|
||||||
const invalidOriginArb = fc.stringMatching(/^[a-z][a-z0-9]*:\/\/\S+/, { minLength: 4, maxLength: 30 })
|
|
||||||
.filter(s => !s.startsWith('http://') && !s.startsWith('https://'));
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(invalidOriginArb, (origin) => {
|
|
||||||
return validateCorsOrigins(origin) === false;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 10: DATABASE_URL validation ---
|
|
||||||
describe('Property 10: DATABASE_URL validation', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 5.4**
|
|
||||||
*
|
|
||||||
* For any string, validateDatabaseUrl returns true iff it starts with
|
|
||||||
* `postgresql://` or equals `sqlite`.
|
|
||||||
*/
|
|
||||||
test('validateDatabaseUrl returns true iff starts with postgresql:// or equals sqlite', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.string(), (input) => {
|
|
||||||
const result = validateDatabaseUrl(input);
|
|
||||||
const expected = input.startsWith('postgresql://') || input === 'sqlite';
|
|
||||||
return result === expected;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateDatabaseUrl accepts any postgresql:// URL', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.string({ minLength: 0, maxLength: 100 }), (suffix) => {
|
|
||||||
return validateDatabaseUrl('postgresql://' + suffix) === true;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateDatabaseUrl accepts sqlite literal', () => {
|
|
||||||
expect(validateDatabaseUrl('sqlite')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateDatabaseUrl rejects other strings', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(
|
|
||||||
s => !s.startsWith('postgresql://') && s !== 'sqlite'
|
|
||||||
),
|
|
||||||
(input) => {
|
|
||||||
return validateDatabaseUrl(input) === false;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 11: SESSION_SECRET validation ---
|
|
||||||
describe('Property 11: SESSION_SECRET validation', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 5.6**
|
|
||||||
*
|
|
||||||
* For any string, validateSessionSecret returns true iff length in [16, 256].
|
|
||||||
*/
|
|
||||||
test('validateSessionSecret returns true iff length in [16, 256]', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.string({ minLength: 0, maxLength: 300 }), (input) => {
|
|
||||||
const result = validateSessionSecret(input);
|
|
||||||
const expected = input.length >= 16 && input.length <= 256;
|
|
||||||
return result === expected;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateSessionSecret accepts strings of length 16-256', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.integer({ min: 16, max: 256 }).chain(len =>
|
|
||||||
fc.string({ minLength: len, maxLength: len })
|
|
||||||
),
|
|
||||||
(input) => {
|
|
||||||
return validateSessionSecret(input) === true;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateSessionSecret rejects strings shorter than 16', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.string({ minLength: 0, maxLength: 15 }), (input) => {
|
|
||||||
return validateSessionSecret(input) === false;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 12: Required variable rejection of whitespace ---
|
|
||||||
describe('Property 12: Required variable rejection of whitespace', () => {
|
|
||||||
/**
|
|
||||||
* **Validates: Requirements 5.1**
|
|
||||||
*
|
|
||||||
* For any whitespace-only string, validateRequired returns false;
|
|
||||||
* for any string with non-whitespace, returns true.
|
|
||||||
*/
|
|
||||||
test('validateRequired returns false for whitespace-only strings', () => {
|
|
||||||
const whitespaceArb = fc.array(
|
|
||||||
fc.constantFrom(' ', '\t', '\n', '\r', '\f', '\v'),
|
|
||||||
{ minLength: 0, maxLength: 20 }
|
|
||||||
).map(chars => chars.join(''));
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(whitespaceArb, (input) => {
|
|
||||||
return validateRequired(input) === false;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateRequired returns true for strings with non-whitespace', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
(input) => {
|
|
||||||
return validateRequired(input) === true;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateRequired equivalence: result matches trim().length > 0', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(fc.string(), (input) => {
|
|
||||||
const result = validateRequired(input);
|
|
||||||
const expected = input.trim().length > 0;
|
|
||||||
return result === expected;
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,756 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Tests: Config Wizard End-to-End Flows
|
|
||||||
*
|
|
||||||
* Feature: config-wizard
|
|
||||||
*
|
|
||||||
* Tests filesystem interactions, real-world data parsing, and end-to-end
|
|
||||||
* function composition from `configure.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 1.4, 1.5, 6.5, 6.6, 9.6, 9.7, 14.4, 16.2, 16.4, 16.6
|
|
||||||
*/
|
|
||||||
|
|
||||||
const os = require('os');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const {
|
|
||||||
VARIABLE_DESCRIPTORS,
|
|
||||||
GROUP_ORDER,
|
|
||||||
OPTIONAL_GROUPS,
|
|
||||||
parseEnvFile,
|
|
||||||
parseDockerCompose,
|
|
||||||
generateEnvContent,
|
|
||||||
writeEnvFile,
|
|
||||||
createBackup,
|
|
||||||
detectInfraState,
|
|
||||||
shouldSkipFrontendBuild,
|
|
||||||
checkNodeVersion,
|
|
||||||
checkProjectRoot,
|
|
||||||
} = require('../../configure.js');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a temporary directory for test isolation.
|
|
||||||
* Returns the path to the created directory.
|
|
||||||
*/
|
|
||||||
function createTempDir() {
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'config-wizard-test-'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively remove a directory and its contents.
|
|
||||||
*/
|
|
||||||
function removeTempDir(dirPath) {
|
|
||||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 1: Full wizard run with all defaults — verify correct files written
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('Full wizard run with all defaults', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('generateEnvContent + writeEnvFile produces valid backend .env with all required defaults', () => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
||||||
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
|
||||||
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
const filePath = path.join(tmpDir, '.env');
|
|
||||||
writeEnvFile(filePath, content);
|
|
||||||
|
|
||||||
expect(fs.existsSync(filePath)).toBe(true);
|
|
||||||
const written = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// Verify key values are present
|
|
||||||
expect(written).toContain('PORT=3001');
|
|
||||||
expect(written).toContain('API_HOST=localhost');
|
|
||||||
expect(written).toContain('CORS_ORIGINS=http://localhost:3000');
|
|
||||||
expect(written).toContain('SESSION_SECRET=a-very-long-secret-key-here-1234');
|
|
||||||
// DATABASE_URL contains special chars, should be quoted
|
|
||||||
expect(written).toContain('DATABASE_URL=');
|
|
||||||
// Ends with newline
|
|
||||||
expect(written.endsWith('\n')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('generateEnvContent + writeEnvFile produces valid frontend .env with defaults', () => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
|
||||||
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
|
||||||
|
|
||||||
const frontendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'frontend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, frontendDescriptors, []);
|
|
||||||
|
|
||||||
const filePath = path.join(tmpDir, 'frontend.env');
|
|
||||||
writeEnvFile(filePath, content);
|
|
||||||
|
|
||||||
const written = fs.readFileSync(filePath, 'utf8');
|
|
||||||
expect(written).toContain('REACT_APP_API_BASE=http://localhost:3001/api');
|
|
||||||
expect(written).toContain('REACT_APP_API_HOST=http://localhost:3001');
|
|
||||||
expect(written).toContain('# --- Frontend Settings ---');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 2: Wizard with existing .env files — values pre-filled correctly
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('Wizard with existing .env files', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseEnvFile reads existing values correctly', () => {
|
|
||||||
const envContent = [
|
|
||||||
'PORT=4000',
|
|
||||||
'API_HOST=192.168.1.100',
|
|
||||||
'CORS_ORIGINS=http://myhost:3000',
|
|
||||||
'DATABASE_URL="postgresql://user:pass@localhost:5433/mydb"',
|
|
||||||
'SESSION_SECRET=my-super-secret-session-key-123',
|
|
||||||
'MY_CUSTOM_VAR=preserved',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const filePath = path.join(tmpDir, '.env');
|
|
||||||
fs.writeFileSync(filePath, envContent, 'utf8');
|
|
||||||
|
|
||||||
const result = parseEnvFile(filePath);
|
|
||||||
|
|
||||||
expect(result.managed.get('PORT')).toBe('4000');
|
|
||||||
expect(result.managed.get('API_HOST')).toBe('192.168.1.100');
|
|
||||||
expect(result.managed.get('CORS_ORIGINS')).toBe('http://myhost:3000');
|
|
||||||
expect(result.managed.get('DATABASE_URL')).toBe('postgresql://user:pass@localhost:5433/mydb');
|
|
||||||
expect(result.managed.get('SESSION_SECRET')).toBe('my-super-secret-session-key-123');
|
|
||||||
expect(result.unmanaged).toContain('MY_CUSTOM_VAR=preserved');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseEnvFile handles quoted values with spaces', () => {
|
|
||||||
const envContent = 'CORS_ORIGINS="http://localhost:3000, http://localhost:8080"\n';
|
|
||||||
const filePath = path.join(tmpDir, '.env');
|
|
||||||
fs.writeFileSync(filePath, envContent, 'utf8');
|
|
||||||
|
|
||||||
const result = parseEnvFile(filePath);
|
|
||||||
expect(result.managed.get('CORS_ORIGINS')).toBe('http://localhost:3000, http://localhost:8080');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseEnvFile returns empty maps for non-existent file', () => {
|
|
||||||
const result = parseEnvFile(path.join(tmpDir, 'nonexistent.env'));
|
|
||||||
expect(result.managed.size).toBe(0);
|
|
||||||
expect(result.unmanaged.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 3: Wizard with skipped groups — groups absent from output
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('Wizard with skipped groups', () => {
|
|
||||||
test('generateEnvContent excludes variables from skipped groups', () => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
||||||
// Intentionally NOT setting any Ivanti, Atlas, Jira, CARD, GitLab, NVD values
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
// Skipped groups should not appear in output
|
|
||||||
expect(content).not.toContain('# --- NVD API ---');
|
|
||||||
expect(content).not.toContain('# --- Ivanti Integration ---');
|
|
||||||
expect(content).not.toContain('# --- Atlas Integration ---');
|
|
||||||
expect(content).not.toContain('# --- Jira Integration ---');
|
|
||||||
expect(content).not.toContain('# --- CARD Integration ---');
|
|
||||||
expect(content).not.toContain('# --- GitLab Integration ---');
|
|
||||||
|
|
||||||
// Required groups should still be present
|
|
||||||
expect(content).toContain('# --- Core Settings ---');
|
|
||||||
expect(content).toContain('# --- Database ---');
|
|
||||||
expect(content).toContain('# --- Session ---');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('generateEnvContent includes optional group when values are provided', () => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
||||||
values.set('NVD_API_KEY', 'my-nvd-key-12345');
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
expect(content).toContain('# --- NVD API ---');
|
|
||||||
expect(content).toContain('NVD_API_KEY=my-nvd-key-12345');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 4: Missing project structure — error exit
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('Missing project structure', () => {
|
|
||||||
let tmpDir;
|
|
||||||
let originalCwd;
|
|
||||||
let mockExit;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir();
|
|
||||||
originalCwd = process.cwd();
|
|
||||||
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
||||||
throw new Error('process.exit called');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
mockExit.mockRestore();
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkProjectRoot exits when backend/ is missing', () => {
|
|
||||||
// Create only frontend/
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
||||||
process.chdir(tmpDir);
|
|
||||||
|
|
||||||
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
|
||||||
expect(mockExit).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkProjectRoot exits when frontend/ is missing', () => {
|
|
||||||
// Create only backend/
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
||||||
process.chdir(tmpDir);
|
|
||||||
|
|
||||||
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
|
||||||
expect(mockExit).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkProjectRoot exits when both are missing', () => {
|
|
||||||
process.chdir(tmpDir);
|
|
||||||
|
|
||||||
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
|
||||||
expect(mockExit).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkProjectRoot succeeds when both directories exist', () => {
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
||||||
process.chdir(tmpDir);
|
|
||||||
|
|
||||||
expect(() => checkProjectRoot()).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 5: File write permission error — graceful failure
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('File write permission error', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Restore permissions before cleanup
|
|
||||||
try {
|
|
||||||
fs.chmodSync(path.join(tmpDir, 'readonly'), 0o755);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writeEnvFile throws on invalid path (non-existent nested directory)', () => {
|
|
||||||
// Use a deeply nested non-existent path that will fail regardless of user
|
|
||||||
const filePath = path.join(tmpDir, 'no', 'such', 'deep', 'path', '.env');
|
|
||||||
expect(() => writeEnvFile(filePath, 'PORT=3001\n')).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writeEnvFile succeeds on valid writable path', () => {
|
|
||||||
const filePath = path.join(tmpDir, '.env');
|
|
||||||
expect(() => writeEnvFile(filePath, 'PORT=3001\n')).not.toThrow();
|
|
||||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('PORT=3001\n');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 6: Infrastructure state detection
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('Infrastructure state detection', () => {
|
|
||||||
let tmpDir;
|
|
||||||
let originalCwd;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir();
|
|
||||||
originalCwd = process.cwd();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detectInfraState returns correct values based on filesystem state', () => {
|
|
||||||
// Set up a minimal project structure
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'backend', 'node_modules'));
|
|
||||||
fs.writeFileSync(path.join(tmpDir, 'backend', '.env'), 'PORT=3001\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, 'backend', 'db-schema.sql'), 'CREATE TABLE test();');
|
|
||||||
// No frontend node_modules, no frontend .env, no frontend build
|
|
||||||
|
|
||||||
process.chdir(tmpDir);
|
|
||||||
const state = detectInfraState();
|
|
||||||
|
|
||||||
expect(state.backendNodeModules).toBe(true);
|
|
||||||
expect(state.frontendNodeModules).toBe(false);
|
|
||||||
expect(state.backendEnvExists).toBe(true);
|
|
||||||
expect(state.frontendEnvExists).toBe(false);
|
|
||||||
expect(state.frontendBuildExists).toBe(false);
|
|
||||||
expect(state.schemaFileExists).toBe(true);
|
|
||||||
expect(state.sqliteDbExists).toBe(false);
|
|
||||||
// npmAvailable should be true in test environment
|
|
||||||
expect(typeof state.npmAvailable).toBe('boolean');
|
|
||||||
expect(typeof state.dockerAvailable).toBe('boolean');
|
|
||||||
expect(typeof state.psqlAvailable).toBe('boolean');
|
|
||||||
expect(typeof state.postgresRunning).toBe('boolean');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detectInfraState detects SQLite database when present', () => {
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
||||||
fs.writeFileSync(path.join(tmpDir, 'backend', 'cve_database.db'), '');
|
|
||||||
|
|
||||||
process.chdir(tmpDir);
|
|
||||||
const state = detectInfraState();
|
|
||||||
|
|
||||||
expect(state.sqliteDbExists).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detectInfraState detects frontend build when present', () => {
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'frontend', 'build'), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(tmpDir, 'frontend', 'build', 'index.html'), '<html></html>');
|
|
||||||
|
|
||||||
process.chdir(tmpDir);
|
|
||||||
const state = detectInfraState();
|
|
||||||
|
|
||||||
expect(state.frontendBuildExists).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 7: Frontend build skip on unchanged REACT_APP_* values
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('Frontend build skip on unchanged REACT_APP_* values', () => {
|
|
||||||
test('shouldSkipFrontendBuild returns true when REACT_APP_* values are identical', () => {
|
|
||||||
const oldEnv = new Map([
|
|
||||||
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
||||||
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
||||||
]);
|
|
||||||
const newEnv = new Map([
|
|
||||||
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
||||||
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldSkipFrontendBuild returns false when REACT_APP_* values differ', () => {
|
|
||||||
const oldEnv = new Map([
|
|
||||||
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
||||||
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
||||||
]);
|
|
||||||
const newEnv = new Map([
|
|
||||||
['REACT_APP_API_BASE', 'http://192.168.1.100:4000/api'],
|
|
||||||
['REACT_APP_API_HOST', 'http://192.168.1.100:4000'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldSkipFrontendBuild returns false when oldFrontendEnv is null', () => {
|
|
||||||
const newEnv = new Map([
|
|
||||||
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
||||||
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(shouldSkipFrontendBuild(null, newEnv)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldSkipFrontendBuild returns false when one REACT_APP_* key differs', () => {
|
|
||||||
const oldEnv = new Map([
|
|
||||||
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
||||||
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
|
||||||
]);
|
|
||||||
const newEnv = new Map([
|
|
||||||
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
|
||||||
['REACT_APP_API_HOST', 'http://newhost:3001'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 8: Node.js version check
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('Node.js version check', () => {
|
|
||||||
let mockExit;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
||||||
throw new Error('process.exit called');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mockExit.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkNodeVersion does not exit on current Node.js version (>= 18)', () => {
|
|
||||||
// Current test environment should be Node 18+
|
|
||||||
expect(() => checkNodeVersion()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkNodeVersion would exit on Node < 18 (simulated via version override)', () => {
|
|
||||||
const originalVersion = process.version;
|
|
||||||
Object.defineProperty(process, 'version', { value: 'v16.20.0', writable: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
expect(() => checkNodeVersion()).toThrow('process.exit called');
|
|
||||||
expect(mockExit).toHaveBeenCalledWith(1);
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(process, 'version', { value: originalVersion, writable: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 9: parseDockerCompose with real docker-compose.yml
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('parseDockerCompose with real docker-compose.yml', () => {
|
|
||||||
test('correctly parses the project actual docker-compose.yml', () => {
|
|
||||||
const composePath = path.join(__dirname, '..', '..', 'docker-compose.yml');
|
|
||||||
const result = parseDockerCompose(composePath);
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result.user).toBe('steam');
|
|
||||||
expect(result.password).toBe('sV4xmC9xAUCFop0ypxMVS056QgPqGrX');
|
|
||||||
expect(result.database).toBe('cve_dashboard');
|
|
||||||
expect(result.port).toBe('5433');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseDockerCompose returns null for non-existent file', () => {
|
|
||||||
const result = parseDockerCompose('/nonexistent/docker-compose.yml');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseDockerCompose returns null for invalid YAML content', () => {
|
|
||||||
const tmpDir = createTempDir();
|
|
||||||
const filePath = path.join(tmpDir, 'docker-compose.yml');
|
|
||||||
fs.writeFileSync(filePath, 'this is not valid yaml at all\nno services here\n');
|
|
||||||
|
|
||||||
const result = parseDockerCompose(filePath);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseDockerCompose handles compose file with shell variable defaults', () => {
|
|
||||||
const tmpDir = createTempDir();
|
|
||||||
const filePath = path.join(tmpDir, 'docker-compose.yml');
|
|
||||||
const content = [
|
|
||||||
'services:',
|
|
||||||
' postgres:',
|
|
||||||
' image: postgres:16-alpine',
|
|
||||||
' environment:',
|
|
||||||
' POSTGRES_DB: testdb',
|
|
||||||
' POSTGRES_USER: testuser',
|
|
||||||
' POSTGRES_PASSWORD: ${PG_PASS:-mysecretpass}',
|
|
||||||
' ports:',
|
|
||||||
' - "5434:5432"',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(filePath, content, 'utf8');
|
|
||||||
|
|
||||||
const result = parseDockerCompose(filePath);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result.user).toBe('testuser');
|
|
||||||
expect(result.password).toBe('mysecretpass');
|
|
||||||
expect(result.database).toBe('testdb');
|
|
||||||
expect(result.port).toBe('5434');
|
|
||||||
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 10: parseEnvFile round-trip — write and re-read produces identical values
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('parseEnvFile round-trip', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writing and re-reading produces identical managed values', () => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://steam:sV4xmC9xAUCFop0ypxMVS056QgPqGrX@localhost:5433/cve_dashboard');
|
|
||||||
values.set('SESSION_SECRET', 'my-session-secret-at-least-16-chars');
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
const filePath = path.join(tmpDir, '.env');
|
|
||||||
writeEnvFile(filePath, content);
|
|
||||||
|
|
||||||
const parsed = parseEnvFile(filePath);
|
|
||||||
|
|
||||||
// All values we set should be recovered
|
|
||||||
for (const [key, value] of values) {
|
|
||||||
const descriptor = VARIABLE_DESCRIPTORS.find(d => d.name === key);
|
|
||||||
if (descriptor && descriptor.target === 'backend') {
|
|
||||||
expect(parsed.managed.get(key)).toBe(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('round-trip preserves values with special characters', () => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://user:p@ss$word@localhost:5433/db');
|
|
||||||
values.set('SESSION_SECRET', 'secret with spaces and #hash and $dollar');
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
const filePath = path.join(tmpDir, '.env');
|
|
||||||
writeEnvFile(filePath, content);
|
|
||||||
|
|
||||||
const parsed = parseEnvFile(filePath);
|
|
||||||
|
|
||||||
expect(parsed.managed.get('PORT')).toBe('3001');
|
|
||||||
expect(parsed.managed.get('API_HOST')).toBe('localhost');
|
|
||||||
// Values with special chars are quoted, parseEnvFile strips quotes
|
|
||||||
expect(parsed.managed.get('SESSION_SECRET')).toBe('secret with spaces and #hash and $dollar');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 11: generateEnvContent with all groups — complete output format
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('generateEnvContent with all groups', () => {
|
|
||||||
test('produces complete output with all group headers and values', () => {
|
|
||||||
const values = new Map();
|
|
||||||
// Core Settings
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
// Database
|
|
||||||
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
||||||
// Session
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
||||||
// NVD API
|
|
||||||
values.set('NVD_API_KEY', 'nvd-key-123');
|
|
||||||
// Ivanti
|
|
||||||
values.set('IVANTI_API_KEY', 'ivanti-key-456');
|
|
||||||
values.set('IVANTI_CLIENT_ID', '1550');
|
|
||||||
// Atlas
|
|
||||||
values.set('ATLAS_API_URL', 'https://atlas.example.com');
|
|
||||||
values.set('ATLAS_API_USER', 'atlasuser');
|
|
||||||
values.set('ATLAS_API_PASS', 'atlaspass');
|
|
||||||
// Jira
|
|
||||||
values.set('JIRA_BASE_URL', 'https://jira.example.com');
|
|
||||||
values.set('JIRA_AUTH_METHOD', 'basic');
|
|
||||||
values.set('JIRA_API_USER', 'jirauser');
|
|
||||||
values.set('JIRA_API_TOKEN', 'jira-token-789');
|
|
||||||
// CARD
|
|
||||||
values.set('CARD_API_URL', 'https://card.example.com');
|
|
||||||
values.set('CARD_API_USER', 'carduser');
|
|
||||||
values.set('CARD_API_PASS', 'cardpass');
|
|
||||||
// GitLab
|
|
||||||
values.set('GITLAB_URL', 'http://steam-gitlab.charterlab.com');
|
|
||||||
values.set('GITLAB_PROJECT_ID', '42');
|
|
||||||
values.set('GITLAB_PAT', 'glpat-abc123');
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
// Verify all group headers present
|
|
||||||
expect(content).toContain('# --- Core Settings ---');
|
|
||||||
expect(content).toContain('# --- Database ---');
|
|
||||||
expect(content).toContain('# --- Session ---');
|
|
||||||
expect(content).toContain('# --- NVD API ---');
|
|
||||||
expect(content).toContain('# --- Ivanti Integration ---');
|
|
||||||
expect(content).toContain('# --- Atlas Integration ---');
|
|
||||||
expect(content).toContain('# --- Jira Integration ---');
|
|
||||||
expect(content).toContain('# --- CARD Integration ---');
|
|
||||||
expect(content).toContain('# --- GitLab Integration ---');
|
|
||||||
|
|
||||||
// Verify values present
|
|
||||||
expect(content).toContain('PORT=3001');
|
|
||||||
expect(content).toContain('NVD_API_KEY=nvd-key-123');
|
|
||||||
expect(content).toContain('IVANTI_CLIENT_ID=1550');
|
|
||||||
expect(content).toContain('GITLAB_PROJECT_ID=42');
|
|
||||||
|
|
||||||
// Verify LF line endings (no \r)
|
|
||||||
expect(content).not.toContain('\r');
|
|
||||||
// Verify trailing newline
|
|
||||||
expect(content.endsWith('\n')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 12: generateEnvContent with skipped groups — excluded from output
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('generateEnvContent with skipped groups', () => {
|
|
||||||
test('skipped groups produce no KEY=value lines in output', () => {
|
|
||||||
const values = new Map();
|
|
||||||
// Only set required group values
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
// Verify no optional group variables appear
|
|
||||||
const optionalVarNames = VARIABLE_DESCRIPTORS
|
|
||||||
.filter(d => OPTIONAL_GROUPS.includes(d.group))
|
|
||||||
.map(d => d.name);
|
|
||||||
|
|
||||||
for (const varName of optionalVarNames) {
|
|
||||||
// Should not have any KEY= line for these variables
|
|
||||||
const regex = new RegExp(`^${varName}=`, 'm');
|
|
||||||
expect(content).not.toMatch(regex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial optional groups — only configured groups appear', () => {
|
|
||||||
const values = new Map();
|
|
||||||
values.set('PORT', '3001');
|
|
||||||
values.set('API_HOST', 'localhost');
|
|
||||||
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
|
||||||
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
|
||||||
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
|
||||||
// Only configure Jira
|
|
||||||
values.set('JIRA_BASE_URL', 'https://jira.example.com');
|
|
||||||
values.set('JIRA_API_USER', 'user');
|
|
||||||
values.set('JIRA_API_TOKEN', 'token-value-here');
|
|
||||||
|
|
||||||
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
|
||||||
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
|
||||||
|
|
||||||
expect(content).toContain('# --- Jira Integration ---');
|
|
||||||
expect(content).toContain('JIRA_BASE_URL=https://jira.example.com');
|
|
||||||
// Other optional groups should not appear
|
|
||||||
expect(content).not.toContain('# --- NVD API ---');
|
|
||||||
expect(content).not.toContain('# --- Ivanti Integration ---');
|
|
||||||
expect(content).not.toContain('# --- Atlas Integration ---');
|
|
||||||
expect(content).not.toContain('# --- CARD Integration ---');
|
|
||||||
expect(content).not.toContain('# --- GitLab Integration ---');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Test 13: createBackup — backup file creation with timestamp naming
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
describe('createBackup', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
removeTempDir(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creates backup file with timestamp naming', () => {
|
|
||||||
const originalPath = path.join(tmpDir, '.env');
|
|
||||||
fs.writeFileSync(originalPath, 'PORT=3001\nAPI_HOST=localhost\n');
|
|
||||||
|
|
||||||
const backupPath = createBackup(originalPath);
|
|
||||||
|
|
||||||
expect(fs.existsSync(backupPath)).toBe(true);
|
|
||||||
// Backup should match pattern: .env.backup.YYYYMMDD_HHmmss
|
|
||||||
expect(backupPath).toMatch(/\.env\.backup\.\d{8}_\d{6}$/);
|
|
||||||
// Content should be identical
|
|
||||||
const originalContent = fs.readFileSync(originalPath, 'utf8');
|
|
||||||
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
|
||||||
expect(backupContent).toBe(originalContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creates numbered backup when timestamp backup already exists', () => {
|
|
||||||
const originalPath = path.join(tmpDir, '.env');
|
|
||||||
fs.writeFileSync(originalPath, 'PORT=3001\n');
|
|
||||||
|
|
||||||
// Create first backup
|
|
||||||
const firstBackup = createBackup(originalPath);
|
|
||||||
expect(fs.existsSync(firstBackup)).toBe(true);
|
|
||||||
|
|
||||||
// Modify original
|
|
||||||
fs.writeFileSync(originalPath, 'PORT=4000\n');
|
|
||||||
|
|
||||||
// Create second backup — since timestamp is same second, it should use .bak.N
|
|
||||||
// We simulate by creating the expected timestamp backup manually
|
|
||||||
const now = new Date();
|
|
||||||
const timestamp = now.getFullYear().toString() +
|
|
||||||
String(now.getMonth() + 1).padStart(2, '0') +
|
|
||||||
String(now.getDate()).padStart(2, '0') + '_' +
|
|
||||||
String(now.getHours()).padStart(2, '0') +
|
|
||||||
String(now.getMinutes()).padStart(2, '0') +
|
|
||||||
String(now.getSeconds()).padStart(2, '0');
|
|
||||||
const expectedTimestampPath = `${originalPath}.backup.${timestamp}`;
|
|
||||||
|
|
||||||
// If the timestamp backup already exists (from first call), second call uses .bak.N
|
|
||||||
if (fs.existsSync(expectedTimestampPath)) {
|
|
||||||
const secondBackup = createBackup(originalPath);
|
|
||||||
expect(secondBackup).toMatch(/\.bak\.\d+$/);
|
|
||||||
expect(fs.existsSync(secondBackup)).toBe(true);
|
|
||||||
const content = fs.readFileSync(secondBackup, 'utf8');
|
|
||||||
expect(content).toBe('PORT=4000\n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('backup preserves file content exactly', () => {
|
|
||||||
const originalPath = path.join(tmpDir, '.env');
|
|
||||||
const content = '# --- Core Settings ---\nPORT=3001\nAPI_HOST=localhost\n\n# Custom\nMY_VAR=hello\n';
|
|
||||||
fs.writeFileSync(originalPath, content);
|
|
||||||
|
|
||||||
const backupPath = createBackup(originalPath);
|
|
||||||
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
|
||||||
expect(backupContent).toBe(content);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,576 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bug Condition Exploration Property Test: Ivanti Queue Clear Completed FK Violation
|
|
||||||
*
|
|
||||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
|
||||||
*
|
|
||||||
* BUG CONDITION (from design.md):
|
|
||||||
* isBugCondition(input) returns true when linkedItems.length > 0
|
|
||||||
* — completed queue items have associated rows in jira_ticket_queue_items
|
|
||||||
*
|
|
||||||
* The current DELETE /completed handler issues a bare:
|
|
||||||
* DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'
|
|
||||||
* which fails with a FK violation when child rows exist in jira_ticket_queue_items.
|
|
||||||
*
|
|
||||||
* THIS TEST ENCODES THE EXPECTED (FIXED) BEHAVIOR:
|
|
||||||
* The handler should delete junction table references first, then delete
|
|
||||||
* completed queue items, all within a transaction, and return success.
|
|
||||||
*
|
|
||||||
* ON UNFIXED CODE, THIS TEST WILL FAIL:
|
|
||||||
* The current handler does not use transactions or clean up junction rows.
|
|
||||||
* When pool.query receives the bare DELETE and junction rows exist, the mock
|
|
||||||
* simulates the FK violation error that PostgreSQL would throw.
|
|
||||||
* The handler catches the error and returns 500 instead of the expected 200.
|
|
||||||
*
|
|
||||||
* COUNTEREXAMPLE:
|
|
||||||
* "DELETE FROM ivanti_todo_queue fails with FK violation when junction rows
|
|
||||||
* reference completed items"
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 1.1**
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
const fc = require('fast-check');
|
|
||||||
|
|
||||||
// --- Mocks (must be installed BEFORE requiring the route module) ---
|
|
||||||
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, _res, next) => {
|
|
||||||
req.user = { id: 42, username: 'testuser', group: 'Admin' };
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
requireGroup: () => (_req, _res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
|
|
||||||
// Programmable pg pool mock
|
|
||||||
let queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn((text, params) => queryHandler(text, params)),
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPool = {
|
|
||||||
query: jest.fn((text, params) => queryHandler(text, params)),
|
|
||||||
connect: jest.fn(() => Promise.resolve(mockClient)),
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('../db', () => mockPool);
|
|
||||||
|
|
||||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
|
||||||
|
|
||||||
// --- HTTP helper ---
|
|
||||||
|
|
||||||
function request(server, method, urlPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const addr = server.address();
|
|
||||||
const options = {
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: addr.port,
|
|
||||||
path: urlPath,
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const raw = Buffer.concat(chunks).toString();
|
|
||||||
let body;
|
|
||||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
|
||||||
resolve({ statusCode: res.statusCode, body });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Generators ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a non-empty array of completed queue item IDs (simulating items
|
|
||||||
* that belong to the authenticated user and have status = 'complete').
|
|
||||||
*/
|
|
||||||
const completedItemIdsArb = fc.array(
|
|
||||||
fc.integer({ min: 1, max: 10000 }),
|
|
||||||
{ minLength: 1, maxLength: 10 }
|
|
||||||
).map(ids => [...new Set(ids)]); // ensure unique IDs
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate junction table links for a subset of completed items.
|
|
||||||
* At least one item MUST have a junction link (this is the bug condition).
|
|
||||||
*/
|
|
||||||
const junctionLinksArb = (itemIds) => {
|
|
||||||
// Generate at least 1 junction link, up to 3 per item
|
|
||||||
return fc.array(
|
|
||||||
fc.record({
|
|
||||||
jira_ticket_id: fc.integer({ min: 1, max: 5000 }),
|
|
||||||
queue_item_id: fc.constantFrom(...itemIds),
|
|
||||||
}),
|
|
||||||
{ minLength: 1, maxLength: Math.min(itemIds.length * 3, 15) }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Test Suite ---
|
|
||||||
|
|
||||||
describe('Bug Condition Exploration: FK Violation on Clear Completed With Junction Table Links', () => {
|
|
||||||
let app;
|
|
||||||
let server;
|
|
||||||
|
|
||||||
beforeAll((done) => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
|
||||||
server = app.listen(0, '127.0.0.1', done);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.query.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property 1: Bug Condition - FK Violation on Clear Completed With Junction Table Links
|
|
||||||
*
|
|
||||||
* For any set of completed queue items where at least one has junction table links,
|
|
||||||
* the FIXED handler should:
|
|
||||||
* 1. Use a transaction (BEGIN/COMMIT)
|
|
||||||
* 2. Delete junction table references first
|
|
||||||
* 3. Delete the completed queue items
|
|
||||||
* 4. Return 200 with { message, deleted: N }
|
|
||||||
*
|
|
||||||
* On UNFIXED code: The handler uses a bare pool.query DELETE which will receive
|
|
||||||
* a FK violation error from our mock (simulating PostgreSQL behavior), causing
|
|
||||||
* the handler to return 500. This test FAILS, confirming the bug exists.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 1.1**
|
|
||||||
*/
|
|
||||||
it('Property 1: completed items with junction links are deleted successfully (encodes expected fixed behavior)', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
completedItemIdsArb,
|
|
||||||
fc.context(),
|
|
||||||
async (itemIds, ctx) => {
|
|
||||||
// Generate junction links for these items
|
|
||||||
const junctionLinks = itemIds.map(id => ({
|
|
||||||
jira_ticket_id: id * 10,
|
|
||||||
queue_item_id: id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
ctx.log(`Testing with ${itemIds.length} completed items, ${junctionLinks.length} junction links`);
|
|
||||||
ctx.log(`Item IDs: ${JSON.stringify(itemIds)}`);
|
|
||||||
|
|
||||||
// Reset mocks for this iteration
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.query.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
|
|
||||||
// Configure the query handler to simulate the bug condition:
|
|
||||||
// - If the code uses pool.query with a bare DELETE on ivanti_todo_queue,
|
|
||||||
// simulate the FK violation error (this is what the UNFIXED code does)
|
|
||||||
// - If the code uses a transaction (BEGIN, SELECT, DELETE junction, DELETE queue, COMMIT),
|
|
||||||
// simulate successful execution (this is what the FIXED code should do)
|
|
||||||
const fkViolationError = new Error(
|
|
||||||
'update or delete on table "ivanti_todo_queue" violates foreign key constraint ' +
|
|
||||||
'"jira_ticket_queue_items_queue_item_id_fkey" on table "jira_ticket_queue_items"'
|
|
||||||
);
|
|
||||||
fkViolationError.code = '23503'; // PostgreSQL FK violation error code
|
|
||||||
|
|
||||||
// Handler for pool.query (unfixed code path)
|
|
||||||
mockPool.query.mockImplementation((text, params) => {
|
|
||||||
// The unfixed code issues a bare DELETE — simulate FK violation
|
|
||||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
|
||||||
return Promise.reject(fkViolationError);
|
|
||||||
}
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handler for client.query (fixed code path — transaction-based)
|
|
||||||
mockClient.query.mockImplementation((text, params) => {
|
|
||||||
if (text === 'BEGIN') {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
}
|
|
||||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
|
||||||
return Promise.resolve({
|
|
||||||
rows: itemIds.map(id => ({ id })),
|
|
||||||
rowCount: itemIds.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
|
||||||
return Promise.resolve({
|
|
||||||
rows: [],
|
|
||||||
rowCount: junctionLinks.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
|
||||||
return Promise.resolve({
|
|
||||||
rows: [],
|
|
||||||
rowCount: itemIds.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (text === 'COMMIT') {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
}
|
|
||||||
if (text === 'ROLLBACK') {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
}
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make the request
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
// Assert EXPECTED (fixed) behavior:
|
|
||||||
// The handler should return 200 with the correct deleted count
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.message).toBe('Completed items cleared.');
|
|
||||||
expect(res.body.deleted).toBe(itemIds.length);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, verbose: 2 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- Preservation Property Tests ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property 2: Preservation - Clear Completed Without Junction Table Links
|
|
||||||
*
|
|
||||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
|
||||||
*
|
|
||||||
* PRESERVATION GOAL:
|
|
||||||
* Verify that the UNFIXED code already handles non-bug-condition cases correctly.
|
|
||||||
* These tests establish a baseline that must be preserved after the fix is applied.
|
|
||||||
*
|
|
||||||
* NON-BUG-CONDITION CASES:
|
|
||||||
* - Completed items WITHOUT junction table links → DELETE succeeds, returns count
|
|
||||||
* - No completed items exist → returns { deleted: 0 }
|
|
||||||
* - Pending/in-progress items → never touched by the DELETE
|
|
||||||
*
|
|
||||||
* The current handler uses:
|
|
||||||
* pool.query("DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'", [req.user.id])
|
|
||||||
* This works correctly when no FK violations occur (no junction table references).
|
|
||||||
*
|
|
||||||
* EXPECTED OUTCOME: All tests PASS on unfixed code.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 3.1, 3.2, 3.3**
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('Preservation: Clear Completed Without Junction Table Links', () => {
|
|
||||||
let app;
|
|
||||||
let server;
|
|
||||||
|
|
||||||
beforeAll((done) => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
|
||||||
server = app.listen(0, '127.0.0.1', done);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.query.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property 2a: Completed items without junction links are all deleted
|
|
||||||
*
|
|
||||||
* For any random count of completed items (1–20) belonging to the user,
|
|
||||||
* when none have junction table links, the DELETE succeeds and returns
|
|
||||||
* the correct count.
|
|
||||||
*
|
|
||||||
* The fixed code uses a transaction (client.query) so we mock client.query
|
|
||||||
* to simulate successful execution without FK violations.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 3.1**
|
|
||||||
*/
|
|
||||||
it('Property 2a: completed items without junction links are deleted and correct count returned', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
fc.integer({ min: 1, max: 20 }),
|
|
||||||
fc.context(),
|
|
||||||
async (completedCount, ctx) => {
|
|
||||||
ctx.log(`Testing with ${completedCount} completed items (no junction links)`);
|
|
||||||
|
|
||||||
// Reset mocks for this iteration
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
|
|
||||||
// Generate item IDs
|
|
||||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
|
||||||
|
|
||||||
// Mock client.query for the transaction-based handler
|
|
||||||
mockClient.query.mockImplementation((text, params) => {
|
|
||||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
|
||||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 }); // no junction links
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
// Assert preservation behavior
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.message).toBe('Completed items cleared.');
|
|
||||||
expect(res.body.deleted).toBe(completedCount);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 30, verbose: 2 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property 2b: When no completed items exist, returns deleted: 0
|
|
||||||
*
|
|
||||||
* When the user has no completed items, the SELECT returns empty rows
|
|
||||||
* and the endpoint returns { message: 'Completed items cleared.', deleted: 0 }.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 3.2**
|
|
||||||
*/
|
|
||||||
it('Property 2b: no completed items returns deleted: 0', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
fc.constant(null),
|
|
||||||
fc.context(),
|
|
||||||
async (_unused, ctx) => {
|
|
||||||
ctx.log('Testing with 0 completed items');
|
|
||||||
|
|
||||||
// Reset mocks for this iteration
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
|
|
||||||
// Mock client.query: SELECT returns empty rows (no completed items)
|
|
||||||
mockClient.query.mockImplementation((text, params) => {
|
|
||||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
}
|
|
||||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.message).toBe('Completed items cleared.');
|
|
||||||
expect(res.body.deleted).toBe(0);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 5, verbose: 2 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property 2c: Pending/in-progress items are never touched
|
|
||||||
*
|
|
||||||
* The SELECT query only fetches items with status = 'complete' for the user.
|
|
||||||
* We verify the query text and parameters to ensure non-complete items
|
|
||||||
* are never affected.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 3.3**
|
|
||||||
*/
|
|
||||||
it('Property 2c: DELETE only targets complete status for the authenticated user', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
fc.integer({ min: 0, max: 15 }),
|
|
||||||
fc.context(),
|
|
||||||
async (completedCount, ctx) => {
|
|
||||||
ctx.log(`Testing query targeting with ${completedCount} completed items`);
|
|
||||||
|
|
||||||
// Reset mocks for this iteration
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
|
|
||||||
// Track the queries issued via client
|
|
||||||
const queriesIssued = [];
|
|
||||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
|
||||||
|
|
||||||
mockClient.query.mockImplementation((text, params) => {
|
|
||||||
queriesIssued.push({ text, params });
|
|
||||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
|
||||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
|
|
||||||
// Verify the SELECT query targets only complete status for user 42
|
|
||||||
const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('complete'));
|
|
||||||
expect(selectQueries.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
for (const q of selectQueries) {
|
|
||||||
expect(q.text).toMatch(/status\s*=\s*'\s*complete\s*'/i);
|
|
||||||
expect(q.text).toMatch(/user_id\s*=/i);
|
|
||||||
expect(q.params).toContain(42);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, verbose: 2 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property 2d: Other users' items remain untouched
|
|
||||||
*
|
|
||||||
* The SELECT query is parameterized with req.user.id (mocked as 42).
|
|
||||||
* For any random count of completed items, the query only affects user 42's items.
|
|
||||||
* We verify the user_id parameter is always the authenticated user's ID.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 3.1, 3.3**
|
|
||||||
*/
|
|
||||||
it('Property 2d: DELETE is scoped to the authenticated user only', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
fc.integer({ min: 1, max: 10 }),
|
|
||||||
fc.context(),
|
|
||||||
async (completedCount, ctx) => {
|
|
||||||
ctx.log(`Testing user isolation with ${completedCount} completed items`);
|
|
||||||
|
|
||||||
// Reset mocks for this iteration
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
|
|
||||||
const queriesIssued = [];
|
|
||||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
|
||||||
|
|
||||||
mockClient.query.mockImplementation((text, params) => {
|
|
||||||
queriesIssued.push({ text, params });
|
|
||||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
|
||||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.deleted).toBe(completedCount);
|
|
||||||
|
|
||||||
// Verify the SELECT query is scoped to user 42
|
|
||||||
const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('user_id'));
|
|
||||||
for (const q of selectQueries) {
|
|
||||||
expect(q.params).toContain(42); // req.user.id from mock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, verbose: 2 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property 2e: Response shape is always { message: string, deleted: number }
|
|
||||||
*
|
|
||||||
* For any random count of completed items (including 0), the response
|
|
||||||
* always has the correct shape with message as a string and deleted as a number.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 3.1, 3.2**
|
|
||||||
*/
|
|
||||||
it('Property 2e: response shape is { message: string, deleted: number }', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
fc.integer({ min: 0, max: 50 }),
|
|
||||||
fc.context(),
|
|
||||||
async (completedCount, ctx) => {
|
|
||||||
ctx.log(`Testing response shape with deleted count: ${completedCount}`);
|
|
||||||
|
|
||||||
// Reset mocks for this iteration
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
|
|
||||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
|
||||||
|
|
||||||
mockClient.query.mockImplementation((text, params) => {
|
|
||||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
|
||||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
}
|
|
||||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
|
||||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
|
||||||
}
|
|
||||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(typeof res.body.message).toBe('string');
|
|
||||||
expect(typeof res.body.deleted).toBe('number');
|
|
||||||
expect(res.body.message).toBe('Completed items cleared.');
|
|
||||||
expect(res.body.deleted).toBe(completedCount);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 25, verbose: 2 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for DELETE /api/ivanti/todo-queue/completed transaction logic
|
|
||||||
*
|
|
||||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
|
||||||
*
|
|
||||||
* Validates: Requirements 2.1, 2.2, 3.1, 3.2
|
|
||||||
*
|
|
||||||
* Tests verify:
|
|
||||||
* - Correct query sequence within a transaction (BEGIN → SELECT → DELETE junction → DELETE queue → COMMIT)
|
|
||||||
* - ROLLBACK is called when any query in the transaction fails
|
|
||||||
* - Client is always released in the finally block (even on error)
|
|
||||||
* - Empty completed set triggers early COMMIT and returns { deleted: 0 }
|
|
||||||
* - Response shape is { message: 'Completed items cleared.', deleted: N }
|
|
||||||
*/
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
// --- Mocks ---
|
|
||||||
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, _res, next) => {
|
|
||||||
req.user = { id: 7, username: 'testuser', group: 'Admin' };
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
requireGroup: () => (_req, _res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
|
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn(),
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPool = {
|
|
||||||
query: jest.fn(),
|
|
||||||
connect: jest.fn(() => Promise.resolve(mockClient)),
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('../db', () => mockPool);
|
|
||||||
|
|
||||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
|
||||||
|
|
||||||
// --- HTTP helper ---
|
|
||||||
|
|
||||||
function request(server, method, urlPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const addr = server.address();
|
|
||||||
const options = {
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: addr.port,
|
|
||||||
path: urlPath,
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const raw = Buffer.concat(chunks).toString();
|
|
||||||
let body;
|
|
||||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
|
||||||
resolve({ statusCode: res.statusCode, body });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Test Suite ---
|
|
||||||
|
|
||||||
describe('DELETE /api/ivanti/todo-queue/completed — Transaction Logic', () => {
|
|
||||||
let app;
|
|
||||||
let server;
|
|
||||||
|
|
||||||
beforeAll((done) => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
|
||||||
server = app.listen(0, '127.0.0.1', done);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockClient.query.mockReset();
|
|
||||||
mockClient.release.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.connect.mockResolvedValue(mockClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test 1: Correct query sequence
|
|
||||||
* Validates: Requirements 2.1, 2.2
|
|
||||||
*
|
|
||||||
* Verifies the handler issues queries in the correct order:
|
|
||||||
* BEGIN → SELECT IDs → DELETE junction → DELETE queue → COMMIT
|
|
||||||
*/
|
|
||||||
it('executes queries in correct transaction order: BEGIN → SELECT → DELETE junction → DELETE queue → COMMIT', async () => {
|
|
||||||
const completedIds = [10, 20, 30];
|
|
||||||
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: completedIds.map(id => ({ id })), rowCount: 3 }) // SELECT
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // DELETE junction
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // DELETE queue
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
|
|
||||||
// Verify query sequence
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
expect(calls.length).toBe(5);
|
|
||||||
|
|
||||||
// BEGIN
|
|
||||||
expect(calls[0][0]).toBe('BEGIN');
|
|
||||||
|
|
||||||
// SELECT completed IDs
|
|
||||||
expect(calls[1][0]).toContain('SELECT');
|
|
||||||
expect(calls[1][0]).toContain('ivanti_todo_queue');
|
|
||||||
expect(calls[1][0]).toContain('complete');
|
|
||||||
expect(calls[1][1]).toEqual([7]); // user id
|
|
||||||
|
|
||||||
// DELETE junction table references
|
|
||||||
expect(calls[2][0]).toContain('DELETE FROM jira_ticket_queue_items');
|
|
||||||
expect(calls[2][0]).toContain('ANY');
|
|
||||||
expect(calls[2][1]).toEqual([completedIds]);
|
|
||||||
|
|
||||||
// DELETE queue items
|
|
||||||
expect(calls[3][0]).toContain('DELETE FROM ivanti_todo_queue');
|
|
||||||
expect(calls[3][0]).toContain('ANY');
|
|
||||||
expect(calls[3][1]).toEqual([completedIds]);
|
|
||||||
|
|
||||||
// COMMIT
|
|
||||||
expect(calls[4][0]).toBe('COMMIT');
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test 2: ROLLBACK on error
|
|
||||||
* Validates: Requirements 2.1
|
|
||||||
*
|
|
||||||
* Verifies ROLLBACK is called when any query in the transaction fails.
|
|
||||||
*/
|
|
||||||
describe('ROLLBACK on error', () => {
|
|
||||||
it('calls ROLLBACK when SELECT query fails', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockRejectedValueOnce(new Error('SELECT failed')); // SELECT throws
|
|
||||||
|
|
||||||
// After the catch, ROLLBACK is called
|
|
||||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
|
||||||
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
|
||||||
expect(rollbackCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls ROLLBACK when DELETE junction query fails', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 }) // SELECT
|
|
||||||
.mockRejectedValueOnce(new Error('DELETE junction failed')); // DELETE junction throws
|
|
||||||
|
|
||||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
|
||||||
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
|
||||||
expect(rollbackCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls ROLLBACK when DELETE queue query fails', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 5 }], rowCount: 1 }) // SELECT
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
|
||||||
.mockRejectedValueOnce(new Error('DELETE queue failed')); // DELETE queue throws
|
|
||||||
|
|
||||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
|
||||||
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
|
||||||
expect(rollbackCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls ROLLBACK when COMMIT fails', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // SELECT
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
|
||||||
.mockRejectedValueOnce(new Error('COMMIT failed')); // COMMIT throws
|
|
||||||
|
|
||||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
|
||||||
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
|
||||||
expect(rollbackCall).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test 3: Client always released
|
|
||||||
* Validates: Requirements 2.1
|
|
||||||
*
|
|
||||||
* Verifies client.release() is always called in the finally block,
|
|
||||||
* even when an error occurs.
|
|
||||||
*/
|
|
||||||
describe('client always released', () => {
|
|
||||||
it('releases client after successful transaction', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // SELECT
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
|
||||||
|
|
||||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('releases client after failed transaction (error in SELECT)', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockRejectedValueOnce(new Error('DB error')); // SELECT throws
|
|
||||||
|
|
||||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
|
||||||
|
|
||||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('releases client after failed transaction (error in DELETE)', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 }) // SELECT
|
|
||||||
.mockRejectedValueOnce(new Error('FK violation')); // DELETE junction throws
|
|
||||||
|
|
||||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
|
||||||
|
|
||||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('releases client when empty completed set triggers early return', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT (empty)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
|
||||||
|
|
||||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test 4: Empty completed set
|
|
||||||
* Validates: Requirements 3.1, 3.2
|
|
||||||
*
|
|
||||||
* When SELECT returns no completed items, the handler should:
|
|
||||||
* - Issue COMMIT (not ROLLBACK)
|
|
||||||
* - Return { message: 'Completed items cleared.', deleted: 0 }
|
|
||||||
* - NOT issue any DELETE queries
|
|
||||||
*/
|
|
||||||
it('empty completed set triggers early COMMIT and returns { deleted: 0 }', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT (empty)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body).toEqual({ message: 'Completed items cleared.', deleted: 0 });
|
|
||||||
|
|
||||||
// Verify query sequence: BEGIN → SELECT → COMMIT (no DELETEs)
|
|
||||||
const calls = mockClient.query.mock.calls;
|
|
||||||
expect(calls.length).toBe(3);
|
|
||||||
expect(calls[0][0]).toBe('BEGIN');
|
|
||||||
expect(calls[1][0]).toContain('SELECT');
|
|
||||||
expect(calls[2][0]).toBe('COMMIT');
|
|
||||||
|
|
||||||
// No DELETE queries issued
|
|
||||||
const deleteQueries = calls.filter(c => c[0].includes('DELETE'));
|
|
||||||
expect(deleteQueries.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test 5: Response shape preserved
|
|
||||||
* Validates: Requirements 2.2, 3.1
|
|
||||||
*
|
|
||||||
* Verifies the response is always { message: 'Completed items cleared.', deleted: N }
|
|
||||||
*/
|
|
||||||
describe('response shape preserved', () => {
|
|
||||||
it('returns correct shape with deleted count matching rowCount', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }], rowCount: 5 }) // SELECT
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // DELETE junction
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 5 }) // DELETE queue
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body).toEqual({
|
|
||||||
message: 'Completed items cleared.',
|
|
||||||
deleted: 5,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct shape with single deleted item', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 99 }], rowCount: 1 }) // SELECT
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // DELETE junction
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body).toEqual({
|
|
||||||
message: 'Completed items cleared.',
|
|
||||||
deleted: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error shape on failure', async () => {
|
|
||||||
mockClient.query
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockRejectedValueOnce(new Error('Something broke')); // SELECT throws
|
|
||||||
|
|
||||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
|
||||||
|
|
||||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Ivanti Queue Remediation — Notes System
|
|
||||||
*
|
|
||||||
* Feature: ivanti-queue-remediation
|
|
||||||
*
|
|
||||||
* Tests properties 3–7 from the design document:
|
|
||||||
* - Property 3: Whitespace-only note content is always rejected
|
|
||||||
* - Property 4: Note creation round-trip
|
|
||||||
* - Property 5: Notes returned in descending creation order
|
|
||||||
* - Property 6: Ownership enforcement
|
|
||||||
* - Property 7: Cascade delete removes all associated notes
|
|
||||||
*
|
|
||||||
* These tests validate the pure logic and simulate the API behavior
|
|
||||||
* without requiring a running database.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Simulate the backend validation and data layer logic
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates POST /api/ivanti/todo-queue/:id/notes validation logic.
|
|
||||||
* Returns { accepted: boolean, status: number, error?: string }
|
|
||||||
*/
|
|
||||||
function validateNoteCreation(note_text, queueItemExists, isOwner) {
|
|
||||||
// Ownership check
|
|
||||||
if (!queueItemExists || !isOwner) {
|
|
||||||
return { accepted: false, status: 404, error: 'Queue item not found.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text validation
|
|
||||||
if (!note_text || typeof note_text !== 'string' || note_text.trim().length === 0) {
|
|
||||||
return { accepted: false, status: 400, error: 'Note text is required.' };
|
|
||||||
}
|
|
||||||
if (note_text.length > 5000) {
|
|
||||||
return { accepted: false, status: 400, error: 'Note text must not exceed 5000 characters.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { accepted: true, status: 201 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates a simple in-memory note store for round-trip and ordering tests.
|
|
||||||
*/
|
|
||||||
class NoteStore {
|
|
||||||
constructor() {
|
|
||||||
this.notes = [];
|
|
||||||
this.nextId = 1;
|
|
||||||
this.queueItems = new Map(); // id -> { user_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
createQueueItem(userId) {
|
|
||||||
const id = this.nextId++;
|
|
||||||
this.queueItems.set(id, { user_id: userId });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
createNote(queueItemId, userId, username, noteText) {
|
|
||||||
const item = this.queueItems.get(queueItemId);
|
|
||||||
if (!item) return { error: 'Queue item not found.', status: 404 };
|
|
||||||
if (item.user_id !== userId) return { error: 'Queue item not found.', status: 404 };
|
|
||||||
if (!noteText || typeof noteText !== 'string' || noteText.trim().length === 0) {
|
|
||||||
return { error: 'Note text is required.', status: 400 };
|
|
||||||
}
|
|
||||||
if (noteText.length > 5000) {
|
|
||||||
return { error: 'Note text must not exceed 5000 characters.', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const note = {
|
|
||||||
id: this.nextId++,
|
|
||||||
queue_item_id: queueItemId,
|
|
||||||
user_id: userId,
|
|
||||||
username,
|
|
||||||
note_text: noteText,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
this.notes.push(note);
|
|
||||||
return { note, status: 201 };
|
|
||||||
}
|
|
||||||
|
|
||||||
getNotes(queueItemId, userId) {
|
|
||||||
const item = this.queueItems.get(queueItemId);
|
|
||||||
if (!item) return { error: 'Queue item not found.', status: 404 };
|
|
||||||
if (item.user_id !== userId) return { error: 'Queue item not found.', status: 404 };
|
|
||||||
|
|
||||||
const notes = this.notes
|
|
||||||
.filter(n => n.queue_item_id === queueItemId)
|
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
||||||
return { notes, status: 200 };
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteQueueItem(queueItemId) {
|
|
||||||
this.queueItems.delete(queueItemId);
|
|
||||||
// Simulate ON DELETE CASCADE
|
|
||||||
this.notes = this.notes.filter(n => n.queue_item_id !== queueItemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
getNotesForItem(queueItemId) {
|
|
||||||
return this.notes.filter(n => n.queue_item_id === queueItemId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Arbitraries
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Whitespace-only strings (including empty)
|
|
||||||
const arbWhitespaceOnly = fc.oneof(
|
|
||||||
fc.constant(''),
|
|
||||||
fc.array(fc.constantFrom(' ', '\t', '\n', '\r', '\f'), { minLength: 1, maxLength: 50 })
|
|
||||||
.map(arr => arr.join(''))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Valid note text: 1–5000 chars, at least one non-whitespace
|
|
||||||
const arbValidNoteText = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
|
|
||||||
|
|
||||||
// Over-length note text
|
|
||||||
const arbOverlengthNoteText = fc.string({ minLength: 5001, maxLength: 5100 });
|
|
||||||
|
|
||||||
// User IDs (positive integers)
|
|
||||||
const arbUserId = fc.integer({ min: 1, max: 10000 });
|
|
||||||
|
|
||||||
// Usernames
|
|
||||||
const arbUsername = fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0);
|
|
||||||
|
|
||||||
// Number of notes to create (for ordering test)
|
|
||||||
const arbNoteCount = fc.integer({ min: 2, max: 10 });
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 3: Whitespace-only note content is always rejected
|
|
||||||
// **Validates: Requirements 3.5, 4.4, 5.8**
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 3: Whitespace-only note content is always rejected', () => {
|
|
||||||
it('rejects any string composed entirely of whitespace characters', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbWhitespaceOnly, (noteText) => {
|
|
||||||
const result = validateNoteCreation(noteText, true, true);
|
|
||||||
expect(result.accepted).toBe(false);
|
|
||||||
expect(result.status).toBe(400);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects undefined and null as note_text', () => {
|
|
||||||
const arbNullish = fc.oneof(fc.constant(undefined), fc.constant(null));
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbNullish, (noteText) => {
|
|
||||||
const result = validateNoteCreation(noteText, true, true);
|
|
||||||
expect(result.accepted).toBe(false);
|
|
||||||
expect(result.status).toBe(400);
|
|
||||||
}),
|
|
||||||
{ numRuns: 10 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 4: Note creation round-trip
|
|
||||||
// **Validates: Requirements 4.1, 3.3**
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 4: Note creation round-trip', () => {
|
|
||||||
it('creates a note and retrieves it with exact same text, correct username, and valid created_at', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbValidNoteText, arbUserId, arbUsername, (noteText, userId, username) => {
|
|
||||||
const store = new NoteStore();
|
|
||||||
const itemId = store.createQueueItem(userId);
|
|
||||||
|
|
||||||
const createResult = store.createNote(itemId, userId, username, noteText);
|
|
||||||
expect(createResult.status).toBe(201);
|
|
||||||
expect(createResult.note.note_text).toBe(noteText);
|
|
||||||
expect(createResult.note.username).toBe(username);
|
|
||||||
|
|
||||||
const getResult = store.getNotes(itemId, userId);
|
|
||||||
expect(getResult.status).toBe(200);
|
|
||||||
expect(getResult.notes.length).toBe(1);
|
|
||||||
expect(getResult.notes[0].note_text).toBe(noteText);
|
|
||||||
expect(getResult.notes[0].username).toBe(username);
|
|
||||||
expect(getResult.notes[0].created_at).toBeTruthy();
|
|
||||||
// Verify created_at is a valid ISO timestamp
|
|
||||||
expect(new Date(getResult.notes[0].created_at).toString()).not.toBe('Invalid Date');
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 5: Notes returned in descending creation order
|
|
||||||
// **Validates: Requirements 4.2**
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 5: Notes returned in descending creation order', () => {
|
|
||||||
it('for N >= 2 notes, GET returns them with created_at in descending order', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbNoteCount, arbUserId, arbUsername, (count, userId, username) => {
|
|
||||||
const store = new NoteStore();
|
|
||||||
const itemId = store.createQueueItem(userId);
|
|
||||||
|
|
||||||
// Create N notes
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const result = store.createNote(itemId, userId, username, `Note ${i + 1}`);
|
|
||||||
expect(result.status).toBe(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getResult = store.getNotes(itemId, userId);
|
|
||||||
expect(getResult.status).toBe(200);
|
|
||||||
expect(getResult.notes.length).toBe(count);
|
|
||||||
|
|
||||||
// Verify descending order
|
|
||||||
for (let i = 0; i < getResult.notes.length - 1; i++) {
|
|
||||||
const current = new Date(getResult.notes[i].created_at);
|
|
||||||
const next = new Date(getResult.notes[i + 1].created_at);
|
|
||||||
expect(current.getTime()).toBeGreaterThanOrEqual(next.getTime());
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 6: Ownership enforcement
|
|
||||||
// **Validates: Requirements 4.3**
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 6: Ownership enforcement', () => {
|
|
||||||
it('returns 404 when user B attempts to create notes on user A queue item', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbUserId,
|
|
||||||
arbUserId.filter(id => id > 1), // ensure we can generate different users
|
|
||||||
arbValidNoteText,
|
|
||||||
arbUsername,
|
|
||||||
(userA, userBOffset, noteText, username) => {
|
|
||||||
const userB = userA + userBOffset; // guarantee different user
|
|
||||||
const store = new NoteStore();
|
|
||||||
const itemId = store.createQueueItem(userA);
|
|
||||||
|
|
||||||
const result = store.createNote(itemId, userB, username, noteText);
|
|
||||||
expect(result.status).toBe(404);
|
|
||||||
expect(result.error).toBe('Queue item not found.');
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 when user B attempts to get notes on user A queue item', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbUserId,
|
|
||||||
arbUserId.filter(id => id > 1),
|
|
||||||
arbValidNoteText,
|
|
||||||
arbUsername,
|
|
||||||
(userA, userBOffset, noteText, username) => {
|
|
||||||
const userB = userA + userBOffset;
|
|
||||||
const store = new NoteStore();
|
|
||||||
const itemId = store.createQueueItem(userA);
|
|
||||||
|
|
||||||
// User A creates a note
|
|
||||||
store.createNote(itemId, userA, username, noteText);
|
|
||||||
|
|
||||||
// User B tries to read
|
|
||||||
const result = store.getNotes(itemId, userB);
|
|
||||||
expect(result.status).toBe(404);
|
|
||||||
expect(result.error).toBe('Queue item not found.');
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 7: Cascade delete removes all associated notes
|
|
||||||
// **Validates: Requirements 3.4, 7.3**
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 7: Cascade delete removes all associated notes', () => {
|
|
||||||
it('deleting a queue item removes all its associated notes', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbNoteCount,
|
|
||||||
arbUserId,
|
|
||||||
arbUsername,
|
|
||||||
(count, userId, username) => {
|
|
||||||
const store = new NoteStore();
|
|
||||||
const itemId = store.createQueueItem(userId);
|
|
||||||
|
|
||||||
// Create N notes
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
store.createNote(itemId, userId, username, `Remediation step ${i + 1}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify notes exist
|
|
||||||
expect(store.getNotesForItem(itemId).length).toBe(count);
|
|
||||||
|
|
||||||
// Delete the queue item (simulates CASCADE)
|
|
||||||
store.deleteQueueItem(itemId);
|
|
||||||
|
|
||||||
// Verify zero notes remain
|
|
||||||
expect(store.getNotesForItem(itemId).length).toBe(0);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deleting a queue item does not affect notes for other queue items', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbNoteCount,
|
|
||||||
arbUserId,
|
|
||||||
arbUsername,
|
|
||||||
(count, userId, username) => {
|
|
||||||
const store = new NoteStore();
|
|
||||||
const itemA = store.createQueueItem(userId);
|
|
||||||
const itemB = store.createQueueItem(userId);
|
|
||||||
|
|
||||||
// Create notes for both items
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
store.createNote(itemA, userId, username, `Note A-${i}`);
|
|
||||||
store.createNote(itemB, userId, username, `Note B-${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete item A
|
|
||||||
store.deleteQueueItem(itemA);
|
|
||||||
|
|
||||||
// Item B notes are unaffected
|
|
||||||
expect(store.getNotesForItem(itemB).length).toBe(count);
|
|
||||||
expect(store.getNotesForItem(itemA).length).toBe(0);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Ivanti Queue Remediation — Vendor Validation
|
|
||||||
*
|
|
||||||
* Feature: ivanti-queue-remediation
|
|
||||||
* Property 1: Remediate vendor validation
|
|
||||||
*
|
|
||||||
* For any non-empty string of 1–200 characters (trimmed, with at least one
|
|
||||||
* non-whitespace character), submitting it as the vendor field with workflow_type
|
|
||||||
* "Remediate" to the queue API SHALL be accepted; and for any empty,
|
|
||||||
* whitespace-only, or >200 character vendor string, the request SHALL be
|
|
||||||
* rejected with a 400 status.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 1.2, 1.3**
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Replicate the pure validation logic from ivantiTodoQueue.js
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'];
|
|
||||||
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
|
||||||
|
|
||||||
function isValidVendor(vendor) {
|
|
||||||
if (typeof vendor !== 'string') return false;
|
|
||||||
const trimmed = vendor.trim();
|
|
||||||
return trimmed.length > 0 && trimmed.length <= 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates the validation logic for the batch/single add endpoints.
|
|
||||||
* Returns { accepted: boolean, status: number } mirroring the route behavior.
|
|
||||||
*/
|
|
||||||
function validateRemediateRequest(vendor) {
|
|
||||||
const workflow_type = 'Remediate';
|
|
||||||
|
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
|
||||||
return { accepted: false, status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remediate is NOT in INVENTORY_TYPES, so vendor is required
|
|
||||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
|
||||||
if (!isValidVendor(vendor)) {
|
|
||||||
return { accepted: false, status: 400 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { accepted: true, status: 201 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Arbitraries
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Valid vendor: 1–200 chars trimmed, at least one non-whitespace char
|
|
||||||
const arbValidVendor = fc.string({ minLength: 1, maxLength: 200 }).filter(s => {
|
|
||||||
const trimmed = s.trim();
|
|
||||||
return trimmed.length > 0 && trimmed.length <= 200;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalid vendor: empty string
|
|
||||||
const arbEmptyVendor = fc.constant('');
|
|
||||||
|
|
||||||
// Invalid vendor: whitespace-only strings
|
|
||||||
const arbWhitespaceOnlyVendor = fc.array(
|
|
||||||
fc.constantFrom(' ', '\t', '\n', '\r'),
|
|
||||||
{ minLength: 1, maxLength: 50 }
|
|
||||||
).map(arr => arr.join(''));
|
|
||||||
|
|
||||||
// Invalid vendor: strings > 200 chars when trimmed
|
|
||||||
const arbOverlengthVendor = fc.string({ minLength: 201, maxLength: 400 }).filter(s => {
|
|
||||||
return s.trim().length > 200;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 1: Remediate vendor validation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 1: Remediate vendor validation', () => {
|
|
||||||
it('accepts any non-empty vendor string of 1–200 trimmed characters with at least one non-whitespace', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbValidVendor, (vendor) => {
|
|
||||||
const result = validateRemediateRequest(vendor);
|
|
||||||
expect(result.accepted).toBe(true);
|
|
||||||
expect(result.status).toBe(201);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty string as vendor for Remediate workflow', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbEmptyVendor, (vendor) => {
|
|
||||||
const result = validateRemediateRequest(vendor);
|
|
||||||
expect(result.accepted).toBe(false);
|
|
||||||
expect(result.status).toBe(400);
|
|
||||||
}),
|
|
||||||
{ numRuns: 10 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects whitespace-only strings as vendor for Remediate workflow', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbWhitespaceOnlyVendor, (vendor) => {
|
|
||||||
const result = validateRemediateRequest(vendor);
|
|
||||||
expect(result.accepted).toBe(false);
|
|
||||||
expect(result.status).toBe(400);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects vendor strings exceeding 200 characters when trimmed', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbOverlengthVendor, (vendor) => {
|
|
||||||
const result = validateRemediateRequest(vendor);
|
|
||||||
expect(result.accepted).toBe(false);
|
|
||||||
expect(result.status).toBe(400);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-string vendor values (undefined, null, number)', () => {
|
|
||||||
const arbNonString = fc.oneof(
|
|
||||||
fc.constant(undefined),
|
|
||||||
fc.constant(null),
|
|
||||||
fc.integer(),
|
|
||||||
fc.boolean()
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbNonString, (vendor) => {
|
|
||||||
const result = validateRemediateRequest(vendor);
|
|
||||||
expect(result.accepted).toBe(false);
|
|
||||||
expect(result.status).toBe(400);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for GET /api/ivanti/todo-queue/ticket-links endpoint
|
|
||||||
* Validates: Requirements 6.3, 6.4
|
|
||||||
*/
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
// Mock auth middleware
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, _res, next) => {
|
|
||||||
req.user = { id: 7, username: 'testuser' };
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
requireGroup: () => (_req, _res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock audit log
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
|
|
||||||
// Mock the db pool
|
|
||||||
jest.mock('../db', () => ({
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [] })),
|
|
||||||
connect: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pool = require('../db');
|
|
||||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: send an HTTP request and return { statusCode, body }.
|
|
||||||
*/
|
|
||||||
function request(server, method, path) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const addr = server.address();
|
|
||||||
const options = {
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: addr.port,
|
|
||||||
path,
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const raw = Buffer.concat(chunks).toString();
|
|
||||||
let body;
|
|
||||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
|
||||||
resolve({ statusCode: res.statusCode, body });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /api/ivanti/todo-queue/ticket-links', () => {
|
|
||||||
let app;
|
|
||||||
let server;
|
|
||||||
|
|
||||||
beforeAll((done) => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
|
||||||
server = app.listen(0, '127.0.0.1', done);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty links object when no associations exist', async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body).toEqual({ links: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a map of queue_item_id to ticket info', async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({
|
|
||||||
rows: [
|
|
||||||
{ queue_item_id: 12, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
|
||||||
{ queue_item_id: 15, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
|
||||||
{ queue_item_id: 22, ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body).toEqual({
|
|
||||||
links: {
|
|
||||||
'12': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
|
||||||
'15': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
|
||||||
'22': { ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by the authenticated user ID', async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
|
||||||
|
|
||||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
|
||||||
|
|
||||||
const [sql, params] = pool.query.mock.calls[0];
|
|
||||||
expect(sql).toContain('q.user_id = $1');
|
|
||||||
expect(params).toEqual([7]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
|
||||||
|
|
||||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
|
||||||
|
|
||||||
const [sql] = pool.query.mock.calls[0];
|
|
||||||
expect(sql).toContain('jira_ticket_queue_items');
|
|
||||||
expect(sql).toContain('JOIN jira_tickets');
|
|
||||||
expect(sql).toContain('JOIN ivanti_todo_queue');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 500 on database error', async () => {
|
|
||||||
pool.query.mockRejectedValueOnce(new Error('DB connection failed'));
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -100,8 +100,7 @@ describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always
|
|||||||
|
|
||||||
expect(capturedJql).not.toBeNull();
|
expect(capturedJql).not.toBeNull();
|
||||||
expect(capturedJql).toContain('updated >= -72h');
|
expect(capturedJql).toContain('updated >= -72h');
|
||||||
// project filter intentionally removed — issue keys are globally unique
|
expect(capturedJql).toContain('project =');
|
||||||
// and the filter broke cross-project ticket sync
|
|
||||||
}),
|
}),
|
||||||
{ numRuns: 100 }
|
{ numRuns: 100 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit Tests: POST /api/jira-tickets/:id/queue-items
|
|
||||||
*
|
|
||||||
* Feature: multi-item-jira-ticket
|
|
||||||
*
|
|
||||||
* Tests the junction endpoint that links queue items to a Jira ticket.
|
|
||||||
* Validates: Requirements 5.3, 6.1, 6.2
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
// Mock the auth middleware so routes don't require real sessions/cookies.
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, res, next) => {
|
|
||||||
req.user = { id: 1, username: 'test', group: 'Admin' };
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
requireGroup: (...groups) => (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the audit log helper to be a no-op.
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
|
|
||||||
// Mock the jiraApi helper
|
|
||||||
jest.mock('../helpers/jiraApi', () => ({
|
|
||||||
isConfigured: false,
|
|
||||||
getRateLimitStatus: jest.fn(() => ({
|
|
||||||
burst: { remaining: 60, limit: 60 },
|
|
||||||
daily: { remaining: 1440, limit: 1440 },
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pool = require('../db');
|
|
||||||
jest.mock('../db', () => ({
|
|
||||||
query: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createJiraTicketsRouter = require('../routes/jiraTickets');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: send an HTTP request to the test server and return { statusCode, body }.
|
|
||||||
*/
|
|
||||||
function request(server, method, path, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const addr = server.address();
|
|
||||||
const options = {
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: addr.port,
|
|
||||||
path,
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const raw = Buffer.concat(chunks).toString();
|
|
||||||
let parsed;
|
|
||||||
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
|
||||||
resolve({ statusCode: res.statusCode, body: parsed });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
|
|
||||||
if (body) {
|
|
||||||
req.write(JSON.stringify(body));
|
|
||||||
}
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('POST /api/jira-tickets/:id/queue-items', () => {
|
|
||||||
let app;
|
|
||||||
let server;
|
|
||||||
|
|
||||||
beforeAll((done) => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use('/api/jira-tickets', createJiraTicketsRouter());
|
|
||||||
server = app.listen(0, '127.0.0.1', done);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
pool.query.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Validation tests
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('returns 400 when queue_item_ids is missing', async () => {
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when queue_item_ids is an empty array', async () => {
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
|
||||||
queue_item_ids: [],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when queue_item_ids is not an array', async () => {
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
|
||||||
queue_item_ids: 'not-an-array',
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when queue_item_ids contains non-integers', async () => {
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
|
||||||
queue_item_ids: [1, 2.5, 3],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when queue_item_ids contains strings', async () => {
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
|
||||||
queue_item_ids: [1, 'abc', 3],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Ticket existence check
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('returns 404 when jira ticket does not exist', async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({ rows: [] }); // ticket lookup
|
|
||||||
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/999/queue-items', {
|
|
||||||
queue_item_ids: [1, 2, 3],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(404);
|
|
||||||
expect(res.body.error).toBe('Jira ticket not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Queue item existence check
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('returns 400 when some queue items do not exist', async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); // only 2 of 3 exist
|
|
||||||
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
|
||||||
queue_item_ids: [1, 2, 3],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(res.body.error).toBe('One or more queue items not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Successful linking
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('returns 201 with linked_count on success', async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }, { id: 18 }] }) // all queue items exist
|
|
||||||
.mockResolvedValueOnce({ rowCount: 3 }); // insert result
|
|
||||||
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
|
||||||
queue_item_ids: [12, 15, 18],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
expect(res.body.message).toBe('Queue items linked to ticket');
|
|
||||||
expect(res.body.ticket_id).toBe(42);
|
|
||||||
expect(res.body.linked_count).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns linked_count reflecting ON CONFLICT DO NOTHING (duplicates ignored)', async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }] }) // all queue items exist
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1 }); // only 1 new row (1 was duplicate)
|
|
||||||
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
|
||||||
queue_item_ids: [12, 15],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
expect(res.body.linked_count).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Error handling
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('returns 500 on database error', async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 12 }] }) // queue items exist
|
|
||||||
.mockRejectedValueOnce(new Error('Connection lost')); // insert fails
|
|
||||||
|
|
||||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
|
||||||
queue_item_ids: [12],
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body.error).toContain('Connection lost');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// Migration Idempotency Integration Test
|
|
||||||
// This test requires a running PostgreSQL instance with DATABASE_URL configured in backend/.env.
|
|
||||||
// It runs ALL Postgres migrations twice (via run-all.js) to verify they are idempotent (safe to re-run),
|
|
||||||
// then checks that key tables and columns exist.
|
|
||||||
//
|
|
||||||
// SKIPS AUTOMATICALLY when DATABASE_URL is not set (e.g., in CI environments without DB access).
|
|
||||||
//
|
|
||||||
// Run separately: npx jest backend/__tests__/migrations-idempotency.integration.test.js --forceExit
|
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const BACKEND_DIR = path.join(__dirname, '..');
|
|
||||||
|
|
||||||
// Load .env manually to check for DATABASE_URL without triggering db.js process.exit
|
|
||||||
function loadEnvFile() {
|
|
||||||
const envPath = path.join(BACKEND_DIR, '.env');
|
|
||||||
if (!fs.existsSync(envPath)) return {};
|
|
||||||
const content = fs.readFileSync(envPath, 'utf8');
|
|
||||||
const vars = {};
|
|
||||||
for (const line of content.split('\n')) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
||||||
const eqIdx = trimmed.indexOf('=');
|
|
||||||
if (eqIdx === -1) continue;
|
|
||||||
vars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
|
||||||
}
|
|
||||||
return vars;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envVars = loadEnvFile();
|
|
||||||
const hasDatabase = !!(process.env.DATABASE_URL || envVars.DATABASE_URL);
|
|
||||||
|
|
||||||
// Skip entire suite if no database is available
|
|
||||||
const describeIfDb = hasDatabase ? describe : describe.skip;
|
|
||||||
|
|
||||||
let pool;
|
|
||||||
|
|
||||||
if (hasDatabase) {
|
|
||||||
// Set DATABASE_URL in process.env so db.js picks it up
|
|
||||||
if (!process.env.DATABASE_URL && envVars.DATABASE_URL) {
|
|
||||||
process.env.DATABASE_URL = envVars.DATABASE_URL;
|
|
||||||
}
|
|
||||||
pool = require('../db');
|
|
||||||
}
|
|
||||||
|
|
||||||
function runAllMigrations() {
|
|
||||||
execSync('node migrations/run-all.js', {
|
|
||||||
cwd: BACKEND_DIR,
|
|
||||||
stdio: 'pipe',
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (pool) await pool.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
describeIfDb('Migration Idempotency', () => {
|
|
||||||
it('runs all migrations twice without errors (idempotent)', () => {
|
|
||||||
// First run
|
|
||||||
runAllMigrations();
|
|
||||||
// Second run — should not throw if migrations are truly idempotent
|
|
||||||
runAllMigrations();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
it('key tables exist after migrations', async () => {
|
|
||||||
const expectedTables = [
|
|
||||||
'compliance_items',
|
|
||||||
'compliance_item_history',
|
|
||||||
'compliance_notes',
|
|
||||||
'jira_tickets',
|
|
||||||
'ivanti_fp_submissions',
|
|
||||||
];
|
|
||||||
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT table_name
|
|
||||||
FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = ANY($1)
|
|
||||||
`, [expectedTables]);
|
|
||||||
|
|
||||||
const foundTables = rows.map(r => r.table_name);
|
|
||||||
for (const table of expectedTables) {
|
|
||||||
expect(foundTables).toContain(table);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
it('compliance_item_history has expected columns', async () => {
|
|
||||||
const expectedColumns = [
|
|
||||||
'id',
|
|
||||||
'hostname',
|
|
||||||
'field_name',
|
|
||||||
'old_value',
|
|
||||||
'new_value',
|
|
||||||
'change_reason',
|
|
||||||
'changed_by',
|
|
||||||
'changed_at',
|
|
||||||
'metric_id',
|
|
||||||
];
|
|
||||||
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'compliance_item_history'
|
|
||||||
`);
|
|
||||||
|
|
||||||
const foundColumns = rows.map(r => r.column_name);
|
|
||||||
for (const col of expectedColumns) {
|
|
||||||
expect(foundColumns).toContain(col);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
});
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: VCL Aggregated Burndown
|
|
||||||
*
|
|
||||||
* Feature: vcl-aggregated-burndown
|
|
||||||
*
|
|
||||||
* Tests the pure helper functions `deduplicateByHostname` and `computeAggregatedBurndown`
|
|
||||||
* from `backend/helpers/vclHelpers.js`.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 1.5, 1.6, 1.7, 2.2, 2.3, 2.4, 2.5, 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.4
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
|
|
||||||
// Mock db pool before importing anything
|
|
||||||
jest.mock('../db', () => ({
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
connect: jest.fn(() => Promise.resolve({
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
release: jest.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
jest.mock('../helpers/ivantiApi', () => ({
|
|
||||||
ivantiFormPost: jest.fn(),
|
|
||||||
ivantiPost: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
|
||||||
deduplicateByHostname,
|
|
||||||
computeAggregatedBurndown,
|
|
||||||
} = require('../helpers/vclHelpers');
|
|
||||||
|
|
||||||
// --- Generators ---
|
|
||||||
|
|
||||||
const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 20 });
|
|
||||||
|
|
||||||
const validDateArb = fc.record({
|
|
||||||
year: fc.integer({ min: 2020, max: 2030 }),
|
|
||||||
month: fc.integer({ min: 1, max: 12 }),
|
|
||||||
day: fc.integer({ min: 1, max: 28 }),
|
|
||||||
}).map(({ year, month, day }) =>
|
|
||||||
`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const verticalCodeArb = fc.constantFrom('NTS_AEO', 'SDIT_CISO', 'TSI', 'SR', 'AllOthers');
|
|
||||||
|
|
||||||
const deviceArb = fc.record({
|
|
||||||
hostname: hostnameArb,
|
|
||||||
resolution_date: fc.oneof(fc.constant(null), validDateArb),
|
|
||||||
vertical: verticalCodeArb,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generator for items that may have duplicate hostnames (for deduplication testing)
|
|
||||||
const duplicateItemsArb = fc.array(
|
|
||||||
fc.record({
|
|
||||||
hostname: fc.constantFrom('srv-001', 'srv-002', 'srv-003', 'srv-004', 'srv-005'),
|
|
||||||
resolution_date: fc.oneof(fc.constant(null), validDateArb),
|
|
||||||
vertical: verticalCodeArb,
|
|
||||||
}),
|
|
||||||
{ minLength: 0, maxLength: 30 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Property 1: Partition Invariant ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 1: Partition Invariant', () => {
|
|
||||||
/**
|
|
||||||
* For any array of device objects passed to computeAggregatedBurndown,
|
|
||||||
* blockers + with_dates = total.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 2.2**
|
|
||||||
*/
|
|
||||||
it('blockers + with_dates = total for any input', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
|
||||||
(devices) => {
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
expect(result.blockers + result.with_dates).toBe(result.total);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 2: Monthly Bucket Conservation ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 2: Monthly Bucket Conservation', () => {
|
|
||||||
/**
|
|
||||||
* For any array of device objects, the sum of all values in monthly
|
|
||||||
* must equal with_dates.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 2.3, 1.5**
|
|
||||||
*/
|
|
||||||
it('sum of monthly values = with_dates', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
|
||||||
(devices) => {
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
const monthlySum = Object.values(result.monthly).reduce((s, v) => s + v, 0);
|
|
||||||
expect(monthlySum).toBe(result.with_dates);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 3: Chronological Monthly Ordering ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 3: Chronological Monthly Ordering', () => {
|
|
||||||
/**
|
|
||||||
* For any array of device objects, the keys of monthly must be in
|
|
||||||
* ascending chronological order (lexicographic sort of YYYY-MM strings).
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 2.4**
|
|
||||||
*/
|
|
||||||
it('monthly keys are in ascending chronological order', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
|
||||||
(devices) => {
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
const keys = Object.keys(result.monthly);
|
|
||||||
for (let i = 1; i < keys.length; i++) {
|
|
||||||
expect(keys[i - 1] < keys[i]).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 4: Cumulative Projection Consistency ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 4: Cumulative Projection Consistency', () => {
|
|
||||||
/**
|
|
||||||
* For any array of device objects, projection[month].remaining =
|
|
||||||
* total - (cumulative sum of monthly[m] for all m <= month).
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 2.5**
|
|
||||||
*/
|
|
||||||
it('projection remaining = total - cumulative remediated', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
|
||||||
(devices) => {
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
const months = Object.keys(result.monthly);
|
|
||||||
let cumulative = 0;
|
|
||||||
for (const month of months) {
|
|
||||||
cumulative += result.monthly[month];
|
|
||||||
expect(result.projection[month].remediated).toBe(result.monthly[month]);
|
|
||||||
expect(result.projection[month].remaining).toBe(result.total - cumulative);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 5: Projected Clear Date Logic ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 5: Projected Clear Date Logic', () => {
|
|
||||||
/**
|
|
||||||
* If blockers > 0, projected_clear_date must be null.
|
|
||||||
* If blockers = 0 and with_dates > 0, projected_clear_date must equal the last month key.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 1.7**
|
|
||||||
*/
|
|
||||||
it('null when blockers > 0, last month key when blockers = 0 and with_dates > 0', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
|
||||||
(devices) => {
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
if (result.blockers > 0) {
|
|
||||||
expect(result.projected_clear_date).toBeNull();
|
|
||||||
} else if (result.with_dates > 0) {
|
|
||||||
const months = Object.keys(result.monthly);
|
|
||||||
expect(result.projected_clear_date).toBe(months[months.length - 1]);
|
|
||||||
} else {
|
|
||||||
// total = 0 case
|
|
||||||
expect(result.projected_clear_date).toBeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 6: Hostname Deduplication with Earliest Date ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 6: Hostname Deduplication with Earliest Date', () => {
|
|
||||||
/**
|
|
||||||
* For any array of items where the same hostname appears multiple times,
|
|
||||||
* deduplicateByHostname produces exactly one entry per unique hostname,
|
|
||||||
* and that entry's resolution_date is the earliest non-null date (or null if all null).
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 1.6**
|
|
||||||
*/
|
|
||||||
it('one entry per hostname with earliest non-null date', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
duplicateItemsArb,
|
|
||||||
(items) => {
|
|
||||||
const result = deduplicateByHostname(items);
|
|
||||||
|
|
||||||
// One entry per unique hostname
|
|
||||||
const uniqueHostnames = new Set(items.map(i => i.hostname));
|
|
||||||
expect(result.length).toBe(uniqueHostnames.size);
|
|
||||||
|
|
||||||
// Each result hostname appears exactly once
|
|
||||||
const resultHostnames = result.map(r => r.hostname);
|
|
||||||
expect(new Set(resultHostnames).size).toBe(result.length);
|
|
||||||
|
|
||||||
// For each hostname, verify the date is the earliest non-null
|
|
||||||
for (const entry of result) {
|
|
||||||
const allForHost = items.filter(i => i.hostname === entry.hostname);
|
|
||||||
const nonNullDates = allForHost
|
|
||||||
.map(i => i.resolution_date)
|
|
||||||
.filter(d => d != null);
|
|
||||||
|
|
||||||
if (nonNullDates.length === 0) {
|
|
||||||
expect(entry.resolution_date).toBeNull();
|
|
||||||
} else {
|
|
||||||
const earliest = nonNullDates.sort()[0];
|
|
||||||
expect(entry.resolution_date).toBe(earliest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 7: Aggregation Consistency with Per-Vertical Computation ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 7: Aggregation Consistency with Per-Vertical Computation', () => {
|
|
||||||
/**
|
|
||||||
* Aggregated total = sum of per-vertical totals.
|
|
||||||
* Aggregated blockers = sum of per-vertical blockers.
|
|
||||||
* Aggregated with_dates = sum of per-vertical with_dates.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
|
||||||
*/
|
|
||||||
it('aggregated totals = sum of per-vertical totals', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
|
||||||
(devices) => {
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
|
|
||||||
const sumTotal = result.by_vertical.reduce((s, v) => s + v.total, 0);
|
|
||||||
const sumBlockers = result.by_vertical.reduce((s, v) => s + v.blockers, 0);
|
|
||||||
const sumWithDates = result.by_vertical.reduce((s, v) => s + v.with_dates, 0);
|
|
||||||
|
|
||||||
expect(sumTotal).toBe(result.total);
|
|
||||||
expect(sumBlockers).toBe(result.blockers);
|
|
||||||
expect(sumWithDates).toBe(result.with_dates);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Property 8: By-Vertical Sorting and Filtering ---
|
|
||||||
|
|
||||||
describe('Feature: vcl-aggregated-burndown, Property 8: By-Vertical Sorting and Filtering', () => {
|
|
||||||
/**
|
|
||||||
* by_vertical is sorted descending by total, contains no zero-total entries,
|
|
||||||
* and the sum of all by_vertical[i].total equals the overall total.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 5.1, 5.2, 5.4**
|
|
||||||
*/
|
|
||||||
it('sorted descending by total, no zero entries, sum = total', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
|
||||||
(devices) => {
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
|
|
||||||
// Sorted descending by total
|
|
||||||
for (let i = 1; i < result.by_vertical.length; i++) {
|
|
||||||
expect(result.by_vertical[i - 1].total).toBeGreaterThanOrEqual(result.by_vertical[i].total);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No zero-total entries
|
|
||||||
for (const v of result.by_vertical) {
|
|
||||||
expect(v.total).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sum = overall total
|
|
||||||
const sum = result.by_vertical.reduce((s, v) => s + v.total, 0);
|
|
||||||
expect(sum).toBe(result.total);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit and Integration Tests: VCL Aggregated Burndown
|
|
||||||
*
|
|
||||||
* Feature: vcl-aggregated-burndown
|
|
||||||
*
|
|
||||||
* Tests cover:
|
|
||||||
* - deduplicateByHostname edge cases
|
|
||||||
* - computeAggregatedBurndown edge cases
|
|
||||||
* - GET /burndown endpoint with mocked DB
|
|
||||||
* - Empty DB returns zero/empty response
|
|
||||||
* - All-blocker scenario
|
|
||||||
* - Auth middleware enforcement
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
// Mock auth middleware
|
|
||||||
jest.mock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, res, next) => {
|
|
||||||
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
requireGroup: () => (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
||||||
jest.mock('../helpers/ivantiApi', () => ({
|
|
||||||
ivantiFormPost: jest.fn(),
|
|
||||||
ivantiPost: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock driftChecker
|
|
||||||
jest.mock('../helpers/driftChecker', () => ({
|
|
||||||
loadConfig: jest.fn(() => ({})),
|
|
||||||
compareSchemaToDrift: jest.fn(() => null),
|
|
||||||
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockPool = {
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
connect: jest.fn(() => Promise.resolve({
|
|
||||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
|
||||||
release: jest.fn(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
jest.mock('../db', () => mockPool);
|
|
||||||
|
|
||||||
const {
|
|
||||||
deduplicateByHostname,
|
|
||||||
computeAggregatedBurndown,
|
|
||||||
} = require('../helpers/vclHelpers');
|
|
||||||
|
|
||||||
const { createVCLMultiVerticalRouter } = require('../routes/vclMultiVertical');
|
|
||||||
|
|
||||||
// --- HTTP helper ---
|
|
||||||
|
|
||||||
function request(server, method, path, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const addr = server.address();
|
|
||||||
const options = {
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: addr.port,
|
|
||||||
path,
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const rawBody = Buffer.concat(chunks).toString();
|
|
||||||
let json;
|
|
||||||
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
|
|
||||||
resolve({ statusCode: res.statusCode, body: json });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Setup ---
|
|
||||||
|
|
||||||
let app, server;
|
|
||||||
|
|
||||||
beforeAll((done) => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
const mockUpload = { array: () => (req, res, next) => next() };
|
|
||||||
const router = createVCLMultiVerticalRouter(mockUpload);
|
|
||||||
app.use('/api/compliance/vcl-multi', router);
|
|
||||||
|
|
||||||
server = app.listen(0, '127.0.0.1', done);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPool.query.mockReset();
|
|
||||||
mockPool.connect.mockReset();
|
|
||||||
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- deduplicateByHostname unit tests ---
|
|
||||||
|
|
||||||
describe('deduplicateByHostname', () => {
|
|
||||||
it('returns empty array for empty input', () => {
|
|
||||||
expect(deduplicateByHostname([])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes through single item unchanged', () => {
|
|
||||||
const items = [{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }];
|
|
||||||
const result = deduplicateByHostname(items);
|
|
||||||
expect(result).toEqual([{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deduplicates by hostname keeping earliest non-null date', () => {
|
|
||||||
const items = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-08-15', vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-07-10', vertical: 'TSI' },
|
|
||||||
];
|
|
||||||
const result = deduplicateByHostname(items);
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].hostname).toBe('srv-001');
|
|
||||||
expect(result[0].resolution_date).toBe('2026-06-01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null date when all entries for a hostname have null dates', () => {
|
|
||||||
const items = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
||||||
];
|
|
||||||
const result = deduplicateByHostname(items);
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].resolution_date).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('picks earliest non-null date even when some entries are null', () => {
|
|
||||||
const items = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-09-01', vertical: 'SDIT_CISO' },
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
|
||||||
];
|
|
||||||
const result = deduplicateByHostname(items);
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].resolution_date).toBe('2026-06-15');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves vertical from the first entry', () => {
|
|
||||||
const items = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
|
||||||
];
|
|
||||||
const result = deduplicateByHostname(items);
|
|
||||||
expect(result[0].vertical).toBe('NTS_AEO');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- computeAggregatedBurndown unit tests ---
|
|
||||||
|
|
||||||
describe('computeAggregatedBurndown', () => {
|
|
||||||
it('returns zero/empty for empty input', () => {
|
|
||||||
const result = computeAggregatedBurndown([]);
|
|
||||||
expect(result.total).toBe(0);
|
|
||||||
expect(result.blockers).toBe(0);
|
|
||||||
expect(result.with_dates).toBe(0);
|
|
||||||
expect(result.monthly).toEqual({});
|
|
||||||
expect(result.projection).toEqual({});
|
|
||||||
expect(result.projected_clear_date).toBeNull();
|
|
||||||
expect(result.by_vertical).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all blockers — with_dates=0, monthly={}, projected_clear_date=null', () => {
|
|
||||||
const devices = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
||||||
];
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
expect(result.total).toBe(3);
|
|
||||||
expect(result.blockers).toBe(3);
|
|
||||||
expect(result.with_dates).toBe(0);
|
|
||||||
expect(result.monthly).toEqual({});
|
|
||||||
expect(result.projected_clear_date).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('single device with date — correct monthly bucket and projection', () => {
|
|
||||||
const devices = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
||||||
];
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
expect(result.total).toBe(1);
|
|
||||||
expect(result.blockers).toBe(0);
|
|
||||||
expect(result.with_dates).toBe(1);
|
|
||||||
expect(result.monthly).toEqual({ '2026-06': 1 });
|
|
||||||
expect(result.projection).toEqual({ '2026-06': { remediated: 1, remaining: 0 } });
|
|
||||||
expect(result.projected_clear_date).toBe('2026-06');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mixed blockers and in-progress — projected_clear_date is null', () => {
|
|
||||||
const devices = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
];
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
expect(result.total).toBe(2);
|
|
||||||
expect(result.blockers).toBe(1);
|
|
||||||
expect(result.with_dates).toBe(1);
|
|
||||||
expect(result.projected_clear_date).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('multiple months — correct cumulative projection', () => {
|
|
||||||
const devices = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-002', resolution_date: '2026-06-20', vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-003', resolution_date: '2026-07-10', vertical: 'SDIT_CISO' },
|
|
||||||
{ hostname: 'srv-004', resolution_date: '2026-08-01', vertical: 'TSI' },
|
|
||||||
];
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
expect(result.total).toBe(4);
|
|
||||||
expect(result.monthly).toEqual({ '2026-06': 2, '2026-07': 1, '2026-08': 1 });
|
|
||||||
expect(result.projection['2026-06'].remaining).toBe(2); // 4 - 2
|
|
||||||
expect(result.projection['2026-07'].remaining).toBe(1); // 4 - 3
|
|
||||||
expect(result.projection['2026-08'].remaining).toBe(0); // 4 - 4
|
|
||||||
expect(result.projected_clear_date).toBe('2026-08');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('by_vertical sorted descending by total, omits zero-total verticals', () => {
|
|
||||||
const devices = [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
|
||||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-003', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-004', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
|
||||||
];
|
|
||||||
const result = computeAggregatedBurndown(devices);
|
|
||||||
expect(result.by_vertical[0].vertical).toBe('NTS_AEO');
|
|
||||||
expect(result.by_vertical[0].total).toBe(3);
|
|
||||||
expect(result.by_vertical[1].vertical).toBe('TSI');
|
|
||||||
expect(result.by_vertical[1].total).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- GET /burndown endpoint tests ---
|
|
||||||
|
|
||||||
describe('GET /api/compliance/vcl-multi/burndown', () => {
|
|
||||||
it('returns zero/empty response when no active devices exist', async () => {
|
|
||||||
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.total_non_compliant).toBe(0);
|
|
||||||
expect(res.body.blockers).toBe(0);
|
|
||||||
expect(res.body.with_dates).toBe(0);
|
|
||||||
expect(res.body.monthly_forecast).toEqual({});
|
|
||||||
expect(res.body.projected_clear_date).toBeNull();
|
|
||||||
expect(res.body.by_vertical).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct burndown data with mocked DB rows', async () => {
|
|
||||||
mockPool.query.mockResolvedValueOnce({
|
|
||||||
rows: [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-002', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'SDIT_CISO' }, // duplicate hostname
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
// srv-001 deduplicated: earliest date is 2026-06-15
|
|
||||||
expect(res.body.total_non_compliant).toBe(3); // srv-001, srv-002, srv-003
|
|
||||||
expect(res.body.blockers).toBe(1); // srv-003
|
|
||||||
expect(res.body.with_dates).toBe(2); // srv-001, srv-002
|
|
||||||
expect(res.body.monthly_forecast['2026-06']).toBe(1);
|
|
||||||
expect(res.body.monthly_forecast['2026-07']).toBe(1);
|
|
||||||
expect(res.body.projected_clear_date).toBeNull(); // blockers > 0
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns all-blocker response correctly', async () => {
|
|
||||||
mockPool.query.mockResolvedValueOnce({
|
|
||||||
rows: [
|
|
||||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
||||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.total_non_compliant).toBe(2);
|
|
||||||
expect(res.body.blockers).toBe(2);
|
|
||||||
expect(res.body.with_dates).toBe(0);
|
|
||||||
expect(res.body.monthly_forecast).toEqual({});
|
|
||||||
expect(res.body.projected_clear_date).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 500 on database error', async () => {
|
|
||||||
mockPool.query.mockRejectedValueOnce(new Error('Connection refused'));
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(500);
|
|
||||||
expect(res.body.error).toBe('Database error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('response shape matches API contract', async () => {
|
|
||||||
mockPool.query.mockResolvedValueOnce({
|
|
||||||
rows: [
|
|
||||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('total_non_compliant');
|
|
||||||
expect(res.body).toHaveProperty('blockers');
|
|
||||||
expect(res.body).toHaveProperty('with_dates');
|
|
||||||
expect(res.body).toHaveProperty('monthly_forecast');
|
|
||||||
expect(res.body).toHaveProperty('projected_clear_date');
|
|
||||||
expect(res.body).toHaveProperty('by_vertical');
|
|
||||||
expect(Array.isArray(res.body.by_vertical)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Auth enforcement test ---
|
|
||||||
|
|
||||||
describe('GET /burndown — auth enforcement', () => {
|
|
||||||
it('returns 401 when auth middleware rejects', async () => {
|
|
||||||
// Create a separate app with rejecting auth
|
|
||||||
const rejectApp = express();
|
|
||||||
rejectApp.use(express.json());
|
|
||||||
|
|
||||||
// Override requireAuth to reject
|
|
||||||
jest.resetModules();
|
|
||||||
jest.doMock('../middleware/auth', () => ({
|
|
||||||
requireAuth: () => (req, res, next) => {
|
|
||||||
res.status(401).json({ error: 'Authentication required' });
|
|
||||||
},
|
|
||||||
requireGroup: () => (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createVCLMultiVerticalRouter: createRouter } = require('../routes/vclMultiVertical');
|
|
||||||
const mockUpload = { array: () => (req, res, next) => next() };
|
|
||||||
const router = createRouter(mockUpload);
|
|
||||||
rejectApp.use('/api/compliance/vcl-multi', router);
|
|
||||||
|
|
||||||
const rejectServer = await new Promise((resolve) => {
|
|
||||||
const s = rejectApp.listen(0, '127.0.0.1', () => resolve(s));
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await request(rejectServer, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
||||||
expect(res.statusCode).toBe(401);
|
|
||||||
expect(res.body.error).toBe('Authentication required');
|
|
||||||
} finally {
|
|
||||||
await new Promise((resolve) => rejectServer.close(resolve));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -112,27 +112,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('PATCH /items/:hostname/metadata', () => {
|
describe('PATCH /items/:hostname/metadata', () => {
|
||||||
it('happy path — updates resolution_date and remediation_plan', async () => {
|
it('happy path — updates resolution_date and remediation_plan', async () => {
|
||||||
// Mock client.query: first call = SELECT current values, second+ = INSERT history / UPDATE
|
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 2 });
|
||||||
const mockClient = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
// Override connect to return our mock client
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
// The first call from the handler is BEGIN, then SELECT, then inserts, then UPDATE, then COMMIT
|
|
||||||
mockClient.query = jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current values
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||||
resolution_date: '2026-06-15',
|
resolution_date: '2026-06-15',
|
||||||
@@ -163,14 +143,7 @@ describe('PATCH /items/:hostname/metadata', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 when hostname not found', async () => {
|
it('returns 404 when hostname not found', async () => {
|
||||||
const mockClient = {
|
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current values — empty = not found
|
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', {
|
const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', {
|
||||||
resolution_date: '2026-06-15',
|
resolution_date: '2026-06-15',
|
||||||
@@ -304,9 +277,6 @@ describe('Integration: full bulk upload flow (preview → commit)', () => {
|
|||||||
};
|
};
|
||||||
mockClient.query
|
mockClient.query
|
||||||
.mockResolvedValueOnce({}) // BEGIN
|
.mockResolvedValueOnce({}) // BEGIN
|
||||||
.mockResolvedValueOnce({ rows: [{ hostname: 'srv-001', resolution_date: null, remediation_plan: null }] }) // SELECT current values for all hostnames
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (resolution_date)
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (remediation_plan)
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001
|
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001
|
||||||
.mockResolvedValueOnce({}); // COMMIT
|
.mockResolvedValueOnce({}); // COMMIT
|
||||||
|
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Vendor Issue Type Dropdown
|
|
||||||
*
|
|
||||||
* Feature: vendor-issue-type-dropdown
|
|
||||||
*
|
|
||||||
* Tests the pure determination logic that decides which issue type list
|
|
||||||
* to display based on the project key input.
|
|
||||||
*
|
|
||||||
* Validates: Requirements 1.2, 1.3, 1.4, 1.5, 2.1, 2.3, 3.4, 3.5, 4.1, 4.2, 6.3
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fc = require('fast-check');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Replicate the pure functions from JiraPage.js for testing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const VENDOR_PROJECT_KEYS = [
|
|
||||||
'AA_ADTRAN',
|
|
||||||
'AA_ADVA',
|
|
||||||
'AA_CASA',
|
|
||||||
'AA_CISCO',
|
|
||||||
'AACOMMSCOP',
|
|
||||||
'AA_COMMSCOP',
|
|
||||||
'AA_HARMONI',
|
|
||||||
'AA_JUNIPER',
|
|
||||||
'AA_VECIMA',
|
|
||||||
'AA_VIAVI',
|
|
||||||
];
|
|
||||||
|
|
||||||
const VENDOR_ISSUE_TYPES = [
|
|
||||||
'Epic',
|
|
||||||
'Story',
|
|
||||||
'Task',
|
|
||||||
'Defect',
|
|
||||||
'Production Defect/Incident Fix',
|
|
||||||
'New Feature',
|
|
||||||
'Spike',
|
|
||||||
'Release Candidate',
|
|
||||||
'Documentation',
|
|
||||||
];
|
|
||||||
|
|
||||||
const STEAM_ISSUE_TYPES = [
|
|
||||||
'Story',
|
|
||||||
'Epic',
|
|
||||||
'Program',
|
|
||||||
'Project',
|
|
||||||
'Reservation',
|
|
||||||
'Automation Maintenance',
|
|
||||||
];
|
|
||||||
|
|
||||||
function isVendorProject(projectKey, vendorKeys) {
|
|
||||||
if (!projectKey || typeof projectKey !== 'string') return false;
|
|
||||||
const normalized = projectKey.trim().toUpperCase();
|
|
||||||
if (normalized.length === 0) return false;
|
|
||||||
return vendorKeys.includes(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) {
|
|
||||||
return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates the project_key onChange logic: resets issue_type only on context switch.
|
|
||||||
*/
|
|
||||||
function simulateProjectKeyChange(oldKey, newKey, currentIssueType, vendorKeys) {
|
|
||||||
const wasVendor = isVendorProject(oldKey, vendorKeys);
|
|
||||||
const isNowVendor = isVendorProject(newKey, vendorKeys);
|
|
||||||
return (wasVendor !== isNowVendor) ? '' : currentIssueType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: generate a vendor key with random casing and optional whitespace
|
|
||||||
const arbVendorKey = fc.constantFrom(...VENDOR_PROJECT_KEYS).chain(key =>
|
|
||||||
fc.oneof(
|
|
||||||
fc.constant(key),
|
|
||||||
fc.constant(key.toLowerCase()),
|
|
||||||
fc.constant(` ${key} `),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper: generate a string that does NOT match any vendor key after normalization
|
|
||||||
const arbNonVendorKey = fc.string({ minLength: 0, maxLength: 50 }).filter(s => {
|
|
||||||
const normalized = s.trim().toUpperCase();
|
|
||||||
return !VENDOR_PROJECT_KEYS.includes(normalized);
|
|
||||||
});
|
|
||||||
|
|
||||||
const arbNonVendorKeyNonEmpty = fc.string({ minLength: 1, maxLength: 30 }).filter(s => {
|
|
||||||
const normalized = s.trim().toUpperCase();
|
|
||||||
return !VENDOR_PROJECT_KEYS.includes(normalized) && normalized.length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 1: Issue type list determination
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: vendor-issue-type-dropdown, Property 1: Issue type list determination', () => {
|
|
||||||
it('returns VENDOR_ISSUE_TYPES when project key matches a vendor key (case-insensitive, trimmed)', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbVendorKey, (key) => {
|
|
||||||
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
|
||||||
expect(result).toBe(VENDOR_ISSUE_TYPES);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns STEAM_ISSUE_TYPES for any string that does not match a vendor key after normalization', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbNonVendorKey, (key) => {
|
|
||||||
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
|
||||||
expect(result).toBe(STEAM_ISSUE_TYPES);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns STEAM_ISSUE_TYPES for null, undefined, and empty string', () => {
|
|
||||||
const emptyInputs = fc.oneof(
|
|
||||||
fc.constant(null),
|
|
||||||
fc.constant(undefined),
|
|
||||||
fc.constant(''),
|
|
||||||
fc.constant(' '),
|
|
||||||
);
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(emptyInputs, (key) => {
|
|
||||||
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
|
||||||
expect(result).toBe(STEAM_ISSUE_TYPES);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 2: Context switch resets issue type selection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: vendor-issue-type-dropdown, Property 2: Context switch resets issue type', () => {
|
|
||||||
it('resets issue_type to empty when switching from vendor to non-vendor context', () => {
|
|
||||||
const anyIssueType = fc.string({ minLength: 1, maxLength: 50 });
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbVendorKey, arbNonVendorKeyNonEmpty, anyIssueType, (oldKey, newKey, issueType) => {
|
|
||||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
|
||||||
expect(result).toBe('');
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets issue_type to empty when switching from non-vendor to vendor context', () => {
|
|
||||||
const anyIssueType = fc.string({ minLength: 1, maxLength: 50 });
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbNonVendorKeyNonEmpty, arbVendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
|
||||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
|
||||||
expect(result).toBe('');
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 3: Same context preserves issue type selection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: vendor-issue-type-dropdown, Property 3: Same context preserves issue type', () => {
|
|
||||||
it('preserves issue_type when both old and new keys resolve to STEAM context', () => {
|
|
||||||
const anyIssueType = fc.string({ minLength: 0, maxLength: 50 });
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbNonVendorKey, arbNonVendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
|
||||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
|
||||||
expect(result).toBe(issueType);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves issue_type when both old and new keys resolve to vendor context', () => {
|
|
||||||
const anyIssueType = fc.string({ minLength: 0, maxLength: 50 });
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(arbVendorKey, arbVendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
|
||||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
|
||||||
expect(result).toBe(issueType);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS jira_tickets (
|
|||||||
ticket_key TEXT NOT NULL,
|
ticket_key TEXT NOT NULL,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
summary TEXT,
|
summary TEXT,
|
||||||
status TEXT DEFAULT 'Open',
|
status TEXT DEFAULT 'Open' CHECK (status IN ('Open', 'In Progress', 'Closed')),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -398,7 +398,6 @@ CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
|||||||
has_action_plan BOOLEAN NOT NULL DEFAULT FALSE,
|
has_action_plan BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
plan_count INTEGER NOT NULL DEFAULT 0,
|
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||||
plans_json TEXT NOT NULL DEFAULT '[]',
|
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||||
atlas_known BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,6 @@
|
|||||||
|
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const dns = require('dns');
|
|
||||||
|
|
||||||
// Force IPv4-first DNS resolution — card.charter.com has both IPv4 and IPv6
|
|
||||||
// records but IPv6 is unreachable from this network, causing timeouts.
|
|
||||||
dns.setDefaultResultOrder('ipv4first');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Configuration — read from process.env at module load
|
// Configuration — read from process.env at module load
|
||||||
@@ -62,13 +57,12 @@ function acquireToken(timeout) {
|
|||||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
path: fullUrl.pathname + fullUrl.search,
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
|
||||||
headers: {
|
headers: {
|
||||||
'accept': 'application/json',
|
'accept': 'application/json',
|
||||||
'authorization': 'Basic ' + authString,
|
'authorization': 'Basic ' + authString,
|
||||||
'content-length': '0',
|
'content-length': '0',
|
||||||
},
|
},
|
||||||
timeout: timeout || 30000,
|
timeout: timeout || 15000,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isHttps) {
|
if (isHttps) {
|
||||||
@@ -129,7 +123,7 @@ async function ensureToken(timeout) {
|
|||||||
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function cardRequest(method, urlPath, body, options) {
|
async function cardRequest(method, urlPath, body, options) {
|
||||||
const timeout = (options && options.timeout) || 30000;
|
const timeout = (options && options.timeout) || 15000;
|
||||||
const skipAuth = (options && options.skipAuth) || false;
|
const skipAuth = (options && options.skipAuth) || false;
|
||||||
|
|
||||||
async function doRequest(bearerToken) {
|
async function doRequest(bearerToken) {
|
||||||
@@ -156,7 +150,6 @@ async function cardRequest(method, urlPath, body, options) {
|
|||||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
path: fullUrl.pathname + fullUrl.search,
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
method,
|
method,
|
||||||
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
|
||||||
headers,
|
headers,
|
||||||
timeout,
|
timeout,
|
||||||
};
|
};
|
||||||
@@ -252,8 +245,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
|||||||
/**
|
/**
|
||||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||||
*/
|
*/
|
||||||
async function getOwner(assetId, options) {
|
async function getOwner(assetId) {
|
||||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,96 +288,6 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
|||||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/v2/asset-search/{ivantiHostId}?search_param=deep_search
|
|
||||||
* Search CARD by Ivanti Asset ID (8-digit integer). Returns the CARD asset
|
|
||||||
* record directly — no suffix guessing required.
|
|
||||||
*
|
|
||||||
* @param {string|number} ivantiHostId - 8-character integer Ivanti Host ID
|
|
||||||
* @param {object} [options] - { timeout }
|
|
||||||
*/
|
|
||||||
async function searchByIvantiHostId(ivantiHostId, options) {
|
|
||||||
const hostId = String(ivantiHostId).trim();
|
|
||||||
if (!hostId || !/^\d+$/.test(hostId)) {
|
|
||||||
return { status: 400, body: '{"error":"Invalid Ivanti host ID — must be an integer."}', ok: false };
|
|
||||||
}
|
|
||||||
const res = await cardGet(`/api/v2/asset-search/${hostId}?search_param=deep_search`, options);
|
|
||||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/v2/asset-search/{assetId}?search_param=deep_search
|
|
||||||
* Search CARD by asset ID (e.g., "24.24.100.20-CTEC"). Returns the full
|
|
||||||
* enriched asset record including ncim_discovery, netops_granite_allips, etc.
|
|
||||||
*
|
|
||||||
* @param {string} assetId - CARD asset identifier (IP-SUFFIX format)
|
|
||||||
* @param {object} [options] - { timeout }
|
|
||||||
*/
|
|
||||||
async function searchByAssetId(assetId, options) {
|
|
||||||
const id = (assetId || '').trim();
|
|
||||||
if (!id) {
|
|
||||||
return { status: 400, body: '{"error":"Asset ID is required."}', ok: false };
|
|
||||||
}
|
|
||||||
const res = await cardGet(`/api/v2/asset-search/${encodeURIComponent(id)}?search_param=deep_search`, options);
|
|
||||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
|
|
||||||
* Returns the first asset ID that returns a valid owner record, or null if none found.
|
|
||||||
*
|
|
||||||
* @param {string} ip - IP address or existing asset ID
|
|
||||||
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
|
|
||||||
*/
|
|
||||||
async function resolveAssetId(ip, options) {
|
|
||||||
const quick = options && options.quick;
|
|
||||||
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
|
||||||
const timeout = quick ? 30000 : undefined; // 30s timeout for quick mode
|
|
||||||
const trimmedIp = (ip || '').trim();
|
|
||||||
if (!trimmedIp) return null;
|
|
||||||
|
|
||||||
// If it already has a suffix (contains a dash followed by letters), use as-is
|
|
||||||
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
|
|
||||||
try {
|
|
||||||
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
|
|
||||||
if (result.ok) return trimmedIp;
|
|
||||||
} catch (err) {
|
|
||||||
// Timeout — throw so caller can distinguish from "not found"
|
|
||||||
if (quick && err.message && err.message.includes('timed out')) {
|
|
||||||
throw new Error('CARD_TIMEOUT');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try each suffix
|
|
||||||
for (const suffix of SUFFIXES) {
|
|
||||||
const candidate = `${trimmedIp}-${suffix}`;
|
|
||||||
try {
|
|
||||||
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
|
||||||
if (result.ok) return candidate;
|
|
||||||
} catch (err) {
|
|
||||||
// Timeout — throw so caller can distinguish from "not found"
|
|
||||||
if (quick && err.message && err.message.includes('timed out')) {
|
|
||||||
throw new Error('CARD_TIMEOUT');
|
|
||||||
}
|
|
||||||
// Continue to next suffix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
|
|
||||||
if (!quick) {
|
|
||||||
try {
|
|
||||||
const result = await getOwner(trimmedIp);
|
|
||||||
if (result.ok) return trimmedIp;
|
|
||||||
} catch (_) {
|
|
||||||
// Not found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
isConfigured,
|
isConfigured,
|
||||||
missingVars,
|
missingVars,
|
||||||
@@ -399,7 +302,4 @@ module.exports = {
|
|||||||
declineAsset,
|
declineAsset,
|
||||||
redirectAsset,
|
redirectAsset,
|
||||||
invalidateToken,
|
invalidateToken,
|
||||||
resolveAssetId,
|
|
||||||
searchByIvantiHostId,
|
|
||||||
searchByAssetId,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -276,10 +276,7 @@ function jiraDelete(urlPath, options) {
|
|||||||
* @param {string[]} [fields] - Jira field names to return
|
* @param {string[]} [fields] - Jira field names to return
|
||||||
*/
|
*/
|
||||||
async function getIssue(issueKey, fields) {
|
async function getIssue(issueKey, fields) {
|
||||||
// Use JQL search to look up a single issue by key.
|
const jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`;
|
||||||
// Issue keys are globally unique in Jira — no project filter needed.
|
|
||||||
// Charter compliance: uses GET /rest/api/2/search with explicit field list.
|
|
||||||
const jql = `key = "${issueKey}"`;
|
|
||||||
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
||||||
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
||||||
return { ok: true, data: result.data.issues[0] };
|
return { ok: true, data: result.data.issues[0] };
|
||||||
@@ -303,10 +300,11 @@ async function searchIssuesByKeys(issueKeys, opts) {
|
|||||||
return { ok: true, data: { total: 0, issues: [] } };
|
return { ok: true, data: { total: 0, issues: [] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build JQL: key in (KEY-1, KEY-2, ...) — issue keys are globally unique,
|
// Build JQL: key in (KEY-1, KEY-2, ...) — Charter requires project+updated
|
||||||
// so no project filter needed. Add updated clause for Charter compliance.
|
// or similar, but key-based search is inherently scoped. We add updated
|
||||||
|
// clause for compliance.
|
||||||
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
||||||
const jql = `key in (${keyList}) AND updated >= -72h`;
|
const jql = `key in (${keyList}) AND updated >= -72h AND project = ${JIRA_PROJECT_KEY}`;
|
||||||
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||||
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -205,327 +205,6 @@ function mapColumnHeaders(headers) {
|
|||||||
return mapping;
|
return mapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts vertical code and report date from a filename.
|
|
||||||
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
|
||||||
* The vertical is everything before the trailing _YYYY_MM_DD portion.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
|
||||||
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
|
||||||
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
|
||||||
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
|
||||||
*
|
|
||||||
* Returns null if the filename does not match the expected pattern.
|
|
||||||
*/
|
|
||||||
function parseVerticalFilename(filename) {
|
|
||||||
// Strip .xlsx extension (case-insensitive)
|
|
||||||
const stem = filename.replace(/\.xlsx$/i, '');
|
|
||||||
// Match: everything up to the last _YYYY_MM_DD
|
|
||||||
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const vertical = match[1];
|
|
||||||
const date = `${match[2]}-${match[3]}-${match[4]}`;
|
|
||||||
|
|
||||||
// Validate the date portion is a real date
|
|
||||||
if (!isValidDateString(date)) return null;
|
|
||||||
|
|
||||||
return { vertical, date };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes per-vertical burndown forecast from non-compliant items.
|
|
||||||
* Returns breakdown of items with/without resolution dates and monthly projections.
|
|
||||||
*/
|
|
||||||
function computeVerticalBurndown(items) {
|
|
||||||
const total = items.length;
|
|
||||||
const withDates = items.filter(i => i.resolution_date != null);
|
|
||||||
const blockers = items.filter(i => i.resolution_date == null);
|
|
||||||
|
|
||||||
// Bucket by month
|
|
||||||
const monthly = {};
|
|
||||||
for (const item of withDates) {
|
|
||||||
const dateStr = typeof item.resolution_date === 'string'
|
|
||||||
? item.resolution_date
|
|
||||||
: item.resolution_date.toISOString().slice(0, 10);
|
|
||||||
const month = dateStr.slice(0, 7); // YYYY-MM
|
|
||||||
monthly[month] = (monthly[month] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cumulative projection — how many remain after each month
|
|
||||||
let remaining = total;
|
|
||||||
const projection = {};
|
|
||||||
for (const month of Object.keys(monthly).sort()) {
|
|
||||||
remaining -= monthly[month];
|
|
||||||
projection[month] = { remediated: monthly[month], remaining };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Projected clear date — first month where remaining hits 0 (excluding blockers)
|
|
||||||
let projectedClearDate = null;
|
|
||||||
if (blockers.length === 0 && Object.keys(projection).length > 0) {
|
|
||||||
const sortedMonths = Object.keys(projection).sort();
|
|
||||||
projectedClearDate = sortedMonths[sortedMonths.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
blockers: blockers.length,
|
|
||||||
with_dates: withDates.length,
|
|
||||||
monthly,
|
|
||||||
projection,
|
|
||||||
projected_clear_date: projectedClearDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates devices by hostname, keeping the earliest non-null resolution_date.
|
|
||||||
* A device appearing in multiple metrics counts once.
|
|
||||||
*
|
|
||||||
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items
|
|
||||||
* @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>}
|
|
||||||
*/
|
|
||||||
function deduplicateByHostname(items) {
|
|
||||||
const map = {};
|
|
||||||
for (const item of items) {
|
|
||||||
const key = item.hostname;
|
|
||||||
if (!map[key]) {
|
|
||||||
map[key] = { hostname: item.hostname, resolution_date: item.resolution_date || null, vertical: item.vertical };
|
|
||||||
} else {
|
|
||||||
// Keep the earliest non-null resolution_date
|
|
||||||
const existing = map[key];
|
|
||||||
if (item.resolution_date != null) {
|
|
||||||
if (existing.resolution_date == null || item.resolution_date < existing.resolution_date) {
|
|
||||||
existing.resolution_date = item.resolution_date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.values(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes aggregated burndown from a deduplicated array of device objects.
|
|
||||||
* Each device has { hostname, resolution_date, vertical }.
|
|
||||||
*
|
|
||||||
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices
|
|
||||||
* @returns {{
|
|
||||||
* total: number,
|
|
||||||
* blockers: number,
|
|
||||||
* with_dates: number,
|
|
||||||
* monthly: Object<string, number>,
|
|
||||||
* projection: Object<string, { remediated: number, remaining: number }>,
|
|
||||||
* projected_clear_date: string|null,
|
|
||||||
* by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }>
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
function computeAggregatedBurndown(devices) {
|
|
||||||
const total = devices.length;
|
|
||||||
const withDates = devices.filter(d => d.resolution_date != null);
|
|
||||||
const blockerDevices = devices.filter(d => d.resolution_date == null);
|
|
||||||
const blockers = blockerDevices.length;
|
|
||||||
const with_dates = withDates.length;
|
|
||||||
|
|
||||||
// Bucket by month (YYYY-MM)
|
|
||||||
const monthly = {};
|
|
||||||
for (const device of withDates) {
|
|
||||||
const dateStr = typeof device.resolution_date === 'string'
|
|
||||||
? device.resolution_date
|
|
||||||
: device.resolution_date.toISOString().slice(0, 10);
|
|
||||||
const month = dateStr.slice(0, 7);
|
|
||||||
monthly[month] = (monthly[month] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort monthly keys chronologically
|
|
||||||
const sortedMonths = Object.keys(monthly).sort();
|
|
||||||
const sortedMonthly = {};
|
|
||||||
for (const m of sortedMonths) {
|
|
||||||
sortedMonthly[m] = monthly[m];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cumulative projection
|
|
||||||
let remaining = total;
|
|
||||||
const projection = {};
|
|
||||||
for (const month of sortedMonths) {
|
|
||||||
remaining -= sortedMonthly[month];
|
|
||||||
projection[month] = { remediated: sortedMonthly[month], remaining };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Projected clear date
|
|
||||||
let projected_clear_date = null;
|
|
||||||
if (blockers === 0 && sortedMonths.length > 0) {
|
|
||||||
projected_clear_date = sortedMonths[sortedMonths.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-vertical breakdown
|
|
||||||
const verticalMap = {};
|
|
||||||
for (const device of devices) {
|
|
||||||
const v = device.vertical;
|
|
||||||
if (!verticalMap[v]) {
|
|
||||||
verticalMap[v] = { vertical: v, total: 0, blockers: 0, with_dates: 0 };
|
|
||||||
}
|
|
||||||
verticalMap[v].total++;
|
|
||||||
if (device.resolution_date == null) {
|
|
||||||
verticalMap[v].blockers++;
|
|
||||||
} else {
|
|
||||||
verticalMap[v].with_dates++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort descending by total, filter out zero-total entries
|
|
||||||
const by_vertical = Object.values(verticalMap)
|
|
||||||
.filter(v => v.total > 0)
|
|
||||||
.sort((a, b) => b.total - a.total);
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
blockers,
|
|
||||||
with_dates,
|
|
||||||
monthly: sortedMonthly,
|
|
||||||
projection,
|
|
||||||
projected_clear_date,
|
|
||||||
by_vertical,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes per-metric forecast burndown from device records and historical snapshots.
|
|
||||||
*
|
|
||||||
* Pure function — no side effects, no database access. Suitable for property-based testing.
|
|
||||||
*
|
|
||||||
* @param {Array<{hostname: string, resolution_date: string|null}>} currentDevices
|
|
||||||
* Active non-compliant devices for the metric
|
|
||||||
* @param {number} totalAssets
|
|
||||||
* Total device count in scope for this metric (from snapshot or summary)
|
|
||||||
* @param {Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>} historicalSnapshots
|
|
||||||
* Pre-computed historical data points (up to 4 months)
|
|
||||||
* @returns {{
|
|
||||||
* historical: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
|
||||||
* forecast: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
|
||||||
* current_snapshot: {total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number}
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots) {
|
|
||||||
// Compute compliance_pct helper
|
|
||||||
function calcCompliancePct(total, nc) {
|
|
||||||
if (total === 0) return 0;
|
|
||||||
return Math.round(((total - nc) / total) * 1000) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Historical — pass through as-is
|
|
||||||
const historical = (historicalSnapshots || []).map(snap => ({
|
|
||||||
month: snap.month,
|
|
||||||
total_assets: snap.total_assets,
|
|
||||||
non_compliant: snap.non_compliant,
|
|
||||||
compliance_pct: snap.compliance_pct,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Requirement 3.7: empty currentDevices → empty forecast, zeroed snapshot except total_assets
|
|
||||||
if (!currentDevices || currentDevices.length === 0) {
|
|
||||||
return {
|
|
||||||
historical,
|
|
||||||
forecast: [],
|
|
||||||
current_snapshot: {
|
|
||||||
total_assets: totalAssets,
|
|
||||||
non_compliant: 0,
|
|
||||||
compliant: 0,
|
|
||||||
compliance_pct: 0,
|
|
||||||
blockers: 0,
|
|
||||||
with_dates: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nonCompliant = currentDevices.length;
|
|
||||||
|
|
||||||
// Partition devices into blockers (no resolution_date) and with_dates
|
|
||||||
const blockers = currentDevices.filter(d => d.resolution_date == null).length;
|
|
||||||
const withDates = nonCompliant - blockers;
|
|
||||||
|
|
||||||
// Current snapshot
|
|
||||||
const compliant = totalAssets - nonCompliant;
|
|
||||||
const currentCompliancePct = calcCompliancePct(totalAssets, nonCompliant);
|
|
||||||
|
|
||||||
const current_snapshot = {
|
|
||||||
total_assets: totalAssets,
|
|
||||||
non_compliant: nonCompliant,
|
|
||||||
compliant: compliant,
|
|
||||||
compliance_pct: currentCompliancePct,
|
|
||||||
blockers: blockers,
|
|
||||||
with_dates: withDates,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If no devices have resolution dates, return empty forecast
|
|
||||||
if (withDates === 0) {
|
|
||||||
return { historical, forecast: [], current_snapshot };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine current month (YYYY-MM)
|
|
||||||
const now = new Date();
|
|
||||||
const currentYear = now.getFullYear();
|
|
||||||
const currentMonth = now.getMonth(); // 0-indexed
|
|
||||||
|
|
||||||
function formatMonth(year, month) {
|
|
||||||
return `${year}-${String(month + 1).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMonthStr = formatMonth(currentYear, currentMonth);
|
|
||||||
|
|
||||||
// Bucket devices with resolution dates by their resolution month
|
|
||||||
// Past-due dates (month before current month) are treated as remediated in current month
|
|
||||||
const buckets = {};
|
|
||||||
for (const device of currentDevices) {
|
|
||||||
if (device.resolution_date == null) continue;
|
|
||||||
// Handle both Date objects (from PostgreSQL) and YYYY-MM-DD strings
|
|
||||||
const dateStr = device.resolution_date instanceof Date
|
|
||||||
? device.resolution_date.toISOString().slice(0, 7)
|
|
||||||
: String(device.resolution_date).slice(0, 7);
|
|
||||||
const resMonth = dateStr; // YYYY-MM
|
|
||||||
if (resMonth < currentMonthStr) {
|
|
||||||
// Past-due: treat as remediated in current month
|
|
||||||
buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
|
|
||||||
} else {
|
|
||||||
buckets[resMonth] = (buckets[resMonth] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate forecast months starting from NEXT month, up to 12 months max
|
|
||||||
const forecast = [];
|
|
||||||
let remainingNonCompliant = nonCompliant;
|
|
||||||
|
|
||||||
// Account for devices remediated in the current month (past-due dates bucketed here)
|
|
||||||
if (buckets[currentMonthStr]) {
|
|
||||||
remainingNonCompliant -= buckets[currentMonthStr];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 1; i <= 12; i++) {
|
|
||||||
const forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
|
|
||||||
const forecastMonth = (currentMonth + i) % 12;
|
|
||||||
const monthStr = formatMonth(forecastYear, forecastMonth);
|
|
||||||
|
|
||||||
// Decrement by devices remediated in this month
|
|
||||||
if (buckets[monthStr]) {
|
|
||||||
remainingNonCompliant -= buckets[monthStr];
|
|
||||||
}
|
|
||||||
|
|
||||||
const pct = calcCompliancePct(totalAssets, remainingNonCompliant);
|
|
||||||
|
|
||||||
forecast.push({
|
|
||||||
month: monthStr,
|
|
||||||
total_assets: totalAssets,
|
|
||||||
non_compliant: remainingNonCompliant,
|
|
||||||
compliance_pct: pct,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Terminate early if all dated devices are remediated (only blockers remain)
|
|
||||||
if (remainingNonCompliant <= blockers) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { historical, forecast, current_snapshot };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
truncateText,
|
truncateText,
|
||||||
validateRemediationPlan,
|
validateRemediationPlan,
|
||||||
@@ -538,9 +217,4 @@ module.exports = {
|
|||||||
matchByHostname,
|
matchByHostname,
|
||||||
computeBulkDiff,
|
computeBulkDiff,
|
||||||
mapColumnHeaders,
|
mapColumnHeaders,
|
||||||
parseVerticalFilename,
|
|
||||||
computeVerticalBurndown,
|
|
||||||
deduplicateByHostname,
|
|
||||||
computeAggregatedBurndown,
|
|
||||||
computeMetricForecastBurndown,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
// Migration: Add archer_templates table for the Archer Template Library feature
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting archer_templates table migration...');
|
|
||||||
try {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS archer_templates (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
vendor VARCHAR(100) NOT NULL,
|
|
||||||
platform VARCHAR(100) NOT NULL,
|
|
||||||
model VARCHAR(100) NOT NULL,
|
|
||||||
environment_overview TEXT NOT NULL DEFAULT '',
|
|
||||||
segmentation TEXT NOT NULL DEFAULT '',
|
|
||||||
mitigating_controls TEXT NOT NULL DEFAULT '',
|
|
||||||
additional_info TEXT NOT NULL DEFAULT '',
|
|
||||||
charter_network_banner TEXT NOT NULL DEFAULT '',
|
|
||||||
data_classification TEXT NOT NULL DEFAULT '',
|
|
||||||
charter_network TEXT NOT NULL DEFAULT '',
|
|
||||||
additional_access_list TEXT NOT NULL DEFAULT '',
|
|
||||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
console.log('✓ archer_templates table created (or already exists)');
|
|
||||||
|
|
||||||
// Case-insensitive uniqueness on trimmed vendor/platform/model
|
|
||||||
await pool.query(`
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_archer_templates_unique_combo
|
|
||||||
ON archer_templates (LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)))
|
|
||||||
`);
|
|
||||||
console.log('✓ idx_archer_templates_unique_combo index created (or already exists)');
|
|
||||||
|
|
||||||
// Indexes for list query performance
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_vendor
|
|
||||||
ON archer_templates(vendor)
|
|
||||||
`);
|
|
||||||
console.log('✓ idx_archer_templates_vendor index created (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_platform
|
|
||||||
ON archer_templates(platform)
|
|
||||||
`);
|
|
||||||
console.log('✓ idx_archer_templates_platform index created (or already exists)');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { run };
|
|
||||||
|
|
||||||
// Self-execute when run directly
|
|
||||||
if (require.main === module) {
|
|
||||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// Migration: Add atlas_known column to atlas_action_plans_cache
|
|
||||||
//
|
|
||||||
// Distinguishes between hosts Atlas actively tracks (atlas_known = true)
|
|
||||||
// and hosts that were synced but Atlas has no data for (atlas_known = false).
|
|
||||||
// The badge only renders for atlas_known hosts, preventing noise from BUs
|
|
||||||
// not covered by Atlas.
|
|
||||||
//
|
|
||||||
// Safe to re-run — uses ADD COLUMN IF NOT EXISTS pattern.
|
|
||||||
//
|
|
||||||
// Usage: node backend/migrations/add_atlas_known_column.js
|
|
||||||
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
console.log('Starting atlas_known column migration...');
|
|
||||||
|
|
||||||
// Add column (IF NOT EXISTS not supported for ADD COLUMN in all PG versions, use DO block)
|
|
||||||
await pool.query(`
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'atlas_action_plans_cache' AND column_name = 'atlas_known'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE atlas_action_plans_cache ADD COLUMN atlas_known BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
console.log('✓ atlas_known column ready');
|
|
||||||
|
|
||||||
// Backfill: mark hosts that have at least one plan as atlas_known = true
|
|
||||||
const { rowCount } = await pool.query(`
|
|
||||||
UPDATE atlas_action_plans_cache SET atlas_known = true WHERE has_action_plan = true
|
|
||||||
`);
|
|
||||||
console.log(`✓ Backfilled ${rowCount} rows with atlas_known = true (hosts with plans)`);
|
|
||||||
|
|
||||||
// Also mark hosts belonging to managed BUs as atlas_known
|
|
||||||
// These are the BUs Atlas is supposed to cover
|
|
||||||
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
|
|
||||||
.split(',').map(b => b.trim()).filter(Boolean);
|
|
||||||
const patterns = managedBUs.map(b => `%${b}%`);
|
|
||||||
|
|
||||||
const { rowCount: buCount } = await pool.query(`
|
|
||||||
UPDATE atlas_action_plans_cache SET atlas_known = true
|
|
||||||
WHERE host_id IN (
|
|
||||||
SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
|
||||||
)
|
|
||||||
`, [patterns]);
|
|
||||||
console.log(`✓ Backfilled ${buCount} rows for managed BU hosts as atlas_known = true`);
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate()
|
|
||||||
.then(() => { pool.end(); })
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Migration failed:', err);
|
|
||||||
pool.end();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting compliance_item_history metric_id column migration...');
|
|
||||||
try {
|
|
||||||
// Idempotent: only add column if it doesn't already exist
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'compliance_item_history'
|
|
||||||
AND column_name = 'metric_id'
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE compliance_item_history
|
|
||||||
ADD COLUMN metric_id TEXT
|
|
||||||
`);
|
|
||||||
console.log('✓ metric_id column added to compliance_item_history');
|
|
||||||
} else {
|
|
||||||
console.log('✓ metric_id column already exists (skipped)');
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric
|
|
||||||
ON compliance_item_history(hostname, metric_id)
|
|
||||||
`);
|
|
||||||
console.log('✓ hostname/metric_id index created');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { run };
|
|
||||||
|
|
||||||
// Self-execute when run directly
|
|
||||||
if (require.main === module) {
|
|
||||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting compliance_item_history migration...');
|
|
||||||
try {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
hostname TEXT NOT NULL,
|
|
||||||
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
|
||||||
old_value TEXT,
|
|
||||||
new_value TEXT,
|
|
||||||
change_reason TEXT,
|
|
||||||
changed_by TEXT NOT NULL,
|
|
||||||
changed_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
console.log('✓ compliance_item_history table created (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
|
||||||
ON compliance_item_history(hostname, field_name)
|
|
||||||
`);
|
|
||||||
console.log('✓ hostname/field_name index created');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
|
||||||
ON compliance_item_history(changed_at)
|
|
||||||
`);
|
|
||||||
console.log('✓ changed_at index created');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { run };
|
|
||||||
|
|
||||||
// Self-execute when run directly
|
|
||||||
if (require.main === module) {
|
|
||||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ async function migrate() {
|
|||||||
await pool.query(`
|
await pool.query(`
|
||||||
ALTER TABLE ivanti_todo_queue
|
ALTER TABLE ivanti_todo_queue
|
||||||
ADD CONSTRAINT ivanti_todo_queue_workflow_type_check
|
ADD CONSTRAINT ivanti_todo_queue_workflow_type_check
|
||||||
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'))
|
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'))
|
||||||
`);
|
`);
|
||||||
console.log('✓ Added updated workflow_type constraint (includes DECOM)');
|
console.log('✓ Added updated workflow_type constraint (includes DECOM)');
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
// Migration: Add flexible Jira ticket creation support
|
|
||||||
// - Drops NOT NULL on cve_id and vendor columns
|
|
||||||
// - Adds source_context column with CHECK constraint
|
|
||||||
// - Backfills existing rows with source_context = 'manual'
|
|
||||||
// - Adds index on source_context
|
|
||||||
// Idempotent — safe to run multiple times.
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting flexible Jira ticket creation migration...');
|
|
||||||
|
|
||||||
// Verify jira_tickets table exists before proceeding
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT 1 FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
|
||||||
`);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ jira_tickets table exists');
|
|
||||||
|
|
||||||
// Drop NOT NULL constraint on cve_id (idempotent — no-op if already nullable)
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`);
|
|
||||||
console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)');
|
|
||||||
|
|
||||||
// Drop NOT NULL constraint on vendor (idempotent — no-op if already nullable)
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`);
|
|
||||||
console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)');
|
|
||||||
|
|
||||||
// Add jira_id, jira_status, last_synced_at, created_by columns
|
|
||||||
// (originally from SQLite migration add_jira_sync_columns.js — never ported to Postgres run-all)
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_id TEXT`);
|
|
||||||
console.log('✓ jira_id column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_status TEXT`);
|
|
||||||
console.log('✓ jira_status column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ`);
|
|
||||||
console.log('✓ last_synced_at column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS created_by INTEGER`);
|
|
||||||
console.log('✓ created_by column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)`);
|
|
||||||
console.log('✓ jira_id index created (or already exists)');
|
|
||||||
|
|
||||||
// Add source_context column with default value (IF NOT EXISTS makes it idempotent)
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE jira_tickets
|
|
||||||
ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual'
|
|
||||||
`);
|
|
||||||
console.log('✓ source_context column added (or already exists)');
|
|
||||||
|
|
||||||
// Add CHECK constraint for allowed source_context values (idempotent guard)
|
|
||||||
await pool.query(`
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE jira_tickets
|
|
||||||
ADD CONSTRAINT jira_tickets_source_context_check
|
|
||||||
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual'));
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
console.log('✓ source_context CHECK constraint added (or already exists)');
|
|
||||||
|
|
||||||
// Backfill existing rows where source_context is NULL
|
|
||||||
const result = await pool.query(`
|
|
||||||
UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL
|
|
||||||
`);
|
|
||||||
console.log(`✓ Backfilled ${result.rowCount} rows with source_context = 'manual'`);
|
|
||||||
|
|
||||||
// Add index on source_context for filtering performance
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context
|
|
||||||
ON jira_tickets(source_context)
|
|
||||||
`);
|
|
||||||
console.log('✓ source_context index created (or already exists)');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// Migration: Add requeued_at column to ivanti_fp_submissions table
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting FP submissions requeued_at migration...');
|
|
||||||
try {
|
|
||||||
await pool.query(`ALTER TABLE ivanti_fp_submissions ADD COLUMN IF NOT EXISTS requeued_at TIMESTAMPTZ DEFAULT NULL`);
|
|
||||||
console.log('✓ requeued_at column added (or already exists)');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error adding requeued_at column:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('Migration complete.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Migration: Add qualys_ipv6 and primary_ipv6 columns to ivanti_findings
|
|
||||||
// These capture IPv6 addresses for findings that have no IPv4.
|
|
||||||
// Qualys IPv6 comes from hostAdditionalDetails; Primary IPv6 from assetCustomAttributes.
|
|
||||||
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Adding IPv6 columns to ivanti_findings...');
|
|
||||||
try {
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE ivanti_findings
|
|
||||||
ADD COLUMN IF NOT EXISTS qualys_ipv6 TEXT DEFAULT NULL
|
|
||||||
`);
|
|
||||||
console.log('✓ qualys_ipv6 column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE ivanti_findings
|
|
||||||
ADD COLUMN IF NOT EXISTS primary_ipv6 TEXT DEFAULT NULL
|
|
||||||
`);
|
|
||||||
console.log('✓ primary_ipv6 column added (or already exists)');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// Migration: Add Jira sync columns to jira_tickets (Postgres version)
|
|
||||||
// Adds jira_id, jira_status, last_synced_at, and created_by columns.
|
|
||||||
// These were originally added via a SQLite migration that was never ported to Postgres.
|
|
||||||
// Idempotent — safe to run multiple times.
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Adding Jira sync columns to jira_tickets (Postgres)...');
|
|
||||||
|
|
||||||
// Verify table exists
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT 1 FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
|
||||||
`);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_id TEXT`);
|
|
||||||
console.log('✓ jira_id column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_status TEXT`);
|
|
||||||
console.log('✓ jira_status column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ`);
|
|
||||||
console.log('✓ last_synced_at column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS created_by INTEGER`);
|
|
||||||
console.log('✓ created_by column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)`);
|
|
||||||
console.log('✓ jira_id index created (or already exists)');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// Migration: Add multi-item Jira ticket junction table
|
|
||||||
// - Creates jira_ticket_queue_items table linking jira_tickets to ivanti_todo_queue items
|
|
||||||
// - Adds UNIQUE constraint on (jira_ticket_id, queue_item_id)
|
|
||||||
// - Adds indexes on queue_item_id and jira_ticket_id
|
|
||||||
// Idempotent — safe to run multiple times.
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting multi-item Jira ticket migration...');
|
|
||||||
|
|
||||||
// Verify prerequisite tables exist
|
|
||||||
const { rows: jiraTable } = await pool.query(`
|
|
||||||
SELECT 1 FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
|
||||||
`);
|
|
||||||
if (jiraTable.length === 0) {
|
|
||||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ jira_tickets table exists');
|
|
||||||
|
|
||||||
const { rows: queueTable } = await pool.query(`
|
|
||||||
SELECT 1 FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
|
||||||
`);
|
|
||||||
if (queueTable.length === 0) {
|
|
||||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ ivanti_todo_queue table exists');
|
|
||||||
|
|
||||||
// Create junction table
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
|
|
||||||
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE (jira_ticket_id, queue_item_id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
console.log('✓ jira_ticket_queue_items table created (or already exists)');
|
|
||||||
|
|
||||||
// Add index on queue_item_id for efficient lookup of tickets by queue item
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
|
|
||||||
ON jira_ticket_queue_items(queue_item_id)
|
|
||||||
`);
|
|
||||||
console.log('✓ queue_item_id index created (or already exists)');
|
|
||||||
|
|
||||||
// Add index on jira_ticket_id for efficient lookup of queue items by ticket
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
|
|
||||||
ON jira_ticket_queue_items(jira_ticket_id)
|
|
||||||
`);
|
|
||||||
console.log('✓ jira_ticket_id index created (or already exists)');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting notifications table migration...');
|
|
||||||
try {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS notifications (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER REFERENCES users(id),
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL DEFAULT 'issue_resolved',
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
issue_number INTEGER,
|
|
||||||
read BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
console.log('✓ notifications table created (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notifications_username
|
|
||||||
ON notifications(username)
|
|
||||||
`);
|
|
||||||
console.log('✓ username index created');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notifications_read
|
|
||||||
ON notifications(username, read)
|
|
||||||
`);
|
|
||||||
console.log('✓ username/read index created');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { run };
|
|
||||||
|
|
||||||
// Self-execute when run directly
|
|
||||||
if (require.main === module) {
|
|
||||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Migration: Create queue_remediation_notes table
|
|
||||||
// Stores remediation notes for Ivanti todo queue items (append-only).
|
|
||||||
// FK cascade ensures notes are deleted when the parent queue item is removed.
|
|
||||||
// Idempotent — safe to re-run multiple times.
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting queue_remediation_notes migration...');
|
|
||||||
|
|
||||||
// Verify prerequisite table exists
|
|
||||||
const { rows: queueTable } = await pool.query(`
|
|
||||||
SELECT 1 FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
|
||||||
`);
|
|
||||||
if (queueTable.length === 0) {
|
|
||||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ ivanti_todo_queue table exists');
|
|
||||||
|
|
||||||
// Create queue_remediation_notes table
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS queue_remediation_notes (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
username VARCHAR(100) NOT NULL,
|
|
||||||
note_text TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
CONSTRAINT chk_note_text_length CHECK (char_length(note_text) <= 5000)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
console.log('✓ queue_remediation_notes table created (or already exists)');
|
|
||||||
|
|
||||||
// Create index on queue_item_id for efficient lookup
|
|
||||||
await pool.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_remediation_notes_queue_item
|
|
||||||
ON queue_remediation_notes(queue_item_id)
|
|
||||||
`);
|
|
||||||
console.log('✓ queue_item_id index created (or already exists)');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// Migration: Add 'Remediate' to the ivanti_todo_queue workflow_type constraint
|
|
||||||
// Uses idempotent pattern: drop constraint IF EXISTS, then re-add with full set.
|
|
||||||
// Safe to re-run multiple times.
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting add_remediate_workflow_type migration...');
|
|
||||||
|
|
||||||
// Verify prerequisite table exists
|
|
||||||
const { rows: queueTable } = await pool.query(`
|
|
||||||
SELECT 1 FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
|
||||||
`);
|
|
||||||
if (queueTable.length === 0) {
|
|
||||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ ivanti_todo_queue table exists');
|
|
||||||
|
|
||||||
// Drop the existing workflow_type check constraint if it exists
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE ivanti_todo_queue
|
|
||||||
DROP CONSTRAINT IF EXISTS ivanti_todo_queue_workflow_type_check
|
|
||||||
`);
|
|
||||||
console.log('✓ Dropped existing workflow_type check constraint (if any)');
|
|
||||||
|
|
||||||
// Also drop alternative constraint name patterns
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE ivanti_todo_queue
|
|
||||||
DROP CONSTRAINT IF EXISTS chk_workflow_type
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Re-add the constraint with 'Remediate' included
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE ivanti_todo_queue
|
|
||||||
ADD CONSTRAINT ivanti_todo_queue_workflow_type_check
|
|
||||||
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'))
|
|
||||||
`);
|
|
||||||
console.log('✓ Added workflow_type check constraint with Remediate included');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Migration: Add ivanti_first_name and ivanti_last_name to users table
|
|
||||||
// Allows per-user Ivanti identity for workflow filtering.
|
|
||||||
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Adding Ivanti identity columns to users table...');
|
|
||||||
try {
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS ivanti_first_name VARCHAR(100) DEFAULT NULL
|
|
||||||
`);
|
|
||||||
console.log('✓ ivanti_first_name column added (or already exists)');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS ivanti_last_name VARCHAR(100) DEFAULT NULL
|
|
||||||
`);
|
|
||||||
console.log('✓ ivanti_last_name column added (or already exists)');
|
|
||||||
|
|
||||||
console.log('Migration complete.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// Migration: Add multi-vertical support for VCL compliance reporting
|
|
||||||
// Adds vertical column to compliance_items and compliance_uploads,
|
|
||||||
// creates vcl_multi_vertical_summary table for per-vertical metric data.
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('Starting VCL multi-vertical migration...');
|
|
||||||
try {
|
|
||||||
// Add vertical column to compliance_items
|
|
||||||
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
|
||||||
console.log('✓ vertical column added to compliance_items');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical)`);
|
|
||||||
console.log('✓ idx_compliance_items_vertical index created');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status)`);
|
|
||||||
console.log('✓ idx_compliance_items_vertical_status index created');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_metric ON compliance_items(vertical, metric_id, status)`);
|
|
||||||
console.log('✓ idx_compliance_items_vertical_metric index created');
|
|
||||||
|
|
||||||
// Add vertical column to compliance_uploads
|
|
||||||
await pool.query(`ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
|
||||||
console.log('✓ vertical column added to compliance_uploads');
|
|
||||||
|
|
||||||
// Create summary table for per-vertical metric data from Summary sheets
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
|
||||||
vertical TEXT NOT NULL,
|
|
||||||
metric_id TEXT NOT NULL,
|
|
||||||
metric_desc TEXT DEFAULT '',
|
|
||||||
category TEXT DEFAULT 'Other',
|
|
||||||
team TEXT DEFAULT '',
|
|
||||||
priority TEXT DEFAULT '',
|
|
||||||
non_compliant INTEGER DEFAULT 0,
|
|
||||||
compliant INTEGER DEFAULT 0,
|
|
||||||
total INTEGER DEFAULT 0,
|
|
||||||
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
|
||||||
target NUMERIC(5,2) DEFAULT 0,
|
|
||||||
status TEXT DEFAULT '',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
console.log('✓ vcl_multi_vertical_summary table created');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical ON vcl_multi_vertical_summary(vertical)`);
|
|
||||||
console.log('✓ idx_vcl_multi_summary_vertical index created');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload ON vcl_multi_vertical_summary(upload_id)`);
|
|
||||||
console.log('✓ idx_vcl_multi_summary_upload index created');
|
|
||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical_metric ON vcl_multi_vertical_summary(vertical, metric_id)`);
|
|
||||||
console.log('✓ idx_vcl_multi_summary_vertical_metric index created');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration error:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('Migration complete.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Migration: Drop CHECK constraint on jira_tickets.status
|
|
||||||
// Allows storing raw Jira status strings (e.g. "Approval/Handoff", "Prioritizing")
|
|
||||||
// instead of mapping to the limited set of Open/In Progress/Closed.
|
|
||||||
// Idempotent — safe to run multiple times.
|
|
||||||
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('[Migration] Dropping jira_tickets_status_check constraint...');
|
|
||||||
await pool.query(`ALTER TABLE jira_tickets DROP CONSTRAINT IF EXISTS jira_tickets_status_check`);
|
|
||||||
console.log('✓ jira_tickets status CHECK constraint dropped (or did not exist)');
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(err => {
|
|
||||||
console.error('Migration failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -16,23 +16,8 @@ const MIGRATIONS_DIR = __dirname;
|
|||||||
const POSTGRES_MIGRATIONS = [
|
const POSTGRES_MIGRATIONS = [
|
||||||
'add_decom_workflow_type.js',
|
'add_decom_workflow_type.js',
|
||||||
'add_fp_submissions_dismissed.js',
|
'add_fp_submissions_dismissed.js',
|
||||||
'add_fp_submissions_requeued_at.js',
|
|
||||||
'add_vcl_reporting_columns.js',
|
'add_vcl_reporting_columns.js',
|
||||||
'add_vcl_vertical_metadata.js',
|
'add_vcl_vertical_metadata.js',
|
||||||
'add_vcl_multi_vertical.js',
|
|
||||||
'add_compliance_item_history.js',
|
|
||||||
'add_jira_sync_columns_pg.js',
|
|
||||||
'add_flexible_jira_ticket_creation.js',
|
|
||||||
'add_multi_item_jira_ticket.js',
|
|
||||||
'drop_jira_status_check_constraint.js',
|
|
||||||
'add_compliance_history_metric_id.js',
|
|
||||||
'add_archer_templates_table.js',
|
|
||||||
'add_queue_remediation_notes_table.js',
|
|
||||||
'add_remediate_workflow_type.js',
|
|
||||||
'add_notifications_table.js',
|
|
||||||
'add_ivanti_findings_ipv6_columns.js',
|
|
||||||
'add_user_ivanti_identity.js',
|
|
||||||
'add_atlas_known_column.js',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
@@ -1,543 +0,0 @@
|
|||||||
// routes/archerTemplates.js
|
|
||||||
const express = require('express');
|
|
||||||
const pool = require('../db');
|
|
||||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
|
||||||
const logAudit = require('../helpers/auditLog');
|
|
||||||
|
|
||||||
// Section fields and their max length
|
|
||||||
const SECTION_FIELDS = [
|
|
||||||
'environment_overview',
|
|
||||||
'segmentation',
|
|
||||||
'mitigating_controls',
|
|
||||||
'additional_info',
|
|
||||||
'charter_network_banner',
|
|
||||||
'data_classification',
|
|
||||||
'charter_network',
|
|
||||||
'additional_access_list'
|
|
||||||
];
|
|
||||||
const SECTION_MAX_LENGTH = 10000;
|
|
||||||
|
|
||||||
function createArcherTemplatesRouter() {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/archer-templates/hierarchy/vendors
|
|
||||||
*
|
|
||||||
* Returns a sorted array of distinct vendor names across all templates.
|
|
||||||
*
|
|
||||||
* @returns {string[]} 200 - Array of vendor names sorted alphabetically
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.get('/hierarchy/vendors', requireAuth(), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
'SELECT DISTINCT vendor FROM archer_templates ORDER BY vendor ASC'
|
|
||||||
);
|
|
||||||
res.json(rows.map(r => r.vendor));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching template vendors:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/archer-templates/hierarchy/platforms
|
|
||||||
*
|
|
||||||
* Returns a sorted array of distinct platform names for a given vendor.
|
|
||||||
*
|
|
||||||
* @query {string} vendor - (required) The vendor to filter platforms by
|
|
||||||
* @returns {string[]} 200 - Array of platform names sorted alphabetically
|
|
||||||
* @returns {object} 400 - { error: 'vendor query parameter is required' }
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.get('/hierarchy/platforms', requireAuth(), async (req, res) => {
|
|
||||||
const { vendor } = req.query;
|
|
||||||
if (!vendor) {
|
|
||||||
return res.status(400).json({ error: 'vendor query parameter is required' });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
'SELECT DISTINCT platform FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) ORDER BY platform ASC',
|
|
||||||
[vendor]
|
|
||||||
);
|
|
||||||
res.json(rows.map(r => r.platform));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching template platforms:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/archer-templates/hierarchy/models
|
|
||||||
*
|
|
||||||
* Returns a sorted array of distinct model names for a given vendor and platform.
|
|
||||||
*
|
|
||||||
* @query {string} vendor - (required) The vendor to filter by
|
|
||||||
* @query {string} platform - (required) The platform to filter by
|
|
||||||
* @returns {string[]} 200 - Array of model names sorted alphabetically
|
|
||||||
* @returns {object} 400 - { error: 'Missing required query parameters: ...' }
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.get('/hierarchy/models', requireAuth(), async (req, res) => {
|
|
||||||
const { vendor, platform } = req.query;
|
|
||||||
const missing = [];
|
|
||||||
if (!vendor) missing.push('vendor');
|
|
||||||
if (!platform) missing.push('platform');
|
|
||||||
if (missing.length > 0) {
|
|
||||||
return res.status(400).json({ error: `Missing required query parameters: ${missing.join(', ')}` });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
'SELECT DISTINCT model FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) ORDER BY model ASC',
|
|
||||||
[vendor, platform]
|
|
||||||
);
|
|
||||||
res.json(rows.map(r => r.model));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching template models:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Core CRUD endpoints ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/archer-templates
|
|
||||||
*
|
|
||||||
* Creates a new Archer template with vendor/platform/model hierarchy and section content.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @body {string} vendor - (required) Vendor name, 1-100 chars after trim
|
|
||||||
* @body {string} platform - (required) Platform name, 1-100 chars after trim
|
|
||||||
* @body {string} model - (required) Model name, 1-100 chars after trim
|
|
||||||
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [segmentation] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [additional_info] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [data_classification] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [charter_network] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
|
||||||
* @returns {object} 201 - The created template record (all columns)
|
|
||||||
* @returns {object} 400 - { error: 'validation message' }
|
|
||||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
||||||
const { vendor, platform, model } = req.body;
|
|
||||||
|
|
||||||
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
|
||||||
const errors = [];
|
|
||||||
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
|
||||||
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
|
||||||
errors.push(`${field} is required`);
|
|
||||||
} else if (value.trim().length > 100) {
|
|
||||||
errors.push(`${field} must be 100 characters or fewer`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (errors.length > 0) {
|
|
||||||
return res.status(400).json({ error: errors.join('; ') });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate section fields — max 10,000 chars each, default to empty string
|
|
||||||
const sectionValues = {};
|
|
||||||
for (const field of SECTION_FIELDS) {
|
|
||||||
const val = req.body[field];
|
|
||||||
if (val !== undefined && val !== null && typeof val === 'string') {
|
|
||||||
if (val.length > SECTION_MAX_LENGTH) {
|
|
||||||
return res.status(400).json({ error: `${field} must be 10,000 characters or fewer` });
|
|
||||||
}
|
|
||||||
sectionValues[field] = val;
|
|
||||||
} else {
|
|
||||||
sectionValues[field] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
||||||
RETURNING *`,
|
|
||||||
[
|
|
||||||
vendor.trim(),
|
|
||||||
platform.trim(),
|
|
||||||
model.trim(),
|
|
||||||
sectionValues.environment_overview,
|
|
||||||
sectionValues.segmentation,
|
|
||||||
sectionValues.mitigating_controls,
|
|
||||||
sectionValues.additional_info,
|
|
||||||
sectionValues.charter_network_banner,
|
|
||||||
sectionValues.data_classification,
|
|
||||||
sectionValues.charter_network,
|
|
||||||
sectionValues.additional_access_list,
|
|
||||||
req.user.id
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fire-and-forget audit log
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'template_created',
|
|
||||||
entityType: 'archer_template',
|
|
||||||
entityId: String(rows[0].id),
|
|
||||||
details: { vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === '23505') {
|
|
||||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
|
||||||
}
|
|
||||||
console.error('Error creating template:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/archer-templates
|
|
||||||
*
|
|
||||||
* Lists all templates with optional search and exact-match filters.
|
|
||||||
* Results are sorted by vendor, platform, model (ascending).
|
|
||||||
*
|
|
||||||
* @query {string} [search] - Substring search across vendor, platform, and model (ILIKE)
|
|
||||||
* @query {string} [vendor] - Exact-match filter on vendor (case-insensitive)
|
|
||||||
* @query {string} [platform] - Exact-match filter on platform (case-insensitive)
|
|
||||||
* @query {string} [model] - Exact-match filter on model (case-insensitive)
|
|
||||||
* @returns {object[]} 200 - Array of template records sorted by vendor/platform/model
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.get('/', requireAuth(), async (req, res) => {
|
|
||||||
const { search, vendor, platform, model } = req.query;
|
|
||||||
|
|
||||||
let query = 'SELECT * FROM archer_templates WHERE 1=1';
|
|
||||||
const params = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
// Search — ILIKE substring match across vendor, platform, model
|
|
||||||
const trimmedSearch = search ? search.trim() : '';
|
|
||||||
if (trimmedSearch.length > 0) {
|
|
||||||
query += ` AND (vendor ILIKE $${paramIndex} OR platform ILIKE $${paramIndex} OR model ILIKE $${paramIndex})`;
|
|
||||||
params.push(`%${trimmedSearch}%`);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact-match filters (case-insensitive via LOWER/TRIM)
|
|
||||||
if (vendor) {
|
|
||||||
query += ` AND LOWER(TRIM(vendor)) = LOWER(TRIM($${paramIndex}))`;
|
|
||||||
params.push(vendor);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
if (platform) {
|
|
||||||
query += ` AND LOWER(TRIM(platform)) = LOWER(TRIM($${paramIndex}))`;
|
|
||||||
params.push(platform);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
if (model) {
|
|
||||||
query += ` AND LOWER(TRIM(model)) = LOWER(TRIM($${paramIndex}))`;
|
|
||||||
params.push(model);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY vendor ASC, platform ASC, model ASC';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(query, params);
|
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching templates:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/archer-templates/:id/clone
|
|
||||||
*
|
|
||||||
* Clones an existing template's section content into a new template with different
|
|
||||||
* vendor/platform/model hierarchy values. Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {number} id - The ID of the source template to clone from
|
|
||||||
* @body {string} vendor - (required) New vendor name, 1-100 chars after trim
|
|
||||||
* @body {string} platform - (required) New platform name, 1-100 chars after trim
|
|
||||||
* @body {string} model - (required) New model name, 1-100 chars after trim
|
|
||||||
* @returns {object} 201 - The newly created cloned template record
|
|
||||||
* @returns {object} 400 - { error: 'validation message' }
|
|
||||||
* @returns {object} 404 - { error: 'Template not found' }
|
|
||||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.post('/:id/clone', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { vendor, platform, model } = req.body;
|
|
||||||
|
|
||||||
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
|
||||||
const errors = [];
|
|
||||||
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
|
||||||
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
|
||||||
errors.push(`${field} is required`);
|
|
||||||
} else if (value.trim().length > 100) {
|
|
||||||
errors.push(`${field} must be 100 characters or fewer`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (errors.length > 0) {
|
|
||||||
return res.status(400).json({ error: errors.join('; ') });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify source template exists
|
|
||||||
const { rows: sourceRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
|
||||||
if (sourceRows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Template not found' });
|
|
||||||
}
|
|
||||||
const source = sourceRows[0];
|
|
||||||
|
|
||||||
// INSERT copying all 8 section fields from source with new hierarchy values
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
||||||
RETURNING *`,
|
|
||||||
[
|
|
||||||
vendor.trim(),
|
|
||||||
platform.trim(),
|
|
||||||
model.trim(),
|
|
||||||
source.environment_overview,
|
|
||||||
source.segmentation,
|
|
||||||
source.mitigating_controls,
|
|
||||||
source.additional_info,
|
|
||||||
source.charter_network_banner,
|
|
||||||
source.data_classification,
|
|
||||||
source.charter_network,
|
|
||||||
source.additional_access_list,
|
|
||||||
req.user.id
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fire-and-forget audit log
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'template_cloned',
|
|
||||||
entityType: 'archer_template',
|
|
||||||
entityId: String(rows[0].id),
|
|
||||||
details: { sourceId: Number(id), newId: rows[0].id, vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === '23505') {
|
|
||||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
|
||||||
}
|
|
||||||
console.error('Error cloning template:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/archer-templates/:id
|
|
||||||
*
|
|
||||||
* Fetches a single template by its ID.
|
|
||||||
*
|
|
||||||
* @param {number} id - The template ID
|
|
||||||
* @returns {object} 200 - The template record
|
|
||||||
* @returns {object} 404 - { error: 'Template not found' }
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.get('/:id', requireAuth(), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Template not found' });
|
|
||||||
}
|
|
||||||
res.json(rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching template:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/archer-templates/:id
|
|
||||||
*
|
|
||||||
* Updates an existing template. Supports partial updates — only provided fields are changed.
|
|
||||||
* Always updates `updated_at` to NOW(). Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {number} id - The template ID to update
|
|
||||||
* @body {string} [vendor] - New vendor name, 1-100 chars after trim
|
|
||||||
* @body {string} [platform] - New platform name, 1-100 chars after trim
|
|
||||||
* @body {string} [model] - New model name, 1-100 chars after trim
|
|
||||||
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [segmentation] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [additional_info] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [data_classification] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [charter_network] - Section content, max 10,000 chars
|
|
||||||
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
|
||||||
* @returns {object} 200 - The updated template record
|
|
||||||
* @returns {object} 400 - { error: 'validation message' }
|
|
||||||
* @returns {object} 404 - { error: 'Template not found' }
|
|
||||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify template exists
|
|
||||||
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
|
||||||
if (existingRows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Template not found' });
|
|
||||||
}
|
|
||||||
const existing = existingRows[0];
|
|
||||||
|
|
||||||
// Validate provided hierarchy fields
|
|
||||||
const errors = [];
|
|
||||||
const updatedFields = {};
|
|
||||||
const changedFieldNames = [];
|
|
||||||
|
|
||||||
for (const field of ['vendor', 'platform', 'model']) {
|
|
||||||
const value = req.body[field];
|
|
||||||
if (value !== undefined) {
|
|
||||||
if (value === null || typeof value !== 'string' || value.trim().length === 0) {
|
|
||||||
errors.push(`${field} is required`);
|
|
||||||
} else if (value.trim().length > 100) {
|
|
||||||
errors.push(`${field} must be 100 characters or fewer`);
|
|
||||||
} else {
|
|
||||||
updatedFields[field] = value.trim();
|
|
||||||
if (value.trim() !== existing[field]) {
|
|
||||||
changedFieldNames.push(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate provided section fields
|
|
||||||
for (const field of SECTION_FIELDS) {
|
|
||||||
const val = req.body[field];
|
|
||||||
if (val !== undefined) {
|
|
||||||
if (val !== null && typeof val === 'string') {
|
|
||||||
if (val.length > SECTION_MAX_LENGTH) {
|
|
||||||
errors.push(`${field} must be 10,000 characters or fewer`);
|
|
||||||
} else {
|
|
||||||
updatedFields[field] = val;
|
|
||||||
if (val !== existing[field]) {
|
|
||||||
changedFieldNames.push(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updatedFields[field] = '';
|
|
||||||
if ('' !== existing[field]) {
|
|
||||||
changedFieldNames.push(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
return res.status(400).json({ error: errors.join('; ') });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check uniqueness if vendor/platform/model changed (excluding self)
|
|
||||||
const newVendor = updatedFields.vendor || existing.vendor;
|
|
||||||
const newPlatform = updatedFields.platform || existing.platform;
|
|
||||||
const newModel = updatedFields.model || existing.model;
|
|
||||||
|
|
||||||
if (updatedFields.vendor !== undefined || updatedFields.platform !== undefined || updatedFields.model !== undefined) {
|
|
||||||
const { rows: conflictRows } = await pool.query(
|
|
||||||
`SELECT id FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) AND LOWER(TRIM(model)) = LOWER(TRIM($3)) AND id != $4`,
|
|
||||||
[newVendor, newPlatform, newModel, id]
|
|
||||||
);
|
|
||||||
if (conflictRows.length > 0) {
|
|
||||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build dynamic UPDATE SET clause for only provided fields
|
|
||||||
const setClauses = [];
|
|
||||||
const params = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
for (const [field, value] of Object.entries(updatedFields)) {
|
|
||||||
setClauses.push(`${field} = $${paramIndex}`);
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always set updated_at = NOW()
|
|
||||||
setClauses.push(`updated_at = NOW()`);
|
|
||||||
|
|
||||||
// Execute update
|
|
||||||
params.push(id);
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE archer_templates SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fire-and-forget audit log
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'template_updated',
|
|
||||||
entityType: 'archer_template',
|
|
||||||
entityId: String(id),
|
|
||||||
details: { changedFields: changedFieldNames },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error updating template:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/archer-templates/:id
|
|
||||||
*
|
|
||||||
* Permanently deletes a template. Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {number} id - The template ID to delete
|
|
||||||
* @returns {object} 200 - { message: 'Template deleted successfully' }
|
|
||||||
* @returns {object} 404 - { error: 'Template not found' }
|
|
||||||
* @returns {object} 500 - { error: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify template exists
|
|
||||||
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
|
||||||
if (existingRows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Template not found' });
|
|
||||||
}
|
|
||||||
const existing = existingRows[0];
|
|
||||||
|
|
||||||
// Delete the template
|
|
||||||
await pool.query('DELETE FROM archer_templates WHERE id = $1', [id]);
|
|
||||||
|
|
||||||
// Fire-and-forget audit log
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'template_deleted',
|
|
||||||
entityType: 'archer_template',
|
|
||||||
entityId: String(id),
|
|
||||||
details: { vendor: existing.vendor, platform: existing.platform, model: existing.model },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Template deleted successfully' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error deleting template:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createArcherTemplatesRouter;
|
|
||||||
@@ -74,10 +74,7 @@ function createAtlasRouter() {
|
|||||||
* GET /metrics
|
* GET /metrics
|
||||||
*
|
*
|
||||||
* Returns aggregated Atlas action plan metrics from the local cache.
|
* Returns aggregated Atlas action plan metrics from the local cache.
|
||||||
* Accepts optional `teams` query parameter to scope metrics to hosts
|
|
||||||
* belonging to specific BUs (via JOIN on ivanti_findings).
|
|
||||||
*
|
*
|
||||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
|
||||||
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
|
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
|
||||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on database failure
|
* @returns {Object} 500 - { error } on database failure
|
||||||
@@ -88,37 +85,9 @@ function createAtlasRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teamsParam = req.query.teams;
|
const { rows } = await pool.query(
|
||||||
let rows;
|
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||||
|
|
||||||
if (teamsParam) {
|
|
||||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
if (teams.length > 0) {
|
|
||||||
const patterns = teams.map(t => `%${t}%`);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT a.has_action_plan, a.plans_json
|
|
||||||
FROM atlas_action_plans_cache a
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
|
||||||
) f ON a.host_id = f.host_id
|
|
||||||
WHERE a.atlas_known = true`,
|
|
||||||
[patterns]
|
|
||||||
);
|
);
|
||||||
rows = result.rows;
|
|
||||||
} else {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
|
||||||
);
|
|
||||||
rows = result.rows;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
|
||||||
);
|
|
||||||
rows = result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = aggregateAtlasMetrics(rows);
|
const metrics = aggregateAtlasMetrics(rows);
|
||||||
res.json(metrics);
|
res.json(metrics);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +99,9 @@ function createAtlasRouter() {
|
|||||||
/**
|
/**
|
||||||
* GET /status
|
* GET /status
|
||||||
*
|
*
|
||||||
* Returns atlas_action_plans_cache contents for status display.
|
* Returns the full atlas_action_plans_cache table contents for status display.
|
||||||
* Accepts optional `teams` query parameter to scope results to hosts
|
|
||||||
* belonging to specific BUs (via JOIN on ivanti_findings).
|
|
||||||
*
|
*
|
||||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, synced_at }
|
||||||
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at }
|
|
||||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on database failure
|
* @returns {Object} 500 - { error } on database failure
|
||||||
*/
|
*/
|
||||||
@@ -145,36 +111,9 @@ function createAtlasRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teamsParam = req.query.teams;
|
const { rows } = await pool.query(
|
||||||
let rows;
|
`SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
|
||||||
|
|
||||||
if (teamsParam) {
|
|
||||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
if (teams.length > 0) {
|
|
||||||
const patterns = teams.map(t => `%${t}%`);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at
|
|
||||||
FROM atlas_action_plans_cache a
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
|
||||||
) f ON a.host_id = f.host_id`,
|
|
||||||
[patterns]
|
|
||||||
);
|
);
|
||||||
rows = result.rows;
|
|
||||||
} else {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
|
||||||
);
|
|
||||||
rows = result.rows;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
|
||||||
);
|
|
||||||
rows = result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Atlas] Error fetching status:', err.message);
|
console.error('[Atlas] Error fetching status:', err.message);
|
||||||
@@ -187,12 +126,8 @@ function createAtlasRouter() {
|
|||||||
*
|
*
|
||||||
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
||||||
* Fetches plans per host in batches of 5 and upserts into the local cache.
|
* Fetches plans per host in batches of 5 and upserts into the local cache.
|
||||||
* Scopes to the provided teams or falls back to IVANTI_MANAGED_BUS.
|
|
||||||
* Requires Admin or Standard_User group.
|
* Requires Admin or Standard_User group.
|
||||||
*
|
*
|
||||||
* @query {string} [teams] - Comma-separated team names to scope sync (e.g. 'STEAM,ACCESS-ENG')
|
|
||||||
* @param {Object} [req.body]
|
|
||||||
* @param {string} [req.body.teams] - Comma-separated team names (alternative to query param)
|
|
||||||
* @returns {Object} 200 - { synced, withPlans, failed }
|
* @returns {Object} 200 - { synced, withPlans, failed }
|
||||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on unexpected failure
|
* @returns {Object} 500 - { error } on unexpected failure
|
||||||
@@ -203,67 +138,16 @@ function createAtlasRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Scope sync to the user's active teams if provided, otherwise sync only
|
// Read Ivanti findings and extract unique non-null hostIds
|
||||||
// findings from managed BUs (IVANTI_MANAGED_BUS) to avoid polluting cache
|
const { rows: findingsRows } = await pool.query(
|
||||||
// with "no plan" entries for BUs not covered by Atlas.
|
`SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0`
|
||||||
const teamsParam = req.query.teams || req.body.teams || '';
|
|
||||||
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
|
|
||||||
.split(',').map(b => b.trim()).filter(Boolean);
|
|
||||||
|
|
||||||
let findingsRows;
|
|
||||||
if (teamsParam) {
|
|
||||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
if (teams.length > 0) {
|
|
||||||
const patterns = teams.map(t => `%${t}%`);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE host_id IS NOT NULL AND host_id > 0
|
|
||||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
||||||
[patterns]
|
|
||||||
);
|
);
|
||||||
findingsRows = result.rows;
|
|
||||||
} else {
|
|
||||||
// No valid teams — fall back to managed BUs
|
|
||||||
const patterns = managedBUs.map(b => `%${b}%`);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE host_id IS NOT NULL AND host_id > 0
|
|
||||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
||||||
[patterns]
|
|
||||||
);
|
|
||||||
findingsRows = result.rows;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No teams specified — default to managed BUs only
|
|
||||||
const patterns = managedBUs.map(b => `%${b}%`);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE host_id IS NOT NULL AND host_id > 0
|
|
||||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
||||||
[patterns]
|
|
||||||
);
|
|
||||||
findingsRows = result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostIds = findingsRows.map(r => r.host_id);
|
const hostIds = findingsRows.map(r => r.host_id);
|
||||||
|
|
||||||
if (hostIds.length === 0) {
|
if (hostIds.length === 0) {
|
||||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a set of host IDs belonging to managed BUs — these always show the badge
|
|
||||||
const managedPatterns = managedBUs.map(b => `%${b}%`);
|
|
||||||
let managedHostIds = new Set();
|
|
||||||
try {
|
|
||||||
const { rows: managedRows } = await pool.query(
|
|
||||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE host_id IS NOT NULL AND host_id > 0
|
|
||||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
||||||
[managedPatterns]
|
|
||||||
);
|
|
||||||
managedHostIds = new Set(managedRows.map(r => r.host_id));
|
|
||||||
} catch (_) { /* non-fatal — fall back to plans-only logic */ }
|
|
||||||
|
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
let withPlans = 0;
|
let withPlans = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -286,40 +170,27 @@ function createAtlasRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { hostId, result } = settled.value;
|
const { hostId, result } = settled.value;
|
||||||
const isManagedHost = managedHostIds.has(hostId);
|
|
||||||
|
|
||||||
if (result.status >= 200 && result.status < 300) {
|
if (result.status >= 200 && result.status < 300) {
|
||||||
let allPlans = [];
|
let allPlans = [];
|
||||||
let activePlans = [];
|
let activePlans = [];
|
||||||
let atlasRecognizesHost = false;
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(result.body);
|
const parsed = JSON.parse(result.body);
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
// Check for "not found" error responses that come back as 200
|
|
||||||
if (parsed.error || parsed.message?.includes('not found')) {
|
|
||||||
atlasRecognizesHost = false;
|
|
||||||
} else {
|
|
||||||
atlasRecognizesHost = true;
|
|
||||||
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||||
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||||
allPlans = [...activePlans, ...inactive];
|
allPlans = [...activePlans, ...inactive];
|
||||||
}
|
|
||||||
} else if (Array.isArray(parsed)) {
|
} else if (Array.isArray(parsed)) {
|
||||||
atlasRecognizesHost = true;
|
|
||||||
allPlans = parsed;
|
allPlans = parsed;
|
||||||
activePlans = parsed;
|
activePlans = parsed;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
allPlans = [];
|
allPlans = [];
|
||||||
activePlans = [];
|
activePlans = [];
|
||||||
atlasRecognizesHost = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const planCount = activePlans.length;
|
const planCount = activePlans.length;
|
||||||
const hasActionPlan = planCount > 0;
|
const hasActionPlan = planCount > 0;
|
||||||
// Atlas knows this host if it returned a valid structured response
|
|
||||||
// (not "not found" or error). This determines whether the badge renders.
|
|
||||||
const atlasKnown = atlasRecognizesHost;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasActionPlan) {
|
if (!hasActionPlan) {
|
||||||
@@ -345,15 +216,14 @@ function createAtlasRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
ON CONFLICT(host_id) DO UPDATE SET
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
has_action_plan = EXCLUDED.has_action_plan,
|
has_action_plan = EXCLUDED.has_action_plan,
|
||||||
plan_count = EXCLUDED.plan_count,
|
plan_count = EXCLUDED.plan_count,
|
||||||
plans_json = EXCLUDED.plans_json,
|
plans_json = EXCLUDED.plans_json,
|
||||||
atlas_known = EXCLUDED.atlas_known,
|
|
||||||
synced_at = EXCLUDED.synced_at`,
|
synced_at = EXCLUDED.synced_at`,
|
||||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||||
);
|
);
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
||||||
@@ -472,45 +342,6 @@ function createAtlasRouter() {
|
|||||||
if (result.status >= 200 && result.status < 300) {
|
if (result.status >= 200 && result.status < 300) {
|
||||||
let body;
|
let body;
|
||||||
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||||
|
|
||||||
// Update local cache with the created plan
|
|
||||||
try {
|
|
||||||
const { rows: existingRows } = await pool.query(
|
|
||||||
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
|
|
||||||
[hostId]
|
|
||||||
);
|
|
||||||
const existing = existingRows[0];
|
|
||||||
let existingPlans = [];
|
|
||||||
if (existing && existing.plans_json) {
|
|
||||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include the plan ID from Atlas response if available
|
|
||||||
const newPlan = {
|
|
||||||
plan_type,
|
|
||||||
commit_date,
|
|
||||||
source: 'create',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
...(body?.action_plan_id ? { action_plan_id: body.action_plan_id } : {}),
|
|
||||||
...(body?.id ? { action_plan_id: body.id } : {}),
|
|
||||||
};
|
|
||||||
const updatedPlans = [...existingPlans, newPlan];
|
|
||||||
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
|
||||||
VALUES ($1, true, $2, $3, true, NOW())
|
|
||||||
ON CONFLICT(host_id) DO UPDATE SET
|
|
||||||
has_action_plan = true,
|
|
||||||
plan_count = EXCLUDED.plan_count,
|
|
||||||
plans_json = EXCLUDED.plans_json,
|
|
||||||
atlas_known = true,
|
|
||||||
synced_at = EXCLUDED.synced_at`,
|
|
||||||
[hostId, updatedPlans.length, JSON.stringify(updatedPlans)]
|
|
||||||
);
|
|
||||||
} catch (cacheErr) {
|
|
||||||
console.error('[Atlas] Cache update failed after plan create for host', hostId, ':', cacheErr.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(result.status).json(body);
|
res.status(result.status).json(body);
|
||||||
} else {
|
} else {
|
||||||
let errorBody;
|
let errorBody;
|
||||||
@@ -642,38 +473,17 @@ function createAtlasRouter() {
|
|||||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract plan ID from bulk response if available (keyed by host_id)
|
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
|
||||||
let planId = null;
|
|
||||||
if (body && typeof body === 'object') {
|
|
||||||
// Response may be { results: [{host_id, action_plan_id}] } or { [hostId]: {id} }
|
|
||||||
if (Array.isArray(body.results)) {
|
|
||||||
const match = body.results.find(r => r.host_id === hid || r.host_id === String(hid));
|
|
||||||
if (match) planId = match.action_plan_id || match.id;
|
|
||||||
} else if (body[hid]) {
|
|
||||||
planId = body[hid].action_plan_id || body[hid].id;
|
|
||||||
} else if (body[String(hid)]) {
|
|
||||||
planId = body[String(hid)].action_plan_id || body[String(hid)].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stubPlan = {
|
|
||||||
plan_type,
|
|
||||||
commit_date,
|
|
||||||
source: 'bulk-create',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
...(planId ? { action_plan_id: planId } : {}),
|
|
||||||
};
|
|
||||||
const updatedPlans = [...existingPlans, stubPlan];
|
const updatedPlans = [...existingPlans, stubPlan];
|
||||||
const newCount = updatedPlans.length;
|
const newCount = updatedPlans.length;
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||||
VALUES ($1, true, $2, $3, true, NOW())
|
VALUES ($1, true, $2, $3, NOW())
|
||||||
ON CONFLICT(host_id) DO UPDATE SET
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
has_action_plan = true,
|
has_action_plan = true,
|
||||||
plan_count = EXCLUDED.plan_count,
|
plan_count = EXCLUDED.plan_count,
|
||||||
plans_json = EXCLUDED.plans_json,
|
plans_json = EXCLUDED.plans_json,
|
||||||
atlas_known = true,
|
|
||||||
synced_at = EXCLUDED.synced_at`,
|
synced_at = EXCLUDED.synced_at`,
|
||||||
[hid, newCount, JSON.stringify(updatedPlans)]
|
[hid, newCount, JSON.stringify(updatedPlans)]
|
||||||
);
|
);
|
||||||
@@ -751,18 +561,16 @@ function createAtlasRouter() {
|
|||||||
|
|
||||||
const planCount = activePlans.length;
|
const planCount = activePlans.length;
|
||||||
const hasActionPlan = planCount > 0;
|
const hasActionPlan = planCount > 0;
|
||||||
const atlasKnown = allPlans.length > 0;
|
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
ON CONFLICT(host_id) DO UPDATE SET
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
has_action_plan = EXCLUDED.has_action_plan,
|
has_action_plan = EXCLUDED.has_action_plan,
|
||||||
plan_count = EXCLUDED.plan_count,
|
plan_count = EXCLUDED.plan_count,
|
||||||
plans_json = EXCLUDED.plans_json,
|
plans_json = EXCLUDED.plans_json,
|
||||||
atlas_known = EXCLUDED.atlas_known,
|
|
||||||
synced_at = EXCLUDED.synced_at`,
|
synced_at = EXCLUDED.synced_at`,
|
||||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -78,14 +78,8 @@ async function computeDiff(incomingItems) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Write a parsed upload to the DB (within a transaction)
|
// Write a parsed upload to the DB (within a transaction)
|
||||||
//
|
|
||||||
// `vertical` defaults to null for legacy AEO uploads (the /commit route).
|
|
||||||
// When threaded through from a multi-vertical caller it filters the
|
|
||||||
// compliance_snapshots aggregation so the snapshot reflects only the
|
|
||||||
// snapshotted vertical's items — this prevents cross-vertical
|
|
||||||
// contamination on dates where multiple verticals share a report_date.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function persistUpload({ items, summary, reportDate, filename, userId, vertical = null }) {
|
async function persistUpload({ items, summary, reportDate, filename, userId }) {
|
||||||
const { rows: activeRows } = await pool.query(
|
const { rows: activeRows } = await pool.query(
|
||||||
`SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active'`
|
`SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active'`
|
||||||
);
|
);
|
||||||
@@ -159,44 +153,27 @@ async function persistUpload({ items, summary, reportDate, filename, userId, ver
|
|||||||
try {
|
try {
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
|
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
|
||||||
|
|
||||||
// Compute compliance percentages for the snapshotted vertical only.
|
// Compute per-vertical compliance percentages from current state
|
||||||
// CTE classifies each hostname by its worst-case status (active wins
|
|
||||||
// over resolved via MIN) so a hostname with both active and resolved
|
|
||||||
// rows across verticals is counted in exactly one column.
|
|
||||||
const { rows: verticalStats } = await pool.query(
|
const { rows: verticalStats } = await pool.query(
|
||||||
`WITH hostname_status AS (
|
`SELECT team AS vertical,
|
||||||
SELECT team,
|
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||||
hostname,
|
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END)::int AS compliant,
|
||||||
MIN(status) AS status
|
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||||
FROM compliance_items
|
FROM compliance_items
|
||||||
WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1
|
WHERE team IS NOT NULL
|
||||||
GROUP BY team, hostname
|
GROUP BY team`
|
||||||
)
|
|
||||||
SELECT team AS vertical,
|
|
||||||
COUNT(*)::int AS total_devices,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'resolved')::int AS compliant,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'active')::int AS non_compliant
|
|
||||||
FROM hostname_status
|
|
||||||
GROUP BY team`,
|
|
||||||
[vertical]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const vs of verticalStats) {
|
for (const vs of verticalStats) {
|
||||||
const total = vs.total_devices;
|
const total = vs.total_devices;
|
||||||
const compPct = total > 0 ? Math.round((vs.compliant / total) * 100 * 100) / 100 : 0;
|
const compPct = total > 0 ? Math.round((vs.compliant / total) * 100 * 100) / 100 : 0;
|
||||||
// For non-null verticals (multi-vertical uploads), the snapshot
|
|
||||||
// row is keyed by the actual vertical so /vcl/stats consumers
|
|
||||||
// see the correct breakdown. For legacy AEO uploads (vertical
|
|
||||||
// is null), preserve the historical team-as-vertical key so
|
|
||||||
// existing single-vertical-month consumers are unchanged.
|
|
||||||
const snapshotVertical = vs.vertical || vs.team;
|
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO compliance_snapshots (snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct)
|
`INSERT INTO compliance_snapshots (snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
ON CONFLICT (snapshot_month, vertical)
|
ON CONFLICT (snapshot_month, vertical)
|
||||||
DO UPDATE SET total_devices = $3, compliant = $4, non_compliant = $5, compliance_pct = $6`,
|
DO UPDATE SET total_devices = $3, compliant = $4, non_compliant = $5, compliance_pct = $6`,
|
||||||
[currentMonth, snapshotVertical, total, vs.compliant, vs.non_compliant, compPct]
|
[currentMonth, vs.vertical, total, vs.compliant, vs.non_compliant, compPct]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (snapshotErr) {
|
} catch (snapshotErr) {
|
||||||
@@ -223,28 +200,18 @@ function groupByHostname(rows, noteHostnames) {
|
|||||||
deviceMap[row.hostname] = {
|
deviceMap[row.hostname] = {
|
||||||
hostname: row.hostname, ip_address: row.ip_address || '', device_type: row.device_type || '',
|
hostname: row.hostname, ip_address: row.ip_address || '', device_type: row.device_type || '',
|
||||||
team: row.team || '', status: row.status, failing_metrics: [],
|
team: row.team || '', status: row.status, failing_metrics: [],
|
||||||
_seenMetricIds: new Set(),
|
|
||||||
seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
|
seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
|
||||||
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
|
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
|
||||||
has_notes: noteHostnames.has(row.hostname),
|
has_notes: noteHostnames.has(row.hostname),
|
||||||
resolution_date: null, remediation_plan: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const dev = deviceMap[row.hostname];
|
const dev = deviceMap[row.hostname];
|
||||||
if (!dev._seenMetricIds.has(row.metric_id)) {
|
|
||||||
dev._seenMetricIds.add(row.metric_id);
|
|
||||||
dev.failing_metrics.push({ metric_id: row.metric_id, metric_desc: row.metric_desc || '', category: row.category || '' });
|
dev.failing_metrics.push({ metric_id: row.metric_id, metric_desc: row.metric_desc || '', category: row.category || '' });
|
||||||
}
|
|
||||||
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
||||||
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen;
|
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen;
|
||||||
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen;
|
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen;
|
||||||
if (row.resolution_date && !dev.resolution_date) {
|
|
||||||
const rd = row.resolution_date;
|
|
||||||
dev.resolution_date = typeof rd === 'string' ? rd.slice(0, 10) : (rd instanceof Date ? rd.toISOString().slice(0, 10) : rd);
|
|
||||||
}
|
}
|
||||||
if (row.remediation_plan && !dev.remediation_plan) dev.remediation_plan = row.remediation_plan;
|
return Object.values(deviceMap);
|
||||||
}
|
|
||||||
return Object.values(deviceMap).map(({ _seenMetricIds, ...dev }) => dev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -352,7 +319,7 @@ function createComplianceRouter(upload) {
|
|||||||
res.json({
|
res.json({
|
||||||
drift, drift_error, schema: xlsxSchema,
|
drift, drift_error, schema: xlsxSchema,
|
||||||
diff: { new_count: diff.newCount, recurring_count: diff.recurringCount, resolved_count: diff.resolvedCount },
|
diff: { new_count: diff.newCount, recurring_count: diff.recurringCount, resolved_count: diff.resolvedCount },
|
||||||
tempFile: tempFilename, filename: req.file.originalname,
|
tempFile: tempFilePath, filename: req.file.originalname,
|
||||||
report_date: parsed.report_date, total_items: parsed.total,
|
report_date: parsed.report_date, total_items: parsed.total,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -405,13 +372,11 @@ function createComplianceRouter(upload) {
|
|||||||
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { tempFile, filename, report_date } = req.body;
|
const { tempFile, filename, report_date } = req.body;
|
||||||
if (!tempFile || typeof tempFile !== 'string') return res.status(400).json({ error: 'tempFile is required' });
|
if (!tempFile || typeof tempFile !== 'string') return res.status(400).json({ error: 'tempFile is required' });
|
||||||
// Reconstruct full path from basename only — never trust a client-supplied absolute path
|
if (!isSafeTempPath(tempFile)) return res.status(400).json({ error: 'Invalid tempFile path' });
|
||||||
const resolvedTempFile = path.join(TEMP_DIR, path.basename(tempFile));
|
if (!fs.existsSync(tempFile)) return res.status(400).json({ error: 'Preview session expired — please upload again' });
|
||||||
if (!isSafeTempPath(resolvedTempFile)) return res.status(400).json({ error: 'Invalid tempFile path' });
|
|
||||||
if (!fs.existsSync(resolvedTempFile)) return res.status(400).json({ error: 'Preview session expired — please upload again' });
|
|
||||||
|
|
||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = JSON.parse(fs.readFileSync(resolvedTempFile, 'utf8')); }
|
try { parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8')); }
|
||||||
catch { return res.status(400).json({ error: 'Could not read preview data — please upload again' }); }
|
catch { return res.status(400).json({ error: 'Could not read preview data — please upload again' }); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -421,7 +386,7 @@ function createComplianceRouter(upload) {
|
|||||||
filename: filename || parsed.filename,
|
filename: filename || parsed.filename,
|
||||||
userId: req.user?.id || null,
|
userId: req.user?.id || null,
|
||||||
});
|
});
|
||||||
fs.unlink(resolvedTempFile, () => {});
|
fs.unlink(tempFile, () => {});
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count
|
`SELECT id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count
|
||||||
@@ -523,30 +488,18 @@ function createComplianceRouter(upload) {
|
|||||||
* Returns the summary data from the most recent compliance upload, optionally filtered by team.
|
* Returns the summary data from the most recent compliance upload, optionally filtered by team.
|
||||||
*
|
*
|
||||||
* @query team — optional, one of STEAM | ACCESS-ENG | ACCESS-OPS | INTELDEV
|
* @query team — optional, one of STEAM | ACCESS-ENG | ACCESS-OPS | INTELDEV
|
||||||
* @response 200 { entries: Array, overall_scores: object, upload: { id, report_date, uploaded_at } | null, multi_vertical_uploads?: Array<{ id, vertical, uploaded_at }> }
|
* @response 200 { entries: Array, overall_scores: object, upload: { id, report_date, uploaded_at } | null }
|
||||||
* @response 400 { error } — invalid team
|
* @response 400 { error } — invalid team
|
||||||
* @response 500 { error } — database error
|
* @response 500 { error } — database error
|
||||||
*
|
|
||||||
* When two or more uploads share the latest `report_date` (multi-vertical
|
|
||||||
* upload day), the `multi_vertical_uploads` field discloses the sibling
|
|
||||||
* uploads (id/vertical/uploaded_at) so callers know the response is
|
|
||||||
* partial. The field is omitted on single-upload-per-date dates to
|
|
||||||
* preserve the legacy response shape.
|
|
||||||
*/
|
*/
|
||||||
router.get('/summary', async (req, res) => {
|
router.get('/summary', async (req, res) => {
|
||||||
const team = req.query.team;
|
const team = req.query.team;
|
||||||
if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
|
if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try AEO uploads first (vertical IS NULL), fall back to NTS_AEO multi-vertical upload
|
const { rows: latestRows } = await pool.query(
|
||||||
let { rows: latestRows } = await pool.query(
|
`SELECT id, summary_json, report_date, uploaded_at FROM compliance_uploads ORDER BY id DESC LIMIT 1`
|
||||||
`SELECT id, summary_json, report_date, uploaded_at FROM compliance_uploads WHERE vertical IS NULL ORDER BY id DESC LIMIT 1`
|
|
||||||
);
|
);
|
||||||
if (latestRows.length === 0 || !latestRows[0].summary_json) {
|
|
||||||
({ rows: latestRows } = await pool.query(
|
|
||||||
`SELECT id, summary_json, report_date, uploaded_at FROM compliance_uploads WHERE vertical = 'NTS_AEO' ORDER BY id DESC LIMIT 1`
|
|
||||||
));
|
|
||||||
}
|
|
||||||
const latestUpload = latestRows[0];
|
const latestUpload = latestRows[0];
|
||||||
if (!latestUpload || !latestUpload.summary_json) return res.json({ entries: [], overall_scores: {}, upload: null });
|
if (!latestUpload || !latestUpload.summary_json) return res.json({ entries: [], overall_scores: {}, upload: null });
|
||||||
|
|
||||||
@@ -556,28 +509,7 @@ function createComplianceRouter(upload) {
|
|||||||
let entries = summary.entries || [];
|
let entries = summary.entries || [];
|
||||||
if (team) entries = entries.filter(e => e.team === team);
|
if (team) entries = entries.filter(e => e.team === team);
|
||||||
|
|
||||||
// Disclose sibling uploads sharing the same report_date so callers
|
res.json({ entries, overall_scores: summary.overall_scores || {}, upload: { id: latestUpload.id, report_date: latestUpload.report_date, uploaded_at: latestUpload.uploaded_at } });
|
||||||
// know the response is a single vertical's view of a multi-vertical
|
|
||||||
// day. Field is omitted when no siblings exist (preserves legacy
|
|
||||||
// single-upload-per-date response shape).
|
|
||||||
const { rows: siblingRows } = await pool.query(
|
|
||||||
`SELECT id, vertical, uploaded_at FROM compliance_uploads WHERE report_date = $1 AND id != $2 ORDER BY id ASC`,
|
|
||||||
[latestUpload.report_date, latestUpload.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
entries,
|
|
||||||
overall_scores: summary.overall_scores || {},
|
|
||||||
upload: { id: latestUpload.id, report_date: latestUpload.report_date, uploaded_at: latestUpload.uploaded_at },
|
|
||||||
};
|
|
||||||
if (siblingRows.length > 0) {
|
|
||||||
response.multi_vertical_uploads = siblingRows.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
vertical: s.vertical,
|
|
||||||
uploaded_at: s.uploaded_at,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
res.json(response);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Compliance] GET /summary error:', err.message);
|
console.error('[Compliance] GET /summary error:', err.message);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
@@ -601,19 +533,15 @@ function createComplianceRouter(upload) {
|
|||||||
if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Include items from both AEO uploads (vertical IS NULL) and NTS_AEO multi-vertical uploads
|
|
||||||
// DISTINCT ON deduplicates cross-vertical (hostname, metric_id) pairs, keeping the representative row
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT DISTINCT ON (ci.hostname, ci.metric_id)
|
`SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count,
|
||||||
ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count,
|
|
||||||
ci.resolution_date, ci.remediation_plan,
|
|
||||||
fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on
|
fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on
|
||||||
FROM compliance_items ci
|
FROM compliance_items ci
|
||||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
WHERE ci.team = $1 AND ci.status = $2 AND (ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')
|
WHERE ci.team = $1 AND ci.status = $2
|
||||||
ORDER BY ci.hostname, ci.metric_id, ci.seen_count DESC, ci.upload_id DESC`,
|
ORDER BY ci.hostname, ci.metric_id`,
|
||||||
[team, status]
|
[team, status]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -643,8 +571,7 @@ function createComplianceRouter(upload) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows: metricRows } = await pool.query(
|
const { rows: metricRows } = await pool.query(
|
||||||
`SELECT DISTINCT ON (ci.metric_id, ci.status)
|
`SELECT ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json,
|
||||||
ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json,
|
|
||||||
ci.resolution_date, ci.remediation_plan,
|
ci.resolution_date, ci.remediation_plan,
|
||||||
fu.report_date AS first_seen, fu.uploaded_at AS first_seen_at, lu.report_date AS last_seen, lu.uploaded_at AS last_seen_at, ru.report_date AS resolved_on
|
fu.report_date AS first_seen, fu.uploaded_at AS first_seen_at, lu.report_date AS last_seen, lu.uploaded_at AS last_seen_at, ru.report_date AS resolved_on
|
||||||
FROM compliance_items ci
|
FROM compliance_items ci
|
||||||
@@ -652,16 +579,10 @@ function createComplianceRouter(upload) {
|
|||||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
WHERE ci.hostname = $1
|
WHERE ci.hostname = $1
|
||||||
ORDER BY ci.metric_id, ci.status, ci.seen_count DESC, ci.upload_id DESC`, [hostname]
|
ORDER BY ci.status DESC, ci.metric_id`, [hostname]
|
||||||
);
|
);
|
||||||
if (metricRows.length === 0) return res.status(404).json({ error: 'Device not found' });
|
if (metricRows.length === 0) return res.status(404).json({ error: 'Device not found' });
|
||||||
|
|
||||||
// Reproduce original ORDER BY ci.status DESC, ci.metric_id on the deduped rows
|
|
||||||
metricRows.sort((a, b) => {
|
|
||||||
if (a.status !== b.status) return b.status.localeCompare(a.status);
|
|
||||||
return a.metric_id.localeCompare(b.metric_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const metrics = metricRows.map(r => ({ ...r, extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(), extra_json: undefined }));
|
const metrics = metricRows.map(r => ({ ...r, extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(), extra_json: undefined }));
|
||||||
|
|
||||||
const { rows: notes } = await pool.query(
|
const { rows: notes } = await pool.query(
|
||||||
@@ -670,23 +591,10 @@ function createComplianceRouter(upload) {
|
|||||||
WHERE cn.hostname = $1 ORDER BY cn.created_at DESC`, [hostname]
|
WHERE cn.hostname = $1 ORDER BY cn.created_at DESC`, [hostname]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch remediation history
|
|
||||||
let history = [];
|
|
||||||
try {
|
|
||||||
const { rows: historyRows } = await pool.query(
|
|
||||||
`SELECT id, metric_id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
|
||||||
FROM compliance_item_history WHERE hostname = $1 ORDER BY changed_at DESC LIMIT 10`,
|
|
||||||
[hostname]
|
|
||||||
);
|
|
||||||
history = historyRows;
|
|
||||||
} catch (histErr) {
|
|
||||||
console.error('[Compliance] History fetch error:', histErr.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
|
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
|
||||||
// Return resolution_date and remediation_plan from the first active item (or any item)
|
// Return resolution_date and remediation_plan from the first active item (or any item)
|
||||||
const resDate = identity.resolution_date ? (typeof identity.resolution_date === 'string' ? identity.resolution_date : identity.resolution_date.toISOString().slice(0, 10)) : null;
|
const resDate = identity.resolution_date ? (typeof identity.resolution_date === 'string' ? identity.resolution_date : identity.resolution_date.toISOString().slice(0, 10)) : null;
|
||||||
res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes, history });
|
res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Compliance] GET /items/:hostname error:', err.message);
|
console.error('[Compliance] GET /items/:hostname error:', err.message);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
@@ -840,31 +748,19 @@ function createComplianceRouter(upload) {
|
|||||||
router.get('/trends', async (req, res) => {
|
router.get('/trends', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows: uploads } = await pool.query(
|
const { rows: uploads } = await pool.query(
|
||||||
`SELECT report_date,
|
`SELECT id, report_date, COALESCE(new_count, 0) AS new_count, COALESCE(recurring_count, 0) AS recurring_count, COALESCE(resolved_count, 0) AS resolved_count, COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active FROM compliance_uploads ORDER BY report_date ASC`
|
||||||
SUM(COALESCE(new_count, 0))::int AS new_count,
|
|
||||||
SUM(COALESCE(recurring_count, 0))::int AS recurring_count,
|
|
||||||
SUM(COALESCE(resolved_count, 0))::int AS resolved_count,
|
|
||||||
SUM(COALESCE(new_count, 0) + COALESCE(recurring_count, 0))::int AS total_active
|
|
||||||
FROM compliance_uploads
|
|
||||||
WHERE report_date IS NOT NULL
|
|
||||||
GROUP BY report_date
|
|
||||||
ORDER BY report_date ASC`
|
|
||||||
);
|
);
|
||||||
if (uploads.length === 0) return res.json({ trends: [] });
|
if (uploads.length === 0) return res.json({ trends: [] });
|
||||||
|
|
||||||
const { rows: teamRows } = await pool.query(
|
const { rows: teamRows } = await pool.query(
|
||||||
`SELECT cu.report_date, ci.team, COUNT(ci.id)::int AS count
|
`SELECT ci.upload_id, ci.team, COUNT(ci.id)::int AS count FROM compliance_items ci WHERE ci.team IS NOT NULL GROUP BY ci.upload_id, ci.team`
|
||||||
FROM compliance_items ci
|
|
||||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
|
||||||
WHERE ci.team IS NOT NULL AND cu.report_date IS NOT NULL
|
|
||||||
GROUP BY cu.report_date, ci.team`
|
|
||||||
);
|
);
|
||||||
const teamMap = {};
|
const teamMap = {};
|
||||||
teamRows.forEach(r => { if (!teamMap[r.report_date]) teamMap[r.report_date] = {}; teamMap[r.report_date][r.team] = r.count; });
|
teamRows.forEach(r => { if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {}; teamMap[r.upload_id][r.team] = r.count; });
|
||||||
|
|
||||||
const trends = uploads.map(u => ({
|
const trends = uploads.map(u => ({
|
||||||
report_date: u.report_date, new_count: u.new_count, recurring_count: u.recurring_count, resolved_count: u.resolved_count, total_active: u.total_active,
|
report_date: u.report_date, new_count: u.new_count, recurring_count: u.recurring_count, resolved_count: u.resolved_count, total_active: u.total_active,
|
||||||
STEAM: teamMap[u.report_date]?.STEAM || 0, 'ACCESS-ENG': teamMap[u.report_date]?.['ACCESS-ENG'] || 0, 'ACCESS-OPS': teamMap[u.report_date]?.['ACCESS-OPS'] || 0, INTELDEV: teamMap[u.report_date]?.INTELDEV || 0,
|
STEAM: teamMap[u.id]?.STEAM || 0, 'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0, 'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0, INTELDEV: teamMap[u.id]?.INTELDEV || 0,
|
||||||
}));
|
}));
|
||||||
res.json({ trends });
|
res.json({ trends });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -882,13 +778,7 @@ function createComplianceRouter(upload) {
|
|||||||
*/
|
*/
|
||||||
router.get('/mttr', async (req, res) => {
|
router.get('/mttr', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(`SELECT COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active'`);
|
||||||
`SELECT DISTINCT ON (hostname, metric_id)
|
|
||||||
COALESCE(seen_count, 1) AS seen_count, team
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC`
|
|
||||||
);
|
|
||||||
if (rows.length === 0) return res.json({ aging: [] });
|
if (rows.length === 0) return res.json({ aging: [] });
|
||||||
const aging = bucketAgingItems(rows);
|
const aging = bucketAgingItems(rows);
|
||||||
res.json({ aging });
|
res.json({ aging });
|
||||||
@@ -908,14 +798,7 @@ function createComplianceRouter(upload) {
|
|||||||
router.get('/top-recurring', async (req, res) => {
|
router.get('/top-recurring', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT report_date,
|
`SELECT id, report_date, COALESCE(new_count, 0) AS new_count, COALESCE(recurring_count, 0) AS recurring_count, COALESCE(resolved_count, 0) AS resolved_count FROM compliance_uploads ORDER BY report_date ASC`
|
||||||
SUM(COALESCE(new_count, 0))::int AS new_count,
|
|
||||||
SUM(COALESCE(recurring_count, 0))::int AS recurring_count,
|
|
||||||
SUM(COALESCE(resolved_count, 0))::int AS resolved_count
|
|
||||||
FROM compliance_uploads
|
|
||||||
WHERE report_date IS NOT NULL
|
|
||||||
GROUP BY report_date
|
|
||||||
ORDER BY report_date ASC`
|
|
||||||
);
|
);
|
||||||
const waterfall = computeWaterfall(rows);
|
const waterfall = computeWaterfall(rows);
|
||||||
res.json({ waterfall });
|
res.json({ waterfall });
|
||||||
@@ -935,13 +818,9 @@ function createComplianceRouter(upload) {
|
|||||||
router.get('/category-trend', async (req, res) => {
|
router.get('/category-trend', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT cu.report_date,
|
`SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id)::int AS count
|
||||||
COALESCE(ci.category, 'Unknown') AS category,
|
|
||||||
COUNT(ci.id)::int AS count
|
|
||||||
FROM compliance_uploads cu JOIN compliance_items ci ON ci.upload_id = cu.id
|
FROM compliance_uploads cu JOIN compliance_items ci ON ci.upload_id = cu.id
|
||||||
WHERE cu.report_date IS NOT NULL
|
GROUP BY cu.id, cu.report_date, category ORDER BY cu.report_date ASC`
|
||||||
GROUP BY cu.report_date, COALESCE(ci.category, 'Unknown')
|
|
||||||
ORDER BY cu.report_date ASC, category ASC`
|
|
||||||
);
|
);
|
||||||
res.json({ categoryTrend: rows });
|
res.json({ categoryTrend: rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -952,14 +831,12 @@ function createComplianceRouter(upload) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /items/:hostname/metadata
|
* PATCH /items/:hostname/metadata
|
||||||
* Updates resolution_date and/or remediation_plan for active compliance items matching a hostname.
|
* Updates resolution_date and/or remediation_plan for all active compliance items matching a hostname.
|
||||||
* Supports optional per-metric scoping via metric_id (single) or metric_ids (array).
|
|
||||||
* Records field-level change history in compliance_item_history for each modified field.
|
|
||||||
*
|
*
|
||||||
* @param hostname — the device hostname
|
* @param hostname — the device hostname
|
||||||
* @body { resolution_date?: string|null, remediation_plan?: string|null, change_reason?: string|null, metric_id?: string, metric_ids?: string[] }
|
* @body { resolution_date?: string|null, remediation_plan?: string|null }
|
||||||
* @response 200 { updated: number }
|
* @response 200 { updated: number }
|
||||||
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, change_reason exceeds 500 chars, no fields provided, or invalid metric_id
|
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, or no fields provided
|
||||||
* @response 404 { error } — device not found
|
* @response 404 { error } — device not found
|
||||||
* @response 500 { error } — update failure
|
* @response 500 { error } — update failure
|
||||||
*/
|
*/
|
||||||
@@ -967,7 +844,7 @@ function createComplianceRouter(upload) {
|
|||||||
const hostname = req.params.hostname;
|
const hostname = req.params.hostname;
|
||||||
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||||
|
|
||||||
const { resolution_date, remediation_plan, change_reason, metric_id, metric_ids } = req.body;
|
const { resolution_date, remediation_plan } = req.body;
|
||||||
|
|
||||||
// Validate resolution_date: must be a valid ISO date string or null
|
// Validate resolution_date: must be a valid ISO date string or null
|
||||||
if (resolution_date !== undefined && resolution_date !== null) {
|
if (resolution_date !== undefined && resolution_date !== null) {
|
||||||
@@ -984,36 +861,8 @@ function createComplianceRouter(upload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate change_reason: optional, max 500 chars
|
try {
|
||||||
if (change_reason !== undefined && change_reason !== null && change_reason.length > 500) {
|
// Build dynamic SET clause for provided fields only
|
||||||
return res.status(400).json({ error: 'Change reason exceeds 500 characters' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve metric scoping: metric_ids takes precedence over metric_id
|
|
||||||
let resolvedMetricIds = null; // null means hostname-level (no metric scoping)
|
|
||||||
if (metric_ids !== undefined) {
|
|
||||||
if (!Array.isArray(metric_ids)) return res.status(400).json({ error: 'metric_ids must be an array' });
|
|
||||||
if (metric_ids.length === 0) return res.status(400).json({ error: 'metric_ids must contain at least one entry' });
|
|
||||||
for (let i = 0; i < metric_ids.length; i++) {
|
|
||||||
const mid = metric_ids[i];
|
|
||||||
if (!mid || typeof mid !== 'string' || mid.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
|
||||||
}
|
|
||||||
if (mid.length > 100) {
|
|
||||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolvedMetricIds = metric_ids;
|
|
||||||
} else if (metric_id !== undefined && metric_id !== null) {
|
|
||||||
if (typeof metric_id !== 'string' || metric_id.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
|
||||||
}
|
|
||||||
if (metric_id.length > 100) {
|
|
||||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
|
||||||
}
|
|
||||||
resolvedMetricIds = [metric_id];
|
|
||||||
}
|
|
||||||
|
|
||||||
const setClauses = [];
|
const setClauses = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
let paramIdx = 1;
|
let paramIdx = 1;
|
||||||
@@ -1031,163 +880,33 @@ function createComplianceRouter(upload) {
|
|||||||
return res.status(400).json({ error: 'No fields to update' });
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
|
|
||||||
const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null;
|
|
||||||
|
|
||||||
if (resolvedMetricIds !== null) {
|
|
||||||
// --- Per-metric scoping path ---
|
|
||||||
// Validate that each metric_id corresponds to an active compliance_item for this hostname
|
|
||||||
const { rows: activeMetricRows } = await client.query(
|
|
||||||
`SELECT metric_id, resolution_date, remediation_plan
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active'`,
|
|
||||||
[hostname, resolvedMetricIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeMetricMap = new Map();
|
|
||||||
for (const row of activeMetricRows) {
|
|
||||||
activeMetricMap.set(row.metric_id, row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for invalid metric_ids
|
|
||||||
for (const mid of resolvedMetricIds) {
|
|
||||||
if (!activeMetricMap.has(mid)) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
client.release();
|
|
||||||
return res.status(400).json({ error: `Invalid metric_id: ${mid} — no active compliance item found` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert history per metric per changed field
|
|
||||||
for (const mid of resolvedMetricIds) {
|
|
||||||
const current = activeMetricMap.get(mid);
|
|
||||||
const currentResDate = current.resolution_date
|
|
||||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
|
||||||
: null;
|
|
||||||
const currentPlan = current.remediation_plan || null;
|
|
||||||
|
|
||||||
if (resolution_date !== undefined) {
|
|
||||||
const newVal = resolution_date || null;
|
|
||||||
if (currentResDate !== newVal) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
|
||||||
VALUES ($1, $2, 'resolution_date', $3, $4, $5, $6)`,
|
|
||||||
[hostname, mid, currentResDate, newVal, reasonText, req.user.username]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (remediation_plan !== undefined) {
|
|
||||||
const newVal = remediation_plan || null;
|
|
||||||
if (currentPlan !== newVal) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
|
||||||
VALUES ($1, $2, 'remediation_plan', $3, $4, $5, $6)`,
|
|
||||||
[hostname, mid, currentPlan, newVal, reasonText, req.user.username]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update only matching rows
|
|
||||||
values.push(hostname);
|
values.push(hostname);
|
||||||
values.push(resolvedMetricIds);
|
const result = await pool.query(
|
||||||
const result = await client.query(
|
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
|
||||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND metric_id = ANY($${paramIdx + 1}) AND status = 'active'`,
|
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query('COMMIT');
|
if (result.rowCount === 0) {
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'compliance_metadata_update',
|
|
||||||
entityType: 'compliance_item',
|
|
||||||
entityId: hostname,
|
|
||||||
details: { resolution_date, remediation_plan, change_reason: reasonText, metric_ids: resolvedMetricIds },
|
|
||||||
ipAddress: req.ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ updated: result.rowCount });
|
|
||||||
} else {
|
|
||||||
// --- Hostname-level path (backward compatible, NULL metric_id in history) ---
|
|
||||||
// Get current values before updating (pick one representative row)
|
|
||||||
const { rows: currentRows } = await client.query(
|
|
||||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
|
||||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
|
||||||
ORDER BY hostname, id DESC LIMIT 1`,
|
|
||||||
[hostname]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentRows.length === 0) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
client.release();
|
|
||||||
return res.status(404).json({ error: 'Device not found' });
|
return res.status(404).json({ error: 'Device not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = currentRows[0];
|
|
||||||
const currentResDate = current.resolution_date
|
|
||||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
|
||||||
: null;
|
|
||||||
const currentPlan = current.remediation_plan || null;
|
|
||||||
|
|
||||||
// Insert history for each changed field with NULL metric_id
|
|
||||||
if (resolution_date !== undefined) {
|
|
||||||
const newVal = resolution_date || null;
|
|
||||||
if (currentResDate !== newVal) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
|
||||||
VALUES ($1, NULL, 'resolution_date', $2, $3, $4, $5)`,
|
|
||||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (remediation_plan !== undefined) {
|
|
||||||
const newVal = remediation_plan || null;
|
|
||||||
if (currentPlan !== newVal) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
|
||||||
VALUES ($1, NULL, 'remediation_plan', $2, $3, $4, $5)`,
|
|
||||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all active items for hostname
|
|
||||||
values.push(hostname);
|
|
||||||
const result = await client.query(
|
|
||||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
action: 'compliance_metadata_update',
|
action: 'compliance_metadata_update',
|
||||||
entityType: 'compliance_item',
|
entityType: 'compliance_item',
|
||||||
entityId: hostname,
|
entityId: hostname,
|
||||||
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
details: { resolution_date, remediation_plan },
|
||||||
ipAddress: req.ip,
|
ipAddress: req.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ updated: result.rowCount });
|
res.json({ updated: result.rowCount });
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
|
||||||
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to update device metadata' });
|
res.status(500).json({ error: 'Failed to update device metadata' });
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /vcl/stats
|
* GET /vcl/stats
|
||||||
* Returns VCL executive summary statistics including device counts, compliance percentage,
|
* Returns VCL executive summary statistics including device counts, compliance percentage,
|
||||||
@@ -1196,6 +915,8 @@ function createComplianceRouter(upload) {
|
|||||||
* @response 200 { stats: { total_devices, in_scope, compliant, non_compliant, remediations_required, compliance_pct, target_pct }, donut: { blocked: { count, pct }, in_progress: { count, pct } }, heavy_hitters: Array<{ vertical, team, non_compliant, compliance_date, notes }>, vertical_breakdown: Array<{ vertical, compliance_pct, team, non_compliant, actual_burndown, forecast_burndown, blockers, risk_acceptances, notes }> }
|
* @response 200 { stats: { total_devices, in_scope, compliant, non_compliant, remediations_required, compliance_pct, target_pct }, donut: { blocked: { count, pct }, in_progress: { count, pct } }, heavy_hitters: Array<{ vertical, team, non_compliant, compliance_date, notes }>, vertical_breakdown: Array<{ vertical, compliance_pct, team, non_compliant, actual_burndown, forecast_burndown, blockers, risk_acceptances, notes }> }
|
||||||
* @response 500 { error } — database error
|
* @response 500 { error } — database error
|
||||||
*/
|
*/
|
||||||
|
const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95;
|
||||||
|
|
||||||
router.get('/vcl/stats', async (req, res) => {
|
router.get('/vcl/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Compute device-level stats using DISTINCT hostname
|
// Compute device-level stats using DISTINCT hostname
|
||||||
@@ -1244,21 +965,13 @@ function createComplianceRouter(upload) {
|
|||||||
const donut = categorizeNonCompliant(donutRows);
|
const donut = categorizeNonCompliant(donutRows);
|
||||||
|
|
||||||
// Heavy hitters: group by team, count non-compliant DEVICES per team
|
// Heavy hitters: group by team, count non-compliant DEVICES per team
|
||||||
// CTE deduplicates hostnames to one team via representative row (highest seen_count, most recent upload_id)
|
|
||||||
const { rows: teamRows } = await pool.query(`
|
const { rows: teamRows } = await pool.query(`
|
||||||
WITH device_team AS (
|
SELECT
|
||||||
SELECT DISTINCT ON (hostname)
|
|
||||||
hostname,
|
|
||||||
COALESCE(team, 'Unknown') AS team,
|
COALESCE(team, 'Unknown') AS team,
|
||||||
resolution_date
|
COUNT(DISTINCT hostname) AS non_compliant,
|
||||||
|
MAX(resolution_date) AS compliance_date
|
||||||
FROM compliance_items
|
FROM compliance_items
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
|
||||||
)
|
|
||||||
SELECT team,
|
|
||||||
COUNT(DISTINCT hostname)::int AS non_compliant,
|
|
||||||
MAX(resolution_date) AS compliance_date
|
|
||||||
FROM device_team
|
|
||||||
GROUP BY team
|
GROUP BY team
|
||||||
ORDER BY COUNT(DISTINCT hostname) DESC
|
ORDER BY COUNT(DISTINCT hostname) DESC
|
||||||
`);
|
`);
|
||||||
@@ -1276,28 +989,18 @@ function createComplianceRouter(upload) {
|
|||||||
const team = teamRow.team;
|
const team = teamRow.team;
|
||||||
const teamNonCompliant = parseInt(teamRow.non_compliant);
|
const teamNonCompliant = parseInt(teamRow.non_compliant);
|
||||||
|
|
||||||
// Get total devices for this team (all statuses) — CTE deduplicates hostnames to one team
|
// Get total devices for this team (all statuses)
|
||||||
const { rows: teamTotalRows } = await pool.query(
|
const { rows: teamTotalRows } = await pool.query(
|
||||||
`WITH device_team AS (
|
`SELECT COUNT(DISTINCT hostname) AS total FROM compliance_items WHERE COALESCE(team, 'Unknown') = $1`,
|
||||||
SELECT DISTINCT ON (hostname)
|
|
||||||
hostname,
|
|
||||||
COALESCE(team, 'Unknown') AS team
|
|
||||||
FROM compliance_items
|
|
||||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
|
||||||
)
|
|
||||||
SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1`,
|
|
||||||
[team]
|
[team]
|
||||||
);
|
);
|
||||||
const teamTotal = parseInt(teamTotalRows[0]?.total) || 0;
|
const teamTotal = parseInt(teamTotalRows[0]?.total) || 0;
|
||||||
const teamCompliant = teamTotal - teamNonCompliant;
|
const teamCompliant = teamTotal - teamNonCompliant;
|
||||||
const compliance_pct_team = teamTotal > 0 ? Math.round((teamCompliant / teamTotal) * 100) : 0;
|
const compliance_pct_team = teamTotal > 0 ? Math.round((teamCompliant / teamTotal) * 100) : 0;
|
||||||
|
|
||||||
// Forecast burndown from resolution_dates — DISTINCT ON deduplicates cross-vertical (hostname, metric_id) pairs
|
// Forecast burndown from resolution_dates
|
||||||
const { rows: forecastItems } = await pool.query(
|
const { rows: forecastItems } = await pool.query(
|
||||||
`SELECT DISTINCT ON (hostname, metric_id) resolution_date
|
`SELECT resolution_date FROM compliance_items WHERE status = 'active' AND COALESCE(team, 'Unknown') = $1 AND resolution_date IS NOT NULL`,
|
||||||
FROM compliance_items
|
|
||||||
WHERE status = 'active' AND COALESCE(team, 'Unknown') = $1 AND resolution_date IS NOT NULL
|
|
||||||
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC`,
|
|
||||||
[team]
|
[team]
|
||||||
);
|
);
|
||||||
const forecast_burndown = computeForecastBurndown(forecastItems);
|
const forecast_burndown = computeForecastBurndown(forecastItems);
|
||||||
@@ -1570,22 +1273,6 @@ function createComplianceRouter(upload) {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Pre-fetch current values for all hostnames in the batch
|
|
||||||
const hostnames = changes.map(c => c.hostname);
|
|
||||||
const { rows: currentRows } = await client.query(
|
|
||||||
`SELECT DISTINCT ON (hostname) hostname, resolution_date, remediation_plan
|
|
||||||
FROM compliance_items WHERE status = 'active' AND hostname = ANY($1)
|
|
||||||
ORDER BY hostname, id DESC`,
|
|
||||||
[hostnames]
|
|
||||||
);
|
|
||||||
const currentData = new Map();
|
|
||||||
for (const row of currentRows) {
|
|
||||||
currentData.set(row.hostname, {
|
|
||||||
resolution_date: row.resolution_date ? (typeof row.resolution_date === 'string' ? row.resolution_date : row.resolution_date.toISOString().slice(0, 10)) : null,
|
|
||||||
remediation_plan: row.remediation_plan || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let committedCount = 0;
|
let committedCount = 0;
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const setClauses = [];
|
const setClauses = [];
|
||||||
@@ -1607,29 +1294,6 @@ function createComplianceRouter(upload) {
|
|||||||
|
|
||||||
if (setClauses.length === 0) continue;
|
if (setClauses.length === 0) continue;
|
||||||
|
|
||||||
// Record history for changed fields
|
|
||||||
const current = currentData.get(change.hostname) || { resolution_date: null, remediation_plan: null };
|
|
||||||
if (change.resolution_date !== undefined) {
|
|
||||||
const newVal = change.resolution_date || null;
|
|
||||||
if (current.resolution_date !== newVal) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
|
||||||
VALUES ($1, 'resolution_date', $2, $3, NULL, $4)`,
|
|
||||||
[change.hostname, current.resolution_date, newVal, req.user.username]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (change.remediation_plan !== undefined) {
|
|
||||||
const newVal = change.remediation_plan || null;
|
|
||||||
if (current.remediation_plan !== newVal) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
|
||||||
VALUES ($1, 'remediation_plan', $2, $3, NULL, $4)`,
|
|
||||||
[change.hostname, current.remediation_plan, newVal, req.user.username]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(change.hostname);
|
values.push(change.hostname);
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
||||||
@@ -1745,4 +1409,4 @@ function createComplianceRouter(upload) {
|
|||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload, groupByHostname };
|
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall };
|
||||||
|
|||||||
@@ -1,54 +1,11 @@
|
|||||||
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
|
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
|
||||||
// Supports optional screenshot uploads (up to 3 images, 5MB each).
|
|
||||||
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
|
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const multer = require('multer');
|
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
// Multer setup for screenshot uploads — same temp directory pattern as compliance
|
|
||||||
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
|
||||||
|
|
||||||
const screenshotStorage = multer.diskStorage({
|
|
||||||
destination: (req, file, cb) => {
|
|
||||||
if (!fs.existsSync(TEMP_DIR)) {
|
|
||||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
cb(null, TEMP_DIR);
|
|
||||||
},
|
|
||||||
filename: (req, file, cb) => {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const safeName = file.originalname
|
|
||||||
.replace(/\0/g, '')
|
|
||||||
.replace(/\.\./g, '')
|
|
||||||
.replace(/[\/\\]/g, '')
|
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
||||||
.trim();
|
|
||||||
cb(null, `${timestamp}-${safeName}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
||||||
|
|
||||||
const screenshotUpload = multer({
|
|
||||||
storage: screenshotStorage,
|
|
||||||
fileFilter: (req, file, cb) => {
|
|
||||||
if (!ALLOWED_IMAGE_TYPES.has(file.mimetype)) {
|
|
||||||
return cb(new Error(`File type '${file.mimetype}' not allowed. Only PNG, JPG, GIF, and WebP images are accepted.`));
|
|
||||||
}
|
|
||||||
cb(null, true);
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
fileSize: MAX_FILE_SIZE,
|
|
||||||
files: 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function createFeedbackRouter() {
|
function createFeedbackRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -56,94 +13,7 @@ function createFeedbackRouter() {
|
|||||||
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
||||||
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
||||||
|
|
||||||
/**
|
router.post('/', requireAuth(), async (req, res) => {
|
||||||
* Upload a single file to GitLab's project uploads API.
|
|
||||||
* Returns { markdown, url } on success, null on failure.
|
|
||||||
*/
|
|
||||||
function uploadFileToGitlab(filePath, fileName) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/uploads`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(apiUrl);
|
|
||||||
const transport = parsed.protocol === 'https:' ? https : http;
|
|
||||||
|
|
||||||
const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
|
|
||||||
const fileContent = fs.readFileSync(filePath);
|
|
||||||
|
|
||||||
const header = Buffer.from(
|
|
||||||
`--${boundary}\r\n` +
|
|
||||||
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
|
|
||||||
`Content-Type: application/octet-stream\r\n\r\n`
|
|
||||||
);
|
|
||||||
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
||||||
const body = Buffer.concat([header, fileContent, footer]);
|
|
||||||
|
|
||||||
const reqOpts = {
|
|
||||||
method: 'POST',
|
|
||||||
hostname: parsed.hostname,
|
|
||||||
port: parsed.port,
|
|
||||||
path: parsed.pathname + parsed.search,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
||||||
'PRIVATE-TOKEN': GITLAB_PAT,
|
|
||||||
'Content-Length': body.length,
|
|
||||||
},
|
|
||||||
rejectAuthorized: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiReq = transport.request(reqOpts, (apiRes) => {
|
|
||||||
let data = '';
|
|
||||||
apiRes.on('data', chunk => data += chunk);
|
|
||||||
apiRes.on('end', () => {
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(data);
|
|
||||||
if (apiRes.statusCode === 201 && result.markdown) {
|
|
||||||
resolve(result);
|
|
||||||
} else {
|
|
||||||
console.error(`[Feedback] GitLab upload returned ${apiRes.statusCode}:`, data);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.error('[Feedback] GitLab upload returned invalid JSON:', data);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
apiReq.on('error', (err) => {
|
|
||||||
console.error('[Feedback] GitLab upload request error:', err.message);
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
apiReq.write(body);
|
|
||||||
apiReq.end();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Feedback] GitLab upload error:', err.message);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up temp files after processing.
|
|
||||||
*/
|
|
||||||
function cleanupFiles(files) {
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(file.path)) {
|
|
||||||
fs.unlinkSync(file.path);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Feedback] Failed to clean up temp file ${file.path}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post('/', requireAuth(), screenshotUpload.array('screenshots', 3), async (req, res) => {
|
|
||||||
const uploadedFiles = req.files || [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
||||||
return res.status(503).json({ error: 'Feedback integration not configured' });
|
return res.status(503).json({ error: 'Feedback integration not configured' });
|
||||||
}
|
}
|
||||||
@@ -162,16 +32,7 @@ function createFeedbackRouter() {
|
|||||||
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
|
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
|
||||||
const username = req.user?.username || 'unknown';
|
const username = req.user?.username || 'unknown';
|
||||||
|
|
||||||
// Upload screenshots to GitLab and collect markdown links
|
const body = [
|
||||||
const imageMarkdowns = [];
|
|
||||||
for (const file of uploadedFiles) {
|
|
||||||
const result = await uploadFileToGitlab(file.path, file.originalname);
|
|
||||||
if (result && result.markdown) {
|
|
||||||
imageMarkdowns.push(result.markdown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyParts = [
|
|
||||||
`**Submitted by:** ${username}`,
|
`**Submitted by:** ${username}`,
|
||||||
page ? `**Page:** ${page}` : null,
|
page ? `**Page:** ${page}` : null,
|
||||||
`**Type:** ${prefix}`,
|
`**Type:** ${prefix}`,
|
||||||
@@ -179,22 +40,7 @@ function createFeedbackRouter() {
|
|||||||
'---',
|
'---',
|
||||||
'',
|
'',
|
||||||
description,
|
description,
|
||||||
].filter(Boolean);
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
// Append screenshot markdown at the bottom
|
|
||||||
if (imageMarkdowns.length > 0) {
|
|
||||||
bodyParts.push('');
|
|
||||||
bodyParts.push('---');
|
|
||||||
bodyParts.push('');
|
|
||||||
bodyParts.push('**Screenshots:**');
|
|
||||||
bodyParts.push('');
|
|
||||||
for (const md of imageMarkdowns) {
|
|
||||||
bodyParts.push(md);
|
|
||||||
bodyParts.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = bodyParts.join('\n');
|
|
||||||
|
|
||||||
const postData = JSON.stringify({
|
const postData = JSON.stringify({
|
||||||
title: `[${prefix}] ${title}`,
|
title: `[${prefix}] ${title}`,
|
||||||
@@ -204,6 +50,7 @@ function createFeedbackRouter() {
|
|||||||
|
|
||||||
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
|
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
const parsed = new URL(apiUrl);
|
const parsed = new URL(apiUrl);
|
||||||
const transport = parsed.protocol === 'https:' ? https : http;
|
const transport = parsed.protocol === 'https:' ? https : http;
|
||||||
@@ -255,9 +102,6 @@ function createFeedbackRouter() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Feedback] Request failed:', err.message);
|
console.error('[Feedback] Request failed:', err.message);
|
||||||
res.status(502).json({ error: 'Failed to connect to GitLab' });
|
res.status(502).json({ error: 'Failed to connect to GitLab' });
|
||||||
} finally {
|
|
||||||
// Always clean up temp files
|
|
||||||
cleanupFiles(uploadedFiles);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,6 @@ const pool = require('../db');
|
|||||||
|
|
||||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// PostgreSQL DATE columns return JS Date objects — normalize to 'YYYY-MM-DD' strings
|
|
||||||
function formatDate(val) {
|
|
||||||
if (!val) return null;
|
|
||||||
if (val instanceof Date) {
|
|
||||||
const y = val.getFullYear();
|
|
||||||
const m = String(val.getMonth() + 1).padStart(2, '0');
|
|
||||||
const d = String(val.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
|
||||||
// Already a string — strip any time portion (e.g. "2025-05-22T00:00:00.000Z")
|
|
||||||
return String(val).slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configurable BU filter — broadened via env var to support multi-tenancy.
|
// Configurable BU filter — broadened via env var to support multi-tenancy.
|
||||||
// Users see only their assigned teams' findings (filtered at query time).
|
// Users see only their assigned teams' findings (filtered at query time).
|
||||||
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
||||||
@@ -89,22 +76,6 @@ const CLOSED_COUNT_FILTERS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Extract Qualys IPv6 address from hostAdditionalDetails
|
|
||||||
// Looks for "IPv6 Address" (string) or "IPv6 Addresses" (array) fields
|
|
||||||
// in the scanner-specific details from Qualys.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function extractQualysIpv6(f) {
|
|
||||||
const details = f.hostAdditionalDetails || [];
|
|
||||||
for (const entry of details) {
|
|
||||||
if (entry['IPv6 Address']) return entry['IPv6 Address'];
|
|
||||||
if (Array.isArray(entry['IPv6 Addresses']) && entry['IPv6 Addresses'].length > 0) {
|
|
||||||
return entry['IPv6 Addresses'][0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Extract only the fields we need from a raw finding object
|
// Extract only the fields we need from a raw finding object
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -165,10 +136,7 @@ function extractFinding(f) {
|
|||||||
lastFoundOn: f.lastFoundOn || '',
|
lastFoundOn: f.lastFoundOn || '',
|
||||||
buOwnership,
|
buOwnership,
|
||||||
cves,
|
cves,
|
||||||
workflow,
|
workflow
|
||||||
// IPv6 fallbacks for findings with no IPv4
|
|
||||||
qualysIpv6: extractQualysIpv6(f),
|
|
||||||
primaryIpv6: f.assetCustomAttributes?.['1550_host_6']?.[0] || '',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +173,7 @@ async function upsertFindingsBatch(findings, state) {
|
|||||||
const placeholders = [];
|
const placeholders = [];
|
||||||
|
|
||||||
batch.forEach((f, idx) => {
|
batch.forEach((f, idx) => {
|
||||||
const offset = idx * 20;
|
const offset = idx * 18;
|
||||||
values.push(
|
values.push(
|
||||||
f.id,
|
f.id,
|
||||||
f.hostId,
|
f.hostId,
|
||||||
@@ -224,15 +192,13 @@ async function upsertFindingsBatch(findings, state) {
|
|||||||
f.workflow ? f.workflow.id : null,
|
f.workflow ? f.workflow.id : null,
|
||||||
f.workflow ? f.workflow.state : null,
|
f.workflow ? f.workflow.state : null,
|
||||||
f.workflow ? f.workflow.type : null,
|
f.workflow ? f.workflow.type : null,
|
||||||
state,
|
state
|
||||||
f.qualysIpv6 || null,
|
|
||||||
f.primaryIpv6 || null
|
|
||||||
);
|
);
|
||||||
placeholders.push(
|
placeholders.push(
|
||||||
`($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` +
|
`($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` +
|
||||||
`$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` +
|
`$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` +
|
||||||
`$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` +
|
`$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` +
|
||||||
`$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20})`
|
`$${offset+16}, $${offset+17}, $${offset+18})`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,8 +207,7 @@ async function upsertFindingsBatch(findings, state) {
|
|||||||
id, host_id, title, severity, vrr_group,
|
id, host_id, title, severity, vrr_group,
|
||||||
host_name, ip_address, dns, status, sla_status,
|
host_name, ip_address, dns, status, sla_status,
|
||||||
due_date, last_found_on, bu_ownership, cves,
|
due_date, last_found_on, bu_ownership, cves,
|
||||||
workflow_id, workflow_state, workflow_type, state,
|
workflow_id, workflow_state, workflow_type, state
|
||||||
qualys_ipv6, primary_ipv6
|
|
||||||
)
|
)
|
||||||
VALUES ${placeholders.join(', ')}
|
VALUES ${placeholders.join(', ')}
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
@@ -263,8 +228,6 @@ async function upsertFindingsBatch(findings, state) {
|
|||||||
workflow_state = EXCLUDED.workflow_state,
|
workflow_state = EXCLUDED.workflow_state,
|
||||||
workflow_type = EXCLUDED.workflow_type,
|
workflow_type = EXCLUDED.workflow_type,
|
||||||
state = EXCLUDED.state,
|
state = EXCLUDED.state,
|
||||||
qualys_ipv6 = EXCLUDED.qualys_ipv6,
|
|
||||||
primary_ipv6 = EXCLUDED.primary_ipv6,
|
|
||||||
synced_at = NOW()
|
synced_at = NOW()
|
||||||
`, values);
|
`, values);
|
||||||
}
|
}
|
||||||
@@ -681,7 +644,7 @@ async function syncFPWorkflowCounts(openFindings, apiKey, clientId, skipTls) {
|
|||||||
const MANAGED_BUS_VALUE = process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
const MANAGED_BUS_VALUE = process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
||||||
const EXPECTED_BUS = new Set(MANAGED_BUS_VALUE.split(',').map(b => b.trim()).filter(Boolean));
|
const EXPECTED_BUS = new Set(MANAGED_BUS_VALUE.split(',').map(b => b.trim()).filter(Boolean));
|
||||||
|
|
||||||
async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls, previousBuMap) {
|
async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) {
|
||||||
const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||||
|
|
||||||
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
|
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
|
||||||
@@ -734,9 +697,7 @@ async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls, pr
|
|||||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||||
const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0;
|
const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0;
|
||||||
const state = f.status || f.generic_state || '';
|
const state = f.status || f.generic_state || '';
|
||||||
const title = f.title || '';
|
foundMap.set(String(f.id), { bu, severity, state });
|
||||||
const hostName = f.host?.hostName || f.hostName || '';
|
|
||||||
foundMap.set(String(f.id), { bu, severity, state, title, hostName });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
page++;
|
page++;
|
||||||
@@ -793,26 +754,6 @@ async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls, pr
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message);
|
console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record BU reassignment in ivanti_finding_bu_history for detail view
|
|
||||||
if (classification === 'bu_reassignment' && found) {
|
|
||||||
try {
|
|
||||||
// Determine previous BU from the pre-sync snapshot (passed in from syncFindings)
|
|
||||||
const previousBu = (previousBuMap && previousBuMap.get(id)) || '';
|
|
||||||
|
|
||||||
// Only record if we have a known previous BU — "UNKNOWN → X" entries
|
|
||||||
// provide no actionable insight for asset movement tracking.
|
|
||||||
if (previousBu && EXPECTED_BUS.has(previousBu)) {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
||||||
[id, found.title || '', found.hostName || '', previousBu, found.bu]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[BU Drift Checker] Error recording BU change for finding ${id}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[BU Drift Checker] Classification complete:`, summary);
|
console.log(`[BU Drift Checker] Classification complete:`, summary);
|
||||||
@@ -898,14 +839,12 @@ async function syncFindings() {
|
|||||||
|
|
||||||
// Read previous open findings from DB for archive detection
|
// Read previous open findings from DB for archive detection
|
||||||
let previousFindings = [];
|
let previousFindings = [];
|
||||||
let previousBuMap = new Map(); // id → bu_ownership snapshot BEFORE upsert
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, title, host_name AS "hostName", ip_address AS "ipAddress", severity, bu_ownership AS "buOwnership"
|
`SELECT id, title, host_name AS "hostName", ip_address AS "ipAddress", severity, bu_ownership AS "buOwnership"
|
||||||
FROM ivanti_findings WHERE state = 'open'`
|
FROM ivanti_findings WHERE state = 'open'`
|
||||||
);
|
);
|
||||||
previousFindings = rows;
|
previousFindings = rows;
|
||||||
previousBuMap = new Map(rows.map(f => [String(f.id), f.buOwnership || '']));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
||||||
}
|
}
|
||||||
@@ -957,21 +896,6 @@ async function syncFindings() {
|
|||||||
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove archived findings from ivanti_findings to prevent stale re-detection
|
|
||||||
if (archiveResult.disappearedIds && archiveResult.disappearedIds.length > 0) {
|
|
||||||
try {
|
|
||||||
const { rowCount } = await pool.query(
|
|
||||||
`DELETE FROM ivanti_findings WHERE state = 'open' AND CAST(id AS TEXT) = ANY($1::text[])`,
|
|
||||||
[archiveResult.disappearedIds.map(String)]
|
|
||||||
);
|
|
||||||
if (rowCount > 0) {
|
|
||||||
console.log(`[Ivanti Findings] Removed ${rowCount} archived findings from ivanti_findings`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Ivanti Findings] Failed to clean archived findings from table (non-fatal):', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas
|
// Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas
|
||||||
let previousOpenCount = 0;
|
let previousOpenCount = 0;
|
||||||
let previousClosedCount = 0;
|
let previousClosedCount = 0;
|
||||||
@@ -991,23 +915,9 @@ async function syncFindings() {
|
|||||||
await syncFPWorkflowCounts(allFindings, apiKey, clientId, skipTls);
|
await syncFPWorkflowCounts(allFindings, apiKey, clientId, skipTls);
|
||||||
|
|
||||||
// Post-sync: BU drift checker for newly archived findings
|
// Post-sync: BU drift checker for newly archived findings
|
||||||
// Filter out findings that were already in ARCHIVED state from a previous sync —
|
|
||||||
// only pass genuinely new disappearances to avoid re-classifying the same set every cycle.
|
|
||||||
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||||
try {
|
try {
|
||||||
let idsToCheck = archiveResult.disappearedIds || [];
|
classificationBreakdown = await runBUDriftChecker(archiveResult.disappearedIds, apiKey, clientId, skipTls);
|
||||||
if (idsToCheck.length > 0) {
|
|
||||||
const { rows: alreadyArchived } = await pool.query(
|
|
||||||
`SELECT finding_id FROM ivanti_finding_archives
|
|
||||||
WHERE current_state = 'ARCHIVED'
|
|
||||||
AND last_transition_at < NOW() - INTERVAL '2 hours'`
|
|
||||||
);
|
|
||||||
const alreadyArchivedSet = new Set(alreadyArchived.map(r => String(r.finding_id)));
|
|
||||||
const newlyArchivedOnly = idsToCheck.filter(id => !alreadyArchivedSet.has(String(id)));
|
|
||||||
console.log(`[BU Drift Checker] ${idsToCheck.length} disappeared total, ${newlyArchivedOnly.length} genuinely new (${alreadyArchivedSet.size} already archived, skipped)`);
|
|
||||||
idsToCheck = newlyArchivedOnly;
|
|
||||||
}
|
|
||||||
classificationBreakdown = await runBUDriftChecker(idsToCheck, apiKey, clientId, skipTls, previousBuMap);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
||||||
}
|
}
|
||||||
@@ -1123,14 +1033,12 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
dns: row.dns,
|
dns: row.dns,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
slaStatus: row.sla_status,
|
slaStatus: row.sla_status,
|
||||||
dueDate: formatDate(row.due_date),
|
dueDate: row.due_date,
|
||||||
lastFoundOn: formatDate(row.last_found_on),
|
lastFoundOn: row.last_found_on,
|
||||||
buOwnership: row.bu_ownership,
|
buOwnership: row.bu_ownership,
|
||||||
cves: row.cves || [],
|
cves: row.cves || [],
|
||||||
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
||||||
note: row.note || '',
|
note: row.note || '',
|
||||||
qualysIpv6: row.qualys_ipv6 || null,
|
|
||||||
primaryIpv6: row.primary_ipv6 || null,
|
|
||||||
overrides: {
|
overrides: {
|
||||||
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
||||||
...(row.override_dns ? { dns: row.override_dns } : {})
|
...(row.override_dns ? { dns: row.override_dns } : {})
|
||||||
@@ -1181,14 +1089,12 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
dns: row.dns,
|
dns: row.dns,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
slaStatus: row.sla_status,
|
slaStatus: row.sla_status,
|
||||||
dueDate: formatDate(row.due_date),
|
dueDate: row.due_date,
|
||||||
lastFoundOn: formatDate(row.last_found_on),
|
lastFoundOn: row.last_found_on,
|
||||||
buOwnership: row.bu_ownership,
|
buOwnership: row.bu_ownership,
|
||||||
cves: row.cves || [],
|
cves: row.cves || [],
|
||||||
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
||||||
note: row.note || '',
|
note: row.note || '',
|
||||||
qualysIpv6: row.qualys_ipv6 || null,
|
|
||||||
primaryIpv6: row.primary_ipv6 || null,
|
|
||||||
overrides: {
|
overrides: {
|
||||||
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
||||||
...(row.override_dns ? { dns: row.override_dns } : {})
|
...(row.override_dns ? { dns: row.override_dns } : {})
|
||||||
@@ -1486,40 +1392,18 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
/**
|
/**
|
||||||
* GET /api/ivanti/findings/bu-changes
|
* GET /api/ivanti/findings/bu-changes
|
||||||
*
|
*
|
||||||
* Return BU change events from ivanti_finding_bu_history.
|
* Return all BU change events from ivanti_finding_bu_history.
|
||||||
* Accepts optional `since` to filter by date, or `limit` to cap the result count.
|
|
||||||
* If `since` is provided, returns all changes on or after that timestamp.
|
|
||||||
* If neither is provided, returns the most recent 200 rows (max 500).
|
|
||||||
*
|
*
|
||||||
* @query {string} [since] - ISO timestamp; return changes where detected_at >= this value
|
* @returns {Object} 200 - { changes: Array<Object> }
|
||||||
* @query {string} [limit] - Maximum number of rows to return (default 200, max 500); ignored when `since` is provided
|
|
||||||
* @returns {Object} 200 - { changes: Array<{ id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at }> }
|
|
||||||
* @returns {Object} 500 - { error: string } on database error
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
*/
|
*/
|
||||||
router.get('/bu-changes', async (req, res) => {
|
router.get('/bu-changes', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { since, limit } = req.query;
|
const { rows } = await pool.query(
|
||||||
let rows;
|
|
||||||
if (since) {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
||||||
FROM ivanti_finding_bu_history
|
FROM ivanti_finding_bu_history
|
||||||
WHERE detected_at >= $1
|
ORDER BY detected_at DESC`
|
||||||
ORDER BY detected_at DESC`,
|
|
||||||
[since]
|
|
||||||
);
|
);
|
||||||
rows = result.rows;
|
|
||||||
} else {
|
|
||||||
const maxRows = Math.min(parseInt(limit) || 200, 500);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
|
||||||
FROM ivanti_finding_bu_history
|
|
||||||
ORDER BY detected_at DESC
|
|
||||||
LIMIT $1`,
|
|
||||||
[maxRows]
|
|
||||||
);
|
|
||||||
rows = result.rows;
|
|
||||||
}
|
|
||||||
res.json({ changes: rows });
|
res.json({ changes: rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Ivanti Findings] GET /bu-changes error:', err.message);
|
console.error('[Ivanti Findings] GET /bu-changes error:', err.message);
|
||||||
|
|||||||
@@ -137,24 +137,7 @@ const fpUpload = multer({ storage: uploadStorage, limits: { fileSize: 10 * 1024
|
|||||||
function createIvantiFpWorkflowRouter() {
|
function createIvantiFpWorkflowRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
/**
|
// GET /documents/search
|
||||||
* GET /api/ivanti/fp-workflow/documents/search
|
|
||||||
*
|
|
||||||
* Searches the documents library by name, CVE ID, or vendor.
|
|
||||||
* Returns up to 50 results ordered by upload date descending.
|
|
||||||
*
|
|
||||||
* @query {string} q — Optional search term; matches against name, cve_id, or vendor (ILIKE)
|
|
||||||
* @returns {Array<Object>} Array of document metadata objects
|
|
||||||
* - id {number}
|
|
||||||
* - cve_id {string}
|
|
||||||
* - vendor {string}
|
|
||||||
* - name {string}
|
|
||||||
* - type {string}
|
|
||||||
* - file_size {number}
|
|
||||||
* - mime_type {string}
|
|
||||||
* - uploaded_at {string}
|
|
||||||
* @error 500 Database error
|
|
||||||
*/
|
|
||||||
router.get('/documents/search', requireAuth(), async (req, res) => {
|
router.get('/documents/search', requireAuth(), async (req, res) => {
|
||||||
const q = (req.query.q || '').trim();
|
const q = (req.query.q || '').trim();
|
||||||
try {
|
try {
|
||||||
@@ -177,36 +160,7 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// POST / — Create FP workflow
|
||||||
* POST /api/ivanti/fp-workflow
|
|
||||||
*
|
|
||||||
* Creates a new False Positive workflow in Ivanti and records the submission locally.
|
|
||||||
* Validates queue items belong to the user, are FP type, and are pending.
|
|
||||||
* Uploads local files and library documents as attachments.
|
|
||||||
* Marks associated queue items as complete on success.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @body {Object} multipart/form-data
|
|
||||||
* - findingIds {string} JSON array of Ivanti finding IDs
|
|
||||||
* - queueItemIds {string} JSON array of todo queue item IDs
|
|
||||||
* - libraryDocIds {string} Optional JSON array of document library IDs to attach
|
|
||||||
* - name {string} Workflow name (required, max 255 chars)
|
|
||||||
* - reason {string} Reason for false positive (required)
|
|
||||||
* - description {string} Optional description (max 2000 chars)
|
|
||||||
* - expirationDate {string} Required, must be future date within 120 days
|
|
||||||
* - scopeOverride {string} Optional: Authorized, None, or Automated
|
|
||||||
* - attachments {File[]} Optional uploaded files (max 10, each max 10 MB)
|
|
||||||
* @returns {Object}
|
|
||||||
* - success {boolean}
|
|
||||||
* - workflowBatchId {number} Ivanti workflow batch ID
|
|
||||||
* - queueItemsUpdated {number} Count of queue items marked complete
|
|
||||||
* - status {string} "success"
|
|
||||||
* @error 400 Invalid input, queue item validation failure
|
|
||||||
* @error 403 Queue items belong to another user
|
|
||||||
* @error 429 Ivanti API rate limit
|
|
||||||
* @error 500 Ivanti API key not configured
|
|
||||||
* @error 502 Ivanti API connection or response failure
|
|
||||||
*/
|
|
||||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
fpUpload(req, res, (multerErr) => {
|
fpUpload(req, res, (multerErr) => {
|
||||||
if (multerErr) {
|
if (multerErr) {
|
||||||
@@ -260,12 +214,6 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
const foundIds = new Set(libraryDocs.map(d => d.id));
|
const foundIds = new Set(libraryDocs.map(d => d.id));
|
||||||
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
||||||
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
||||||
// Validate file types for library docs (same rules as local uploads)
|
|
||||||
const unsupportedDocs = libraryDocs.filter(d => !isAllowedFileExtension(d.name));
|
|
||||||
if (unsupportedDocs.length > 0) {
|
|
||||||
const names = unsupportedDocs.map(d => d.name).join(', ');
|
|
||||||
return res.status(400).json({ error: `Library document file type not supported by Ivanti: ${names}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryFormFiles = [];
|
const libraryFormFiles = [];
|
||||||
@@ -287,19 +235,8 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
|
|
||||||
if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) {
|
if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) {
|
||||||
const errorMap = { 401: 'Ivanti API key is invalid or missing.', 419: 'API key lacks workflow creation permissions.', 429: 'Ivanti API rate limit reached.' };
|
const errorMap = { 401: 'Ivanti API key is invalid or missing.', 419: 'API key lacks workflow creation permissions.', 429: 'Ivanti API rate limit reached.' };
|
||||||
let errorMsg = errorMap[createResult.status];
|
const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`;
|
||||||
if (!errorMsg) {
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: errorMsg, status: createResult.status, findingIds }, ipAddress: req.ip });
|
||||||
// Try to extract detail from the Ivanti response body
|
|
||||||
let bodyDetail = '';
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(createResult.body);
|
|
||||||
bodyDetail = parsed.message || parsed.error || parsed.detail || JSON.stringify(parsed);
|
|
||||||
} catch (_) {
|
|
||||||
bodyDetail = (createResult.body || '').slice(0, 500);
|
|
||||||
}
|
|
||||||
errorMsg = `Workflow creation failed (${createResult.status}): ${bodyDetail || 'No details returned by Ivanti API.'}`;
|
|
||||||
}
|
|
||||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: errorMsg, status: createResult.status, responseBody: (createResult.body || '').slice(0, 1000), findingIds }, ipAddress: req.ip });
|
|
||||||
return res.status(createResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg, step: 'create_workflow' });
|
return res.status(createResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg, step: 'create_workflow' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,38 +272,7 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// GET /submissions
|
||||||
* GET /api/ivanti/fp-workflow/submissions
|
|
||||||
*
|
|
||||||
* Returns all FP workflow submissions belonging to the authenticated user,
|
|
||||||
* enriched with Ivanti workflow state (rework notes, approval notes, current state).
|
|
||||||
* Automatically syncs lifecycle_status from Ivanti currentState when it differs.
|
|
||||||
* Includes submission history entries for each submission.
|
|
||||||
*
|
|
||||||
* @query None
|
|
||||||
* @returns {Array<Object>} Array of submission objects
|
|
||||||
* - id {number}
|
|
||||||
* - user_id {number}
|
|
||||||
* - username {string}
|
|
||||||
* - ivanti_workflow_batch_id {number}
|
|
||||||
* - ivanti_workflow_batch_uuid {string|null}
|
|
||||||
* - workflow_name {string}
|
|
||||||
* - reason {string}
|
|
||||||
* - description {string|null}
|
|
||||||
* - expiration_date {string}
|
|
||||||
* - scope_override {string}
|
|
||||||
* - finding_ids_json {string}
|
|
||||||
* - queue_item_ids_json {string}
|
|
||||||
* - attachment_count {number}
|
|
||||||
* - lifecycle_status {string} submitted|approved|rejected|rework|resubmitted
|
|
||||||
* - dismissed_at {string|null}
|
|
||||||
* - requeued_at {string|null}
|
|
||||||
* - history {Array<Object>} Submission history entries
|
|
||||||
* - ivanti_rework_note {string|null} Enriched from Ivanti API
|
|
||||||
* - ivanti_approval_note {string|null} Enriched from Ivanti API
|
|
||||||
* - ivanti_current_state {string|null} Enriched from Ivanti API
|
|
||||||
* @error 500 Internal server error
|
|
||||||
*/
|
|
||||||
router.get('/submissions', requireAuth(), async (req, res) => {
|
router.get('/submissions', requireAuth(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows: submissions } = await pool.query(`SELECT * FROM ivanti_fp_submissions WHERE user_id = $1 ORDER BY created_at DESC`, [req.user.id]);
|
const { rows: submissions } = await pool.query(`SELECT * FROM ivanti_fp_submissions WHERE user_id = $1 ORDER BY created_at DESC`, [req.user.id]);
|
||||||
@@ -396,53 +302,13 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { console.error('Error enriching submissions with Ivanti notes:', e.message); }
|
} catch (e) { console.error('Error enriching submissions with Ivanti notes:', e.message); }
|
||||||
|
|
||||||
// Sync lifecycle_status from Ivanti currentState when it differs
|
|
||||||
const STATE_MAP = { 'APPROVED': 'approved', 'REJECTED': 'rejected', 'REWORK': 'rework' };
|
|
||||||
for (const sub of submissions) {
|
|
||||||
if (!sub.ivanti_current_state) continue;
|
|
||||||
const mappedStatus = STATE_MAP[sub.ivanti_current_state.toUpperCase()];
|
|
||||||
if (mappedStatus && mappedStatus !== sub.lifecycle_status) {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE ivanti_fp_submissions SET lifecycle_status = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
[mappedStatus, sub.id]
|
|
||||||
);
|
|
||||||
sub.lifecycle_status = mappedStatus;
|
|
||||||
} catch (syncErr) { console.error(`Failed to sync lifecycle_status for submission ${sub.id}:`, syncErr.message); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json(submissions);
|
res.json(submissions);
|
||||||
} catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); }
|
} catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// PUT /submissions/:id — Edit FP workflow fields
|
||||||
* PUT /api/ivanti/fp-workflow/submissions/:id
|
|
||||||
*
|
|
||||||
* Edits the workflow fields of an existing FP submission. Proxies the update
|
|
||||||
* to the Ivanti API, then updates the local record. Transitions lifecycle_status
|
|
||||||
* to "resubmitted" if currently rejected or rework.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {string} id — Submission ID (URL parameter)
|
|
||||||
* @body {Object}
|
|
||||||
* - name {string} Workflow name (required, max 255 chars)
|
|
||||||
* - reason {string} Reason (required)
|
|
||||||
* - description {string} Optional (max 2000 chars)
|
|
||||||
* - expirationDate {string} Required, future date within 120 days
|
|
||||||
* - scopeOverride {string} Optional: Authorized, None, or Automated
|
|
||||||
* @returns {Object}
|
|
||||||
* - success {boolean}
|
|
||||||
* - submission {Object} The updated submission record
|
|
||||||
* @error 400 Validation errors or submission is finalized
|
|
||||||
* @error 403 Submission belongs to another user
|
|
||||||
* @error 404 Submission not found
|
|
||||||
* @error 429 Ivanti API rate limit
|
|
||||||
* @error 500 Ivanti API key not configured or local DB failure
|
|
||||||
* @error 502 Ivanti API connection or response failure
|
|
||||||
*/
|
|
||||||
router.put('/submissions/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.put('/submissions/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
const submissionId = req.params.id;
|
||||||
@@ -577,30 +443,7 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// POST /submissions/:id/findings — Map additional findings to existing workflow
|
||||||
* POST /api/ivanti/fp-workflow/submissions/:id/findings
|
|
||||||
*
|
|
||||||
* Maps additional findings to an existing FP workflow in Ivanti. Resolves the
|
|
||||||
* workflow batch UUID, then maps each finding individually. Successfully mapped
|
|
||||||
* findings are merged into the submission's finding_ids_json and their queue items
|
|
||||||
* are marked complete.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {string} id — Submission ID (URL parameter)
|
|
||||||
* @body {Object}
|
|
||||||
* - findingIds {Array<number|string>} Finding IDs to map (at least one required)
|
|
||||||
* - queueItemIds {Array<number>} Corresponding queue item IDs (at least one required)
|
|
||||||
* @returns {Object}
|
|
||||||
* - success {boolean}
|
|
||||||
* - addedFindings {Array} Successfully mapped finding IDs
|
|
||||||
* - failedFindings {Array} Finding IDs that failed to map
|
|
||||||
* - queueItemsUpdated {number} Count of queue items marked complete
|
|
||||||
* @error 400 Invalid input, queue item validation, or UUID resolution failure
|
|
||||||
* @error 403 Submission or queue items belong to another user
|
|
||||||
* @error 404 Submission not found
|
|
||||||
* @error 500 Ivanti API key not configured
|
|
||||||
* @error 502 All findings failed to map
|
|
||||||
*/
|
|
||||||
router.post('/submissions/:id/findings', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.post('/submissions/:id/findings', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
const submissionId = req.params.id;
|
||||||
@@ -757,27 +600,7 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// POST /submissions/:id/attachments — Upload additional attachments
|
||||||
* POST /api/ivanti/fp-workflow/submissions/:id/attachments
|
|
||||||
*
|
|
||||||
* Uploads additional attachments (local files and/or library documents) to an
|
|
||||||
* existing FP workflow in Ivanti. Updates the local submission's attachment count
|
|
||||||
* and results.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {string} id — Submission ID (URL parameter)
|
|
||||||
* @body {Object} multipart/form-data
|
|
||||||
* - attachments {File[]} Local files to upload (max 10, each max 10 MB)
|
|
||||||
* - libraryDocIds {string} Optional JSON array of document library IDs
|
|
||||||
* @returns {Object}
|
|
||||||
* - success {boolean}
|
|
||||||
* - attachmentResults {Array<Object>} Per-file upload results with filename, success, source
|
|
||||||
* - status {string} "success" if all uploaded, "partial" if some failed
|
|
||||||
* @error 400 No files provided, invalid file type, or submission is finalized
|
|
||||||
* @error 403 Submission belongs to another user
|
|
||||||
* @error 404 Submission not found
|
|
||||||
* @error 500 Ivanti API key not configured
|
|
||||||
*/
|
|
||||||
router.post('/submissions/:id/attachments', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.post('/submissions/:id/attachments', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
fpUpload(req, res, (multerErr) => {
|
fpUpload(req, res, (multerErr) => {
|
||||||
if (multerErr) {
|
if (multerErr) {
|
||||||
@@ -827,12 +650,6 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
const foundIds = new Set(libraryDocs.map(d => d.id));
|
const foundIds = new Set(libraryDocs.map(d => d.id));
|
||||||
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
||||||
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
||||||
// Validate file types for library docs
|
|
||||||
const unsupportedDocs = libraryDocs.filter(d => !isAllowedFileExtension(d.name));
|
|
||||||
if (unsupportedDocs.length > 0) {
|
|
||||||
const names = unsupportedDocs.map(d => d.name).join(', ');
|
|
||||||
return res.status(400).json({ error: `Library document file type not supported by Ivanti: ${names}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.IVANTI_API_KEY;
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
@@ -914,21 +731,7 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// PATCH /submissions/:id/dismiss — Dismiss a rejected submission
|
||||||
* PATCH /api/ivanti/fp-workflow/submissions/:id/dismiss
|
|
||||||
*
|
|
||||||
* Dismisses a rejected FP submission by setting dismissed_at timestamp.
|
|
||||||
* Only rejected submissions can be dismissed.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {string} id — Submission ID (URL parameter)
|
|
||||||
* @body None
|
|
||||||
* @returns {Object} { success: true }
|
|
||||||
* @error 400 Submission is not in rejected status
|
|
||||||
* @error 403 Submission belongs to another user
|
|
||||||
* @error 404 Submission not found
|
|
||||||
* @error 500 Internal server error
|
|
||||||
*/
|
|
||||||
router.patch('/submissions/:id/dismiss', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.patch('/submissions/:id/dismiss', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
const submissionId = req.params.id;
|
||||||
@@ -967,174 +770,7 @@ function createIvantiFpWorkflowRouter() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// PATCH /submissions/:id/status — Update lifecycle status
|
||||||
* POST /api/ivanti/fp-workflow/submissions/:id/requeue
|
|
||||||
*
|
|
||||||
* Re-queues findings from a rejected FP submission into the todo queue
|
|
||||||
* under a specified target workflow type. Creates new pending queue items
|
|
||||||
* for each finding referenced in the submission's queue_item_ids_json.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {string} id — Submission ID (URL parameter)
|
|
||||||
* @body {Object}
|
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
|
||||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
|
||||||
* @returns {Object}
|
|
||||||
* - success {boolean}
|
|
||||||
* - items {Array<Object>} Newly created queue items with parsed cves
|
|
||||||
* - count {number} Number of items created
|
|
||||||
* @error 400 Invalid input, submission not rejected, or already re-queued
|
|
||||||
* @error 403 Submission belongs to another user
|
|
||||||
* @error 404 Submission not found
|
|
||||||
* @error 500 Internal server error
|
|
||||||
*/
|
|
||||||
router.post('/submissions/:id/requeue', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
||||||
(async () => {
|
|
||||||
const submissionId = req.params.id;
|
|
||||||
const { workflow_type, vendor } = req.body;
|
|
||||||
|
|
||||||
// Validate workflow_type
|
|
||||||
const VALID_REQUEUE_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'];
|
|
||||||
const INVENTORY_TYPES = ['CARD', 'GRANITE'];
|
|
||||||
if (!VALID_REQUEUE_TYPES.includes(workflow_type)) {
|
|
||||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate vendor for FP/Archer/DECOM
|
|
||||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
|
||||||
if (!vendor || typeof vendor !== 'string' || vendor.trim().length === 0) {
|
|
||||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and DECOM workflows.' });
|
|
||||||
}
|
|
||||||
if (vendor.trim().length > 200) {
|
|
||||||
return res.status(400).json({ error: 'vendor must be 200 characters or fewer.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim();
|
|
||||||
|
|
||||||
// Fetch submission
|
|
||||||
const { rows: subRows } = await pool.query(
|
|
||||||
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
|
||||||
);
|
|
||||||
const submission = subRows[0];
|
|
||||||
|
|
||||||
if (!submission) return res.status(404).json({ error: 'Submission not found.' });
|
|
||||||
if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only re-queue your own submissions.' });
|
|
||||||
if (submission.lifecycle_status !== 'rejected') return res.status(400).json({ error: 'Only rejected submissions can be re-queued.' });
|
|
||||||
if (submission.requeued_at) return res.status(400).json({ error: 'Findings from this submission have already been re-queued.' });
|
|
||||||
|
|
||||||
// Parse original queue item IDs
|
|
||||||
let queueItemIds = [];
|
|
||||||
try {
|
|
||||||
queueItemIds = JSON.parse(submission.queue_item_ids_json || '[]');
|
|
||||||
} catch (e) { /* ignore parse errors */ }
|
|
||||||
|
|
||||||
// Parse finding IDs (always available, even for submissions created outside dashboard)
|
|
||||||
let findingIds = [];
|
|
||||||
try {
|
|
||||||
findingIds = JSON.parse(submission.finding_ids_json || '[]');
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
if ((!Array.isArray(queueItemIds) || queueItemIds.length === 0) &&
|
|
||||||
(!Array.isArray(findingIds) || findingIds.length === 0)) {
|
|
||||||
return res.status(400).json({ error: 'No findings associated with this submission.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch original queue items to get finding data (if they still exist)
|
|
||||||
let findingsToQueue = [];
|
|
||||||
if (queueItemIds.length > 0) {
|
|
||||||
const { rows: originalItems } = await pool.query(
|
|
||||||
`SELECT finding_id, finding_title, cves_json, ip_address, hostname FROM ivanti_todo_queue WHERE id = ANY($1)`,
|
|
||||||
[queueItemIds]
|
|
||||||
);
|
|
||||||
findingsToQueue = originalItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: if original queue items were deleted or never existed,
|
|
||||||
// use finding_ids_json to look up finding data from ivanti_findings
|
|
||||||
if (findingsToQueue.length === 0 && findingIds.length > 0) {
|
|
||||||
const { rows: findings } = await pool.query(
|
|
||||||
`SELECT id AS finding_id, title AS finding_title, cves, ip_address, host_name AS hostname FROM ivanti_findings WHERE id = ANY($1)`,
|
|
||||||
[findingIds.map(String)]
|
|
||||||
);
|
|
||||||
findingsToQueue = findings.map(f => ({
|
|
||||||
...f,
|
|
||||||
cves_json: Array.isArray(f.cves) ? JSON.stringify(f.cves) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Last resort: create items with just the finding IDs (minimal data)
|
|
||||||
if (findingsToQueue.length === 0) {
|
|
||||||
findingsToQueue = findingIds.map(id => ({
|
|
||||||
finding_id: String(id),
|
|
||||||
finding_title: null,
|
|
||||||
cves_json: null,
|
|
||||||
ip_address: null,
|
|
||||||
hostname: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// INSERT new pending queue items for each finding
|
|
||||||
const newItems = [];
|
|
||||||
for (const item of findingsToQueue) {
|
|
||||||
const { rows: inserted } = await pool.query(
|
|
||||||
`INSERT INTO ivanti_todo_queue
|
|
||||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING *`,
|
|
||||||
[req.user.id, item.finding_id, item.finding_title, item.cves_json, item.ip_address, item.hostname, vendorVal, workflow_type]
|
|
||||||
);
|
|
||||||
newItems.push(inserted[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UPDATE submission to mark as requeued
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE ivanti_fp_submissions SET requeued_at = NOW() WHERE id = $1`,
|
|
||||||
[submissionId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Audit log (fire-and-forget)
|
|
||||||
const auditFindingIds = findingsToQueue.map(i => i.finding_id).filter(Boolean);
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id, username: req.user.username,
|
|
||||||
action: 'fp_submission_requeued', entityType: 'ivanti_fp_submissions',
|
|
||||||
entityId: String(submission.id),
|
|
||||||
details: { target_workflow_type: workflow_type, items_created: newItems.length, finding_ids: auditFindingIds },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return items with parsed cves
|
|
||||||
const itemsWithCves = newItems.map(i => ({
|
|
||||||
...i,
|
|
||||||
cves: i.cves_json ? JSON.parse(i.cves_json) : []
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.status(201).json({ success: true, items: itemsWithCves, count: newItems.length });
|
|
||||||
})().catch((unexpectedErr) => {
|
|
||||||
console.error('Unexpected error in POST /submissions/:id/requeue:', unexpectedErr);
|
|
||||||
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/ivanti/fp-workflow/submissions/:id/status
|
|
||||||
*
|
|
||||||
* Manually updates the lifecycle status of an FP submission.
|
|
||||||
* Validates the transition is allowed (e.g., approved submissions cannot be changed).
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {string} id — Submission ID (URL parameter)
|
|
||||||
* @body {Object}
|
|
||||||
* - lifecycle_status {string} New status. One of: submitted, approved, rejected, rework, resubmitted
|
|
||||||
* @returns {Object}
|
|
||||||
* - success {boolean}
|
|
||||||
* - previousStatus {string}
|
|
||||||
* - newStatus {string}
|
|
||||||
* @error 400 Invalid transition or invalid status value
|
|
||||||
* @error 403 Submission belongs to another user
|
|
||||||
* @error 404 Submission not found
|
|
||||||
* @error 500 Internal server error
|
|
||||||
*/
|
|
||||||
router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
const submissionId = req.params.id;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const pool = require('../db');
|
|||||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
const logAudit = require('../helpers/auditLog');
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'];
|
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'];
|
||||||
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
||||||
const VALID_STATUSES = ['pending', 'complete'];
|
const VALID_STATUSES = ['pending', 'complete'];
|
||||||
|
|
||||||
@@ -32,36 +32,24 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* - ip_address {string|null}
|
* - ip_address {string|null}
|
||||||
* - hostname {string|null}
|
* - hostname {string|null}
|
||||||
* - vendor {string}
|
* - vendor {string}
|
||||||
* - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
* - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM
|
||||||
* - status {string} pending | complete
|
* - status {string} pending | complete
|
||||||
* - host_id {string|null} From the linked ivanti_findings record
|
|
||||||
* - remediation_notes_count {number}
|
|
||||||
* - created_at {string}
|
* - created_at {string}
|
||||||
* - updated_at {string}
|
* - updated_at {string}
|
||||||
*/
|
*/
|
||||||
router.get('/', requireAuth(), async (req, res) => {
|
router.get('/', requireAuth(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT q.*, COALESCE(nc.note_count, 0) AS remediation_notes_count,
|
`SELECT q.*
|
||||||
f.host_id AS host_id
|
|
||||||
FROM ivanti_todo_queue q
|
FROM ivanti_todo_queue q
|
||||||
LEFT JOIN (
|
|
||||||
SELECT queue_item_id, COUNT(*) AS note_count
|
|
||||||
FROM queue_remediation_notes
|
|
||||||
GROUP BY queue_item_id
|
|
||||||
) nc ON nc.queue_item_id = q.id
|
|
||||||
LEFT JOIN ivanti_findings f ON f.id = q.finding_id
|
|
||||||
WHERE q.user_id = $1
|
WHERE q.user_id = $1
|
||||||
ORDER BY q.vendor ASC, q.created_at ASC`,
|
ORDER BY q.vendor ASC, q.created_at ASC`,
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
const parsed = rows.map((r) => {
|
const parsed = rows.map((r) => ({
|
||||||
let cves = [];
|
...r,
|
||||||
if (r.cves_json) {
|
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||||
try { cves = JSON.parse(r.cves_json); } catch (e) { cves = []; }
|
}));
|
||||||
}
|
|
||||||
return { ...r, remediation_notes_count: parseInt(r.remediation_notes_count, 10), cves };
|
|
||||||
});
|
|
||||||
res.json(parsed);
|
res.json(parsed);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching todo queue:', err);
|
console.error('Error fetching todo queue:', err);
|
||||||
@@ -82,8 +70,8 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* - cves {Array<string>} Optional
|
* - cves {Array<string>} Optional
|
||||||
* - ip_address {string} Optional, max 64 chars
|
* - ip_address {string} Optional, max 64 chars
|
||||||
* - hostname {string} Optional, max 255 chars
|
* - hostname {string} Optional, max 255 chars
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||||
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||||
* @returns {Object} { items: Array<Object> } — inserted queue items with parsed `cves` array
|
* @returns {Object} { items: Array<Object> } — inserted queue items with parsed `cves` array
|
||||||
* @error 400 Invalid input
|
* @error 400 Invalid input
|
||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
@@ -103,12 +91,12 @@ function createIvantiTodoQueueRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||||
if (!isValidVendor(vendor)) {
|
if (!isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' });
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,8 +181,8 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* - cves {Array<string>} Optional
|
* - cves {Array<string>} Optional
|
||||||
* - ip_address {string} Optional, max 64 chars
|
* - ip_address {string} Optional, max 64 chars
|
||||||
* - hostname {string} Optional, max 255 chars
|
* - hostname {string} Optional, max 255 chars
|
||||||
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||||
* @returns {Object} The created queue item with parsed `cves` array
|
* @returns {Object} The created queue item with parsed `cves` array
|
||||||
* @error 400 Invalid input
|
* @error 400 Invalid input
|
||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
@@ -206,10 +194,10 @@ function createIvantiTodoQueueRouter() {
|
|||||||
return res.status(400).json({ error: 'finding_id is required.' });
|
return res.status(400).json({ error: 'finding_id is required.' });
|
||||||
}
|
}
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||||
}
|
}
|
||||||
if (!INVENTORY_TYPES.includes(workflow_type) && !isValidVendor(vendor)) {
|
if (!INVENTORY_TYPES.includes(workflow_type) && !isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' });
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
}
|
}
|
||||||
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||||
@@ -251,7 +239,7 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* @param {string} id — Queue item ID (URL parameter)
|
* @param {string} id — Queue item ID (URL parameter)
|
||||||
* @body {Object} At least one field required:
|
* @body {Object} At least one field required:
|
||||||
* - vendor {string} Optional, non-empty, max 200 chars
|
* - vendor {string} Optional, non-empty, max 200 chars
|
||||||
* - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
* - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||||
* - status {string} Optional. One of: pending, complete
|
* - status {string} Optional. One of: pending, complete
|
||||||
* @returns {Object} The updated queue item with parsed `cves` array
|
* @returns {Object} The updated queue item with parsed `cves` array
|
||||||
* @error 400 Invalid input or no fields to update
|
* @error 400 Invalid input or no fields to update
|
||||||
@@ -266,7 +254,7 @@ function createIvantiTodoQueueRouter() {
|
|||||||
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
||||||
}
|
}
|
||||||
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||||
}
|
}
|
||||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||||
@@ -327,17 +315,16 @@ function createIvantiTodoQueueRouter() {
|
|||||||
/**
|
/**
|
||||||
* POST /api/ivanti/todo-queue/:id/redirect
|
* POST /api/ivanti/todo-queue/:id/redirect
|
||||||
*
|
*
|
||||||
* Redirects a queue item to a different workflow type. If the item is pending,
|
* Redirects a completed queue item to a different workflow by creating a new
|
||||||
* updates workflow_type in place. If the item is complete, creates a new pending
|
* pending queue item with the same finding data but a new workflow type/vendor.
|
||||||
* queue item with the same finding data but a new workflow type/vendor.
|
|
||||||
* Requires Admin or Standard_User group.
|
* Requires Admin or Standard_User group.
|
||||||
*
|
*
|
||||||
* @param {string} id — Queue item ID (URL parameter)
|
* @param {string} id — Queue item ID of the completed item (URL parameter)
|
||||||
* @body {Object}
|
* @body {Object}
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||||
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||||
* @returns {Object} The updated or newly created queue item with parsed `cves` array
|
* @returns {Object} The newly created queue item with parsed `cves` array
|
||||||
* @error 400 Invalid input
|
* @error 400 Invalid input or item not in complete status
|
||||||
* @error 404 Queue item not found
|
* @error 404 Queue item not found
|
||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
*/
|
*/
|
||||||
@@ -346,11 +333,11 @@ function createIvantiTodoQueueRouter() {
|
|||||||
const { workflow_type, vendor } = req.body;
|
const { workflow_type, vendor } = req.body;
|
||||||
|
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||||
}
|
}
|
||||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||||
if (!isValidVendor(vendor)) {
|
if (!isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' });
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||||
@@ -368,38 +355,10 @@ function createIvantiTodoQueueRouter() {
|
|||||||
if (!original) {
|
if (!original) {
|
||||||
return res.status(404).json({ error: 'Queue item not found.' });
|
return res.status(404).json({ error: 'Queue item not found.' });
|
||||||
}
|
}
|
||||||
|
if (original.status !== 'complete') {
|
||||||
// If the item is still pending, update workflow_type in place (no duplication)
|
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||||
if (original.status === 'pending') {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE ivanti_todo_queue SET workflow_type = $1, vendor = $2, updated_at = NOW()
|
|
||||||
WHERE id = $3 AND user_id = $4 RETURNING *`,
|
|
||||||
[workflow_type, vendorVal, id, req.user.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'queue_item_redirected',
|
|
||||||
entityType: 'ivanti_todo_queue',
|
|
||||||
entityId: String(original.id),
|
|
||||||
details: {
|
|
||||||
original_workflow_type: original.workflow_type,
|
|
||||||
target_workflow_type: workflow_type,
|
|
||||||
method: 'in_place_update',
|
|
||||||
vendor: vendorVal,
|
|
||||||
},
|
|
||||||
ipAddress: req.ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
...rows[0],
|
|
||||||
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
|
|
||||||
};
|
|
||||||
return res.json(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the item is complete, create a new pending item (legacy behavior)
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO ivanti_todo_queue
|
`INSERT INTO ivanti_todo_queue
|
||||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||||
@@ -417,7 +376,6 @@ function createIvantiTodoQueueRouter() {
|
|||||||
details: {
|
details: {
|
||||||
original_workflow_type: original.workflow_type,
|
original_workflow_type: original.workflow_type,
|
||||||
target_workflow_type: workflow_type,
|
target_workflow_type: workflow_type,
|
||||||
method: 'new_item_from_complete',
|
|
||||||
new_item_id: rows[0].id,
|
new_item_id: rows[0].id,
|
||||||
vendor: vendorVal,
|
vendor: vendorVal,
|
||||||
},
|
},
|
||||||
@@ -435,41 +393,6 @@ function createIvantiTodoQueueRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/ivanti/todo-queue/ticket-links
|
|
||||||
*
|
|
||||||
* Returns Jira ticket associations for the current user's queue items.
|
|
||||||
* Joins jira_ticket_queue_items with jira_tickets to get ticket_key and url.
|
|
||||||
*
|
|
||||||
* @returns {Object} { links: { [queue_item_id]: { ticket_key, jira_url } } }
|
|
||||||
* @error 500 Internal server error
|
|
||||||
*/
|
|
||||||
router.get('/ticket-links', requireAuth(), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT jtqi.queue_item_id, jt.ticket_key, jt.url AS jira_url
|
|
||||||
FROM jira_ticket_queue_items jtqi
|
|
||||||
JOIN jira_tickets jt ON jt.id = jtqi.jira_ticket_id
|
|
||||||
JOIN ivanti_todo_queue q ON q.id = jtqi.queue_item_id
|
|
||||||
WHERE q.user_id = $1`,
|
|
||||||
[req.user.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const links = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
links[row.queue_item_id] = {
|
|
||||||
ticket_key: row.ticket_key,
|
|
||||||
jira_url: row.jira_url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ links });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching ticket links:', err);
|
|
||||||
res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/ivanti/todo-queue/completed
|
* DELETE /api/ivanti/todo-queue/completed
|
||||||
*
|
*
|
||||||
@@ -480,43 +403,15 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
*/
|
*/
|
||||||
router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
const result = await pool.query(
|
||||||
|
"DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
||||||
// Select completed item IDs for this user
|
|
||||||
const { rows: completedRows } = await client.query(
|
|
||||||
"SELECT id FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
|
res.json({ message: 'Completed items cleared.', deleted: result.rowCount });
|
||||||
if (completedRows.length === 0) {
|
|
||||||
await client.query('COMMIT');
|
|
||||||
return res.json({ message: 'Completed items cleared.', deleted: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = completedRows.map(r => r.id);
|
|
||||||
|
|
||||||
// Delete junction table references first
|
|
||||||
await client.query(
|
|
||||||
'DELETE FROM jira_ticket_queue_items WHERE queue_item_id = ANY($1::int[])',
|
|
||||||
[ids]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete the completed queue items
|
|
||||||
const deleteResult = await client.query(
|
|
||||||
'DELETE FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
|
|
||||||
[ids]
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
res.json({ message: 'Completed items cleared.', deleted: deleteResult.rowCount });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
|
||||||
console.error('Error clearing completed queue items:', err);
|
console.error('Error clearing completed queue items:', err);
|
||||||
res.status(500).json({ error: 'Internal server error.' });
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -554,118 +449,6 @@ function createIvantiTodoQueueRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Remediation Notes Routes
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ivanti/todo-queue/:id/notes
|
|
||||||
*
|
|
||||||
* Creates a remediation note for a queue item owned by the authenticated user.
|
|
||||||
* Requires Admin or Standard_User group.
|
|
||||||
*
|
|
||||||
* @param {string} id — Queue item ID (URL parameter)
|
|
||||||
* @body {Object}
|
|
||||||
* - note_text {string} Required, 1–5000 characters, non-whitespace-only
|
|
||||||
* @returns {Object} The created note with id, queue_item_id, user_id, username, note_text, created_at
|
|
||||||
* @error 400 Invalid note_text
|
|
||||||
* @error 404 Queue item not found or not owned
|
|
||||||
* @error 500 Internal server error
|
|
||||||
*/
|
|
||||||
router.post('/:id/notes', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { note_text } = req.body;
|
|
||||||
|
|
||||||
// Validate queue item exists and belongs to user
|
|
||||||
try {
|
|
||||||
const { rows: itemRows } = await pool.query(
|
|
||||||
'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
|
||||||
[id, req.user.id]
|
|
||||||
);
|
|
||||||
if (!itemRows[0]) {
|
|
||||||
return res.status(404).json({ error: 'Queue item not found.' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error checking queue item ownership:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate note_text
|
|
||||||
if (!note_text || typeof note_text !== 'string' || note_text.trim().length === 0) {
|
|
||||||
return res.status(400).json({ error: 'Note text is required.' });
|
|
||||||
}
|
|
||||||
if (note_text.length > 5000) {
|
|
||||||
return res.status(400).json({ error: 'Note text must not exceed 5000 characters.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`INSERT INTO queue_remediation_notes (queue_item_id, user_id, username, note_text)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, queue_item_id, user_id, username, note_text, created_at`,
|
|
||||||
[id, req.user.id, req.user.username, note_text]
|
|
||||||
);
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'create_remediation_note',
|
|
||||||
entityType: 'queue_remediation_notes',
|
|
||||||
entityId: String(rows[0].id),
|
|
||||||
details: { queue_item_id: parseInt(id, 10) },
|
|
||||||
ipAddress: req.ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(201).json(rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error creating remediation note:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/ivanti/todo-queue/:id/notes
|
|
||||||
*
|
|
||||||
* Returns all remediation notes for a queue item owned by the authenticated user.
|
|
||||||
* Notes are ordered by created_at descending (most recent first).
|
|
||||||
*
|
|
||||||
* @param {string} id — Queue item ID (URL parameter)
|
|
||||||
* @returns {Array<Object>} Array of note objects (empty array if none)
|
|
||||||
* @error 404 Queue item not found or not owned
|
|
||||||
* @error 500 Internal server error
|
|
||||||
*/
|
|
||||||
router.get('/:id/notes', requireAuth(), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
// Validate queue item exists and belongs to user
|
|
||||||
try {
|
|
||||||
const { rows: itemRows } = await pool.query(
|
|
||||||
'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
|
||||||
[id, req.user.id]
|
|
||||||
);
|
|
||||||
if (!itemRows[0]) {
|
|
||||||
return res.status(404).json({ error: 'Queue item not found.' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error checking queue item ownership:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT id, queue_item_id, user_id, username, note_text, created_at
|
|
||||||
FROM queue_remediation_notes
|
|
||||||
WHERE queue_item_id = $1
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching remediation notes:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|||||||
async function syncWorkflows() {
|
async function syncWorkflows() {
|
||||||
const apiKey = process.env.IVANTI_API_KEY;
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||||
|
const lastName = process.env.IVANTI_LAST_NAME || '';
|
||||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -26,37 +28,9 @@ async function syncWorkflows() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all unique Ivanti identities from users table
|
console.log('[Ivanti] Syncing workflows...');
|
||||||
const { rows: ivantiUsers } = await pool.query(
|
|
||||||
`SELECT DISTINCT ivanti_first_name, ivanti_last_name
|
|
||||||
FROM users
|
|
||||||
WHERE ivanti_first_name IS NOT NULL AND ivanti_last_name IS NOT NULL
|
|
||||||
AND ivanti_first_name != '' AND ivanti_last_name != ''`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fallback to env var if no users have Ivanti identity configured
|
|
||||||
if (ivantiUsers.length === 0) {
|
|
||||||
const envFirst = process.env.IVANTI_FIRST_NAME || '';
|
|
||||||
const envLast = process.env.IVANTI_LAST_NAME || '';
|
|
||||||
if (envFirst && envLast) {
|
|
||||||
ivantiUsers.push({ ivanti_first_name: envFirst, ivanti_last_name: envLast });
|
|
||||||
} else {
|
|
||||||
const errMsg = 'No Ivanti identities configured — set ivanti_first_name/ivanti_last_name on user accounts';
|
|
||||||
console.warn('[Ivanti]', errMsg);
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
||||||
[errMsg]
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Ivanti] Syncing workflows for ${ivantiUsers.length} user(s)...`);
|
|
||||||
|
|
||||||
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
||||||
let allWorkflows = [];
|
|
||||||
|
|
||||||
for (const user of ivantiUsers) {
|
|
||||||
const body = {
|
const body = {
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
@@ -65,7 +39,7 @@ async function syncWorkflows() {
|
|||||||
operator: 'IN',
|
operator: 'IN',
|
||||||
orWithPrevious: false,
|
orWithPrevious: false,
|
||||||
implicitFilters: [],
|
implicitFilters: [],
|
||||||
value: user.ivanti_last_name,
|
value: lastName,
|
||||||
caseSensitive: false
|
caseSensitive: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,7 +48,7 @@ async function syncWorkflows() {
|
|||||||
operator: 'IN',
|
operator: 'IN',
|
||||||
orWithPrevious: false,
|
orWithPrevious: false,
|
||||||
implicitFilters: [],
|
implicitFilters: [],
|
||||||
value: user.ivanti_first_name,
|
value: firstName,
|
||||||
caseSensitive: false
|
caseSensitive: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -97,54 +71,46 @@ async function syncWorkflows() {
|
|||||||
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||||
}
|
}
|
||||||
if (result.status !== 200) {
|
if (result.status !== 200) {
|
||||||
console.error(`[Ivanti] Workflow sync for ${user.ivanti_first_name} ${user.ivanti_last_name} returned ${result.status}`);
|
throw new Error(`Ivanti API returned unexpected status ${result.status}`);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(result.body);
|
const data = JSON.parse(result.body);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
let workflows = [];
|
let workflows = [];
|
||||||
|
|
||||||
if (data.page && typeof data.page.totalElements === 'number') {
|
if (data.page && typeof data.page.totalElements === 'number') {
|
||||||
|
total = data.page.totalElements;
|
||||||
workflows = data._embedded?.workflowBatches
|
workflows = data._embedded?.workflowBatches
|
||||||
|| data._embedded?.workflowBatch
|
|| data._embedded?.workflowBatch
|
||||||
|| [];
|
|| [];
|
||||||
} else if (typeof data.total === 'number') {
|
} else if (typeof data.total === 'number') {
|
||||||
|
total = data.total;
|
||||||
workflows = data.data || data.content || data.results || [];
|
workflows = data.data || data.content || data.results || [];
|
||||||
} else if (typeof data.totalElements === 'number') {
|
} else if (typeof data.totalElements === 'number') {
|
||||||
|
total = data.totalElements;
|
||||||
workflows = data.content || data.data || [];
|
workflows = data.content || data.data || [];
|
||||||
} else if (Array.isArray(data)) {
|
} else if (Array.isArray(data)) {
|
||||||
workflows = data;
|
workflows = data;
|
||||||
}
|
total = data.length;
|
||||||
|
|
||||||
// Tag each workflow with the Ivanti identity that owns it
|
|
||||||
workflows.forEach(w => {
|
|
||||||
w._ivanti_first_name = user.ivanti_first_name;
|
|
||||||
w._ivanti_last_name = user.ivanti_last_name;
|
|
||||||
});
|
|
||||||
|
|
||||||
allWorkflows = allWorkflows.concat(workflows);
|
|
||||||
console.log(`[Ivanti] ${user.ivanti_first_name} ${user.ivanti_last_name}: ${workflows.length} workflows`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Ivanti] Workflow sync failed for ${user.ivanti_first_name} ${user.ivanti_last_name}:`, err.message);
|
|
||||||
// If it's a fatal error (auth), break and report
|
|
||||||
if (err.message.includes('401') || err.message.includes('419')) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
||||||
[err.message]
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE ivanti_sync_state
|
`UPDATE ivanti_sync_state
|
||||||
SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
|
SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
|
||||||
WHERE id=1`,
|
WHERE id=1`,
|
||||||
[allWorkflows.length, JSON.stringify(allWorkflows)]
|
[total, JSON.stringify(workflows)]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[Ivanti] Sync complete — ${allWorkflows.length} total workflows`);
|
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || 'Unknown error';
|
||||||
|
console.error('[Ivanti] Sync failed:', msg);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
||||||
|
[msg]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -208,55 +174,16 @@ function createIvantiWorkflowsRouter() {
|
|||||||
// All routes require authentication
|
// All routes require authentication
|
||||||
router.use(requireAuth());
|
router.use(requireAuth());
|
||||||
|
|
||||||
/**
|
// GET / — return cached data (fast, no external call)
|
||||||
* GET /api/ivanti/workflows
|
|
||||||
*
|
|
||||||
* Returns cached Ivanti workflow data filtered by the logged-in user's
|
|
||||||
* Ivanti identity (ivanti_first_name / ivanti_last_name on their account).
|
|
||||||
* If the user has no Ivanti identity configured, returns all workflows (admin view).
|
|
||||||
*
|
|
||||||
* @returns {object} 200 - { total, workflows, synced_at, sync_status, error_message }
|
|
||||||
* @returns {object} 500 - { error: 'Database error reading sync state' }
|
|
||||||
*/
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const state = await readState();
|
res.json(await readState());
|
||||||
|
|
||||||
// Get logged-in user's Ivanti identity
|
|
||||||
const { rows: userRows } = await pool.query(
|
|
||||||
'SELECT ivanti_first_name, ivanti_last_name FROM users WHERE id = $1',
|
|
||||||
[req.user.id]
|
|
||||||
);
|
|
||||||
const ivantiUser = userRows[0];
|
|
||||||
|
|
||||||
// If user has Ivanti identity, filter workflows to only theirs
|
|
||||||
if (ivantiUser && ivantiUser.ivanti_first_name && ivantiUser.ivanti_last_name) {
|
|
||||||
state.workflows = state.workflows.filter(w =>
|
|
||||||
(w._ivanti_first_name || '').toLowerCase() === ivantiUser.ivanti_first_name.toLowerCase() &&
|
|
||||||
(w._ivanti_last_name || '').toLowerCase() === ivantiUser.ivanti_last_name.toLowerCase()
|
|
||||||
);
|
|
||||||
state.total = state.workflows.length;
|
|
||||||
}
|
|
||||||
// If no Ivanti identity configured, show all (admin view)
|
|
||||||
|
|
||||||
res.json(state);
|
|
||||||
} catch {
|
} catch {
|
||||||
res.status(500).json({ error: 'Database error reading sync state' });
|
res.status(500).json({ error: 'Database error reading sync state' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||||
* POST /api/ivanti/workflows/sync
|
|
||||||
*
|
|
||||||
* Triggers an immediate Ivanti workflow sync for all configured user identities,
|
|
||||||
* awaits completion, and returns the updated cached state. Requires Admin or
|
|
||||||
* Standard_User group.
|
|
||||||
*
|
|
||||||
* @returns {object} 200 - { total, workflows, synced_at, sync_status, error_message }
|
|
||||||
* @returns {object} 401 - { error: 'Authentication required' }
|
|
||||||
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin', 'Standard_User'], current: '...' }
|
|
||||||
* @returns {object} 500 - { error: 'Sync ran but could not read updated state' }
|
|
||||||
*/
|
|
||||||
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
await syncWorkflows();
|
await syncWorkflows();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const jiraApi = require('../helpers/jiraApi');
|
|||||||
|
|
||||||
// Validation helpers
|
// Validation helpers
|
||||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||||
|
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||||
|
|
||||||
function isValidCveId(cveId) {
|
function isValidCveId(cveId) {
|
||||||
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||||
@@ -34,16 +35,6 @@ function createJiraTicketsRouter() {
|
|||||||
// Jira API integration endpoints
|
// Jira API integration endpoints
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jira-tickets/connection-test
|
|
||||||
*
|
|
||||||
* Tests connectivity to the configured Jira instance.
|
|
||||||
*
|
|
||||||
* @requires Admin group
|
|
||||||
* @returns {object} 200 - { connected: true, user: { name, displayName, ... } }
|
|
||||||
* @returns {object} 502 - { connected: false, error: string } on connection failure
|
|
||||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
||||||
*/
|
|
||||||
router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||||
if (!jiraApi.isConfigured) {
|
if (!jiraApi.isConfigured) {
|
||||||
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
||||||
@@ -69,33 +60,10 @@ function createJiraTicketsRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jira-tickets/rate-limit
|
|
||||||
*
|
|
||||||
* Returns the current Jira API rate limit status (burst and daily counters).
|
|
||||||
*
|
|
||||||
* @requires Admin group
|
|
||||||
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
|
|
||||||
*/
|
|
||||||
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
|
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
|
||||||
res.json(jiraApi.getRateLimitStatus());
|
res.json(jiraApi.getRateLimitStatus());
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jira-tickets/lookup/:issueKey
|
|
||||||
*
|
|
||||||
* Looks up a single Jira issue by its key (e.g., PROJECT-123) and returns
|
|
||||||
* a summary of its fields.
|
|
||||||
*
|
|
||||||
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
|
|
||||||
* @requires Authenticated user
|
|
||||||
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
|
||||||
* @returns {object} 400 - { error: string } for invalid issue key format
|
|
||||||
* @returns {object} 404 - { error: string } when issue not found in Jira
|
|
||||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
|
||||||
* @returns {object} 502 - { error: string } on Jira API error
|
|
||||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
||||||
*/
|
|
||||||
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
|
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
|
||||||
if (!jiraApi.isConfigured) {
|
if (!jiraApi.isConfigured) {
|
||||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||||
@@ -125,24 +93,8 @@ function createJiraTicketsRouter() {
|
|||||||
if (result.rateLimited) {
|
if (result.rateLimited) {
|
||||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||||
}
|
}
|
||||||
// Build a meaningful error message from Jira's response
|
|
||||||
let errorMsg = result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.';
|
|
||||||
if (result.body) {
|
|
||||||
try {
|
|
||||||
const parsed = typeof result.body === 'string' ? JSON.parse(result.body) : result.body;
|
|
||||||
if (parsed.errorMessages && parsed.errorMessages.length > 0) {
|
|
||||||
errorMsg = parsed.errorMessages.join('; ');
|
|
||||||
} else if (parsed.errors && Object.keys(parsed.errors).length > 0) {
|
|
||||||
errorMsg = Object.values(parsed.errors).join('; ');
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (typeof result.body === 'string' && result.body.length < 300) {
|
|
||||||
errorMsg = result.body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res.status(result.status === 404 ? 404 : 502).json({
|
return res.status(result.status === 404 ? 404 : 502).json({
|
||||||
error: errorMsg,
|
error: result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.',
|
||||||
details: result.body
|
details: result.body
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -150,63 +102,19 @@ function createJiraTicketsRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jira-tickets/create-in-jira
|
|
||||||
*
|
|
||||||
* Creates a new issue in Jira and saves a local tracking record.
|
|
||||||
*
|
|
||||||
* @requires Admin or Standard_User group
|
|
||||||
* @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+); stored as NULL if absent/empty
|
|
||||||
* @body {string} [vendor] - Optional vendor name (max 200 chars after trim); stored as NULL if absent/empty/whitespace
|
|
||||||
* @body {string} summary - Required issue summary (max 255 chars)
|
|
||||||
* @body {string} [description] - Optional issue description
|
|
||||||
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
|
|
||||||
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
|
|
||||||
* @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (defaults to 'manual')
|
|
||||||
* @returns {object} 201 - { id, ticket_key, jira_url, source_context, message }
|
|
||||||
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local DB save failed
|
|
||||||
* @returns {object} 400 - { error: string } for validation failures
|
|
||||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
|
||||||
* @returns {object} 502 - { error: string } on Jira API error
|
|
||||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
||||||
*/
|
|
||||||
router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!jiraApi.isConfigured) {
|
if (!jiraApi.isConfigured) {
|
||||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body;
|
const { cve_id, vendor, summary, description, project_key, issue_type } = req.body;
|
||||||
|
|
||||||
// --- CVE ID validation: optional, but must match format if non-empty ---
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
let normalizedCveId = null;
|
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
|
||||||
if (!isValidCveId(cve_id)) {
|
|
||||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
|
||||||
}
|
}
|
||||||
normalizedCveId = cve_id;
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Vendor validation: optional, but must be <= 200 chars after trim if non-empty ---
|
|
||||||
let normalizedVendor = null;
|
|
||||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 0) {
|
|
||||||
const trimmedVendor = vendor.trim();
|
|
||||||
if (trimmedVendor.length > 200) {
|
|
||||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
|
||||||
}
|
|
||||||
normalizedVendor = trimmedVendor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- source_context validation: must be in allowed set if provided, default to 'manual' ---
|
|
||||||
const ALLOWED_SOURCE_CONTEXTS = ['cve', 'archer', 'ivanti_queue', 'email', 'manual'];
|
|
||||||
let normalizedSourceContext = 'manual';
|
|
||||||
if (source_context !== undefined && source_context !== null) {
|
|
||||||
if (!ALLOWED_SOURCE_CONTEXTS.includes(source_context)) {
|
|
||||||
return res.status(400).json({ error: 'source_context must be one of: cve, archer, ivanti_queue, email, manual.' });
|
|
||||||
}
|
|
||||||
normalizedSourceContext = source_context;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Summary validation: required, non-empty, max 255 chars ---
|
|
||||||
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
||||||
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
||||||
}
|
}
|
||||||
@@ -245,10 +153,10 @@ function createJiraTicketsRouter() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context)
|
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext]
|
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -257,7 +165,7 @@ function createJiraTicketsRouter() {
|
|||||||
action: 'jira_ticket_create_via_api',
|
action: 'jira_ticket_create_via_api',
|
||||||
entityType: 'jira_ticket',
|
entityType: 'jira_ticket',
|
||||||
entityId: rows[0].id.toString(),
|
entityId: rows[0].id.toString(),
|
||||||
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
|
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +173,6 @@ function createJiraTicketsRouter() {
|
|||||||
id: rows[0].id,
|
id: rows[0].id,
|
||||||
ticket_key: ticketKey,
|
ticket_key: ticketKey,
|
||||||
jira_url: jiraUrl,
|
jira_url: jiraUrl,
|
||||||
source_context: normalizedSourceContext,
|
|
||||||
message: 'Jira issue created and linked successfully'
|
message: 'Jira issue created and linked successfully'
|
||||||
});
|
});
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
@@ -282,58 +189,32 @@ function createJiraTicketsRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jira-tickets/sync-all
|
|
||||||
*
|
|
||||||
* Syncs all local Jira ticket records with their current Jira status using
|
|
||||||
* bulk JQL search. Updates summary, status, and last_synced_at for each ticket.
|
|
||||||
* Stops early if rate limits are approaching.
|
|
||||||
*
|
|
||||||
* @requires Admin group
|
|
||||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
|
||||||
* @returns {object} 500 - { error: string } on internal error
|
|
||||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
||||||
*/
|
|
||||||
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||||
if (!jiraApi.isConfigured) {
|
if (!jiraApi.isConfigured) {
|
||||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Only sync tickets that are NOT in a completed/closed state.
|
|
||||||
// Completed tickets are pulled on the sync where they first become completed,
|
|
||||||
// but on subsequent syncs they are skipped to avoid unnecessary API calls.
|
|
||||||
const { rows: tickets } = await pool.query(
|
const { rows: tickets } = await pool.query(
|
||||||
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
|
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Separate active vs completed tickets
|
if (tickets.length === 0) {
|
||||||
const CLOSED_STATUSES = ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'];
|
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
|
||||||
const isCompleted = (status) => {
|
|
||||||
if (!status) return false;
|
|
||||||
const lower = status.toLowerCase();
|
|
||||||
return CLOSED_STATUSES.some(s => lower.includes(s));
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeTickets = tickets.filter(t => !isCompleted(t.status));
|
|
||||||
const skippedCompleted = tickets.length - activeTickets.length;
|
|
||||||
|
|
||||||
if (activeTickets.length === 0) {
|
|
||||||
return res.json({ synced: 0, failed: 0, skipped: skippedCompleted, unchanged: 0, errors: [], skippedCompleted });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
||||||
|
|
||||||
const BATCH_SIZE = 100;
|
const BATCH_SIZE = 100;
|
||||||
const batches = [];
|
const batches = [];
|
||||||
for (let i = 0; i < activeTickets.length; i += BATCH_SIZE) {
|
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
|
||||||
batches.push(activeTickets.slice(i, i + BATCH_SIZE));
|
batches.push(tickets.slice(i, i + BATCH_SIZE));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const batch of batches) {
|
for (const batch of batches) {
|
||||||
const rateStatus = jiraApi.getRateLimitStatus();
|
const rateStatus = jiraApi.getRateLimitStatus();
|
||||||
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
||||||
const remaining = activeTickets.length - results.synced - results.failed - results.unchanged;
|
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
|
||||||
results.skipped += remaining;
|
results.skipped += remaining;
|
||||||
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
||||||
break;
|
break;
|
||||||
@@ -367,11 +248,12 @@ function createJiraTicketsRouter() {
|
|||||||
|
|
||||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||||
|
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||||
[jiraSummary, jiraStatus || 'Open', jiraStatus, ticket.id]
|
[jiraSummary, localStatus, jiraStatus, ticket.id]
|
||||||
);
|
);
|
||||||
results.synced++;
|
results.synced++;
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
@@ -391,33 +273,17 @@ function createJiraTicketsRouter() {
|
|||||||
action: 'jira_sync_all',
|
action: 'jira_sync_all',
|
||||||
entityType: 'jira_integration',
|
entityType: 'jira_integration',
|
||||||
entityId: null,
|
entityId: null,
|
||||||
details: { ...results, skippedCompleted },
|
details: results,
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ ...results, skippedCompleted });
|
res.json(results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jira-tickets/:id/sync
|
|
||||||
*
|
|
||||||
* Syncs a single local Jira ticket record with its current Jira status.
|
|
||||||
* Fetches the issue by ticket_key and updates summary, status, and last_synced_at.
|
|
||||||
*
|
|
||||||
* @param {string} id - Local ticket ID (path parameter)
|
|
||||||
* @requires Admin or Standard_User group
|
|
||||||
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
|
|
||||||
* @returns {object} 400 - { error: string } when ticket has no Jira key
|
|
||||||
* @returns {object} 404 - { error: string } when local ticket not found
|
|
||||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
|
||||||
* @returns {object} 500 - { error: string } on internal error
|
|
||||||
* @returns {object} 502 - { error: string } on Jira API error
|
|
||||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
||||||
*/
|
|
||||||
router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!jiraApi.isConfigured) {
|
if (!jiraApi.isConfigured) {
|
||||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||||
@@ -447,10 +313,11 @@ function createJiraTicketsRouter() {
|
|||||||
const issue = result.data;
|
const issue = result.data;
|
||||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||||
|
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||||
[jiraSummary, jiraStatus || 'Open', jiraStatus, id]
|
[jiraSummary, localStatus, jiraStatus, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -472,7 +339,7 @@ function createJiraTicketsRouter() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -480,22 +347,8 @@ function createJiraTicketsRouter() {
|
|||||||
// Local CRUD endpoints
|
// Local CRUD endpoints
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jira-tickets
|
|
||||||
*
|
|
||||||
* Lists all Jira tickets with optional filtering by query parameters.
|
|
||||||
* Results are ordered by created_at descending.
|
|
||||||
*
|
|
||||||
* @query {string} [cve_id] - Filter by exact CVE ID
|
|
||||||
* @query {string} [vendor] - Filter by exact vendor name
|
|
||||||
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
|
|
||||||
* @query {string} [source_context] - Filter by source context (cve, archer, ivanti_queue, email, manual)
|
|
||||||
* @requires Authenticated user
|
|
||||||
* @returns {array} 200 - Array of jira_tickets rows
|
|
||||||
* @returns {object} 500 - { error: string } on internal error
|
|
||||||
*/
|
|
||||||
router.get('/', requireAuth(), async (req, res) => {
|
router.get('/', requireAuth(), async (req, res) => {
|
||||||
const { cve_id, vendor, status, source_context } = req.query;
|
const { cve_id, vendor, status } = req.query;
|
||||||
|
|
||||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -513,10 +366,6 @@ function createJiraTicketsRouter() {
|
|||||||
query += ` AND status = $${paramIndex++}`;
|
query += ` AND status = $${paramIndex++}`;
|
||||||
params.push(status);
|
params.push(status);
|
||||||
}
|
}
|
||||||
if (source_context) {
|
|
||||||
query += ` AND source_context = $${paramIndex++}`;
|
|
||||||
params.push(source_context);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY created_at DESC';
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
@@ -525,45 +374,18 @@ function createJiraTicketsRouter() {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching JIRA tickets:', err);
|
console.error('Error fetching JIRA tickets:', err);
|
||||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jira-tickets
|
|
||||||
*
|
|
||||||
* Creates a local Jira ticket record (without creating an issue in Jira).
|
|
||||||
* Used for manually tracking tickets that already exist in Jira.
|
|
||||||
*
|
|
||||||
* @requires Admin or Standard_User group
|
|
||||||
* @body {string} cve_id - Required CVE ID (format: CVE-YYYY-NNNN+)
|
|
||||||
* @body {string} vendor - Required vendor name (max 200 chars)
|
|
||||||
* @body {string} ticket_key - Required Jira ticket key (max 50 chars)
|
|
||||||
* @body {string} [url] - Optional Jira ticket URL (max 500 chars)
|
|
||||||
* @body {string} [summary] - Optional summary (max 500 chars)
|
|
||||||
* @body {string} [status] - Optional status: Open, In Progress, or Closed (defaults to Open)
|
|
||||||
* @returns {object} 201 - { id, message }
|
|
||||||
* @returns {object} 400 - { error: string } for validation failures
|
|
||||||
* @returns {object} 500 - { error: string } on internal error
|
|
||||||
*/
|
|
||||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
// CVE ID is optional — validate format only if provided and non-empty
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
let normalizedCveId = null;
|
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||||
if (cve_id && typeof cve_id === 'string' && cve_id.trim().length > 0) {
|
|
||||||
if (!isValidCveId(cve_id)) {
|
|
||||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
|
||||||
}
|
}
|
||||||
normalizedCveId = cve_id;
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
}
|
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||||
// Vendor is optional — validate length only if provided and non-empty
|
|
||||||
let normalizedVendor = null;
|
|
||||||
if (vendor && typeof vendor === 'string' && vendor.trim().length > 0) {
|
|
||||||
if (vendor.trim().length > 200) {
|
|
||||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
|
||||||
}
|
|
||||||
normalizedVendor = vendor.trim();
|
|
||||||
}
|
}
|
||||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||||
@@ -574,8 +396,8 @@ function createJiraTicketsRouter() {
|
|||||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||||
}
|
}
|
||||||
if (status && typeof status !== 'string') {
|
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||||
return res.status(400).json({ error: 'Status must be a string.' });
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketStatus = status || 'Open';
|
const ticketStatus = status || 'Open';
|
||||||
@@ -585,7 +407,7 @@ function createJiraTicketsRouter() {
|
|||||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[normalizedCveId, normalizedVendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -594,7 +416,7 @@ function createJiraTicketsRouter() {
|
|||||||
action: 'jira_ticket_create',
|
action: 'jira_ticket_create',
|
||||||
entityType: 'jira_ticket',
|
entityType: 'jira_ticket',
|
||||||
entityId: rows[0].id.toString(),
|
entityId: rows[0].id.toString(),
|
||||||
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key, status: ticketStatus },
|
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -604,48 +426,14 @@ function createJiraTicketsRouter() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating JIRA ticket:', err);
|
console.error('Error creating JIRA ticket:', err);
|
||||||
res.status(500).json({ error: `Failed to save ticket: ${err.message}` });
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/jira-tickets/:id
|
|
||||||
*
|
|
||||||
* Updates an existing local Jira ticket record. Only provided fields are updated.
|
|
||||||
* The source_context field is immutable after creation — including it returns 400.
|
|
||||||
*
|
|
||||||
* @param {string} id - Local ticket ID (path parameter)
|
|
||||||
* @requires Admin or Standard_User group
|
|
||||||
* @body {string} [cve_id] - CVE ID (format: CVE-YYYY-NNNN+, null/empty to clear)
|
|
||||||
* @body {string} [vendor] - Vendor name (max 200 chars, null/empty to clear)
|
|
||||||
* @body {string} [ticket_key] - Jira ticket key (max 50 chars)
|
|
||||||
* @body {string} [url] - Jira ticket URL (max 500 chars, null to clear)
|
|
||||||
* @body {string} [summary] - Summary (max 500 chars, null to clear)
|
|
||||||
* @body {string} [status] - Status: Open, In Progress, or Closed
|
|
||||||
* @returns {object} 200 - { message, changes }
|
|
||||||
* @returns {object} 400 - { error: string } for validation failures or source_context mutation attempt
|
|
||||||
* @returns {object} 404 - { error: string } when ticket not found
|
|
||||||
* @returns {object} 500 - { error: string } on internal error
|
|
||||||
*/
|
|
||||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
const { ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
// source_context is immutable after creation (Requirement 3.6)
|
|
||||||
if ('source_context' in req.body) {
|
|
||||||
return res.status(400).json({ error: 'source_context is immutable after creation' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate cve_id if provided
|
|
||||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
|
||||||
if (!isValidCveId(cve_id)) {
|
|
||||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validate vendor if provided
|
|
||||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 200) {
|
|
||||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
|
||||||
}
|
|
||||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||||
}
|
}
|
||||||
@@ -655,16 +443,14 @@ function createJiraTicketsRouter() {
|
|||||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||||
}
|
}
|
||||||
if (status !== undefined && typeof status !== 'string') {
|
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||||
return res.status(400).json({ error: 'Status must be a string.' });
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (cve_id !== undefined) { fields.push(`cve_id = $${paramIndex++}`); values.push(cve_id || null); }
|
|
||||||
if (vendor !== undefined) { fields.push(`vendor = $${paramIndex++}`); values.push(vendor ? vendor.trim() : null); }
|
|
||||||
if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
|
if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
|
||||||
if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
|
if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
|
||||||
if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }
|
if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }
|
||||||
@@ -702,24 +488,10 @@ function createJiraTicketsRouter() {
|
|||||||
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
|
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating JIRA ticket:', err);
|
console.error('Error updating JIRA ticket:', err);
|
||||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/jira-tickets/:id
|
|
||||||
*
|
|
||||||
* Deletes a local Jira ticket record. Admin can delete any ticket.
|
|
||||||
* Standard_User can only delete tickets they created, and only if the ticket
|
|
||||||
* is not linked to an active compliance item.
|
|
||||||
*
|
|
||||||
* @param {string} id - Local ticket ID (path parameter)
|
|
||||||
* @requires Admin or Standard_User group
|
|
||||||
* @returns {object} 200 - { message }
|
|
||||||
* @returns {object} 403 - { error: string } when user lacks permission or ticket is linked to compliance
|
|
||||||
* @returns {object} 404 - { error: string } when ticket not found
|
|
||||||
* @returns {object} 500 - { error: string } on internal error
|
|
||||||
*/
|
|
||||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -783,91 +555,7 @@ function createJiraTicketsRouter() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting JIRA ticket:', err);
|
console.error('Error deleting JIRA ticket:', err);
|
||||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Junction table endpoint — link queue items to a Jira ticket
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jira-tickets/:id/queue-items
|
|
||||||
*
|
|
||||||
* Records associations between a Jira ticket and Ivanti queue items that
|
|
||||||
* contributed to it. Uses ON CONFLICT DO NOTHING to handle duplicates.
|
|
||||||
*
|
|
||||||
* @param {string} id - Local Jira ticket ID (path parameter)
|
|
||||||
* @requires Admin or Standard_User group
|
|
||||||
* @body {number[]} queue_item_ids - Non-empty array of ivanti_todo_queue IDs
|
|
||||||
* @returns {object} 201 - { message, ticket_id, linked_count }
|
|
||||||
* @returns {object} 400 - { error: string } for validation failures
|
|
||||||
* @returns {object} 404 - { error: string } when ticket not found
|
|
||||||
* @returns {object} 500 - { error: string } on internal error
|
|
||||||
*/
|
|
||||||
router.post('/:id/queue-items', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { queue_item_ids } = req.body;
|
|
||||||
|
|
||||||
// Validate queue_item_ids is a non-empty array of integers
|
|
||||||
if (!Array.isArray(queue_item_ids) || queue_item_ids.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const qid of queue_item_ids) {
|
|
||||||
if (!Number.isInteger(qid)) {
|
|
||||||
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify the jira_ticket exists
|
|
||||||
const { rows: ticketRows } = await pool.query(
|
|
||||||
'SELECT id FROM jira_tickets WHERE id = $1',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
if (ticketRows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Jira ticket not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all referenced queue items exist
|
|
||||||
const { rows: existingItems } = await pool.query(
|
|
||||||
'SELECT id FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
|
|
||||||
[queue_item_ids]
|
|
||||||
);
|
|
||||||
if (existingItems.length !== queue_item_ids.length) {
|
|
||||||
return res.status(400).json({ error: 'One or more queue items not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert rows with ON CONFLICT DO NOTHING
|
|
||||||
const values = queue_item_ids.map((qid, idx) => `($1, $${idx + 2})`).join(', ');
|
|
||||||
const params = [id, ...queue_item_ids];
|
|
||||||
|
|
||||||
const { rowCount } = await pool.query(
|
|
||||||
`INSERT INTO jira_ticket_queue_items (jira_ticket_id, queue_item_id)
|
|
||||||
VALUES ${values}
|
|
||||||
ON CONFLICT (jira_ticket_id, queue_item_id) DO NOTHING`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'jira_ticket_link_queue_items',
|
|
||||||
entityType: 'jira_ticket',
|
|
||||||
entityId: id,
|
|
||||||
details: { queue_item_ids, linked_count: rowCount },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
message: 'Queue items linked to ticket',
|
|
||||||
ticket_id: parseInt(id, 10),
|
|
||||||
linked_count: rowCount
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error linking queue items to Jira ticket:', err);
|
|
||||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,22 +40,7 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
return ALLOWED_EXTENSIONS.has(ext);
|
return ALLOWED_EXTENSIONS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// POST /api/knowledge-base/upload
|
||||||
* POST /api/knowledge-base/upload
|
|
||||||
*
|
|
||||||
* Uploads a new knowledge base document.
|
|
||||||
*
|
|
||||||
* @body {FormData} file - The file to upload (max 10MB)
|
|
||||||
* @body {string} title - Document title (required)
|
|
||||||
* @body {string} [description] - Optional description
|
|
||||||
* @body {string} [category] - Category name (defaults to 'General')
|
|
||||||
*
|
|
||||||
* @returns {object} 200 - { success: true, id, title, slug, category }
|
|
||||||
* @returns {object} 400 - { error } if title missing, no file, or invalid file type
|
|
||||||
* @returns {object} 500 - { error } on server failure
|
|
||||||
*
|
|
||||||
* @requires Admin | Standard_User
|
|
||||||
*/
|
|
||||||
router.post('/upload', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
router.post('/upload', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||||
upload.single('file')(req, res, (err) => {
|
upload.single('file')(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -159,18 +144,7 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// GET /api/knowledge-base
|
||||||
* GET /api/knowledge-base
|
|
||||||
*
|
|
||||||
* Lists all knowledge base articles, ordered by creation date descending.
|
|
||||||
*
|
|
||||||
* @returns {Array<object>} 200 - Array of articles with fields:
|
|
||||||
* id, title, slug, description, category, file_name, file_type,
|
|
||||||
* file_size, created_at, updated_at, created_by_username
|
|
||||||
* @returns {object} 500 - { error } on server failure
|
|
||||||
*
|
|
||||||
* @requires Authenticated user (any group)
|
|
||||||
*/
|
|
||||||
router.get('/', requireAuth(), async (req, res) => {
|
router.get('/', requireAuth(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
@@ -189,21 +163,7 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// GET /api/knowledge-base/:id
|
||||||
* GET /api/knowledge-base/:id
|
|
||||||
*
|
|
||||||
* Retrieves metadata for a single knowledge base article.
|
|
||||||
*
|
|
||||||
* @param {number} id - Article ID (route param)
|
|
||||||
*
|
|
||||||
* @returns {object} 200 - Article metadata: id, title, slug, description,
|
|
||||||
* category, file_name, file_type, file_size, created_at, updated_at,
|
|
||||||
* created_by_username
|
|
||||||
* @returns {object} 404 - { error: 'Article not found' }
|
|
||||||
* @returns {object} 500 - { error } on server failure
|
|
||||||
*
|
|
||||||
* @requires Authenticated user (any group)
|
|
||||||
*/
|
|
||||||
router.get('/:id', requireAuth(), async (req, res) => {
|
router.get('/:id', requireAuth(), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -229,21 +189,7 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// GET /api/knowledge-base/:id/content
|
||||||
* GET /api/knowledge-base/:id/content
|
|
||||||
*
|
|
||||||
* Serves the raw file content inline for rendering in the browser.
|
|
||||||
* Sets Content-Security-Policy frame-ancestors for iframe embedding.
|
|
||||||
* Logs a VIEW_KB_ARTICLE audit event.
|
|
||||||
*
|
|
||||||
* @param {number} id - Article ID (route param)
|
|
||||||
*
|
|
||||||
* @returns {file} 200 - Raw file content with appropriate Content-Type
|
|
||||||
* @returns {object} 404 - { error } if article or file not found
|
|
||||||
* @returns {object} 500 - { error } on server failure
|
|
||||||
*
|
|
||||||
* @requires Authenticated user (any group)
|
|
||||||
*/
|
|
||||||
router.get('/:id/content', requireAuth(), async (req, res) => {
|
router.get('/:id/content', requireAuth(), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -257,12 +203,7 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
return res.status(404).json({ error: 'Document not found' });
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve relative paths against the backend directory
|
if (!fs.existsSync(row.file_path)) {
|
||||||
const absoluteFilePath = path.isAbsolute(row.file_path)
|
|
||||||
? row.file_path
|
|
||||||
: path.resolve(path.join(__dirname, '..'), row.file_path);
|
|
||||||
|
|
||||||
if (!fs.existsSync(absoluteFilePath)) {
|
|
||||||
return res.status(404).json({ error: 'File not found on disk' });
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,27 +230,14 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
res.removeHeader('X-Frame-Options');
|
res.removeHeader('X-Frame-Options');
|
||||||
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
|
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
|
||||||
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
|
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
|
||||||
res.sendFile(absoluteFilePath);
|
res.sendFile(row.file_path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching document:', err);
|
console.error('Error fetching document:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch document' });
|
res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// GET /api/knowledge-base/:id/download
|
||||||
* GET /api/knowledge-base/:id/download
|
|
||||||
*
|
|
||||||
* Downloads the file as an attachment (Content-Disposition: attachment).
|
|
||||||
* Logs a DOWNLOAD_KB_ARTICLE audit event.
|
|
||||||
*
|
|
||||||
* @param {number} id - Article ID (route param)
|
|
||||||
*
|
|
||||||
* @returns {file} 200 - File content with attachment disposition
|
|
||||||
* @returns {object} 404 - { error } if article or file not found
|
|
||||||
* @returns {object} 500 - { error } on server failure
|
|
||||||
*
|
|
||||||
* @requires Authenticated user (any group)
|
|
||||||
*/
|
|
||||||
router.get('/:id/download', requireAuth(), async (req, res) => {
|
router.get('/:id/download', requireAuth(), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -323,12 +251,7 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
return res.status(404).json({ error: 'Document not found' });
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve relative paths against the backend directory
|
if (!fs.existsSync(row.file_path)) {
|
||||||
const absoluteFilePath = path.isAbsolute(row.file_path)
|
|
||||||
? row.file_path
|
|
||||||
: path.resolve(path.join(__dirname, '..'), row.file_path);
|
|
||||||
|
|
||||||
if (!fs.existsSync(absoluteFilePath)) {
|
|
||||||
return res.status(404).json({ error: 'File not found on disk' });
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,29 +268,14 @@ function createKnowledgeBaseRouter(upload) {
|
|||||||
const safeDownloadName = row.file_name.replace(/["\r\n\\]/g, '');
|
const safeDownloadName = row.file_name.replace(/["\r\n\\]/g, '');
|
||||||
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
|
||||||
res.sendFile(absoluteFilePath);
|
res.sendFile(row.file_path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching document:', err);
|
console.error('Error fetching document:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch document' });
|
res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// DELETE /api/knowledge-base/:id
|
||||||
* DELETE /api/knowledge-base/:id
|
|
||||||
*
|
|
||||||
* Deletes a knowledge base article and its associated file from disk.
|
|
||||||
* Standard_User can only delete articles they created; Admin can delete any.
|
|
||||||
* Logs a DELETE_KB_ARTICLE audit event.
|
|
||||||
*
|
|
||||||
* @param {number} id - Article ID (route param)
|
|
||||||
*
|
|
||||||
* @returns {object} 200 - { success: true }
|
|
||||||
* @returns {object} 403 - { error } if Standard_User tries to delete another user's article
|
|
||||||
* @returns {object} 404 - { error: 'Article not found' }
|
|
||||||
* @returns {object} 500 - { error } on server failure
|
|
||||||
*
|
|
||||||
* @requires Admin | Standard_User
|
|
||||||
*/
|
|
||||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
// Notifications route — in-app notification management for users
|
|
||||||
// Provides unread notifications, counts, and mark-as-read operations.
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const pool = require('../db');
|
|
||||||
const { requireAuth } = require('../middleware/auth');
|
|
||||||
|
|
||||||
function createNotificationsRouter() {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// All routes require authentication
|
|
||||||
router.use(requireAuth());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/notifications
|
|
||||||
* Returns unread notifications for the current user, ordered by newest first.
|
|
||||||
* Limited to 50 results.
|
|
||||||
*/
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT id, type, title, message, issue_number, read, created_at
|
|
||||||
FROM notifications
|
|
||||||
WHERE username = $1 AND read = FALSE
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 50`,
|
|
||||||
[req.user.username]
|
|
||||||
);
|
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Notifications] Error fetching notifications:', err.message);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/notifications/count
|
|
||||||
* Returns the unread notification count for the current user (for badge display).
|
|
||||||
*/
|
|
||||||
router.get('/count', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT COUNT(*)::int AS unread
|
|
||||||
FROM notifications
|
|
||||||
WHERE username = $1 AND read = FALSE`,
|
|
||||||
[req.user.username]
|
|
||||||
);
|
|
||||||
res.json({ unread: rows[0].unread });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Notifications] Error fetching count:', err.message);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch notification count' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/notifications/:id/read
|
|
||||||
* Marks a single notification as read. Only the owning user can mark their own.
|
|
||||||
*/
|
|
||||||
router.patch('/:id/read', async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
try {
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE notifications SET read = TRUE
|
|
||||||
WHERE id = $1 AND username = $2`,
|
|
||||||
[id, req.user.username]
|
|
||||||
);
|
|
||||||
if (result.rowCount === 0) {
|
|
||||||
return res.status(404).json({ error: 'Notification not found' });
|
|
||||||
}
|
|
||||||
res.json({ status: 'ok' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Notifications] Error marking read:', err.message);
|
|
||||||
res.status(500).json({ error: 'Failed to mark notification as read' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/notifications/read-all
|
|
||||||
* Marks all notifications as read for the current user.
|
|
||||||
*/
|
|
||||||
router.post('/read-all', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE notifications SET read = TRUE
|
|
||||||
WHERE username = $1 AND read = FALSE`,
|
|
||||||
[req.user.username]
|
|
||||||
);
|
|
||||||
res.json({ status: 'ok', marked: result.rowCount });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Notifications] Error marking all read:', err.message);
|
|
||||||
res.status(500).json({ error: 'Failed to mark notifications as read' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createNotificationsRouter;
|
|
||||||
@@ -10,28 +10,11 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|||||||
// All routes require Admin group
|
// All routes require Admin group
|
||||||
router.use(requireAuth(), requireGroup('Admin'));
|
router.use(requireAuth(), requireGroup('Admin'));
|
||||||
|
|
||||||
/**
|
// Get all users
|
||||||
* GET /api/users
|
|
||||||
*
|
|
||||||
* Returns all user accounts ordered by creation date (newest first).
|
|
||||||
*
|
|
||||||
* @returns {Array<Object>} 200 - Array of user objects
|
|
||||||
* @returns {Object} 200[].id - User ID
|
|
||||||
* @returns {string} 200[].username - Username
|
|
||||||
* @returns {string} 200[].email - Email address
|
|
||||||
* @returns {string} 200[].group - Permission group (Admin, Standard_User, Leadership, Read_Only)
|
|
||||||
* @returns {string} 200[].bu_teams - Comma-separated BU team assignments
|
|
||||||
* @returns {Array<string>} 200[].teams - Parsed array of BU team assignments
|
|
||||||
* @returns {boolean} 200[].is_active - Whether the account is active
|
|
||||||
* @returns {string} 200[].created_at - ISO timestamp of account creation
|
|
||||||
* @returns {string|null} 200[].last_login - ISO timestamp of last login
|
|
||||||
* @returns {Object} 500 - { error: string }
|
|
||||||
*/
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows: users } = await pool.query(
|
const { rows: users } = await pool.query(
|
||||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login,
|
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
|
||||||
ivanti_first_name, ivanti_last_name
|
|
||||||
FROM users ORDER BY created_at DESC`
|
FROM users ORDER BY created_at DESC`
|
||||||
);
|
);
|
||||||
// Parse bu_teams into teams array for each user
|
// Parse bu_teams into teams array for each user
|
||||||
@@ -46,21 +29,11 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// Get single user
|
||||||
* GET /api/users/:id
|
|
||||||
*
|
|
||||||
* Returns a single user account by ID.
|
|
||||||
*
|
|
||||||
* @param {string} req.params.id - User ID
|
|
||||||
* @returns {Object} 200 - User object with parsed teams array
|
|
||||||
* @returns {Object} 404 - { error: 'User not found' }
|
|
||||||
* @returns {Object} 500 - { error: string }
|
|
||||||
*/
|
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login,
|
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
|
||||||
ivanti_first_name, ivanti_last_name
|
|
||||||
FROM users WHERE id = $1`,
|
FROM users WHERE id = $1`,
|
||||||
[req.params.id]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
@@ -81,21 +54,7 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// Create new user
|
||||||
* POST /api/users
|
|
||||||
*
|
|
||||||
* Creates a new user account.
|
|
||||||
*
|
|
||||||
* @body {string} username - Required. Unique username
|
|
||||||
* @body {string} email - Required. Unique email address
|
|
||||||
* @body {string} password - Required. Plain-text password (hashed before storage)
|
|
||||||
* @body {string} [group='Read_Only'] - Permission group (Admin, Standard_User, Leadership, Read_Only)
|
|
||||||
* @body {string} [bu_teams=''] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV)
|
|
||||||
* @returns {Object} 201 - { message: string, user: { id, username, email, group, bu_teams, teams } }
|
|
||||||
* @returns {Object} 400 - { error: string } if required fields missing or invalid group/teams
|
|
||||||
* @returns {Object} 409 - { error: string } if username or email already exists
|
|
||||||
* @returns {Object} 500 - { error: string }
|
|
||||||
*/
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { username, email, password, group, bu_teams } = req.body;
|
const { username, email, password, group, bu_teams } = req.body;
|
||||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||||
@@ -161,28 +120,9 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// Update user
|
||||||
* PATCH /api/users/:id
|
|
||||||
*
|
|
||||||
* Updates one or more fields on an existing user account. Only provided fields are modified.
|
|
||||||
*
|
|
||||||
* @param {string} req.params.id - User ID to update
|
|
||||||
* @body {string} [username] - New username
|
|
||||||
* @body {string} [email] - New email address
|
|
||||||
* @body {string} [password] - New plain-text password (hashed before storage)
|
|
||||||
* @body {string} [group] - New permission group (Admin, Standard_User, Leadership, Read_Only)
|
|
||||||
* @body {boolean} [is_active] - Whether the account is active (deactivation deletes sessions)
|
|
||||||
* @body {string} [bu_teams] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV)
|
|
||||||
* @body {string} [ivanti_first_name] - Ivanti first name for finding correlation
|
|
||||||
* @body {string} [ivanti_last_name] - Ivanti last name for finding correlation
|
|
||||||
* @returns {Object} 200 - { message: 'User updated successfully' }
|
|
||||||
* @returns {Object} 400 - { error: string } if invalid group, self-demotion, self-deactivation, invalid teams, or no fields provided
|
|
||||||
* @returns {Object} 404 - { error: 'User not found' }
|
|
||||||
* @returns {Object} 409 - { error: string } if username or email already exists
|
|
||||||
* @returns {Object} 500 - { error: string }
|
|
||||||
*/
|
|
||||||
router.patch('/:id', async (req, res) => {
|
router.patch('/:id', async (req, res) => {
|
||||||
const { username, email, password, group, is_active, bu_teams, ivanti_first_name, ivanti_last_name } = req.body;
|
const { username, email, password, group, is_active, bu_teams } = req.body;
|
||||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
@@ -253,14 +193,6 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|||||||
updates.push(`bu_teams = $${paramIndex++}`);
|
updates.push(`bu_teams = $${paramIndex++}`);
|
||||||
values.push(bu_teams);
|
values.push(bu_teams);
|
||||||
}
|
}
|
||||||
if (typeof ivanti_first_name === 'string') {
|
|
||||||
updates.push(`ivanti_first_name = $${paramIndex++}`);
|
|
||||||
values.push(ivanti_first_name.trim() || null);
|
|
||||||
}
|
|
||||||
if (typeof ivanti_last_name === 'string') {
|
|
||||||
updates.push(`ivanti_last_name = $${paramIndex++}`);
|
|
||||||
values.push(ivanti_last_name.trim() || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return res.status(400).json({ error: 'No fields to update' });
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
@@ -280,8 +212,6 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|||||||
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
||||||
if (password) updatedFields.password_changed = true;
|
if (password) updatedFields.password_changed = true;
|
||||||
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
|
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
|
||||||
if (typeof ivanti_first_name === 'string') updatedFields.ivanti_first_name = ivanti_first_name.trim() || null;
|
|
||||||
if (typeof ivanti_last_name === 'string') updatedFields.ivanti_last_name = ivanti_last_name.trim() || null;
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -340,17 +270,7 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// Delete user
|
||||||
* DELETE /api/users/:id
|
|
||||||
*
|
|
||||||
* Deletes a user account and their associated sessions. Cannot delete your own account.
|
|
||||||
*
|
|
||||||
* @param {string} req.params.id - User ID to delete
|
|
||||||
* @returns {Object} 200 - { message: 'User deleted successfully' }
|
|
||||||
* @returns {Object} 400 - { error: string } if attempting self-deletion
|
|
||||||
* @returns {Object} 404 - { error: 'User not found' }
|
|
||||||
* @returns {Object} 500 - { error: string }
|
|
||||||
*/
|
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
|||||||
// GitLab Webhook Routes — receives issue lifecycle events from GitLab
|
|
||||||
// Used to create in-app notifications when feedback issues are closed.
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
const GITLAB_WEBHOOK_SECRET = process.env.GITLAB_WEBHOOK_SECRET || '';
|
|
||||||
|
|
||||||
function createWebhooksRouter() {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/webhooks/gitlab
|
|
||||||
*
|
|
||||||
* Receives GitLab issue webhook events. When an issue is closed, parses the
|
|
||||||
* submitter username from the issue description and creates an in-app notification.
|
|
||||||
*
|
|
||||||
* Always returns HTTP 200 to prevent GitLab from retrying on app-level failures.
|
|
||||||
*
|
|
||||||
* @header {string} x-gitlab-token - Webhook secret token (must match GITLAB_WEBHOOK_SECRET env var)
|
|
||||||
* @body {object} object_attributes - GitLab issue event payload
|
|
||||||
* @body {string} object_attributes.action - The issue action (only 'close' is processed)
|
|
||||||
* @body {string} object_attributes.title - The issue title
|
|
||||||
* @body {number} object_attributes.iid - The issue number
|
|
||||||
* @body {string} object_attributes.description - The issue description (parsed for "**Submitted by:** username")
|
|
||||||
* @returns {object} 200 - { status: 'ok', notified: username }
|
|
||||||
* @returns {object} 200 - { status: 'ignored', reason: 'invalid token' | 'not a close event' | 'no submitter in description' | 'user not found' }
|
|
||||||
* @returns {object} 200 - { status: 'error', message: string }
|
|
||||||
*/
|
|
||||||
router.post('/gitlab', express.json(), async (req, res) => {
|
|
||||||
// Always return 200 — webhooks should not retry on app-level failures
|
|
||||||
try {
|
|
||||||
// Validate webhook secret token
|
|
||||||
const token = req.headers['x-gitlab-token'];
|
|
||||||
if (!GITLAB_WEBHOOK_SECRET || token !== GITLAB_WEBHOOK_SECRET) {
|
|
||||||
console.warn('[Webhook] Invalid or missing X-Gitlab-Token');
|
|
||||||
return res.status(200).json({ status: 'ignored', reason: 'invalid token' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { object_attributes } = req.body || {};
|
|
||||||
|
|
||||||
// Only process issue close events
|
|
||||||
if (!object_attributes || object_attributes.action !== 'close') {
|
|
||||||
return res.status(200).json({ status: 'ignored', reason: 'not a close event' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const issueTitle = object_attributes.title || 'Untitled';
|
|
||||||
const issueNumber = object_attributes.iid;
|
|
||||||
const description = object_attributes.description || '';
|
|
||||||
|
|
||||||
// Parse submitter username from issue description
|
|
||||||
// Format: **Submitted by:** username
|
|
||||||
const submitterMatch = description.match(/\*\*Submitted by:\*\*\s*(\S+)/);
|
|
||||||
if (!submitterMatch) {
|
|
||||||
console.log('[Webhook] No submitter found in issue description — skipping notification');
|
|
||||||
return res.status(200).json({ status: 'ignored', reason: 'no submitter in description' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = submitterMatch[1];
|
|
||||||
|
|
||||||
// Verify user exists in database
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
'SELECT id FROM users WHERE username = $1',
|
|
||||||
[username]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rows || rows.length === 0) {
|
|
||||||
console.log(`[Webhook] No user found for "${username}" — skipping notification`);
|
|
||||||
return res.status(200).json({ status: 'ignored', reason: 'user not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = rows[0].id;
|
|
||||||
|
|
||||||
// Insert in-app notification
|
|
||||||
const message = `Your bug report **${issueTitle}** (Issue #${issueNumber}) has been resolved and deployed.`;
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO notifications (user_id, username, type, title, message, issue_number)
|
|
||||||
VALUES ($1, $2, 'issue_resolved', $3, $4, $5)`,
|
|
||||||
[userId, username, issueTitle, message, issueNumber]
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`[Webhook] Issue #${issueNumber} closed — notification created for ${username}`);
|
|
||||||
return res.status(200).json({ status: 'ok', notified: username });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Webhook] Error processing GitLab webhook:', err.message);
|
|
||||||
return res.status(200).json({ status: 'error', message: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createWebhooksRouter;
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* CARD API Connectivity Test
|
|
||||||
* Tests: token acquisition → teams list → sample asset lookup
|
|
||||||
*/
|
|
||||||
require('dns').setDefaultResultOrder('ipv4first');
|
|
||||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
||||||
|
|
||||||
const { isConfigured, missingVars, testConnection, getTeams } = require('../helpers/cardApi');
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('=== CARD API Connectivity Test ===');
|
|
||||||
console.log(`Timestamp: ${new Date().toISOString()}`);
|
|
||||||
console.log(`Target: ${process.env.CARD_API_URL}`);
|
|
||||||
console.log(`User: ${process.env.CARD_API_USER}`);
|
|
||||||
console.log(`TLS Skip: ${process.env.CARD_SKIP_TLS}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
if (!isConfigured) {
|
|
||||||
console.error('FAIL: CARD API not configured. Missing:', missingVars.join(', '));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Token acquisition
|
|
||||||
console.log('1. Acquiring Bearer token...');
|
|
||||||
const connResult = await testConnection();
|
|
||||||
if (!connResult.ok) {
|
|
||||||
console.error(' FAIL:', connResult.error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(' OK — token acquired:', connResult.token);
|
|
||||||
|
|
||||||
// Step 2: List teams
|
|
||||||
console.log('2. Fetching teams (GET /api/v1/teams)...');
|
|
||||||
const teamsResult = await getTeams();
|
|
||||||
console.log(' Status:', teamsResult.status);
|
|
||||||
if (!teamsResult.ok) {
|
|
||||||
console.error(' FAIL:', teamsResult.body.substring(0, 300));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let teams;
|
|
||||||
try {
|
|
||||||
teams = JSON.parse(teamsResult.body);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(' FAIL: Could not parse response:', teamsResult.body.substring(0, 200));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(teams)) {
|
|
||||||
console.log(` OK — ${teams.length} teams found`);
|
|
||||||
const sample = teams.slice(0, 8);
|
|
||||||
sample.forEach(t => {
|
|
||||||
const name = t.name || t.team_name || t.teamName || JSON.stringify(t).substring(0, 60);
|
|
||||||
console.log(` • ${name}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log(' Response structure:', Object.keys(teams).join(', '));
|
|
||||||
console.log(' Preview:', JSON.stringify(teams).substring(0, 200));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('=== RESULT: PASS — CARD API is reachable and authenticated ===');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('ERROR:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Temporary diagnostic script — fetch a specific finding and dump host fields
|
|
||||||
require('dotenv').config();
|
|
||||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
|
||||||
|
|
||||||
const apiKey = process.env.IVANTI_API_KEY;
|
|
||||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
||||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
||||||
|
|
||||||
const findingId = process.argv[2] || '2814870699';
|
|
||||||
|
|
||||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
||||||
const body = {
|
|
||||||
filters: [
|
|
||||||
{ field: 'id', exclusive: false, operator: 'EXACT', orWithPrevious: false, implicitFilters: [], value: findingId, caseSensitive: false }
|
|
||||||
],
|
|
||||||
projection: 'internal',
|
|
||||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
||||||
page: 0,
|
|
||||||
size: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
ivantiPost(urlPath, body, apiKey, skipTls).then(r => {
|
|
||||||
const data = JSON.parse(r.body);
|
|
||||||
const finding = (data._embedded && data._embedded.hostFindings || [])[0];
|
|
||||||
if (!finding) { console.log('Finding not found'); process.exit(0); }
|
|
||||||
|
|
||||||
console.log('=== host object ===');
|
|
||||||
console.log(JSON.stringify(finding.host, null, 2));
|
|
||||||
console.log('');
|
|
||||||
console.log('=== hostAdditionalDetails ===');
|
|
||||||
console.log(JSON.stringify(finding.hostAdditionalDetails, null, 2));
|
|
||||||
process.exit(0);
|
|
||||||
}).catch(e => { console.error(e.message); process.exit(1); });
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// ==========================================================================
|
|
||||||
// Jira UAT Test Script
|
|
||||||
// ==========================================================================
|
|
||||||
// Exercises every Jira REST API use case the STEAM Dashboard will run in
|
|
||||||
// production. Run this against the UAT instance before submitting the
|
|
||||||
// ATLSUP Rest API Approval ticket.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// cd backend
|
|
||||||
// node scripts/jira-uat-test.js
|
|
||||||
//
|
|
||||||
// Note: The JQL search test uses a 72-hour window (updated >= -72h) to
|
|
||||||
// match the production bulk-sync behavior and account for weekend gaps.
|
|
||||||
//
|
|
||||||
// Prerequisites:
|
|
||||||
// - backend/.env has JIRA_BASE_URL pointing to UAT
|
|
||||||
// - JIRA_API_USER / JIRA_API_TOKEN set to service account credentials
|
|
||||||
// - JIRA_PROJECT_KEY set to a UAT project your service account can access
|
|
||||||
// - Service account has been granted access to the target space by space owners
|
|
||||||
//
|
|
||||||
// The script logs every API call, response status, and timing to both
|
|
||||||
// console and a log file at backend/scripts/jira-uat-test.log for the
|
|
||||||
// ATLSUP reviewers.
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const jiraApi = require('../helpers/jiraApi');
|
|
||||||
|
|
||||||
const LOG_FILE = path.join(__dirname, 'jira-uat-test.log');
|
|
||||||
const results = [];
|
|
||||||
let createdIssueKey = null;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Logging
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function log(level, message, data) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const entry = { timestamp, level, message };
|
|
||||||
if (data !== undefined) entry.data = data;
|
|
||||||
results.push(entry);
|
|
||||||
|
|
||||||
const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`;
|
|
||||||
console.log(line);
|
|
||||||
if (data) {
|
|
||||||
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
||||||
// Truncate long data to keep logs readable (HTML error pages can be 50KB+)
|
|
||||||
const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr;
|
|
||||||
console.log(' ' + truncated.split('\n').join('\n '));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logPass(testName, data) { log('pass', `PASS: ${testName}`, data); }
|
|
||||||
function logFail(testName, data) { log('fail', `FAIL: ${testName}`, data); }
|
|
||||||
function logInfo(message, data) { log('info', message, data); }
|
|
||||||
function logWarn(message, data) { log('warn', message, data); }
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test runner
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function runTest(name, fn) {
|
|
||||||
logInfo(`--- Running: ${name} ---`);
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
logPass(name, { durationMs: Date.now() - start });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
logFail(name, { error: err.message, durationMs: Date.now() - start });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(condition, message) {
|
|
||||||
if (!condition) throw new Error('Assertion failed: ' + message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 1: Connection Test (GET /rest/api/2/myself)
|
|
||||||
// Production use: Admin clicks "Test Connection" button on Jira settings panel
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testConnection() {
|
|
||||||
const result = await jiraApi.testConnection();
|
|
||||||
assert(result.ok, 'Connection test should succeed. Got: ' + JSON.stringify(result));
|
|
||||||
assert(result.user && result.user.name, 'Should return authenticated user name');
|
|
||||||
logInfo('Authenticated as:', result.user);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 2: Create Issue (POST /rest/api/2/issue)
|
|
||||||
// Production use: User clicks "Create in Jira" from CVE detail panel
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testCreateIssue() {
|
|
||||||
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
|
||||||
assert(projectKey, 'JIRA_PROJECT_KEY must be set in .env');
|
|
||||||
|
|
||||||
// Discover available issue types for this project
|
|
||||||
const projRes = await jiraApi.jiraGet('/rest/api/2/project/' + encodeURIComponent(projectKey));
|
|
||||||
assert(projRes.status === 200, 'Should be able to fetch project metadata. Got HTTP ' + projRes.status + ': ' + (projRes.body || '').substring(0, 300));
|
|
||||||
|
|
||||||
const projData = JSON.parse(projRes.body);
|
|
||||||
const availableTypes = (projData.issueTypes || []).filter(t => !t.subtask);
|
|
||||||
logInfo('Available issue types:', availableTypes.map(t => t.name));
|
|
||||||
|
|
||||||
// Determine which issue type to use: configured type first, then fallback order
|
|
||||||
const configuredType = jiraApi.JIRA_ISSUE_TYPE || 'Task';
|
|
||||||
const fallbackOrder = [configuredType, 'Story', 'Task', 'Bug'];
|
|
||||||
let issueTypeName = null;
|
|
||||||
|
|
||||||
for (const candidate of fallbackOrder) {
|
|
||||||
if (availableTypes.some(t => t.name === candidate)) {
|
|
||||||
issueTypeName = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If none of the preferred types exist, use the first available non-subtask type
|
|
||||||
if (!issueTypeName && availableTypes.length > 0) {
|
|
||||||
issueTypeName = availableTypes[0].name;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(issueTypeName, 'No usable issue type found in project ' + projectKey);
|
|
||||||
|
|
||||||
if (issueTypeName !== configuredType) {
|
|
||||||
logWarn('Configured JIRA_ISSUE_TYPE "' + configuredType + '" not available — falling back to "' + issueTypeName + '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = {
|
|
||||||
project: { key: projectKey },
|
|
||||||
summary: '[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ' + new Date().toISOString(),
|
|
||||||
issuetype: { name: issueTypeName },
|
|
||||||
description: 'UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Epic type requires an Epic Name field — add it if creating an Epic
|
|
||||||
if (issueTypeName === 'Epic') {
|
|
||||||
fields.customfield_10004 = fields.summary; // Epic Name (standard Jira field ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
logInfo('Creating issue with fields:', { project: fields.project, summary: fields.summary, issuetype: fields.issuetype });
|
|
||||||
|
|
||||||
let result = await jiraApi.createIssue(fields);
|
|
||||||
|
|
||||||
// If the first attempt fails with 400, try without description (some screens don't have it)
|
|
||||||
if (!result.ok && result.status === 400) {
|
|
||||||
const errBody = (result.body || '').substring(0, 500);
|
|
||||||
logWarn('Create failed with 400, retrying without description. Error: ' + errBody);
|
|
||||||
|
|
||||||
const retryFields = { ...fields };
|
|
||||||
delete retryFields.description;
|
|
||||||
result = await jiraApi.createIssue(retryFields);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still failing with 400 and we used Epic, try without the customfield_10004
|
|
||||||
// (Epic Name field ID varies across Jira instances)
|
|
||||||
if (!result.ok && result.status === 400 && issueTypeName === 'Epic') {
|
|
||||||
const errBody = (result.body || '').substring(0, 500);
|
|
||||||
logWarn('Epic create failed, retrying with alternate Epic Name field. Error: ' + errBody);
|
|
||||||
|
|
||||||
const retryFields = { ...fields };
|
|
||||||
delete retryFields.customfield_10004;
|
|
||||||
// Try common alternate Epic Name field IDs
|
|
||||||
retryFields.customfield_10011 = fields.summary;
|
|
||||||
result = await jiraApi.createIssue(retryFields);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(result.ok, 'Create issue should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
|
|
||||||
assert(result.data && result.data.key, 'Should return issue key');
|
|
||||||
|
|
||||||
createdIssueKey = result.data.key;
|
|
||||||
logInfo('Created issue:', { key: createdIssueKey, id: result.data.id, self: result.data.self, issueType: issueTypeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 3: Get Single Issue (GET /rest/api/2/issue/{key}?fields=...)
|
|
||||||
// Production use: User clicks "Sync" on a single Jira ticket row
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testGetIssue() {
|
|
||||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
|
||||||
|
|
||||||
const result = await jiraApi.getIssue(createdIssueKey);
|
|
||||||
assert(result.ok, 'Get issue should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
|
||||||
|
|
||||||
const issue = result.data;
|
|
||||||
assert(issue.key === createdIssueKey, 'Returned key should match');
|
|
||||||
assert(issue.fields && issue.fields.summary, 'Should have summary field');
|
|
||||||
assert(issue.fields.status, 'Should have status field');
|
|
||||||
|
|
||||||
logInfo('Fetched issue:', {
|
|
||||||
key: issue.key,
|
|
||||||
summary: issue.fields.summary,
|
|
||||||
status: issue.fields.status.name,
|
|
||||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 4: Update Issue (PUT /rest/api/2/issue/{key})
|
|
||||||
// Production use: Local ticket edits synced back to Jira (future feature)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testUpdateIssue() {
|
|
||||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
|
||||||
|
|
||||||
const result = await jiraApi.updateIssue(createdIssueKey, {
|
|
||||||
summary: `[UAT TEST] STEAM Dashboard - UPDATED - ${new Date().toISOString()}`
|
|
||||||
});
|
|
||||||
assert(result.ok, 'Update issue should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
|
||||||
logInfo('Updated issue summary successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 5: Add Comment (POST /rest/api/2/issue/{key}/comment)
|
|
||||||
// Production use: Dashboard adds audit trail comments to linked Jira tickets
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testAddComment() {
|
|
||||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
|
||||||
|
|
||||||
const commentBody = `STEAM Dashboard UAT test comment.\nTimestamp: ${new Date().toISOString()}\nThis comment was created by the automated test script.`;
|
|
||||||
|
|
||||||
const result = await jiraApi.addComment(createdIssueKey, commentBody);
|
|
||||||
assert(result.ok, 'Add comment should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
|
||||||
assert(result.data && result.data.id, 'Should return comment ID');
|
|
||||||
|
|
||||||
logInfo('Added comment:', { commentId: result.data.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 6: Get Transitions (GET /rest/api/2/issue/{key}/transitions)
|
|
||||||
// Production use: Dashboard checks available workflow transitions before
|
|
||||||
// attempting to move a ticket to a new status
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testGetTransitions() {
|
|
||||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
|
||||||
|
|
||||||
const result = await jiraApi.getTransitions(createdIssueKey);
|
|
||||||
assert(result.ok, 'Get transitions should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
|
||||||
|
|
||||||
const transitions = result.data.transitions || [];
|
|
||||||
logInfo('Available transitions:', transitions.map(t => ({ id: t.id, name: t.name, to: t.to ? t.to.name : null })));
|
|
||||||
|
|
||||||
// Store for the transition test
|
|
||||||
return transitions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 7: Transition Issue (POST /rest/api/2/issue/{key}/transitions)
|
|
||||||
// Production use: Dashboard moves ticket status (e.g., Open → In Progress)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testTransitionIssue(transitions) {
|
|
||||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
|
||||||
|
|
||||||
if (!transitions || transitions.length === 0) {
|
|
||||||
logWarn('No transitions available — skipping transition test. This may be expected depending on the project workflow.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pick the first available transition
|
|
||||||
const transition = transitions[0];
|
|
||||||
logInfo(`Transitioning to: ${transition.name} (id: ${transition.id})`);
|
|
||||||
|
|
||||||
const result = await jiraApi.transitionIssue(createdIssueKey, transition.id);
|
|
||||||
assert(result.ok, 'Transition should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
|
||||||
logInfo('Transition successful');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 8: JQL Search (POST /rest/api/2/search)
|
|
||||||
// Production use: Bulk sync — fetches all tracked tickets in one request
|
|
||||||
// instead of one GET per ticket (Charter-compliant)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testJqlSearch() {
|
|
||||||
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
|
||||||
assert(projectKey, 'JIRA_PROJECT_KEY must be set');
|
|
||||||
|
|
||||||
// Use a 72-hour window to account for weekend gaps between syncs
|
|
||||||
const jql = `project = ${projectKey} AND updated >= -72h ORDER BY updated DESC`;
|
|
||||||
logInfo('Searching with JQL:', jql);
|
|
||||||
|
|
||||||
const result = await jiraApi.searchIssues(jql, { maxResults: 10 });
|
|
||||||
assert(result.ok, 'Search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
|
||||||
|
|
||||||
const data = result.data;
|
|
||||||
logInfo('Search results:', {
|
|
||||||
total: data.total,
|
|
||||||
returned: (data.issues || []).length,
|
|
||||||
issues: (data.issues || []).slice(0, 5).map(i => ({
|
|
||||||
key: i.key,
|
|
||||||
summary: i.fields.summary,
|
|
||||||
status: i.fields.status ? i.fields.status.name : null
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 9: Bulk Key Search (searchIssuesByKeys)
|
|
||||||
// Production use: sync-all endpoint — fetches multiple tickets by key
|
|
||||||
// in a single JQL query
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testBulkKeySearch() {
|
|
||||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
|
||||||
|
|
||||||
// Search for the issue we created plus a fake key to test partial results
|
|
||||||
const keys = [createdIssueKey, 'FAKE-99999'];
|
|
||||||
logInfo('Bulk searching keys:', keys);
|
|
||||||
|
|
||||||
const result = await jiraApi.searchIssuesByKeys(keys);
|
|
||||||
assert(result.ok, 'Bulk key search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
|
||||||
|
|
||||||
logInfo('Bulk search uses project-scoped JQL with project = ' + jiraApi.JIRA_PROJECT_KEY);
|
|
||||||
|
|
||||||
const found = (result.data.issues || []).map(i => i.key);
|
|
||||||
logInfo('Found issues:', found);
|
|
||||||
assert(found.includes(createdIssueKey), 'Should find the created issue');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Use Case 10: Rate Limit Status Check
|
|
||||||
// Production use: Admin views rate limit usage on the Jira settings panel
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function testRateLimitStatus() {
|
|
||||||
const status = jiraApi.getRateLimitStatus();
|
|
||||||
assert(status.daily && typeof status.daily.used === 'number', 'Should have daily usage');
|
|
||||||
assert(status.burst && typeof status.burst.used === 'number', 'Should have burst usage');
|
|
||||||
logInfo('Rate limit status after all tests:', status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function main() {
|
|
||||||
logInfo('=== STEAM Dashboard — Jira UAT Test Run ===');
|
|
||||||
logInfo('Timestamp: ' + new Date().toISOString());
|
|
||||||
logInfo('JIRA_BASE_URL: ' + (process.env.JIRA_BASE_URL || '(not set)'));
|
|
||||||
logInfo('JIRA_AUTH_METHOD: ' + (process.env.JIRA_AUTH_METHOD || 'basic'));
|
|
||||||
logInfo('JIRA_API_USER: ' + (process.env.JIRA_API_USER || '(not set)'));
|
|
||||||
logInfo('JIRA_PROJECT_KEY: ' + (jiraApi.JIRA_PROJECT_KEY || '(not set)'));
|
|
||||||
logInfo('JIRA_ISSUE_TYPE: ' + (jiraApi.JIRA_ISSUE_TYPE || 'Task'));
|
|
||||||
logInfo('JIRA_SKIP_TLS: ' + (process.env.JIRA_SKIP_TLS || 'false'));
|
|
||||||
logInfo('isConfigured: ' + jiraApi.isConfigured);
|
|
||||||
logInfo('');
|
|
||||||
|
|
||||||
if (!jiraApi.isConfigured) {
|
|
||||||
logFail('Pre-flight check', 'Jira API is not configured. Set JIRA_BASE_URL, JIRA_API_USER, and JIRA_API_TOKEN in backend/.env');
|
|
||||||
writeLog();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
let transitions = [];
|
|
||||||
|
|
||||||
// Run tests in order — later tests depend on the created issue
|
|
||||||
if (await runTest('1. Connection Test (GET /myself)', testConnection)) passed++; else failed++;
|
|
||||||
if (await runTest('2. Create Issue (POST /issue)', testCreateIssue)) passed++; else failed++;
|
|
||||||
if (await runTest('3. Get Single Issue (JQL search)', testGetIssue)) passed++; else failed++;
|
|
||||||
if (await runTest('4. Update Issue (PUT /issue/{key})', testUpdateIssue)) passed++; else failed++;
|
|
||||||
if (await runTest('5. Add Comment (POST /issue/{key}/comment)', testAddComment)) passed++; else failed++;
|
|
||||||
|
|
||||||
if (await runTest('6. Get Transitions (GET /issue/{key}/transitions)', async () => {
|
|
||||||
transitions = await testGetTransitions();
|
|
||||||
})) passed++; else failed++;
|
|
||||||
|
|
||||||
if (await runTest('7. Transition Issue (POST /issue/{key}/transitions)', async () => {
|
|
||||||
await testTransitionIssue(transitions);
|
|
||||||
})) passed++; else failed++;
|
|
||||||
|
|
||||||
if (await runTest('8. JQL Search (GET /search)', testJqlSearch)) passed++; else failed++;
|
|
||||||
if (await runTest('9. Bulk Key Search (searchIssuesByKeys)', testBulkKeySearch)) passed++; else failed++;
|
|
||||||
if (await runTest('10. Rate Limit Status', testRateLimitStatus)) passed++; else failed++;
|
|
||||||
|
|
||||||
logInfo('');
|
|
||||||
logInfo('=== Summary ===');
|
|
||||||
logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`);
|
|
||||||
if (createdIssueKey) {
|
|
||||||
logInfo(`Test issue created: ${createdIssueKey} — delete manually after ATLSUP review if desired.`);
|
|
||||||
}
|
|
||||||
logInfo('Rate limit usage:', jiraApi.getRateLimitStatus());
|
|
||||||
|
|
||||||
writeLog();
|
|
||||||
|
|
||||||
if (failed > 0) {
|
|
||||||
console.log('\nSome tests failed. Review the log above and jira-uat-test.log for details.');
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
console.log('\nAll tests passed. Log saved to backend/scripts/jira-uat-test.log');
|
|
||||||
console.log('Next steps:');
|
|
||||||
console.log(' 1. Attach or reference backend/scripts/jira-uat-test.log in the ATLSUP ticket');
|
|
||||||
console.log(' 2. Click "Script ran - Review Logs" on the ATLSUP ticket');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeLog() {
|
|
||||||
const lines = results.map(r => {
|
|
||||||
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
|
|
||||||
if (r.data) {
|
|
||||||
const dataStr = (typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2));
|
|
||||||
const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr;
|
|
||||||
line += '\n ' + truncated.split('\n').join('\n ');
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
fs.writeFileSync(LOG_FILE, lines.join('\n') + '\n', 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('Unhandled error:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
// CVE Management Backend API
|
// CVE Management Backend API
|
||||||
// Install: npm install express pg multer cors dotenv bcryptjs cookie-parser
|
// Install: npm install express pg multer cors dotenv bcryptjs cookie-parser
|
||||||
|
|
||||||
// Force IPv4-first DNS resolution globally — must be set before any network modules load.
|
|
||||||
// card.charter.com has IPv6 AAAA records that are unreachable from this network.
|
|
||||||
require('dns').setDefaultResultOrder('ipv4first');
|
|
||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -26,20 +22,16 @@ const logAudit = require('./helpers/auditLog');
|
|||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
const createArcherTemplatesRouter = require('./routes/archerTemplates');
|
|
||||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||||
const { createComplianceRouter } = require('./routes/compliance');
|
const { createComplianceRouter } = require('./routes/compliance');
|
||||||
const { createVCLMultiVerticalRouter } = require('./routes/vclMultiVertical');
|
|
||||||
const createAtlasRouter = require('./routes/atlas');
|
const createAtlasRouter = require('./routes/atlas');
|
||||||
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||||
const createCardApiRouter = require('./routes/cardApi');
|
const createCardApiRouter = require('./routes/cardApi');
|
||||||
const createFeedbackRouter = require('./routes/feedback');
|
const createFeedbackRouter = require('./routes/feedback');
|
||||||
const createWebhooksRouter = require('./routes/webhooks');
|
|
||||||
const createNotificationsRouter = require('./routes/notifications');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -138,7 +130,7 @@ app.use(express.json({
|
|||||||
type: 'application/json'
|
type: 'application/json'
|
||||||
}));
|
}));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use('/uploads', requireAuth(), express.static('uploads', {
|
app.use('/uploads', express.static('uploads', {
|
||||||
dotfiles: 'deny',
|
dotfiles: 'deny',
|
||||||
index: false
|
index: false
|
||||||
}));
|
}));
|
||||||
@@ -196,22 +188,12 @@ const upload = multer({
|
|||||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||||
});
|
});
|
||||||
|
|
||||||
// Separate multer instance for compliance xlsx uploads — these can be 75MB+ for large verticals
|
|
||||||
const complianceUpload = multer({
|
|
||||||
storage: storage,
|
|
||||||
fileFilter: fileFilter,
|
|
||||||
limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit for compliance spreadsheets
|
|
||||||
});
|
|
||||||
|
|
||||||
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||||
app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
|
||||||
|
|
||||||
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||||
app.use('/api/archer-tickets', createArcherTicketsRouter());
|
app.use('/api/archer-tickets', createArcherTicketsRouter());
|
||||||
|
|
||||||
// Archer template library routes (editor/admin for create/update/delete/clone, all authenticated for view)
|
|
||||||
app.use('/api/archer-templates', createArcherTemplatesRouter());
|
|
||||||
|
|
||||||
// Ivanti / RiskSense workflow routes (all authenticated users)
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
||||||
|
|
||||||
@@ -228,12 +210,8 @@ app.use('/api/ivanti/archive', createIvantiArchiveRouter());
|
|||||||
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
||||||
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
||||||
|
|
||||||
// VCL multi-vertical routes — cross-organizational compliance reporting
|
|
||||||
// Must be mounted BEFORE the general compliance router since both share the /api/compliance prefix
|
|
||||||
app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(complianceUpload));
|
|
||||||
|
|
||||||
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||||
app.use('/api/compliance', createComplianceRouter(complianceUpload));
|
app.use('/api/compliance', createComplianceRouter(upload));
|
||||||
|
|
||||||
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
||||||
app.use('/api/atlas', createAtlasRouter());
|
app.use('/api/atlas', createAtlasRouter());
|
||||||
@@ -247,12 +225,6 @@ app.use('/api/card', createCardApiRouter());
|
|||||||
// Feedback routes — bug reports and feature requests to GitLab
|
// Feedback routes — bug reports and feature requests to GitLab
|
||||||
app.use('/api/feedback', createFeedbackRouter());
|
app.use('/api/feedback', createFeedbackRouter());
|
||||||
|
|
||||||
// In-app notifications routes (authenticated users)
|
|
||||||
app.use('/api/notifications', createNotificationsRouter());
|
|
||||||
|
|
||||||
// GitLab webhook routes — receives issue lifecycle events (no auth required)
|
|
||||||
app.use('/api/webhooks', createWebhooksRouter());
|
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
@@ -1203,30 +1175,8 @@ if (fs.existsSync(frontendBuild)) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start server — use HTTPS if TLS cert/key are available, otherwise plain HTTP
|
// Start server
|
||||||
const TLS_CERT = process.env.TLS_CERT || path.join(__dirname, 'certs', 'cert.pem');
|
|
||||||
const TLS_KEY = process.env.TLS_KEY || path.join(__dirname, 'certs', 'key.pem');
|
|
||||||
const TLS_ENABLED = process.env.TLS_ENABLED !== 'false' && fs.existsSync(TLS_CERT) && fs.existsSync(TLS_KEY);
|
|
||||||
|
|
||||||
if (TLS_ENABLED) {
|
|
||||||
const https = require('https');
|
|
||||||
const httpsOptions = {
|
|
||||||
cert: fs.readFileSync(TLS_CERT),
|
|
||||||
key: fs.readFileSync(TLS_KEY),
|
|
||||||
};
|
|
||||||
https.createServer(httpsOptions, app).listen(PORT, () => {
|
|
||||||
console.log(`CVE API server running on https://${API_HOST}:${PORT}`);
|
|
||||||
console.log(`TLS: enabled (cert: ${TLS_CERT})`);
|
|
||||||
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||||
if (!fs.existsSync(TLS_CERT) || !fs.existsSync(TLS_KEY)) {
|
|
||||||
console.log('TLS: disabled (no certs found in backend/certs/)');
|
|
||||||
} else {
|
|
||||||
console.log('TLS: disabled (TLS_ENABLED=false)');
|
|
||||||
}
|
|
||||||
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|||||||
2152
configure.js
2152
configure.js
File diff suppressed because it is too large
Load Diff
@@ -1,354 +0,0 @@
|
|||||||
# AD/SAML Integration Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes the architecture for integrating Active Directory (AD) authentication via SAML 2.0 into the STEAM Security Dashboard. The integration adds Single Sign-On (SSO) as the primary authentication method while retaining local password login as a break-glass fallback for administrators. AD group memberships drive automatic permission assignment and BU team scoping through a configurable mapping layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication Model
|
|
||||||
|
|
||||||
The dashboard supports two authentication paths simultaneously:
|
|
||||||
|
|
||||||
| Path | Users | Mechanism | Session |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Local | Break-glass admins, service accounts | Username + bcrypt password | Cookie-based, PostgreSQL sessions table |
|
|
||||||
| SAML SSO | All AD users | SP-initiated SAML 2.0 via AD FS | Same cookie-based session (identical to local) |
|
|
||||||
|
|
||||||
Both paths produce the same session artifact — an httpOnly cookie containing a `session_id` that maps to a row in the `sessions` table. Downstream middleware (`requireAuth`, `requireGroup`) is unaware of how the session was created.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SAML 2.0 Authentication Flow
|
|
||||||
|
|
||||||
### SP-Initiated Login (Success Path)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant B as Browser
|
|
||||||
participant SP as Dashboard (SP)
|
|
||||||
participant IdP as AD FS (IdP)
|
|
||||||
participant DB as PostgreSQL
|
|
||||||
|
|
||||||
B->>SP: GET /api/auth/saml/login
|
|
||||||
SP->>SP: Generate AuthnRequest XML
|
|
||||||
SP->>B: HTTP 302 Redirect to IdP SSO URL (with AuthnRequest)
|
|
||||||
B->>IdP: Follow redirect (user sees AD FS login page)
|
|
||||||
IdP->>IdP: Authenticate user against AD
|
|
||||||
IdP->>IdP: Build assertion (NameID, email, groups)
|
|
||||||
IdP->>IdP: Sign assertion with IdP private key
|
|
||||||
IdP->>B: HTTP 200 with auto-submit form (POST to SP callback)
|
|
||||||
B->>SP: POST /api/auth/saml/callback (SAMLResponse in body)
|
|
||||||
SP->>SP: Base64-decode SAMLResponse
|
|
||||||
SP->>SP: Validate XML signature against IdP certificate
|
|
||||||
SP->>SP: Check NotBefore/NotOnOrAfter (120s clock skew tolerance)
|
|
||||||
SP->>SP: Extract NameID, email, displayName, group claims
|
|
||||||
SP->>DB: Look up user by external_id (NameID)
|
|
||||||
alt New user (no matching external_id)
|
|
||||||
SP->>DB: INSERT into users (JIT provisioning)
|
|
||||||
SP->>DB: INSERT audit_log (saml_user_provisioned)
|
|
||||||
else Existing user
|
|
||||||
SP->>DB: UPDATE user group, teams, email
|
|
||||||
SP->>DB: INSERT audit_log (saml_user_updated) if changed
|
|
||||||
end
|
|
||||||
SP->>DB: INSERT into sessions (session_id, user_id, expires_at)
|
|
||||||
SP->>DB: INSERT audit_log (saml_login)
|
|
||||||
SP->>B: Set-Cookie: session_id=xxx; HttpOnly; SameSite=Lax
|
|
||||||
SP->>B: HTTP 302 Redirect to /?saml_success=true
|
|
||||||
B->>SP: GET /api/auth/me (with cookie)
|
|
||||||
SP->>B: 200 { user: { id, username, group, teams, authSource } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Assertion Rejection Path
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant B as Browser
|
|
||||||
participant SP as Dashboard (SP)
|
|
||||||
participant IdP as AD FS (IdP)
|
|
||||||
|
|
||||||
B->>SP: GET /api/auth/saml/login
|
|
||||||
SP->>B: HTTP 302 Redirect to IdP
|
|
||||||
B->>IdP: Authenticate
|
|
||||||
IdP->>B: POST assertion to SP callback
|
|
||||||
B->>SP: POST /api/auth/saml/callback
|
|
||||||
SP->>SP: Validate assertion
|
|
||||||
alt Invalid signature
|
|
||||||
SP->>SP: Log audit (saml_auth_failed, reason: invalid_signature)
|
|
||||||
SP->>B: Redirect /?saml_error=Invalid+assertion+signature
|
|
||||||
else Expired assertion
|
|
||||||
SP->>SP: Log audit (saml_auth_failed, reason: assertion_expired)
|
|
||||||
SP->>B: Redirect /?saml_error=Assertion+expired
|
|
||||||
else Account disabled
|
|
||||||
SP->>SP: Log audit (saml_auth_failed, reason: account_disabled)
|
|
||||||
SP->>B: Redirect /?saml_error=Account+is+disabled
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Express Backend (port 3001) │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ routes/saml.js │ │ routes/auth.js │ │ middleware/auth.js│ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ GET /status │ │ POST /login │ │ requireAuth() │ │
|
|
||||||
│ │ GET /login │ │ POST /logout │ │ requireGroup() │ │
|
|
||||||
│ │ POST /callback │ │ GET /me │ │ │ │
|
|
||||||
│ │ GET /metadata │ │ POST /change-pw │ │ (unchanged — │ │
|
|
||||||
│ └───────┬────────┘ └────────┬────────┘ │ reads session │ │
|
|
||||||
│ │ │ │ cookie only) │ │
|
|
||||||
│ │ │ └──────────────────┘ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ helpers/samlProvisioning.js │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ resolveGroup(adGroups, config) → dashboardGroup │ │
|
|
||||||
│ │ resolveTeams(adGroups, config) → "STEAM,..." │ │
|
|
||||||
│ │ deriveUsername(nameId) → username │ │
|
|
||||||
│ │ provisionOrUpdateUser(assertion, config, ip) │ │
|
|
||||||
│ └───────────────────────┬───────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ▼ │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ helpers/samlConfig.js │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ loadGroupMappingConfig() → validated config obj │ │
|
|
||||||
│ │ (reads config/adGroupMapping.json or env var) │ │
|
|
||||||
│ └───────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Decisions
|
|
||||||
|
|
||||||
1. **Session reuse**: SAML login creates the exact same session format as local login. No changes to `requireAuth()` middleware.
|
|
||||||
2. **Feature flag isolation**: When `SAML_ENABLED=false`, SAML routes return 404 and no SAML library is loaded. Zero runtime cost when disabled.
|
|
||||||
3. **Config-driven mapping**: AD group names are externalized in `config/adGroupMapping.json`. Changing the mapping requires only a file edit and backend restart — no code changes.
|
|
||||||
4. **JIT provisioning**: Users are created on first login, updated on each subsequent login. AD is the source of truth for SSO users.
|
|
||||||
5. **Separation of concerns**: The provisioning logic (`samlProvisioning.js`) is a pure module with no HTTP dependencies — fully unit-testable without a web server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AD Group-to-Permission Mapping
|
|
||||||
|
|
||||||
### Configuration Structure
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"groups": {
|
|
||||||
"<AD-GROUP-CN>": "<Dashboard-Group>",
|
|
||||||
"CVE-Dashboard-Admins": "Admin",
|
|
||||||
"CVE-Dashboard-Users": "Standard_User",
|
|
||||||
"CVE-Dashboard-Leadership": "Leadership",
|
|
||||||
"CVE-Dashboard-ReadOnly": "Read_Only"
|
|
||||||
},
|
|
||||||
"teams": {
|
|
||||||
"<AD-GROUP-CN>": "<BU-Team-ID>",
|
|
||||||
"NTS-AEO-STEAM": "STEAM",
|
|
||||||
"NTS-AEO-ACCESS-ENG": "ACCESS-ENG",
|
|
||||||
"NTS-AEO-ACCESS-OPS": "ACCESS-OPS",
|
|
||||||
"NTS-AEO-INTELDEV": "INTELDEV"
|
|
||||||
},
|
|
||||||
"groupPriority": ["Admin", "Standard_User", "Leadership", "Read_Only"],
|
|
||||||
"defaultGroup": "Read_Only",
|
|
||||||
"attributes": {
|
|
||||||
"email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
|
||||||
"displayName": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
|
||||||
"groups": "http://schemas.xmlsoap.org/claims/Group"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Privilege Hierarchy
|
|
||||||
|
|
||||||
When a user belongs to multiple AD groups that map to different dashboard groups, the highest-privilege group wins:
|
|
||||||
|
|
||||||
```
|
|
||||||
Admin > Standard_User > Leadership > Read_Only
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-Team Assignment
|
|
||||||
|
|
||||||
When a user belongs to multiple AD groups that map to BU teams, all matching teams are assigned:
|
|
||||||
|
|
||||||
```
|
|
||||||
AD Groups: ["NTS-AEO-STEAM", "NTS-AEO-ACCESS-ENG", "CVE-Dashboard-Users"]
|
|
||||||
→ user_group: "Standard_User"
|
|
||||||
→ bu_teams: "ACCESS-ENG,STEAM" (sorted alphabetically, deduplicated)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Placeholder Group Names
|
|
||||||
|
|
||||||
The AD group names in the `groups` and `teams` sections (e.g., "CVE-Dashboard-Admins") are placeholders. When real group CNs are obtained from the AD administrators, update only this configuration file. No code changes required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Schema Changes
|
|
||||||
|
|
||||||
### Migration: `add_saml_auth_columns.js`
|
|
||||||
|
|
||||||
| Column | Type | Nullable | Default | Purpose |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `auth_source` | VARCHAR(10) | NOT NULL | `'local'` | Discriminates local vs SSO users |
|
|
||||||
| `external_id` | VARCHAR(256) | NULL | NULL | SAML NameID for IdP correlation |
|
|
||||||
|
|
||||||
Additional changes:
|
|
||||||
- `password_hash` becomes nullable (SAML users have no local password)
|
|
||||||
- Partial unique index on `external_id WHERE external_id IS NOT NULL`
|
|
||||||
|
|
||||||
### Impact on Existing Data
|
|
||||||
|
|
||||||
- All existing users receive `auth_source = 'local'` and `external_id = NULL`
|
|
||||||
- No existing functionality is affected
|
|
||||||
- Migration is idempotent (safe to re-run)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Required | Description | Example |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `SAML_ENABLED` | Always | Master feature flag for SAML authentication | `false` |
|
|
||||||
| `SAML_IDP_METADATA_URL` | When SAML_ENABLED=true | AD FS federation metadata endpoint | `https://adfs.corp.local/FederationMetadata/2007-06/FederationMetadata.xml` |
|
|
||||||
| `SAML_SP_ENTITY_ID` | When SAML_ENABLED=true | Unique identifier for this Service Provider | `http://71.85.90.6:3001` |
|
|
||||||
| `SAML_SP_CALLBACK_URL` | When SAML_ENABLED=true | Assertion consumer service URL | `http://71.85.90.6:3001/api/auth/saml/callback` |
|
|
||||||
| `SAML_IDP_CERT_PATH` | When SAML_ENABLED=true | File path to IdP signing certificate (PEM format) | `/etc/cve-dashboard/idp-cert.pem` |
|
|
||||||
| `SESSION_LIFETIME_HOURS` | Optional | Session duration (1-720 hours, default: 24) | `8` |
|
|
||||||
| `AD_GROUP_MAPPING_JSON` | Optional | JSON string override for adGroupMapping.json | `{"groups":{...},"teams":{...}}` |
|
|
||||||
|
|
||||||
### Startup Validation
|
|
||||||
|
|
||||||
When `SAML_ENABLED=true`, the server validates at startup:
|
|
||||||
1. All required SAML env vars are set (fails with descriptive error if missing)
|
|
||||||
2. Certificate file exists and is readable (fails if not)
|
|
||||||
3. Group mapping config parses as valid JSON (fails if not)
|
|
||||||
4. All mapped team names exist in KNOWN_TEAMS (fails if not)
|
|
||||||
5. All mapped dashboard groups are valid (fails if not)
|
|
||||||
|
|
||||||
This fail-fast approach prevents silent misconfiguration in production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build vs Wait: Phase Breakdown
|
|
||||||
|
|
||||||
### Phase 1 — Build Now (No AD Access Required)
|
|
||||||
|
|
||||||
| Component | File | Test Strategy |
|
|
||||||
|---|---|---|
|
|
||||||
| Database migration | `backend/migrations/add_saml_auth_columns.js` | Run against test DB, verify columns |
|
|
||||||
| Group mapping config | `backend/config/adGroupMapping.json` | Startup validation tests |
|
|
||||||
| Config loader | `backend/helpers/samlConfig.js` | Unit test with mock JSON files |
|
|
||||||
| JIT provisioner | `backend/helpers/samlProvisioning.js` | Unit test all paths with mock pool |
|
|
||||||
| SAML routes (skeleton) | `backend/routes/saml.js` | Integration test: feature flag, status, metadata |
|
|
||||||
| Session lifetime | `server.js` (startup block) | Unit test env var parsing |
|
|
||||||
| Auth route changes | `backend/routes/auth.js` | Integration test: SAML user login rejection |
|
|
||||||
| User route changes | `backend/routes/users.js` | Integration test: auth_source in responses, password block |
|
|
||||||
| Frontend SSO button | `frontend/src/components/LoginForm.js` | Render test: button hidden when flag=false |
|
|
||||||
| Admin auth_source badges | `frontend/src/components/UserManagement.js` | Render test: badge displays |
|
|
||||||
| Architecture doc | `docs/architecture/ad-saml-integration.md` | N/A (documentation) |
|
|
||||||
|
|
||||||
### Phase 2 — Requires Live AD FS Connection
|
|
||||||
|
|
||||||
| Component | Dependency | Who Provides It |
|
|
||||||
|---|---|---|
|
|
||||||
| SAML library installation | Package selection (`@node-saml/passport-saml` or `saml2-js`) | Development team |
|
|
||||||
| IdP metadata URL | AD FS federation metadata endpoint | AD administrators |
|
|
||||||
| IdP signing certificate | Token-signing cert exported from AD FS | AD administrators |
|
|
||||||
| SP registration | Relying party trust created in AD FS console | AD administrators |
|
|
||||||
| Real AD group names | Actual CNs of permission/team groups | AD administrators |
|
|
||||||
| Assertion parsing implementation | Fill in `routes/saml.js` callback | Development team |
|
|
||||||
| End-to-end flow testing | Working AD user accounts | AD administrators |
|
|
||||||
| Session lifetime tuning | AD FS token lifetime policy value | AD administrators |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Certificate Management
|
|
||||||
|
|
||||||
- The IdP signing certificate is stored on disk at `SAML_IDP_CERT_PATH`
|
|
||||||
- When the IdP rotates its certificate, replace the file and restart the backend
|
|
||||||
- No database migration required for certificate rotation
|
|
||||||
- Consider monitoring certificate expiry dates (AD FS certs typically rotate annually)
|
|
||||||
|
|
||||||
### Assertion Replay Prevention
|
|
||||||
|
|
||||||
- Each SAML assertion is consumed exactly once by the callback handler
|
|
||||||
- The JIT provisioner's idempotent update pattern means replayed assertions would simply re-update the same user record (no escalation possible)
|
|
||||||
- For additional protection in Phase 2, implement InResponseTo validation and a short-lived assertion ID cache
|
|
||||||
|
|
||||||
### Trust Boundary
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
|
|
||||||
│ Trust Zone A │ │ Trust Zone B │
|
|
||||||
│ │ │ │
|
|
||||||
│ Dashboard (SP) │ │ AD FS (IdP) │
|
|
||||||
│ - Validates assertions │ │ - Authenticates users │
|
|
||||||
│ - Trusts ONLY signed assertions │ │ - Signs assertions with │
|
|
||||||
│ - Creates local sessions │ │ private key │
|
|
||||||
│ - Enforces local authorization │ │ - Asserts group memberships │
|
|
||||||
│ │ │ │
|
|
||||||
└────────────────┬─────────────────┘ └────────────────┬────────────────┘
|
|
||||||
│ │
|
|
||||||
└─── SAML 2.0 over HTTPS (HTTP-POST) ────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- The SP trusts assertions only when cryptographically signed by the IdP
|
|
||||||
- Group memberships in the assertion drive permission assignment — the SP does not query AD directly
|
|
||||||
- If the IdP is compromised, an attacker could forge assertions. Mitigate with certificate pinning and monitoring assertion patterns in audit logs.
|
|
||||||
- The SP never sends credentials to the IdP — authentication happens entirely on the IdP side
|
|
||||||
|
|
||||||
### Break-Glass Protection
|
|
||||||
|
|
||||||
- The last local Admin account cannot be deleted or deactivated
|
|
||||||
- If the IdP is unavailable, local Admin users can still log in with username/password
|
|
||||||
- SAML users cannot authenticate via password (and vice versa) — the two paths are isolated per user record
|
|
||||||
|
|
||||||
### Transport Security
|
|
||||||
|
|
||||||
- Production deployments should serve the SP callback over HTTPS
|
|
||||||
- The SAMLResponse is transmitted via HTTP-POST binding (browser-mediated, not direct server-to-server)
|
|
||||||
- The assertion is signed — even if transmitted over HTTP, it cannot be tampered with without detection
|
|
||||||
- For defense in depth, HTTPS prevents assertion interception by network observers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hybrid Login User Experience
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Login Page │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │ │
|
|
||||||
│ │ │ Sign in with SSO │ │ │
|
|
||||||
│ │ │ (redirects to AD FS) │ │ │
|
|
||||||
│ │ └─────────────────────────────┘ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ─── or sign in with local account ─── │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Username: [________________] │ │
|
|
||||||
│ │ Password: [________________] │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ [Sign In] │ │
|
|
||||||
│ └────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Note: SSO button only visible when SAML_ENABLED=true │
|
|
||||||
│ Local login always available (break-glass for admins) │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Specifications
|
|
||||||
|
|
||||||
- `.kiro/specs/ad-saml-integration/requirements.md` — detailed acceptance criteria
|
|
||||||
- `.kiro/specs/ad-saml-integration/design.md` — implementation design with code examples
|
|
||||||
- `.kiro/specs/group-based-access-control/requirements.md` — existing RBAC system
|
|
||||||
- `.kiro/specs/multi-bu-tenancy/design.md` — BU team scoping (leveraged by AD integration)
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
# Split Architecture Proposal: Collector + Indexer
|
|
||||||
|
|
||||||
**Author:** Infrastructure Team
|
|
||||||
**Date:** 2026-06-08
|
|
||||||
**Status:** Draft — Pending Review
|
|
||||||
**Scope:** Scale CVE Dashboard from 2 teams / ~15 users to company-wide deployment (100+ users, 15+ teams)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
The STEAM Security Dashboard currently runs as a monolithic single-process Express application on CT107 (dashboard-dev, 71.85.90.9). This single process simultaneously serves the frontend, handles all API requests, and performs background data collection from Ivanti, Jira, CARD, Atlas, and NVD APIs.
|
|
||||||
|
|
||||||
At current scale (2 teams, <15 users, daily sync), this architecture works. At company-wide scale (15+ teams, hundreds of users, sub-hourly sync), it will not. This document proposes a phased transition to a **Collector + API Server** architecture that separates data ingestion from request serving.
|
|
||||||
|
|
||||||
**Critical constraint:** CT107 (71.85.90.9) has the firewall rules granting access to the production Ivanti, Jira, and CARD APIs. The collector component must remain on this machine or firewall rules must be extended.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Current Architecture](#current-architecture)
|
|
||||||
- [Problem Statement](#problem-statement)
|
|
||||||
- [Proposed Architecture](#proposed-architecture)
|
|
||||||
- [Phase Plan](#phase-plan)
|
|
||||||
- [Infrastructure Requirements](#infrastructure-requirements)
|
|
||||||
- [Risk Assessment](#risk-assessment)
|
|
||||||
- [Decision Points](#decision-points)
|
|
||||||
- [Appendix: Current Data Flow Analysis](#appendix-current-data-flow-analysis)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ CT107 (dashboard-dev) │
|
|
||||||
│ 71.85.90.9 — 48 GB RAM, 250 GB Disk │
|
|
||||||
│ │
|
|
||||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Express Process (port 3001/3100) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ │
|
|
||||||
│ │ │ React SPA │ │ API Routes │ │ Sync Workers │ │ │
|
|
||||||
│ │ │ (static) │ │ (50+ endpts)│ │ (setInterval) │ │ │
|
|
||||||
│ │ └─────────────┘ └──────────────┘ └────────────────┘ │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ │ Shared PG Pool (10 conn) │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ └──────────────────────────┼──────────┼─────────────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌──────────────────────────▼──────────▼─────────────────────┐ │
|
|
||||||
│ │ PostgreSQL 16 (Docker, port 5433) │ │
|
|
||||||
│ └───────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Firewall Access: Ivanti API, Jira DC, CARD API, Atlas API │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Metrics (Current)
|
|
||||||
|
|
||||||
| Metric | Current Value | Company-Wide Projection |
|
|
||||||
|--------|--------------|------------------------|
|
|
||||||
| Concurrent users | 5–15 | 100–300 |
|
|
||||||
| Teams tracked | 2 | 15+ |
|
|
||||||
| Ivanti findings (open) | ~200–500 | 2,000–10,000+ |
|
|
||||||
| Ivanti sync frequency | 24h | 1–4h desired |
|
|
||||||
| PG connection pool | 10 | Insufficient |
|
|
||||||
| Jira API rate limit | 1,440/day | Shared across all users |
|
|
||||||
| Data sources | 5 (Ivanti, NVD, Jira, Atlas, CARD) | 8+ (add CrowdStrike, Qualys, Tanium) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
### 1. Sync Blocks the API Server
|
|
||||||
|
|
||||||
`syncFindings()` runs sequentially through:
|
|
||||||
1. Fetch all open findings pages (100/page)
|
|
||||||
2. Upsert findings batch into PostgreSQL
|
|
||||||
3. Detect archive changes (compare all previous vs current)
|
|
||||||
4. Fetch all closed findings pages
|
|
||||||
5. Upsert closed findings
|
|
||||||
6. Run BU drift checker (makes additional API calls per disappeared finding)
|
|
||||||
7. Sync FP workflow counts (sweeps all closed pages again)
|
|
||||||
8. Compute and store anomaly summary
|
|
||||||
9. Record counts history
|
|
||||||
|
|
||||||
At 500 findings, this takes 2–5 minutes. At 10,000 findings across 15 teams, this could take 15–30 minutes. During sync, the Express process is saturated — API responses slow, the connection pool contends.
|
|
||||||
|
|
||||||
### 2. Single Point of Failure
|
|
||||||
|
|
||||||
One process handles everything. A memory leak during sync, an unhandled promise rejection in the BU drift checker, or a runaway loop in archive detection crashes the entire dashboard for all users.
|
|
||||||
|
|
||||||
### 3. Connection Pool Exhaustion
|
|
||||||
|
|
||||||
10 connections shared between:
|
|
||||||
- User-facing read queries (findings list, compliance items, charts)
|
|
||||||
- Sync bulk upserts (batches of 100 rows × 18 columns)
|
|
||||||
- User writes (notes, overrides, queue operations)
|
|
||||||
|
|
||||||
The pool already logs warnings at 8/10 active. At 100+ concurrent users issuing reads while a sync writes thousands of rows, this will deadlock or time out.
|
|
||||||
|
|
||||||
### 4. Rate Limits Shared Across Functions
|
|
||||||
|
|
||||||
Jira's 1,440/day limit is consumed by both background sync and user-initiated operations (lookups, ticket creation). A bulk sync could exhaust the daily budget, blocking users from creating tickets the rest of the day.
|
|
||||||
|
|
||||||
### 5. No Horizontal Scaling Path
|
|
||||||
|
|
||||||
Cannot add a second API server without also duplicating the sync scheduler, which would cause duplicate syncs, double-writes, and race conditions.
|
|
||||||
|
|
||||||
### 6. Firewall Constraint
|
|
||||||
|
|
||||||
CT107 has the only firewall access to production Ivanti, Jira, and CARD APIs. The collector (data fetcher) must run on this machine. The API server could potentially move elsewhere, but the collector cannot without firewall changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Architecture
|
|
||||||
|
|
||||||
### Target State
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ CT107 (dashboard-dev) │
|
|
||||||
│ 71.85.90.9 — 48 GB RAM, 250 GB Disk │
|
|
||||||
│ ★ Firewall access to prod APIs ★ │
|
|
||||||
│ │
|
|
||||||
│ ┌───────────────────────────────────┐ ┌─────────────────────┐│
|
|
||||||
│ │ API Server (Express, port 3001) │ │ Collector Service ││
|
|
||||||
│ │ │ │ (Node.js worker) ││
|
|
||||||
│ │ • React SPA serving │ │ ││
|
|
||||||
│ │ • All /api/* read endpoints │ │ • Ivanti sync ││
|
|
||||||
│ │ • User writes (notes, queue) │ │ • Jira bulk sync ││
|
|
||||||
│ │ • On-demand lookups (proxied) │ │ • CARD cache sync ││
|
|
||||||
│ │ • Triggers collector via │ │ • Atlas cache sync ││
|
|
||||||
│ │ pg NOTIFY │ │ • NVD bulk sync ││
|
|
||||||
│ │ │ │ • Archive detect ││
|
|
||||||
│ │ Pool: 15 conn (reads + writes) │ │ • BU drift checker ││
|
|
||||||
│ │ │ │ • Anomaly compute ││
|
|
||||||
│ └───────────────┬───────────────────┘ │ • Compliance parse ││
|
|
||||||
│ │ │ ││
|
|
||||||
│ │ │ Pool: 10 conn ││
|
|
||||||
│ │ │ (bulk upserts) ││
|
|
||||||
│ │ │ ││
|
|
||||||
│ │ │ Listens: ││
|
|
||||||
│ │ │ pg LISTEN ││
|
|
||||||
│ │ │ 'sync_trigger' ││
|
|
||||||
│ │ └──────────┬──────────┘│
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌───────────────▼──────────────────────────────────▼─────────┐│
|
|
||||||
│ │ PostgreSQL 16 (Docker, port 5433) ││
|
|
||||||
│ │ Pool: 25 total connections allocated ││
|
|
||||||
│ └────────────────────────────────────────────────────────────┘│
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Responsibilities
|
|
||||||
|
|
||||||
#### API Server (`cve-api.service`)
|
|
||||||
|
|
||||||
| Responsibility | Details |
|
|
||||||
|---|---|
|
|
||||||
| Frontend serving | Static React build via `express.static` |
|
|
||||||
| Read endpoints | All GET routes — findings, compliance, charts, exports |
|
|
||||||
| User writes | Notes, overrides, queue items, ticket CRUD, KB uploads |
|
|
||||||
| On-demand lookups | Single NVD lookup, single Jira issue lookup, CARD real-time queries |
|
|
||||||
| Sync trigger | `SELECT pg_notify('sync_trigger', '{"type":"findings","user":"admin"}')` |
|
|
||||||
| Health/status | Expose collector status via sync_state table reads |
|
|
||||||
|
|
||||||
#### Collector (`cve-collector.service`)
|
|
||||||
|
|
||||||
| Responsibility | Details |
|
|
||||||
|---|---|
|
|
||||||
| Scheduled syncs | Ivanti findings (configurable interval), workflows (24h) |
|
|
||||||
| Bulk API operations | Jira JQL sync-all, Atlas cache refresh, NVD bulk sync |
|
|
||||||
| Post-sync processing | Archive detection, BU drift classification, closed-gone detection |
|
|
||||||
| Anomaly computation | Open/closed deltas, classification breakdown, significance flagging |
|
|
||||||
| Compliance parsing | Spawns Python subprocess for xlsx parsing on upload commit |
|
|
||||||
| Event-driven triggers | Listens on `pg LISTEN sync_trigger` for on-demand requests |
|
|
||||||
| Rate budget management | Owns the Jira daily/burst counters; API server gets a reserved allocation |
|
|
||||||
|
|
||||||
### Communication Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
User clicks "Sync" in UI
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
API Server receives POST /api/ivanti/findings/sync
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
API Server: SELECT pg_notify('sync_trigger', '{"type":"findings"}')
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
API Server responds: { status: 'sync_started', message: 'Check /sync-status' }
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Collector receives NOTIFY, starts syncFindings()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Collector updates ivanti_sync_state (status='syncing')
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Collector completes, updates ivanti_sync_state (status='success')
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Frontend polls GET /api/ivanti/findings/sync-status → sees 'success' → refreshes
|
|
||||||
```
|
|
||||||
|
|
||||||
No Redis. No message broker. Just PostgreSQL LISTEN/NOTIFY — zero new infrastructure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase Plan
|
|
||||||
|
|
||||||
### Phase 0: Immediate Improvements (Week 1–2)
|
|
||||||
**Goal:** Reduce risk within the current monolith. No architectural changes.
|
|
||||||
|
|
||||||
| Task | Effort | Impact |
|
|
||||||
|------|--------|--------|
|
|
||||||
| Make `POST /sync` non-blocking — return immediately, let sync run in background | 2h | Unblocks users during sync |
|
|
||||||
| Add `GET /api/ivanti/findings/sync-status` endpoint | 1h | Frontend can poll for completion |
|
|
||||||
| Increase PG pool from 10 → 20 connections | 10min | Headroom for concurrent operations |
|
|
||||||
| Add `pg_stat_activity` monitoring query to health endpoint | 30min | Visibility into pool pressure |
|
|
||||||
| Update frontend to poll sync-status instead of waiting | 2h | UX improvement |
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- Updated `ivantiFindings.js` with async sync dispatch
|
|
||||||
- New sync-status polling endpoint
|
|
||||||
- Frontend ReportingPage sync UX updated
|
|
||||||
- Pool configuration change in `db.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 1: Extract Collector (Weeks 3–4)
|
|
||||||
**Goal:** Separate data collection into its own process on CT107.
|
|
||||||
|
|
||||||
| Task | Effort | Impact |
|
|
||||||
|------|--------|--------|
|
|
||||||
| Create `backend/collector.js` — standalone Node process | 4h | Fault isolation |
|
|
||||||
| Move sync functions from route files into shared `lib/sync/` modules | 3h | Code reuse between collector and API |
|
|
||||||
| Implement pg LISTEN/NOTIFY trigger mechanism | 2h | API → Collector communication |
|
|
||||||
| Create `cve-collector.service` systemd unit | 30min | Process management |
|
|
||||||
| Add collector health check and status reporting | 1h | Observability |
|
|
||||||
| Update `POST /sync` routes to use pg_notify instead of inline sync | 1h | Complete decoupling |
|
|
||||||
| Add `sync_jobs` table for job tracking (queued, running, complete, failed) | 1h | Multi-user sync coordination |
|
|
||||||
| Update CI/CD pipeline to deploy collector service | 2h | Automated deployment |
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- `backend/collector.js` — entry point for collector process
|
|
||||||
- `backend/lib/sync/` — shared sync logic (extracted from routes)
|
|
||||||
- `systemd/cve-collector.service` — systemd unit
|
|
||||||
- Updated `.gitlab-ci.yml` with collector deploy stage
|
|
||||||
- `sync_jobs` table for job state tracking
|
|
||||||
|
|
||||||
**File structure after Phase 1:**
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── server.js # API server (unchanged entry point)
|
|
||||||
├── collector.js # NEW — collector entry point
|
|
||||||
├── db.js # Shared pool config
|
|
||||||
├── lib/
|
|
||||||
│ └── sync/
|
|
||||||
│ ├── ivantiFindings.js # Extracted from routes/ivantiFindings.js
|
|
||||||
│ ├── ivantiWorkflows.js # Extracted from routes/ivantiWorkflows.js
|
|
||||||
│ ├── jiraBulkSync.js # Extracted from routes/jiraTickets.js
|
|
||||||
│ ├── atlasCache.js # Extracted from routes/atlas.js
|
|
||||||
│ ├── nvdBulkSync.js # New — bulk NVD operations
|
|
||||||
│ ├── archiveDetection.js # Extracted from routes/ivantiFindings.js
|
|
||||||
│ └── anomalyCompute.js # Extracted from routes/ivantiFindings.js
|
|
||||||
├── routes/ # API routes — now thin, read-heavy
|
|
||||||
└── helpers/ # Shared API client helpers (unchanged)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: Multi-Tenancy & Scale Hardening (Weeks 5–8)
|
|
||||||
**Goal:** Prepare for 15 teams and hundreds of users.
|
|
||||||
|
|
||||||
| Task | Effort | Impact |
|
|
||||||
|------|--------|--------|
|
|
||||||
| Per-team sync scheduling — stagger syncs to avoid API burst | 3h | Spreads load |
|
|
||||||
| Jira rate budget partitioning (collector gets 80%, API gets 20%) | 2h | Prevents sync from starving users |
|
|
||||||
| Per-BU finding isolation — team users only see their findings | 4h | Data scoping |
|
|
||||||
| Add connection pooling metrics endpoint (`/api/admin/pool-stats`) | 1h | Operational visibility |
|
|
||||||
| Implement sync queue with priority (user-triggered > scheduled) | 3h | Better UX |
|
|
||||||
| Add retry logic with exponential backoff to collector | 2h | Resilience |
|
|
||||||
| Partial-progress persistence — don't lose work on mid-sync failure | 4h | Data integrity |
|
|
||||||
| PG connection pool separation — API pool (15) + Collector pool (10) | 1h | Isolation |
|
|
||||||
| Add `pg_bouncer` or similar for connection multiplexing (optional) | 4h | Scale past 50 concurrent |
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- Team-scoped sync scheduler in collector
|
|
||||||
- Rate budget allocation system
|
|
||||||
- Retry/backoff logic
|
|
||||||
- Partial progress tracking
|
|
||||||
- Pool separation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: Additional Data Sources (Weeks 9–12)
|
|
||||||
**Goal:** Integrate CrowdStrike, Qualys, and Tanium feeds.
|
|
||||||
|
|
||||||
| Task | Effort | Impact |
|
|
||||||
|------|--------|--------|
|
|
||||||
| CrowdStrike Falcon API integration in collector | 8h | New vulnerability source |
|
|
||||||
| Qualys VMDR API integration in collector | 8h | New vulnerability source |
|
|
||||||
| Tanium asset inventory sync | 6h | Asset correlation |
|
|
||||||
| Cross-source finding deduplication logic | 6h | Data quality |
|
|
||||||
| Unified findings view (merged from all sources) | 4h | Single pane of glass |
|
|
||||||
| Source-specific sync schedules (configurable per source) | 2h | Flexibility |
|
|
||||||
|
|
||||||
**Note:** All new API integrations go into the collector. The API server never makes outbound calls to external vulnerability platforms except for single-item on-demand lookups.
|
|
||||||
|
|
||||||
**Firewall implications:** CrowdStrike, Qualys, and Tanium API access will need firewall rules added to CT107 (71.85.90.9). Submit firewall requests in advance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: Horizontal Scaling (Weeks 13+)
|
|
||||||
**Goal:** Support 300+ concurrent users if company-wide adoption materializes.
|
|
||||||
|
|
||||||
| Task | Effort | Impact |
|
|
||||||
|------|--------|--------|
|
|
||||||
| Move API server to a separate LXC container (with more resources) | 4h | Dedicated API resources |
|
|
||||||
| Run multiple API server instances behind a load balancer | 8h | Horizontal scale |
|
|
||||||
| Keep collector on CT107 (firewall access) | 0h | No change needed |
|
|
||||||
| Add Redis for session store (replace PG sessions) | 4h | Multi-instance sessions |
|
|
||||||
| Add read replicas if PG becomes the bottleneck | 8h | Read scale |
|
|
||||||
| Evaluate moving PG to CT109 (zbl-indexer, 32GB/500GB) | 2h | Larger DB host |
|
|
||||||
|
|
||||||
**Architecture at Phase 4:**
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Load Balancer │
|
|
||||||
│ (nginx/HAProxy)│
|
|
||||||
└────┬───────┬────┘
|
|
||||||
│ │
|
|
||||||
┌─────────────▼─┐ ┌─▼─────────────┐
|
|
||||||
│ API Server 1 │ │ API Server 2 │ (New LXC or CT103)
|
|
||||||
│ (Express) │ │ (Express) │
|
|
||||||
└───────┬───────┘ └───────┬───────┘
|
|
||||||
│ │
|
|
||||||
└─────────┬─────────┘
|
|
||||||
│
|
|
||||||
┌────────────────────────────▼──────────────────────────────────────┐
|
|
||||||
│ CT107 (71.85.90.9) │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │
|
|
||||||
│ │ Collector Service │ │ PostgreSQL 16 │ │
|
|
||||||
│ │ (sole process with │ │ (or moved to CT109) │ │
|
|
||||||
│ │ firewall API access) │ │ │ │
|
|
||||||
│ └─────────────────────────┘ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ★ Firewall: Ivanti, Jira, CARD, Atlas, CrowdStrike, Qualys ★ │
|
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infrastructure Requirements
|
|
||||||
|
|
||||||
### CT107 Resource Allocation (Current → Phase 2)
|
|
||||||
|
|
||||||
| Resource | Current | Phase 2 Target | Notes |
|
|
||||||
|----------|---------|---------------|-------|
|
|
||||||
| RAM | 48 GB | 48 GB (sufficient) | Node processes use <2GB each |
|
|
||||||
| CPU | Shared | May need 4+ dedicated cores | Sync is CPU-intensive during transform |
|
|
||||||
| Disk | 250 GB | 250 GB (sufficient) | PG data + uploads + logs |
|
|
||||||
| PG connections | 10 | 25 (15 API + 10 collector) | Configure in `postgresql.conf` |
|
|
||||||
| Systemd services | 2 (backend + frontend) | 3 (api + collector + postgres) | Frontend served by API |
|
|
||||||
|
|
||||||
### PostgreSQL Tuning (for 15 teams / hundreds of users)
|
|
||||||
|
|
||||||
```
|
|
||||||
# postgresql.conf changes
|
|
||||||
max_connections = 50 # Up from default 100 is fine, need headroom
|
|
||||||
shared_buffers = 4GB # 25% of available RAM for PG
|
|
||||||
effective_cache_size = 12GB # 75% of RAM PG can expect from OS
|
|
||||||
work_mem = 64MB # Per-sort/hash operation
|
|
||||||
maintenance_work_mem = 512MB # For VACUUM, CREATE INDEX
|
|
||||||
wal_level = replica # If read replicas needed later
|
|
||||||
```
|
|
||||||
|
|
||||||
### Firewall Dependencies
|
|
||||||
|
|
||||||
| Service | Endpoint | Required By | Current Access |
|
|
||||||
|---------|----------|-------------|----------------|
|
|
||||||
| Ivanti/RiskSense | platform4.risksense.com:443 | Collector | ✅ CT107 only |
|
|
||||||
| Jira Data Center | jira.charter.com:443 | Collector + API (lookups) | ✅ CT107 only |
|
|
||||||
| CARD API | card.charter.com:443 | API (real-time) | ✅ CT107 only |
|
|
||||||
| Atlas InfoSec | (internal) | Collector | ✅ CT107 only |
|
|
||||||
| NVD API | services.nvd.nist.gov:443 | Collector + API | ✅ Public |
|
|
||||||
| CrowdStrike | api.crowdstrike.com:443 | Collector | ❌ Firewall request needed |
|
|
||||||
| Qualys | qualysapi.qualys.com:443 | Collector | ❌ Firewall request needed |
|
|
||||||
| Tanium | (internal) | Collector | ❌ Firewall request needed |
|
|
||||||
|
|
||||||
**Key constraint:** If the API server moves off CT107 in Phase 4, you'll need firewall rules for the new host to reach Jira (for user lookups) and CARD (for real-time queries). Alternatively, the collector could proxy those on-demand requests — adds latency but avoids firewall changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risk Assessment
|
|
||||||
|
|
||||||
| Risk | Likelihood | Impact | Mitigation |
|
|
||||||
|------|-----------|--------|------------|
|
|
||||||
| Collector crash doesn't affect API users | — | — | This is the primary benefit of splitting |
|
|
||||||
| Collector and API race on DB writes | Medium | Low | Collector does bulk upserts; API does single-row writes. Different tables mostly. Use advisory locks for sync_state. |
|
|
||||||
| Sync trigger lost (pg NOTIFY missed) | Low | Medium | Collector also runs on a schedule. Missed trigger just delays to next interval. |
|
|
||||||
| Phase 1 introduces bugs in extraction | Medium | Medium | Comprehensive test suite exists. Run parallel (old monolith + new split) in staging for 1 week. |
|
|
||||||
| Firewall change delays block Phase 4 | High | Medium | Start firewall requests early. Phase 4 is optional — single-machine split (Phases 1–3) works fine at 15 teams. |
|
|
||||||
| PG becomes bottleneck at 300+ users | Low | High | Phase 4 addresses with read replicas. CT109 (500GB, 32GB) available as larger DB host. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision Points
|
|
||||||
|
|
||||||
These require team/leadership input before proceeding:
|
|
||||||
|
|
||||||
1. **Sync frequency target:** Is 1-hour sync acceptable, or do teams need near-real-time (15 min)? This affects collector design complexity and API rate budget math.
|
|
||||||
|
|
||||||
2. **API server location:** Keep everything on CT107, or move the API server to a separate container? Keeping it on CT107 is simpler (no firewall changes for CARD/Jira lookups) but limits scaling options.
|
|
||||||
|
|
||||||
3. **Database location:** Keep PG on CT107, or move to CT109 (zbl-indexer, 500GB disk, 32GB RAM)? Moving adds network latency but gives more room for growth.
|
|
||||||
|
|
||||||
4. **CrowdStrike/Qualys/Tanium priority:** Which new data sources are most urgent? This affects Phase 3 ordering and firewall request timing.
|
|
||||||
|
|
||||||
5. **Session management:** At 300+ users, PG-backed sessions will be high-churn. Acceptable, or invest in Redis? Redis adds infrastructure but is the industry standard for session stores at scale.
|
|
||||||
|
|
||||||
6. **Multi-instance API:** Is the goal to survive a single API server restart without downtime? If yes, Phase 4 (load balancer + multiple instances) is needed. If brief restarts during deploys are acceptable, single-instance on CT107 works through Phase 3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix: Current Data Flow Analysis
|
|
||||||
|
|
||||||
### Data Collection Patterns
|
|
||||||
|
|
||||||
| Source | Trigger | Frequency | Data Volume | Processing |
|
|
||||||
|--------|---------|-----------|-------------|------------|
|
|
||||||
| Ivanti Findings | Schedule + manual | 24h | 100–500 findings (all pages) | Extract, upsert, archive detect, BU drift, anomaly |
|
|
||||||
| Ivanti Workflows | Schedule + manual | 24h | 50 workflow batches | Store as JSON blob |
|
|
||||||
| Ivanti Closed Findings | During findings sync | 24h | All closed pages | Upsert + closed archive detection |
|
|
||||||
| Jira Bulk Sync | Manual (admin) | On-demand | All tracked tickets via JQL | Status/summary update per ticket |
|
|
||||||
| Jira Single Lookup | User action | Real-time | 1 issue | Proxy + display |
|
|
||||||
| NVD Lookup | User action | Real-time | 1 CVE | Proxy + optional save |
|
|
||||||
| NVD Bulk Sync | Manual | On-demand | All CVEs in DB | Batch update metadata |
|
|
||||||
| Atlas Action Plans | Cache refresh | Background | Per-host plan data | Cache in `atlas_action_plans_cache` |
|
|
||||||
| CARD Operations | User action | Real-time | 1 asset at a time | Proxy (confirm/decline/redirect) |
|
|
||||||
| Compliance xlsx | Manual upload | Weekly | 1 file → hundreds of rows | Python parse → PG upsert (transactional) |
|
|
||||||
|
|
||||||
### What Moves to Collector vs Stays in API
|
|
||||||
|
|
||||||
| Operation | Collector | API Server | Rationale |
|
|
||||||
|-----------|-----------|------------|-----------|
|
|
||||||
| Ivanti findings sync (all pages) | ✅ | | Heavy, multi-page, post-processing |
|
|
||||||
| Ivanti workflows sync | ✅ | | Scheduled background task |
|
|
||||||
| Ivanti closed sweep | ✅ | | Part of findings sync pipeline |
|
|
||||||
| Archive detection | ✅ | | CPU-intensive comparison |
|
|
||||||
| BU drift checker | ✅ | | Makes additional API calls |
|
|
||||||
| Anomaly computation | ✅ | | Depends on sync completion |
|
|
||||||
| Jira bulk sync-all | ✅ | | Consumes rate budget, multi-issue |
|
|
||||||
| NVD bulk sync | ✅ | | Multi-CVE, rate-limited |
|
|
||||||
| Atlas cache refresh | ✅ | | Background, per-host API calls |
|
|
||||||
| Compliance xlsx parse | ✅ | | Spawns Python, heavy DB writes |
|
|
||||||
| Single Jira lookup | | ✅ | User-initiated, real-time, 1 call |
|
|
||||||
| Single NVD lookup | | ✅ | User-initiated, real-time, 1 call |
|
|
||||||
| CARD operations | | ✅ | User-initiated, real-time |
|
|
||||||
| All GET /api/* reads | | ✅ | Pure DB queries, user-facing |
|
|
||||||
| Notes/overrides/queue | | ✅ | Small writes, user-facing |
|
|
||||||
| File uploads | | ✅ | User-initiated, disk I/O |
|
|
||||||
|
|
||||||
### Sync Pipeline Detail (becomes collector's core loop)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Collector Sync Pipeline │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ 1. Fetch Open │ ← Ivanti API (paginated, 100/page) │
|
|
||||||
│ │ Findings │ │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 2. Extract & │ ← Transform raw API → normalized rows │
|
|
||||||
│ │ Transform │ │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 3. Upsert to │ ← Batch INSERT ON CONFLICT (100/batch) │
|
|
||||||
│ │ PG │ Preserves notes + overrides │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 4. Archive │ ← Compare previous IDs vs current IDs │
|
|
||||||
│ │ Detection │ Detect disappeared + returned findings │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 5. Fetch Closed│ ← Ivanti API (all closed pages) │
|
|
||||||
│ │ Findings │ Upsert as state='closed' │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 6. BU Drift │ ← Re-query Ivanti for disappeared IDs │
|
|
||||||
│ │ Checker │ Classify: BU reassign / severity / decom │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 7. FP Workflow │ ← Sweep closed findings for FP# tickets │
|
|
||||||
│ │ Counts │ Aggregate by state │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 8. Anomaly │ ← Compute deltas, write to anomaly_log │
|
|
||||||
│ │ Summary │ │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────▼────────┐ │
|
|
||||||
│ │ 9. Update │ ← sync_state status='success' │
|
|
||||||
│ │ Sync State │ Notify API server: pg_notify('sync_done') │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Timeline Summary
|
|
||||||
|
|
||||||
| Phase | Timeframe | Key Outcome | Required For |
|
|
||||||
|-------|-----------|-------------|--------------|
|
|
||||||
| **0** | Weeks 1–2 | Non-blocking sync, pool increase | Immediate UX fix |
|
|
||||||
| **1** | Weeks 3–4 | Collector extracted, fault isolation | Multi-team onboarding |
|
|
||||||
| **2** | Weeks 5–8 | Multi-tenancy, rate budgeting, retries | 15 teams / 100+ users |
|
|
||||||
| **3** | Weeks 9–12 | New data sources (CS/Qualys/Tanium) | Full vuln coverage |
|
|
||||||
| **4** | Weeks 13+ | Horizontal scaling, load balancing | 300+ users (if needed) |
|
|
||||||
|
|
||||||
Phases 0–2 are recommended regardless of company-wide rollout. Phase 3 depends on data source priority decisions. Phase 4 is contingent on actual adoption numbers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Review this document and provide input on [Decision Points](#decision-points)
|
|
||||||
2. Approve Phase 0 for immediate implementation
|
|
||||||
3. Schedule Phase 1 kickoff once Phase 0 is validated in staging
|
|
||||||
4. Submit firewall requests for CrowdStrike/Qualys/Tanium access to CT107 (long lead time)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# [Bug]: Jira sync fails for tickets in projects other than STEAM
|
|
||||||
|
|
||||||
**Labels:** kind/bug, status/resolved
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Syncing individual Jira tickets (or bulk "Sync All") fails with a 502 "Failed to fetch issue from Jira" error when the ticket belongs to a Jira project other than the configured `JIRA_PROJECT_KEY` (STEAM). For example, ticket `AA_ADTRAN-541` in the `AA_ADTRAN` project cannot be synced because the JQL query hardcodes `AND project = STEAM`, which excludes all cross-project tickets.
|
|
||||||
|
|
||||||
This affects both single-ticket sync and the "Sync All" bulk operation.
|
|
||||||
|
|
||||||
## Steps to Reproduce
|
|
||||||
|
|
||||||
1. Go to the Jira Tickets page
|
|
||||||
2. Add or have a ticket with a key from a non-STEAM project (e.g., `AA_ADTRAN-541`)
|
|
||||||
3. Click the sync button on that ticket (or click "Sync All")
|
|
||||||
4. See browser alert: "Failed to fetch issue from Jira."
|
|
||||||
5. Console shows: `POST /api/jira-tickets/:id/sync` returns 502 (Bad Gateway)
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
- Browser: Chrome (any)
|
|
||||||
- Server: Node.js on 71.85.90.9:3001
|
|
||||||
- Jira: Charter Jira Data Center (on-prem)
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
|
|
||||||
`backend/helpers/jiraApi.js` — both `getIssue()` and `searchIssuesByKeys()` constructed JQL with `AND project = ${JIRA_PROJECT_KEY}` (resolves to `AND project = STEAM`). Since Jira issue keys are globally unique (the project prefix is part of the key), this filter is redundant for key-based lookups and breaks any ticket not in the STEAM project.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
Removed the `AND project = ${JIRA_PROJECT_KEY}` clause from:
|
|
||||||
- `getIssue()` — now uses `key = "${issueKey}"` only
|
|
||||||
- `searchIssuesByKeys()` — now uses `key in (...) AND updated >= -72h` only
|
|
||||||
|
|
||||||
`JIRA_PROJECT_KEY` is still used for issue creation (where it belongs).
|
|
||||||
|
|
||||||
## Relevant Log Output
|
|
||||||
|
|
||||||
```
|
|
||||||
POST http://71.85.90.9:3001/api/jira-tickets/:id/sync 502 (Bad Gateway)
|
|
||||||
Response: { "error": "Failed to fetch issue from Jira.", "details": "Issue not found" }
|
|
||||||
```
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# STEAM Security Dashboard
|
# STEAM Security Dashboard
|
||||||
|
|
||||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM, NTS-AEO-ACCESS-ENG, NTS-AEO-ACCESS-OPS, and NTS-AEO-INTELDEV business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture monitoring, CCP Metrics cross-org compliance reporting, FP/Archer/CARD/GRANITE/DECOM exception workflows, CARD asset ownership management, Granite Loader Sheet generation, Jira ticket management, and internal documentation in a single interface.
|
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture, FP and Archer exception workflows, and internal documentation in a single interface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,20 +18,11 @@ A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM, NTS-AEO-
|
|||||||
- [Home — CVE Management](#home--cve-management)
|
- [Home — CVE Management](#home--cve-management)
|
||||||
- [Reporting — Host Findings](#reporting--host-findings)
|
- [Reporting — Host Findings](#reporting--host-findings)
|
||||||
- [Ivanti Queue](#ivanti-queue)
|
- [Ivanti Queue](#ivanti-queue)
|
||||||
- [FP Workflow Submission](#fp-workflow-submission)
|
|
||||||
- [Compliance — AEO Posture](#compliance--aeo-posture)
|
- [Compliance — AEO Posture](#compliance--aeo-posture)
|
||||||
- [CCP Metrics — Multi-Vertical Compliance](#ccp-metrics--multi-vertical-compliance)
|
|
||||||
- [CARD Asset Ownership](#card-asset-ownership)
|
|
||||||
- [Granite Loader Sheet](#granite-loader-sheet)
|
|
||||||
- [Atlas Action Plans](#atlas-action-plans)
|
|
||||||
- [Finding Archive Tracking](#finding-archive-tracking)
|
|
||||||
- [Knowledge Base](#knowledge-base)
|
- [Knowledge Base](#knowledge-base)
|
||||||
- [Exports](#exports)
|
- [Exports](#exports)
|
||||||
- [Jira Tickets](#jira-tickets)
|
- [Jira Tickets](#jira-tickets)
|
||||||
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
||||||
- [Archer Template Library](#archer-template-library)
|
|
||||||
- [In-App Notifications](#in-app-notifications)
|
|
||||||
- [Feedback — GitLab Integration](#feedback--gitlab-integration)
|
|
||||||
- [Admin Panel](#admin-panel)
|
- [Admin Panel](#admin-panel)
|
||||||
- [Scripts](#scripts)
|
- [Scripts](#scripts)
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
@@ -52,24 +43,13 @@ The application provides:
|
|||||||
|
|
||||||
- A searchable, filterable CVE list with per-vendor tracking and document storage
|
- A searchable, filterable CVE list with per-vendor tracking and document storage
|
||||||
- NVD API integration to auto-populate CVE metadata
|
- NVD API integration to auto-populate CVE metadata
|
||||||
- **Ivanti/RiskSense integration** — sync open host findings with live FP workflow tracking, per-BU trend lines, and archive detection
|
- **Ivanti/RiskSense integration** — sync open host findings with live FP workflow tracking
|
||||||
- **Reporting page** with donut charts, Group by Host toggle, CARD ownership tooltips, advanced per-column filtering, inline editing, Ivanti Queue, and CSV/XLSX export
|
- **Reporting page** with donut charts, advanced per-column filtering, inline editing, Ivanti Queue, and CSV/XLSX export
|
||||||
- **Ivanti Queue** — personal staging list for batch-processing FP, Archer, CARD, GRANITE, DECOM, and Remediate workflows
|
- **Ivanti Queue** — personal staging list for batch-processing FP, Archer, and CARD workflows
|
||||||
- **FP Workflow Submission** — submit False Positive workflows directly to Ivanti API with attachments and lifecycle tracking
|
|
||||||
- **AEO Compliance page** — weekly xlsx upload, diff preview, per-team metric health cards, device-level violation tracking with notes history
|
- **AEO Compliance page** — weekly xlsx upload, diff preview, per-team metric health cards, device-level violation tracking with notes history
|
||||||
- **CCP Metrics page** — multi-vertical VCL upload, cross-org compliance reporting with forecast burndown, metric drill-down, and data management
|
|
||||||
- **CARD Asset Ownership** — owner lookup, confirm/decline/redirect actions, tooltip integration, Granite enrichment
|
|
||||||
- **Granite Loader Sheet** — generate xlsx loader sheets with CARD enrichment, searchable picklists, per-row editing
|
|
||||||
- **Atlas action plan tracking** with per-host vulnerability mapping and badge rendering
|
|
||||||
- **Jira integration** — flexible ticket creation, multi-item consolidation, vendor-specific issue types, JQL sync
|
|
||||||
- **Finding archive tracking** — automatic detection of disappeared/returned findings with anomaly logging
|
|
||||||
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
||||||
- **Archer Template Library** — store and clone static content for Archer Risk Acceptance forms
|
|
||||||
- **In-app notifications** — native notification system for sync events and workflow completions
|
|
||||||
- **Feedback integration** — submit bug reports and feature requests directly to GitLab as issues
|
|
||||||
- A knowledge base for internal documentation and policies
|
- A knowledge base for internal documentation and policies
|
||||||
- Group-based access control (Admin, Standard_User, Leadership, Read_Only) with a full audit trail
|
- Group-based access control (Admin, Standard_User, Leadership, Read_Only) with a full audit trail
|
||||||
- Per-user BU team assignments and Ivanti identity for multi-tenant scoping
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -200,22 +180,12 @@ IVANTI_CLIENT_ID=1550
|
|||||||
# Optional: filter workflows to a specific person's submissions
|
# Optional: filter workflows to a specific person's submissions
|
||||||
IVANTI_FIRST_NAME=
|
IVANTI_FIRST_NAME=
|
||||||
IVANTI_LAST_NAME=
|
IVANTI_LAST_NAME=
|
||||||
# Comma-separated list of BU values to sync from Ivanti.
|
# Set to 'true' if your network has SSL inspection / self-signed certs
|
||||||
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
IVANTI_SKIP_TLS=false
|
||||||
IVANTI_BU_FILTER=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
|
||||||
# Comma-separated list of BUs considered "managed" for archive drift classification.
|
# Comma-separated list of BUs considered "managed" for archive drift classification.
|
||||||
# Findings leaving these BUs are classified as bu_reassignment.
|
# Findings leaving these BUs are classified as bu_reassignment.
|
||||||
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||||
IVANTI_MANAGED_BUS=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
IVANTI_MANAGED_BUS=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||||
# Set to 'true' if your network has SSL inspection / self-signed certs
|
|
||||||
IVANTI_SKIP_TLS=false
|
|
||||||
|
|
||||||
# Atlas InfoSec API (required for Atlas action plan tracking)
|
|
||||||
ATLAS_API_URL=https://atlas-infosec.caas.charterlab.com
|
|
||||||
ATLAS_API_USER=your-atlas-user
|
|
||||||
ATLAS_API_PASS=your-atlas-password
|
|
||||||
# Set to true if behind Charter's SSL inspection proxy
|
|
||||||
ATLAS_SKIP_TLS=false
|
|
||||||
|
|
||||||
# Jira Data Center REST API (required for Jira Tickets page)
|
# Jira Data Center REST API (required for Jira Tickets page)
|
||||||
# VPN or Charter Network connection required for all Jira instances.
|
# VPN or Charter Network connection required for all Jira instances.
|
||||||
@@ -230,25 +200,6 @@ JIRA_API_TOKEN=your-api-token
|
|||||||
JIRA_PROJECT_KEY=VULN
|
JIRA_PROJECT_KEY=VULN
|
||||||
JIRA_ISSUE_TYPE=Task
|
JIRA_ISSUE_TYPE=Task
|
||||||
JIRA_SKIP_TLS=false
|
JIRA_SKIP_TLS=false
|
||||||
|
|
||||||
# CARD Asset Ownership API (required for CARD integration)
|
|
||||||
# OAuth Bearer token auth — service account must be onboarded with the CARD team.
|
|
||||||
# Tokens are acquired automatically via Basic Auth and cached for 1 hour.
|
|
||||||
CARD_API_URL=https://card.charter.com
|
|
||||||
CARD_API_USER=your-card-user
|
|
||||||
CARD_API_PASS=your-card-password
|
|
||||||
# Set to true if behind Charter's SSL inspection proxy
|
|
||||||
CARD_SKIP_TLS=false
|
|
||||||
|
|
||||||
# GitLab Feedback Integration (bug reports and feature requests from the dashboard)
|
|
||||||
# PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings.
|
|
||||||
GITLAB_URL=http://steam-gitlab.charterlab.com
|
|
||||||
GITLAB_PROJECT_ID=13
|
|
||||||
GITLAB_PAT=glpat-xxxxxxxxxxxxx
|
|
||||||
# Webhook secret — shared secret for validating incoming webhook requests.
|
|
||||||
# Set this same value in GitLab project > Settings > Webhooks > Secret Token.
|
|
||||||
# Generate with: openssl rand -hex 20
|
|
||||||
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`SESSION_SECRET` is required.** The server will exit on startup if it is not set. Generate one with `openssl rand -base64 32`.
|
**`SESSION_SECRET` is required.** The server will exit on startup if it is not set. Generate one with `openssl rand -base64 32`.
|
||||||
@@ -361,10 +312,6 @@ All routes require authentication. Four user groups are supported:
|
|||||||
|
|
||||||
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies. Login is rate-limited to 20 attempts per 15-minute window.
|
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies. Login is rate-limited to 20 attempts per 15-minute window.
|
||||||
|
|
||||||
**Per-user settings:**
|
|
||||||
- `bu_teams` — comma-separated BU team assignments for multi-tenant scoping. Determines default BU filter on the Reporting page.
|
|
||||||
- `ivanti_first_name` / `ivanti_last_name` — per-user Ivanti identity for filtering FP workflow views to their own submissions.
|
|
||||||
|
|
||||||
**Migration from legacy roles:** The `add_user_groups.js` migration automatically maps existing users: `admin` → `Admin`, `editor` → `Standard_User`, `viewer` → `Read_Only`. Unrecognized or NULL roles default to `Read_Only`.
|
**Migration from legacy roles:** The `add_user_groups.js` migration automatically maps existing users: `admin` → `Admin`, `editor` → `Standard_User`, `viewer` → `Read_Only`. Unrecognized or NULL roles default to `Read_Only`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -411,7 +358,7 @@ The home page is the primary CVE research and tracking tool.
|
|||||||
|
|
||||||
### Reporting — Host Findings
|
### Reporting — Host Findings
|
||||||
|
|
||||||
The Reporting page is the core operational view for remediation tracking. It integrates with Ivanti/RiskSense to show all host findings for the configured business units, with CARD ownership integration and multi-BU scoping.
|
The Reporting page is the core operational view for remediation tracking. It integrates with Ivanti/RiskSense to show all host findings for the configured business units.
|
||||||
|
|
||||||
#### Syncing Data
|
#### Syncing Data
|
||||||
|
|
||||||
@@ -420,38 +367,11 @@ Click **Sync** (top right) to pull the latest findings from Ivanti. Sync require
|
|||||||
2. Fetches the closed finding count separately
|
2. Fetches the closed finding count separately
|
||||||
3. Sweeps closed findings to capture FP workflow states (including Approved FPs now closed)
|
3. Sweeps closed findings to capture FP workflow states (including Approved FPs now closed)
|
||||||
4. Stores findings as individual rows in the PostgreSQL `ivanti_findings` table
|
4. Stores findings as individual rows in the PostgreSQL `ivanti_findings` table
|
||||||
5. Detects archived findings (present in previous sync, absent in current) and logs transitions
|
|
||||||
6. Records per-BU counts in `ivanti_counts_history_by_bu` for trend analysis
|
|
||||||
7. Logs sync anomalies when significant count deltas are detected
|
|
||||||
|
|
||||||
Findings are also auto-synced on a 24-hour schedule. The last sync timestamp is shown at the top of the page.
|
Findings are also auto-synced on a 24-hour schedule. The last sync timestamp is shown at the top of the page.
|
||||||
|
|
||||||
> `IVANTI_API_KEY` must be set in `backend/.env` for sync to work.
|
> `IVANTI_API_KEY` must be set in `backend/.env` for sync to work.
|
||||||
|
|
||||||
#### Group by Host
|
|
||||||
|
|
||||||
The **Group by Host** toggle in the toolbar collapses duplicate assets (same hostname + IP) with multiple finding IDs into expandable host rows. Hosts with only one finding remain as flat rows. Toggle between grouped and flat views. This reduces visual clutter when a single host has dozens of findings.
|
|
||||||
|
|
||||||
#### CARD Ownership Tooltip
|
|
||||||
|
|
||||||
Hover over any IP address in the findings table to see CARD asset ownership data in an interactive tooltip:
|
|
||||||
- Confirmed, unconfirmed, and candidate team assignments with confidence scores
|
|
||||||
- Click "Actions" to open the CARD Action Modal for direct confirm/decline/redirect operations
|
|
||||||
- Results cached per session — re-hover displays instantly without API calls
|
|
||||||
- Quick mode uses CTEC suffix only with 15s timeout to avoid multi-minute waits
|
|
||||||
- Timeouts (504) are not cached — re-hover will retry the lookup
|
|
||||||
- When Ivanti Host ID is available, uses the faster asset-search path
|
|
||||||
|
|
||||||
#### Multi-BU Scope
|
|
||||||
|
|
||||||
The multi-select BU picker at the top of the page replaces the previous binary scope toggle. Select one or more BUs to filter findings:
|
|
||||||
- NTS-AEO-ACCESS-ENG
|
|
||||||
- NTS-AEO-STEAM
|
|
||||||
- NTS-AEO-ACCESS-OPS
|
|
||||||
- NTS-AEO-INTELDEV
|
|
||||||
|
|
||||||
Per-user BU team assignments (`bu_teams` on the user record) determine the default scope.
|
|
||||||
|
|
||||||
#### Metric Charts
|
#### Metric Charts
|
||||||
|
|
||||||
| Chart | What it shows |
|
| Chart | What it shows |
|
||||||
@@ -497,72 +417,36 @@ Each row represents a single Ivanti host finding.
|
|||||||
|
|
||||||
### Ivanti Queue
|
### Ivanti Queue
|
||||||
|
|
||||||
A personal staging list for batch-processing FP, Archer, CARD, GRANITE, DECOM, and Remediate workflows without context-switching into Ivanti mid-review. Requires Admin or Standard_User group.
|
A personal staging list for batch-processing FP, Archer, and CARD workflows without context-switching into Ivanti mid-review. Requires Admin or Standard_User group.
|
||||||
|
|
||||||
**Adding items:** Check the checkbox at the far left of any finding row. A popover appears:
|
**Adding items:** Check the checkbox at the far left of any finding row. A popover appears:
|
||||||
- For **FP** and **Archer** items: enter the Vendor / Platform (e.g., "Juniper MX", "Cisco IOS-XE")
|
- For **FP** and **Archer** items: enter the Vendor / Platform (e.g., "Juniper MX", "Cisco IOS-XE")
|
||||||
- For **CARD** and **GRANITE** items: no vendor entry required — the IP address is captured automatically
|
- For **CARD** items: no vendor entry required — the IP address is captured automatically
|
||||||
- For **DECOM** items: the finding is flagged for decommission workflow
|
- Select the workflow type: **FP**, **Archer**, or **CARD**
|
||||||
- For **Remediate** items: the finding is flagged for remediation tracking
|
|
||||||
- Select the workflow type: **FP**, **Archer**, **CARD**, **GRANITE**, **DECOM**, or **Remediate**
|
|
||||||
- Click **Add to Queue** — the row checkbox turns solid blue
|
- Click **Add to Queue** — the row checkbox turns solid blue
|
||||||
|
|
||||||
**Queue panel:** Click the **Queue** button (top right of Reporting page) to open the slide-out panel:
|
**Queue panel:** Click the **Queue** button (top right of Reporting page) to open the slide-out panel:
|
||||||
- Collapsible sections per workflow type
|
- **CARD** items appear at the top in their own section with the IP address displayed
|
||||||
- **CARD** items appear with the IP address displayed
|
- **FP and Archer** items are grouped alphabetically by vendor below
|
||||||
- **GRANITE** items show IP and hostname for Granite Loader Sheet generation
|
- Badges show workflow type: amber = FP, sky = Archer, green = CARD
|
||||||
- **FP and Archer** items are grouped alphabetically by vendor
|
|
||||||
- **DECOM** items auto-note and auto-hide the finding on completion
|
|
||||||
- Badges show workflow type colour-coded by category
|
|
||||||
|
|
||||||
**Working the queue:**
|
**Working the queue:**
|
||||||
- Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity)
|
- Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity)
|
||||||
- Delete individual items with the trash icon, or select multiple and use **Delete (N)**
|
- Delete individual items with the trash icon, or select multiple and use **Delete (N)**
|
||||||
- **Clear Completed** removes all marked-complete items at once (FK-safe deletion)
|
- **Clear Completed** removes all marked-complete items at once
|
||||||
- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Attachments can be local file uploads or documents selected from the CVE document library — library documents are read from disk and sent to Ivanti identically to local uploads. Successful submission marks the queue items as complete and records the submission locally.
|
- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Attachments can be local file uploads or documents selected from the CVE document library — library documents are read from disk and sent to Ivanti identically to local uploads. Successful submission marks the queue items as complete and records the submission locally.
|
||||||
- **Create Jira Ticket** — select multiple items and use the consolidation modal to create a single Jira ticket covering all selected findings. Ticket links are displayed on completed items.
|
|
||||||
- **Loader Sheet** — select GRANITE items to generate a Granite Loader Sheet with CARD enrichment
|
|
||||||
- **Archer Template** — select Archer items to open the template selector for pre-filling Archer Risk Acceptance forms
|
|
||||||
|
|
||||||
**Redirecting items:**
|
**Redirecting completed items:**
|
||||||
- Pending items can be redirected to a different workflow type without duplication
|
|
||||||
- Completed items show a redirect button (↱) next to the delete icon
|
- Completed items show a redirect button (↱) next to the delete icon
|
||||||
- Click redirect to open a modal where you select the target workflow type and vendor (required for FP/Archer)
|
- Click redirect to open a modal where you select the target workflow type (FP, Archer, or CARD) and vendor (required for FP/Archer)
|
||||||
- Redirecting creates a new pending queue item with the same finding data under the new workflow type — the original item is preserved
|
- Redirecting creates a new pending queue item with the same finding data under the new workflow type — the original completed item is preserved
|
||||||
- This is useful when a CARD inventory fix is done but the finding still needs an FP or Archer workflow, or when an item was assigned to the wrong workflow initially
|
- This is useful when a CARD inventory fix is done but the finding still needs an FP or Archer workflow, or when an item was assigned to the wrong workflow initially
|
||||||
|
- Not every completed item needs a redirect — it's an optional action for items that require further processing
|
||||||
|
|
||||||
Queue items are stored in the database, are **personal to your login**, and persist across sessions and page refreshes.
|
Queue items are stored in the database, are **personal to your login**, and persist across sessions and page refreshes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### FP Workflow Submission
|
|
||||||
|
|
||||||
Submit False Positive workflows directly to the Ivanti API with attachments and full lifecycle tracking. Accessible from the Ivanti Queue when FP items are selected.
|
|
||||||
|
|
||||||
**Submission workflow:**
|
|
||||||
1. Select pending FP items in the queue
|
|
||||||
2. Fill in workflow name, reason, description, expiration date, and scope override
|
|
||||||
3. Attach supporting documents (local uploads or library documents, 10MB limit per file)
|
|
||||||
4. Submit — the workflow batch is created in Ivanti via API and recorded locally
|
|
||||||
|
|
||||||
**Lifecycle tracking:** Each submission tracks its status through the lifecycle:
|
|
||||||
- `submitted` — initial state after successful API submission
|
|
||||||
- `approved` — FP workflow approved by Ivanti team
|
|
||||||
- `rejected` — FP workflow rejected, requires rework
|
|
||||||
- `rework` — submission is being edited for resubmission
|
|
||||||
- `resubmitted` — edited submission resubmitted to Ivanti
|
|
||||||
|
|
||||||
**Managing submissions:**
|
|
||||||
- View submission history with attachment results and lifecycle status
|
|
||||||
- Edit and resubmit rejected workflows (fields, findings, and attachments)
|
|
||||||
- Dismiss rejected submissions (sets `dismissed_at` timestamp)
|
|
||||||
- Re-queue findings from rejected submissions into the todo queue under a different workflow type
|
|
||||||
- Auto-clear approved submissions from the active list
|
|
||||||
- Collapsible submissions panel with per-user filtering
|
|
||||||
- Sync lifecycle status from Ivanti `currentState` on fetch
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Compliance — AEO Posture
|
### Compliance — AEO Posture
|
||||||
|
|
||||||
The Compliance page tracks NTS-AEO team posture against the AEO compliance framework using weekly xlsx reports exported from the NTS_AEO reporting system.
|
The Compliance page tracks NTS-AEO team posture against the AEO compliance framework using weekly xlsx reports exported from the NTS_AEO reporting system.
|
||||||
@@ -613,170 +497,6 @@ Only **STEAM** and **ACCESS-ENG** teams are tracked. The team selector at the to
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### CCP Metrics — Multi-Vertical Compliance
|
|
||||||
|
|
||||||
The CCP Metrics page provides executive-level visibility into VCL (Vulnerability Compliance Level) compliance posture across multiple verticals (organisational units). It supports multi-file upload, forecast burndown charts, sub-team drill-down, and data management.
|
|
||||||
|
|
||||||
**Upload workflow:**
|
|
||||||
1. Click **Upload** and select 1–14 xlsx files (naming convention: `<VERTICAL>_YYYY_MM_DD.xlsx`)
|
|
||||||
2. Each file is parsed, the vertical is extracted from the filename, and a scoped diff is computed against existing data for that vertical
|
|
||||||
3. The preview shows per-file new/recurring/resolved counts and unrecognised files
|
|
||||||
4. Click **Commit** to persist all files in a single transaction with vertical-scoped resolution
|
|
||||||
|
|
||||||
**Executive overview:**
|
|
||||||
- Aggregated stats across all verticals: total devices, compliant, non-compliant, compliance percentage vs target
|
|
||||||
- Donut chart: blocked (no resolution date) vs in-progress (has resolution date)
|
|
||||||
- Per-vertical breakdown cards with compliance percentage, burndown forecast, and last upload date
|
|
||||||
- Cross-vertical metric breakdown table sorted by non-compliant count
|
|
||||||
- LIVE and LAST REPORT badges showing data freshness
|
|
||||||
|
|
||||||
**Metric drill-down:**
|
|
||||||
- Click a metric ID to see per-vertical breakdown with sub-team data
|
|
||||||
- Per-metric summary statistics and donut breakdown
|
|
||||||
- Per-metric monthly compliance trend with linear regression forecast (3 months forward when 3+ months of history)
|
|
||||||
- Device list per vertical/metric with optional team filtering
|
|
||||||
- Compliant/total counts on metric summary cards
|
|
||||||
- Non-Compliant stat clickable with metric breakdown buttons
|
|
||||||
|
|
||||||
**Burndown forecasts:**
|
|
||||||
- Per-vertical burndown: deduplicates devices by hostname, projects monthly resolution based on `resolution_date` fields
|
|
||||||
- Aggregated burndown: cross-vertical forecast with projected clear date
|
|
||||||
- Per-metric forecast burndown chart
|
|
||||||
|
|
||||||
**Sub-team drill-down:**
|
|
||||||
- Each metric shows sub-team rows (e.g., STEAM, ACCESS-OPS, ACCESS-ENG) beneath the rollup row
|
|
||||||
- Intermediate view between overview and device list
|
|
||||||
- Team filtering on the device list endpoint
|
|
||||||
|
|
||||||
**Data management (Admin only):**
|
|
||||||
- Delete a specific vertical (all items, uploads, summary, snapshots)
|
|
||||||
- Rollback a specific upload (must be the most recent for that vertical)
|
|
||||||
- Reset all multi-vertical data (nuclear option)
|
|
||||||
|
|
||||||
**VCL metric calculations:** See `docs/guides/vcl-metric-calculations.md` for formula reference.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CARD Asset Ownership
|
|
||||||
|
|
||||||
CARD API integration for managing asset ownership — confirm, decline, and redirect ownership for network assets directly from the dashboard.
|
|
||||||
|
|
||||||
**Owner lookup:**
|
|
||||||
- Hover over any IP address in the findings table to see CARD ownership data in a tooltip (confirmed/unconfirmed/candidate teams)
|
|
||||||
- Tooltip uses quick mode (CTEC suffix only, 15s timeout) for performance
|
|
||||||
- Results cached per session for instant re-display — timeouts (504) are not cached and will retry on re-hover
|
|
||||||
- When Ivanti Host ID is available, uses the CARD asset-search endpoint for faster resolution
|
|
||||||
|
|
||||||
**Direct actions (no queue item required):**
|
|
||||||
- Click "Actions" in the CARD tooltip to open the CARD Action Modal
|
|
||||||
- Modal displays full owner context: confirmed, unconfirmed, declined, and candidate teams
|
|
||||||
- Confirm, decline, or redirect ownership via the CARD API
|
|
||||||
- Bare IPs are auto-resolved to CARD asset IDs (via host_id fast path or suffix guessing: CTEC, NATL, CHTR, COML, RESI, WIFI, VOIP)
|
|
||||||
- IP address validation before mutation operations
|
|
||||||
|
|
||||||
**Queue-based actions:**
|
|
||||||
- Add findings to the queue with workflow type CARD
|
|
||||||
- Confirm, decline, or redirect from the queue panel
|
|
||||||
- Queue items are marked complete on successful CARD action
|
|
||||||
- update_token handling for safe concurrent operations
|
|
||||||
|
|
||||||
**Team assets endpoint:**
|
|
||||||
- Paginated team asset lookup by disposition (confirmed, unconfirmed, candidate)
|
|
||||||
- Used by the Granite enrichment batch endpoint for full data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Granite Loader Sheet
|
|
||||||
|
|
||||||
Generate Granite Loader Sheets with CARD enrichment for network device workflows.
|
|
||||||
|
|
||||||
**Generation workflow:**
|
|
||||||
1. Add findings to the queue with workflow type GRANITE
|
|
||||||
2. Click **Loader Sheet** in the queue panel (or use the Loader Sheet button on the Reporting page)
|
|
||||||
3. The modal fetches CARD data for each IP/host_id to enrich with NCIM, Qualys, and Netops Granite fields
|
|
||||||
4. Review and edit per-row data with searchable picklists
|
|
||||||
5. Export as formatted XLSX
|
|
||||||
|
|
||||||
**CARD enrichment fields:**
|
|
||||||
- `equip_inst_id` — NCIM equipment instance ID
|
|
||||||
- `hostname` — resolved hostname from CARD
|
|
||||||
- `site_name` — NCIM site name
|
|
||||||
- `mgmt_ip_asn` — management IP ASN
|
|
||||||
- `responsible_team` — NCIM responsible team
|
|
||||||
- `equipment_class` — equipment classification
|
|
||||||
- `equip_template` — equipment template
|
|
||||||
- `equip_status` — equipment status
|
|
||||||
- `serial_number` — device serial number
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Searchable picklists for teams, statuses, operation types (defined in `graniteLoaderPicklists.js`)
|
|
||||||
- Column groups with configurable visibility (defined in `graniteLoaderConfig.js`)
|
|
||||||
- Per-row inline editing before export
|
|
||||||
- Batch enrichment: accepts up to 200 IPs or host_ids per request
|
|
||||||
- When no team is specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Atlas Action Plans
|
|
||||||
|
|
||||||
Atlas InfoSec action plan tracking with per-host vulnerability mapping. Provides visibility into which hosts have active remediation, risk acceptance, or compensating control plans.
|
|
||||||
|
|
||||||
**AtlasBadge component:**
|
|
||||||
- Appears on finding rows in the Reporting table when a host has one or more action plans
|
|
||||||
- Badge colour indicates plan type: remediation, risk acceptance, or compensating control
|
|
||||||
- Reads from local cache (`atlas_action_plans_cache` table) for instant rendering without API round-trips
|
|
||||||
|
|
||||||
**Slide-out panel:**
|
|
||||||
- Click the AtlasBadge to open the Atlas detail panel
|
|
||||||
- Shows all plans for the host with type, status, and metadata
|
|
||||||
- Qualys vulnerability mapping per host (resolved via `/hosts/vulnerabilities` endpoint)
|
|
||||||
|
|
||||||
**Cache management:**
|
|
||||||
- Local cache stores plan existence, count, and full plan JSON per host_id
|
|
||||||
- Manual refresh triggers a re-fetch from the Atlas API and updates the cache
|
|
||||||
- `atlas_known` flag indicates whether the host has been checked (avoids re-querying hosts with no plans)
|
|
||||||
|
|
||||||
**Plan operations (Admin/Standard_User):**
|
|
||||||
- Create action plans: remediation, risk_acceptance, or compensating_control
|
|
||||||
- Update existing plans (PATCH)
|
|
||||||
- Refresh cache per host
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Finding Archive Tracking
|
|
||||||
|
|
||||||
Automatic detection of findings that disappear between Ivanti syncs, with lifecycle state tracking and anomaly logging.
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
- On each sync, findings present in the previous sync but absent from the current sync are classified as archived
|
|
||||||
- If a previously archived finding reappears, it transitions to RETURNED
|
|
||||||
- Findings that remain absent are eventually classified as CLOSED or CLOSED_GONE
|
|
||||||
|
|
||||||
**Lifecycle states:**
|
|
||||||
| State | Meaning |
|
|
||||||
|-------|---------|
|
|
||||||
| ARCHIVED | Finding disappeared from Ivanti (first detection) |
|
|
||||||
| RETURNED | Previously archived finding reappeared in a subsequent sync |
|
|
||||||
| CLOSED | Finding confirmed closed by Ivanti (workflow completed) |
|
|
||||||
| CLOSED_GONE | Finding disappeared and is confirmed gone (no workflow, long absence) |
|
|
||||||
|
|
||||||
**Anomaly detection:**
|
|
||||||
- Each sync logs open/closed count deltas, newly archived count, and returned count
|
|
||||||
- Significance threshold triggers the AnomalyBanner component on the Reporting page
|
|
||||||
- Classification JSON tracks the breakdown of archive reasons
|
|
||||||
|
|
||||||
**BU reassignment tracking:**
|
|
||||||
- Findings that change BU ownership between syncs are logged in `ivanti_finding_bu_history`
|
|
||||||
- `IVANTI_MANAGED_BUS` env var defines which BUs are "managed" — findings leaving these BUs are classified as `bu_reassignment`
|
|
||||||
- Return classification distinguishes between original finding restoration and new duplicates
|
|
||||||
|
|
||||||
**UI components:**
|
|
||||||
- **AnomalyBanner** — alert bar on the Reporting page when significant sync anomalies are detected
|
|
||||||
- **ArchiveSummaryBar** — state distribution summary (ARCHIVED / RETURNED / CLOSED counts)
|
|
||||||
- Archive view with transition history per finding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Knowledge Base
|
### Knowledge Base
|
||||||
|
|
||||||
A document library for internal reference material — policies, runbooks, vendor advisories, and process guides.
|
A document library for internal reference material — policies, runbooks, vendor advisories, and process guides.
|
||||||
@@ -800,29 +520,24 @@ Bulk export tools for reports and data extracts. Available to Admin, Standard_Us
|
|||||||
|
|
||||||
### Jira Tickets
|
### Jira Tickets
|
||||||
|
|
||||||
A dedicated page for managing Jira Data Center tickets linked to CVE/vendor pairs and Ivanti queue items. Accessible from the navigation drawer. Requires a configured Jira API connection (see [Configuration](#configuration)).
|
A dedicated page for managing Jira Data Center tickets linked to CVE/vendor pairs. Accessible from the navigation drawer. Requires a configured Jira API connection (see [Configuration](#configuration)).
|
||||||
|
|
||||||
**Ticket list**
|
**Ticket list**
|
||||||
- View all tracked Jira tickets with status, CVE ID, vendor, summary, and Jira key
|
- View all tracked Jira tickets with status, CVE ID, vendor, summary, and Jira key
|
||||||
- Filter by status or search by keyword
|
- Filter by status or search by keyword
|
||||||
- Click a Jira key to open the issue in Jira Data Center
|
- Click a Jira key to open the issue in Jira Data Center
|
||||||
- Raw Jira status display — shows the actual Jira status field (no Open/In Progress/Closed mapping)
|
|
||||||
|
|
||||||
**Jira API operations (Admin/Standard_User)**
|
**Jira API operations (Admin/Standard_User)**
|
||||||
- **Lookup** — search for any Jira issue by key and view its current status, assignee, and summary
|
- **Lookup** — search for any Jira issue by key and view its current status, assignee, and summary
|
||||||
- **Create in Jira** — create a new Jira issue directly from the dashboard with project key, issue type, summary, and description; the resulting ticket is automatically linked to a CVE/vendor pair in the local database. CVE/Vendor fields are optional — tickets can be created with source context tracking only.
|
- **Create in Jira** — create a new Jira issue directly from the dashboard with project key, issue type, summary, and description; the resulting ticket is automatically linked to a CVE/vendor pair in the local database
|
||||||
- **Multi-item creation** — from the Ivanti Queue consolidation modal, create a single Jira ticket covering multiple findings
|
|
||||||
- **Save to Dashboard** — save a Jira issue found via lookup to the local database
|
|
||||||
- **Sync** — refresh a single ticket's status and summary from Jira, or bulk-sync all tracked tickets via JQL search
|
- **Sync** — refresh a single ticket's status and summary from Jira, or bulk-sync all tracked tickets via JQL search
|
||||||
- **Create / Edit / Delete** — manage local ticket records linking Jira keys to CVE/vendor pairs
|
- **Create / Edit / Delete** — manage local ticket records linking Jira keys to CVE/vendor pairs
|
||||||
|
|
||||||
**Vendor-specific issue types:** The Issue Type dropdown in the creation modal is context-aware. When the Project Key field matches a recognized vendor project key (e.g., `AA_VECIMA`, `AA_CISCO`, `AA_ADTRAN`), the dropdown switches to vendor-specific issue types (Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation). For all other project keys — including the default from `JIRA_PROJECT_KEY` — the dropdown shows STEAM issue types (Story, Epic, Program, Project, Reservation, Automation Maintenance). Matching is case-insensitive and trims whitespace. Changing the project key such that the context switches (STEAM to vendor or vice versa) resets the selected issue type. The same behavior applies when creating a Jira ticket from the Ivanti Queue. The list of recognized vendor project keys is defined in `VENDOR_PROJECT_KEYS` in `frontend/src/components/pages/JiraPage.js`.
|
|
||||||
|
|
||||||
**Connection test (Admin)** — verify Jira API credentials and connectivity from the page header.
|
**Connection test (Admin)** — verify Jira API credentials and connectivity from the page header.
|
||||||
|
|
||||||
**Rate limit monitoring (Admin)** — view current burst and daily rate limit usage against Charter's posted limits (60/minute burst, 1 440/day).
|
**Rate limit monitoring (Admin)** — view current burst and daily rate limit usage against Charter's posted limits (60/minute burst, 1 440/day).
|
||||||
|
|
||||||
All Jira API calls are proxied through the backend. Credentials are never exposed to the browser. Rate limits are enforced client-side with inter-request delays (1s for GETs, 2s for writes). See `docs/api/jira-api-use-cases.md` for the full API compliance summary.
|
All Jira API calls are proxied through the backend. Credentials are never exposed to the browser. Rate limits are enforced client-side with inter-request delays (1s for GETs, 2s for writes). See `docs/jira-api-use-cases.md` for the full API compliance summary.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -839,75 +554,6 @@ Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Archer Template Library
|
|
||||||
|
|
||||||
Template management system for Archer Risk Acceptance forms. Stores static content (Environment Overview, Segmentation, Mitigating Controls, and 5 additional sections) organised by Vendor, Platform, and Model hierarchy.
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Full CRUD — create, view, update, and delete templates
|
|
||||||
- Clone existing templates into new vendor/platform/model combinations
|
|
||||||
- Search/filter by vendor, platform, or model (case-insensitive)
|
|
||||||
- Hierarchy browsing endpoints: list vendors, platforms per vendor, models per vendor+platform
|
|
||||||
- Per-section copy-to-clipboard buttons in the inline view panel
|
|
||||||
- Template selector integrated into the Ivanti Queue for Archer workflow items
|
|
||||||
- Accessible from nav drawer (Template Mgr) and from Archer queue items
|
|
||||||
|
|
||||||
**Template sections (8 content fields, each max 10,000 chars):**
|
|
||||||
- Environment Overview
|
|
||||||
- Segmentation
|
|
||||||
- Mitigating Controls
|
|
||||||
- Additional Info
|
|
||||||
- Charter Network Banner
|
|
||||||
- Data Classification
|
|
||||||
- Charter Network
|
|
||||||
- Additional Access List
|
|
||||||
|
|
||||||
**Hierarchy:** Vendor > Platform > Model (unique constraint on the combination). Templates are sorted alphabetically by vendor, platform, model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### In-App Notifications
|
|
||||||
|
|
||||||
Native notification system providing per-user alerts for system events without external dependencies (replaces previous Webex bot integration).
|
|
||||||
|
|
||||||
**Notification types:**
|
|
||||||
- `issue_resolved` — GitLab feedback issue closed and deployed
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- NotificationBell component in the header with unread count badge
|
|
||||||
- Click to view list of unread notifications (newest first, limited to 50)
|
|
||||||
- Mark individual notifications as read, or mark all as read
|
|
||||||
- Only the owning user can mark their own notifications
|
|
||||||
|
|
||||||
**Storage:** Notifications are stored in the `notifications` table with `user_id`, `username`, `type`, `title`, `message`, `issue_number`, and `read` flag.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Feedback — GitLab Integration
|
|
||||||
|
|
||||||
In-app bug reports and feature requests submitted directly to the GitLab project as issues. Keeps the GitLab PAT server-side so credentials are never exposed to the browser.
|
|
||||||
|
|
||||||
**Submission workflow:**
|
|
||||||
1. Click the feedback button in the nav drawer
|
|
||||||
2. Select type: Bug Report or Feature Request
|
|
||||||
3. Fill in title and description
|
|
||||||
4. Optionally attach up to 3 screenshots (PNG, JPG, GIF, WebP — 5MB each)
|
|
||||||
5. Submit — creates a GitLab issue with labels (`bug` or `enhancement`) and formatted description
|
|
||||||
|
|
||||||
**Issue lifecycle:**
|
|
||||||
- GitLab webhook receiver (`POST /api/webhooks/gitlab`) listens for issue close events
|
|
||||||
- When a feedback issue is closed, the submitter username is parsed from the issue description
|
|
||||||
- An in-app notification is created for the submitter: "Your bug report has been resolved and deployed"
|
|
||||||
- Webhook secret validation prevents unauthorized requests
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
- `GITLAB_URL` — GitLab instance URL
|
|
||||||
- `GITLAB_PROJECT_ID` — numeric project ID
|
|
||||||
- `GITLAB_PAT` — project access token with `api` scope
|
|
||||||
- `GITLAB_WEBHOOK_SECRET` — shared secret for webhook validation (set same value in GitLab webhook settings)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Admin Panel
|
### Admin Panel
|
||||||
|
|
||||||
The Admin Panel is a full-page, tabbed interface accessible only to Admin-group users. It replaces the previous inline modal rendering and follows the dashboard's dark tactical intelligence theme. Three tabs provide consolidated access to administrative functions:
|
The Admin Panel is a full-page, tabbed interface accessible only to Admin-group users. It replaces the previous inline modal rendering and follows the dashboard's dark tactical intelligence theme. Three tabs provide consolidated access to administrative functions:
|
||||||
@@ -1038,8 +684,6 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/findings` | Admin, Standard_User | Add or remove findings on an existing submission |
|
| POST | `/api/ivanti/fp-workflow/submissions/:id/findings` | Admin, Standard_User | Add or remove findings on an existing submission |
|
||||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/attachments` | Admin, Standard_User | Upload additional attachments (local files and/or `libraryDocIds`) to an existing submission |
|
| POST | `/api/ivanti/fp-workflow/submissions/:id/attachments` | Admin, Standard_User | Upload additional attachments (local files and/or `libraryDocIds`) to an existing submission |
|
||||||
| PATCH | `/api/ivanti/fp-workflow/submissions/:id/status` | Admin, Standard_User | Update submission lifecycle status |
|
| PATCH | `/api/ivanti/fp-workflow/submissions/:id/status` | Admin, Standard_User | Update submission lifecycle status |
|
||||||
| PATCH | `/api/ivanti/fp-workflow/submissions/:id/dismiss` | Admin, Standard_User | Dismiss a rejected submission (sets `dismissed_at` timestamp) |
|
|
||||||
| POST | `/api/ivanti/fp-workflow/submissions/:id/requeue` | Admin, Standard_User | Re-queue findings from a rejected submission into the todo queue under a new workflow type |
|
|
||||||
|
|
||||||
### Ivanti — Todo Queue
|
### Ivanti — Todo Queue
|
||||||
|
|
||||||
@@ -1057,9 +701,7 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
|
|
||||||
| Method | Path | Group | Description |
|
| Method | Path | Group | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/ivanti/archive` | Any | List archive records; optional filters: `state` (ACTIVE/ARCHIVED/RETURNED/CLOSED), `teams` (comma-separated BU names) |
|
| GET | `/api/ivanti/archive` | Any | Get finding archive data for severity score drift tracking |
|
||||||
| GET | `/api/ivanti/archive/anomalies` | Any | Sync anomaly log (significant count deltas and classification data) |
|
|
||||||
| GET | `/api/ivanti/archive/transitions/:archiveId` | Any | Transition history for a specific archive record |
|
|
||||||
|
|
||||||
### Compliance
|
### Compliance
|
||||||
|
|
||||||
@@ -1098,91 +740,6 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
| PUT | `/api/archer-tickets/:id` | Admin, Standard_User | Update an Archer ticket |
|
| PUT | `/api/archer-tickets/:id` | Admin, Standard_User | Update an Archer ticket |
|
||||||
| DELETE | `/api/archer-tickets/:id` | Admin, Standard_User | Delete an Archer ticket (ownership + compliance check for Standard_User) |
|
| DELETE | `/api/archer-tickets/:id` | Admin, Standard_User | Delete an Archer ticket (ownership + compliance check for Standard_User) |
|
||||||
|
|
||||||
### Archer Templates
|
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/archer-templates` | Any | List templates; optional filters: `search`, `vendor`, `platform`, `model` |
|
|
||||||
| GET | `/api/archer-templates/:id` | Any | Get a single template by ID |
|
|
||||||
| POST | `/api/archer-templates` | Admin, Standard_User | Create a new template |
|
|
||||||
| PUT | `/api/archer-templates/:id` | Admin, Standard_User | Update a template (partial update supported) |
|
|
||||||
| DELETE | `/api/archer-templates/:id` | Admin, Standard_User | Delete a template |
|
|
||||||
| POST | `/api/archer-templates/:id/clone` | Admin, Standard_User | Clone a template with new vendor/platform/model |
|
|
||||||
| GET | `/api/archer-templates/hierarchy/vendors` | Any | List distinct vendor names |
|
|
||||||
| GET | `/api/archer-templates/hierarchy/platforms` | Any | List platforms for a vendor; query: `vendor` |
|
|
||||||
| GET | `/api/archer-templates/hierarchy/models` | Any | List models for vendor+platform; query: `vendor`, `platform` |
|
|
||||||
|
|
||||||
### CARD Asset Ownership
|
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/card/status` | Any | Check if CARD API is configured |
|
|
||||||
| GET | `/api/card/teams` | Admin, Standard_User | List all CARD teams |
|
|
||||||
| GET | `/api/card/teams/:teamName/assets` | Admin, Standard_User | Paginated team assets by disposition |
|
|
||||||
| GET | `/api/card/owner/:assetId` | Admin, Standard_User | Get owner record for an asset |
|
|
||||||
| GET | `/api/card/owner-lookup/:ip` | Admin, Standard_User | Resolve IP to asset and return owner data; `?quick=1` for tooltip mode, `?hostId=N` for fast path |
|
|
||||||
| POST | `/api/card/owner/:assetId/confirm` | Admin, Standard_User | Direct confirm ownership (no queue item) |
|
|
||||||
| POST | `/api/card/owner/:assetId/decline` | Admin, Standard_User | Direct decline ownership (no queue item) |
|
|
||||||
| POST | `/api/card/owner/:assetId/redirect` | Admin, Standard_User | Direct redirect between teams (no queue item) |
|
|
||||||
| GET | `/api/card/asset-search/:hostId` | Admin, Standard_User | Search CARD by Ivanti Host ID (deep_search) |
|
|
||||||
| POST | `/api/card/enrich-batch` | Admin, Standard_User | Batch lookup IPs/host_ids for Granite loader fields (max 200) |
|
|
||||||
| POST | `/api/card/queue/:queueItemId/confirm` | Admin, Standard_User | Confirm ownership for a queue item |
|
|
||||||
| POST | `/api/card/queue/:queueItemId/decline` | Admin, Standard_User | Decline ownership for a queue item |
|
|
||||||
| POST | `/api/card/queue/:queueItemId/redirect` | Admin, Standard_User | Redirect ownership for a queue item |
|
|
||||||
|
|
||||||
### Atlas Action Plans
|
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/atlas/hosts/:hostId/plans` | Any | Get action plans for a host (from cache or API) |
|
|
||||||
| PUT | `/api/atlas/hosts/:hostId/plans` | Admin, Standard_User | Create a new action plan |
|
|
||||||
| PATCH | `/api/atlas/hosts/:hostId/plans` | Admin, Standard_User | Update an existing plan |
|
|
||||||
| POST | `/api/atlas/hosts/:hostId/refresh` | Admin, Standard_User | Force cache refresh from Atlas API |
|
|
||||||
|
|
||||||
### VCL Multi-Vertical (CCP Metrics)
|
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/api/compliance/vcl-multi/preview` | Admin, Standard_User | Parse xlsx files, extract verticals, compute scoped diffs |
|
|
||||||
| POST | `/api/compliance/vcl-multi/commit` | Admin, Standard_User | Commit previewed files in a single transaction |
|
|
||||||
| GET | `/api/compliance/vcl-multi/stats` | Any | Aggregated cross-vertical executive summary |
|
|
||||||
| GET | `/api/compliance/vcl-multi/trend` | Any | Monthly compliance trend with linear regression forecast |
|
|
||||||
| GET | `/api/compliance/vcl-multi/burndown` | Any | Aggregated cross-vertical burndown forecast |
|
|
||||||
| GET | `/api/compliance/vcl-multi/verticals` | Any | List known verticals |
|
|
||||||
| GET | `/api/compliance/vcl-multi/metrics` | Any | All metrics aggregated across verticals |
|
|
||||||
| GET | `/api/compliance/vcl-multi/metrics-list` | Any | Distinct metrics with active non-compliant device counts |
|
|
||||||
| GET | `/api/compliance/vcl-multi/metric/:id/stats` | Any | Per-metric summary statistics and donut breakdown |
|
|
||||||
| GET | `/api/compliance/vcl-multi/metric/:id/verticals` | Any | Per-vertical breakdown for a metric with sub-teams |
|
|
||||||
| GET | `/api/compliance/vcl-multi/metric/:id/trend` | Any | Per-metric monthly trend with forecast |
|
|
||||||
| GET | `/api/compliance/vcl-multi/vertical/:code/metrics` | Any | Per-metric breakdown for a specific vertical |
|
|
||||||
| GET | `/api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices` | Any | Device list for a vertical+metric; `?team=X` |
|
|
||||||
| GET | `/api/compliance/vcl-multi/vertical/:code/burndown` | Any | Burndown forecast for a specific vertical |
|
|
||||||
| GET | `/api/compliance/vcl-multi/uploads` | Any | Upload history (most recent 100) |
|
|
||||||
| DELETE | `/api/compliance/vcl-multi/vertical/:code` | Admin | Delete all data for a vertical |
|
|
||||||
| DELETE | `/api/compliance/vcl-multi/upload/:uploadId` | Admin | Rollback a specific upload (must be most recent for that vertical) |
|
|
||||||
| DELETE | `/api/compliance/vcl-multi/all` | Admin | Delete all multi-vertical data |
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/notifications` | Any | Get unread notifications for current user (max 50) |
|
|
||||||
| GET | `/api/notifications/count` | Any | Get unread count for badge display |
|
|
||||||
| PATCH | `/api/notifications/:id/read` | Any | Mark a single notification as read (own only) |
|
|
||||||
| POST | `/api/notifications/read-all` | Any | Mark all notifications as read |
|
|
||||||
|
|
||||||
### Feedback
|
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/api/feedback` | Any | Submit bug report or feature request to GitLab (multipart with optional screenshots) |
|
|
||||||
|
|
||||||
### Webhooks
|
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/api/webhooks/gitlab` | Public | GitLab issue webhook receiver (validated by `x-gitlab-token` header) |
|
|
||||||
|
|
||||||
### Users (Admin only)
|
### Users (Admin only)
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
| Method | Path | Group | Description |
|
||||||
@@ -1215,16 +772,10 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
cve-dashboard/
|
cve-dashboard/
|
||||||
├── start-servers.sh # Start backend + frontend via systemd
|
├── start-servers.sh # Start backend + frontend via systemd
|
||||||
├── stop-servers.sh # Stop both systemd services
|
├── stop-servers.sh # Stop both systemd services
|
||||||
├── configure.js # Interactive configuration wizard
|
|
||||||
├── docker-compose.yml # PostgreSQL 16 container definition
|
├── docker-compose.yml # PostgreSQL 16 container definition
|
||||||
├── package.json # Root package.json (backend dependencies)
|
├── package.json # Root package.json (backend dependencies)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── deploy-postgres.sh # One-time deployment: container, schema, migration
|
│ └── deploy-postgres.sh # One-time deployment: container, schema, migration
|
||||||
│ └── reset-and-migrate.sh # Dev utility: reset DB and re-run migrations
|
|
||||||
├── deploy/
|
|
||||||
│ ├── cve-backend-production.service
|
|
||||||
│ ├── cve-backend-staging.service
|
|
||||||
│ └── setup-staging.sh
|
|
||||||
├── systemd/ # systemd unit files for auto-start on boot
|
├── systemd/ # systemd unit files for auto-start on boot
|
||||||
│ ├── cve-backend.service
|
│ ├── cve-backend.service
|
||||||
│ └── cve-frontend.service
|
│ └── cve-frontend.service
|
||||||
@@ -1234,7 +785,6 @@ cve-dashboard/
|
|||||||
│ ├── db.js # PostgreSQL connection pool (pg)
|
│ ├── db.js # PostgreSQL connection pool (pg)
|
||||||
│ ├── db-schema.sql # Complete DDL for fresh Postgres setup
|
│ ├── db-schema.sql # Complete DDL for fresh Postgres setup
|
||||||
│ ├── setup-postgres.js # Schema initializer (runs db-schema.sql)
|
│ ├── setup-postgres.js # Schema initializer (runs db-schema.sql)
|
||||||
│ ├── setup.js # One-time DB init + default admin creation
|
|
||||||
│ ├── uploads/ # File storage root (gitignored)
|
│ ├── uploads/ # File storage root (gitignored)
|
||||||
│ │ ├── <CVE-ID>/<vendor>/ # CVE documents
|
│ │ ├── <CVE-ID>/<vendor>/ # CVE documents
|
||||||
│ │ ├── knowledge_base/ # Knowledge base documents
|
│ │ ├── knowledge_base/ # Knowledge base documents
|
||||||
@@ -1246,33 +796,20 @@ cve-dashboard/
|
|||||||
│ │ ├── nvdLookup.js # NVD API proxy
|
│ │ ├── nvdLookup.js # NVD API proxy
|
||||||
│ │ ├── knowledgeBase.js # Knowledge base document management
|
│ │ ├── knowledgeBase.js # Knowledge base document management
|
||||||
│ │ ├── archerTickets.js # Archer EXC ticket CRUD
|
│ │ ├── archerTickets.js # Archer EXC ticket CRUD
|
||||||
│ │ ├── archerTemplates.js # Archer template library CRUD + clone + hierarchy
|
|
||||||
│ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache
|
│ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache
|
||||||
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
||||||
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal staging list
|
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list
|
||||||
│ │ ├── ivantiArchive.js # Finding archive tracking, transitions, anomaly log
|
│ │ ├── ivantiArchive.js # Finding archive for severity score drift
|
||||||
│ │ ├── ivantiFpWorkflow.js # FP workflow submission to Ivanti API + lifecycle
|
│ │ ├── jiraTickets.js # Jira ticket CRUD + Jira REST API integration (lookup, sync, create)
|
||||||
│ │ ├── compliance.js # AEO compliance upload, diff, device tracking, notes
|
│ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes
|
||||||
│ │ ├── vclMultiVertical.js # VCL/CCP multi-vertical compliance reporting
|
|
||||||
│ │ ├── atlas.js # Atlas action plan proxy + cache
|
|
||||||
│ │ ├── jiraTickets.js # Jira ticket CRUD + Jira REST API integration
|
|
||||||
│ │ ├── cardApi.js # CARD ownership proxy, mutation, asset-search, enrich
|
|
||||||
│ │ ├── notifications.js # In-app notification system
|
|
||||||
│ │ ├── feedback.js # Bug reports/feature requests to GitLab
|
|
||||||
│ │ └── webhooks.js # GitLab webhook receiver for issue lifecycle
|
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ └── auth.js # requireAuth and requireGroup middleware
|
│ │ └── auth.js # requireAuth and requireGroup middleware
|
||||||
│ ├── helpers/
|
│ ├── helpers/
|
||||||
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
||||||
│ │ ├── cardApi.js # CARD API — OAuth token, owner, confirm/decline/redirect, asset-search
|
|
||||||
│ │ ├── ivantiApi.js # Ivanti API HTTP helpers (multipart, JSON, form POST)
|
|
||||||
│ │ ├── atlasApi.js # Atlas action plan API (Basic auth)
|
|
||||||
│ │ ├── jiraApi.js # Jira Data Center REST API helpers (Basic/PAT auth, rate limiting)
|
|
||||||
│ │ ├── driftChecker.js # Schema drift detection: compareSchemaToDrift(), loadConfig(), reconcileConfig()
|
│ │ ├── driftChecker.js # Schema drift detection: compareSchemaToDrift(), loadConfig(), reconcileConfig()
|
||||||
│ │ ├── vclHelpers.js # VCL metric calculation helpers (burndown, forecast, dedup)
|
│ │ ├── ivantiApi.js # Ivanti API HTTP helpers (multipart, JSON, form POST)
|
||||||
│ │ └── teams.js # Team validation helpers
|
│ │ └── jiraApi.js # Jira Data Center REST API helpers (Basic/PAT auth, rate limiting)
|
||||||
│ ├── migrations/ # Sequential migration scripts (idempotent)
|
│ ├── migrations/ # Legacy SQLite migration scripts (not needed for Postgres)
|
||||||
│ │ └── run-all.js # Run all migrations in order
|
|
||||||
│ └── scripts/
|
│ └── scripts/
|
||||||
│ ├── migrate-to-postgres.js # One-time SQLite → Postgres data migration
|
│ ├── migrate-to-postgres.js # One-time SQLite → Postgres data migration
|
||||||
│ ├── compliance_config.json # Shared parser config (metric_categories, core_cols, skip_sheets)
|
│ ├── compliance_config.json # Shared parser config (metric_categories, core_cols, skip_sheets)
|
||||||
@@ -1287,59 +824,29 @@ cve-dashboard/
|
|||||||
├── App.css # Global styles and CSS variables
|
├── App.css # Global styles and CSS variables
|
||||||
├── contexts/
|
├── contexts/
|
||||||
│ └── AuthContext.js # Auth state provider (login, logout, group helpers)
|
│ └── AuthContext.js # Auth state provider (login, logout, group helpers)
|
||||||
├── utils/
|
|
||||||
│ ├── graniteLoaderConfig.js # Granite column definitions and groups
|
|
||||||
│ ├── graniteLoaderExport.js # XLSX generation logic
|
|
||||||
│ └── graniteLoaderPicklists.js # Searchable dropdown options
|
|
||||||
└── components/
|
└── components/
|
||||||
├── LoginForm.js # Login page
|
├── LoginForm.js # Login page
|
||||||
├── NavDrawer.js # Side navigation drawer
|
├── NavDrawer.js # Side navigation drawer (pages + Admin Panel link for Admin group)
|
||||||
├── UserMenu.js # User dropdown (shows group badge)
|
├── UserMenu.js # User dropdown in header (shows group badge)
|
||||||
├── UserProfilePanel.js # User profile and password change
|
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
||||||
├── CalendarWidget.js # Due-date calendar with finding indicators
|
├── UserManagement.js # Admin user management modal (quick-access from UserMenu)
|
||||||
├── UserManagement.js # Admin user management modal
|
├── AuditLog.js # Admin audit log modal (quick-access from UserMenu)
|
||||||
├── AuditLog.js # Admin audit log modal
|
|
||||||
├── NvdSyncModal.js # Bulk NVD sync dialog
|
├── NvdSyncModal.js # Bulk NVD sync dialog
|
||||||
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
||||||
├── KnowledgeBaseViewer.js # Inline document viewer
|
├── KnowledgeBaseViewer.js # Inline document viewer (sandboxed iframe, sanitized markdown)
|
||||||
├── CardOwnerTooltip.js # CARD ownership hover tooltip
|
├── ConfirmModal.js # Themed confirmation dialog (replaces window.confirm)
|
||||||
├── CardDetailModal.js # CARD asset detail (from reporting)
|
├── CveTooltip.js # Hover tooltip for CVE badges (portal-rendered, cached)
|
||||||
├── CardActionModal.js # CARD confirm/decline/redirect (from queue)
|
├── RedirectModal.js # Queue item redirect modal (workflow type + vendor selection)
|
||||||
├── RedirectModal.js # Queue item redirect modal
|
|
||||||
├── LoaderModal.js # Granite Loader Sheet generator
|
|
||||||
├── SearchableSelect.js # Reusable searchable dropdown
|
|
||||||
├── AtlasBadge.js # Atlas action plan indicator badge
|
|
||||||
├── AtlasIcon.js # Atlas icon component
|
|
||||||
├── AtlasSlideOutPanel.js # Atlas plan detail panel
|
|
||||||
├── AdminScopeToggle.js # BU scope toggle
|
|
||||||
├── ConfirmModal.js # Themed confirmation dialog
|
|
||||||
├── ConsolidationModal.js # Multi-item Jira ticket consolidation
|
|
||||||
├── CveTooltip.js # Hover tooltip for CVE badges
|
|
||||||
├── DeleteConfirmModal.js # Delete confirmation with details
|
|
||||||
├── FeedbackModal.js # Bug report/feature request submission
|
|
||||||
├── NotificationBell.js # Notification bell with unread count
|
|
||||||
├── RemediationModal.js # Remediation plan editing
|
|
||||||
├── TemplateFormModal.js # Archer template create/edit form
|
|
||||||
├── TemplateSelector.js # Archer template picker for queue items
|
|
||||||
└── pages/
|
└── pages/
|
||||||
├── AdminPage.js # Admin panel: users, audit log, system info
|
├── AdminPage.js # Admin panel: user management, audit log, system info
|
||||||
├── ReportingPage.js # Host findings: charts, table, queue, export
|
├── ReportingPage.js # Host findings: charts, table, queue, export
|
||||||
├── IvantiTodoQueuePage.js # Full-page queue view
|
|
||||||
├── IvantiCountsChart.js # Ivanti counts history chart
|
|
||||||
├── AnomalyBanner.js # Sync anomaly alert banner
|
|
||||||
├── ArchiveSummaryBar.js # Finding archive state distribution
|
|
||||||
├── CompliancePage.js # AEO compliance: metric cards, device table
|
├── CompliancePage.js # AEO compliance: metric cards, device table
|
||||||
├── ComplianceUploadModal.js # xlsx upload with drift check + diff preview
|
├── ComplianceUploadModal.js # xlsx upload with diff preview
|
||||||
├── ComplianceDetailPanel.js # Per-device metrics, history, notes
|
├── ComplianceDetailPanel.js # Per-device metrics, history, notes
|
||||||
├── ComplianceChartsPanel.js # Compliance trend charts
|
├── ComplianceChartsPanel.js # Compliance trend charts
|
||||||
├── CCPMetricsPage.js # CCP Metrics: multi-vertical executive view
|
├── IvantiCountsChart.js # Ivanti counts history chart
|
||||||
├── VCLReportPage.js # VCL exec report page
|
├── ArchiveSummaryBar.js # Finding archive summary
|
||||||
├── MetricInfoPanel.js # Metric detail drill-down panel
|
├── JiraPage.js # Jira ticket management and Jira API integration
|
||||||
├── BulkUploadModal.js # Bulk VCL upload
|
|
||||||
├── MultiVerticalUploadModal.js # Multi-vertical upload modal
|
|
||||||
├── ArcherPage.js # Archer tickets management
|
|
||||||
├── ArcherTemplatePage.js # Archer template library
|
|
||||||
├── JiraPage.js # Jira ticket management + API integration
|
|
||||||
├── KnowledgeBasePage.js # Knowledge base page
|
├── KnowledgeBasePage.js # Knowledge base page
|
||||||
└── ExportsPage.js # Exports page (group-gated)
|
└── ExportsPage.js # Exports page (group-gated)
|
||||||
```
|
```
|
||||||
@@ -1380,31 +887,9 @@ All tables are defined in `backend/db-schema.sql` and created by `setup-postgres
|
|||||||
|
|
||||||
**`ivanti_finding_overrides`** — Editor-applied overrides for `hostName` and `dns` fields. `UNIQUE(finding_id, field)`.
|
**`ivanti_finding_overrides`** — Editor-applied overrides for `hostName` and `dns` fields. `UNIQUE(finding_id, field)`.
|
||||||
|
|
||||||
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, CARD, GRANITE, DECOM, or Remediate processing. Keyed by `(user_id, finding_id)`. Workflow type constraint: `FP`, `Archer`, `CARD`, `GRANITE`, `DECOM`. Completed items can be redirected to a different workflow type via `POST /:id/redirect`, which creates a new pending item preserving the original finding data.
|
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, or CARD processing. Keyed by `(user_id, finding_id)`. Completed items can be redirected to a different workflow type via `POST /:id/redirect`, which creates a new pending item preserving the original finding data.
|
||||||
|
|
||||||
**`ivanti_fp_submissions`** — Record of FP workflow submissions to the Ivanti API. Tracks user, workflow batch ID, form fields, finding IDs, queue item IDs, attachment results, and submission status (success/partial/failed). Lifecycle status tracks the workflow through: submitted, approved, rejected, rework, resubmitted. Rejected submissions can be dismissed (`dismissed_at`) or re-queued to the todo queue under a different workflow type (`requeued_at`).
|
**`ivanti_fp_submissions`** — Record of FP workflow submissions to the Ivanti API. Tracks user, workflow batch ID, form fields, finding IDs, queue item IDs, attachment results, and submission status (success/partial/failed).
|
||||||
|
|
||||||
**`ivanti_fp_submission_history`** — Edit history for FP submissions. Tracks change_type (created, fields_updated, findings_added, attachments_added, status_changed) with change_details_json.
|
|
||||||
|
|
||||||
**`ivanti_finding_archives`** — Archived finding records with lifecycle state tracking. States: ARCHIVED, RETURNED, CLOSED, CLOSED_GONE. Tracks severity at time of archival and transition timestamps.
|
|
||||||
|
|
||||||
**`ivanti_archive_transitions`** — State transition history for archived findings. Records from_state, to_state, severity_at_transition, and reason for each transition.
|
|
||||||
|
|
||||||
**`ivanti_sync_anomaly_log`** — Sync anomaly detection log. Records count deltas, newly archived/returned counts, classification breakdown, and significance flag per sync.
|
|
||||||
|
|
||||||
**`ivanti_finding_bu_history`** — BU reassignment history per finding. Records previous_bu, new_bu, and detection timestamp.
|
|
||||||
|
|
||||||
**`ivanti_counts_history_by_bu`** — Per-BU historical open/closed counts, enabling per-BU trend lines on the findings chart.
|
|
||||||
|
|
||||||
**`atlas_action_plans_cache`** — Cached Atlas action plan data for badge rendering. Stores host_id, has_action_plan flag, plan_count, plans_json, and atlas_known flag. Indexed on host_id.
|
|
||||||
|
|
||||||
**`archer_templates`** — Archer template library. Vendor/platform/model hierarchy with 8 section content fields (environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list). `UNIQUE(vendor, platform, model)`.
|
|
||||||
|
|
||||||
**`notifications`** — In-app notifications. Per-user notifications with type, title, message, issue_number, and read flag. Used by the NotificationBell component.
|
|
||||||
|
|
||||||
**`vcl_multi_vertical_summary`** — Per-metric summary data from VCL multi-vertical uploads. Stores metric_id, metric_desc, category, team, priority, non_compliant, compliant, total, compliance_pct, target, and status per upload per vertical. Used for executive reporting without recalculating from items.
|
|
||||||
|
|
||||||
**`compliance_snapshots`** — Monthly compliance snapshots per vertical. Used for trend charts and linear regression forecasting.
|
|
||||||
|
|
||||||
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
||||||
|
|
||||||
@@ -1533,63 +1018,30 @@ After upgrading, clear your browser cookies and log in fresh — session format
|
|||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
> **Note:** The migration scripts in `backend/migrations/` are used for both PostgreSQL and legacy SQLite deployments. Run them via `node migrations/run-all.js` which executes all migrations in order. All are idempotent and safe to re-run.
|
> **Note:** The migration scripts in `backend/migrations/` are legacy SQLite migrations. They are not needed for PostgreSQL deployments — the complete schema is defined in `backend/db-schema.sql` and applied by `setup-postgres.js`. These scripts are retained for reference and for any remaining SQLite-based environments.
|
||||||
|
|
||||||
|
For deployments still on SQLite, run them in the listed order. All are idempotent and safe to re-run.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
node migrations/run-all.js
|
|
||||||
```
|
|
||||||
|
|
||||||
For manual execution or debugging, the individual scripts in order:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node migrations/add_user_groups.js
|
|
||||||
node migrations/add_user_ivanti_identity.js
|
|
||||||
node migrations/add_user_bu_teams.js
|
|
||||||
node migrations/add_knowledge_base_table.js
|
node migrations/add_knowledge_base_table.js
|
||||||
|
node migrations/add_archer_tickets_table.js
|
||||||
node migrations/add_ivanti_sync_table.js
|
node migrations/add_ivanti_sync_table.js
|
||||||
node migrations/add_ivanti_findings_tables.js
|
node migrations/add_ivanti_findings_tables.js
|
||||||
node migrations/add_ivanti_findings_ipv6_columns.js
|
|
||||||
node migrations/add_ivanti_counts_history_table.js
|
|
||||||
node migrations/add_ivanti_todo_queue_table.js
|
node migrations/add_ivanti_todo_queue_table.js
|
||||||
node migrations/add_todo_queue_hostname.js
|
|
||||||
node migrations/add_todo_queue_ip_address.js
|
|
||||||
node migrations/add_granite_workflow_type.js
|
|
||||||
node migrations/add_card_workflow_type.js
|
node migrations/add_card_workflow_type.js
|
||||||
node migrations/add_decom_workflow_type.js
|
node migrations/add_todo_queue_ip_address.js
|
||||||
node migrations/add_remediate_workflow_type.js
|
node migrations/add_todo_queue_hostname.js
|
||||||
node migrations/add_compliance_tables.js
|
node migrations/add_compliance_tables.js
|
||||||
node migrations/add_compliance_notes_group_id.js
|
|
||||||
node migrations/add_compliance_history_metric_id.js
|
|
||||||
node migrations/add_compliance_item_history.js
|
|
||||||
node migrations/add_fp_submissions_table.js
|
|
||||||
node migrations/add_fp_submission_editing.js
|
|
||||||
node migrations/add_fp_submissions_dismissed.js
|
|
||||||
node migrations/add_fp_submissions_requeued_at.js
|
|
||||||
node migrations/add_archer_tickets_table.js
|
|
||||||
node migrations/add_archer_tickets_timestamps.js
|
|
||||||
node migrations/add_archer_templates_table.js
|
|
||||||
node migrations/add_atlas_action_plans_cache.js
|
|
||||||
node migrations/add_atlas_known_column.js
|
|
||||||
node migrations/add_finding_archive_tables.js
|
node migrations/add_finding_archive_tables.js
|
||||||
node migrations/add_closed_gone_state.js
|
node migrations/add_archer_tickets_timestamps.js
|
||||||
node migrations/add_return_classification.js
|
node migrations/add_ivanti_counts_history_table.js
|
||||||
node migrations/add_sync_anomaly_tables.js
|
node migrations/add_fp_submissions_table.js
|
||||||
node migrations/add_flexible_jira_ticket_creation.js
|
node migrations/add_user_groups.js
|
||||||
node migrations/add_multi_item_jira_ticket.js
|
|
||||||
node migrations/add_jira_sync_columns.js
|
|
||||||
node migrations/add_jira_sync_columns_pg.js
|
|
||||||
node migrations/drop_jira_status_check_constraint.js
|
|
||||||
node migrations/add_notifications_table.js
|
|
||||||
node migrations/add_created_by_columns.js
|
node migrations/add_created_by_columns.js
|
||||||
node migrations/add_queue_remediation_notes_table.js
|
node migrations/add_fp_submission_editing.js
|
||||||
node migrations/add_vcl_multi_vertical.js
|
node migrations/add_granite_workflow_type.js
|
||||||
node migrations/add_vcl_reporting_columns.js
|
node migrations/add_compliance_notes_group_id.js
|
||||||
node migrations/add_vcl_vertical_metadata.js
|
|
||||||
node migrations/backfill_anomaly_log.js
|
|
||||||
node migrations/backfill_return_classification.js
|
|
||||||
node migrations/reclassify_bu_roundtrips.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`:
|
For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`:
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
# VCL Executive Reporting — How It Works
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The VCL (Vulnerability Compliance Level) Report page generates an executive-level compliance summary from device-level data already tracked in the STEAM Security Dashboard. It aggregates individual device findings into team-level metrics, burndown projections, and compliance percentages — the same data leadership uses in the VCL deck for quarterly reporting.
|
|
||||||
|
|
||||||
The report is not a separate data source. It reads from the same `compliance_items` table that the AEO Compliance page uses. The difference is the view: Compliance shows device-level detail, VCL shows team-level aggregation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Metrics Explained
|
|
||||||
|
|
||||||
### Stats Bar
|
|
||||||
|
|
||||||
| Metric | What It Means | What Feeds It |
|
|
||||||
|---|---|---|
|
|
||||||
| Total Devices | Count of unique hostnames across all compliance items (active + resolved) | Weekly compliance xlsx upload |
|
|
||||||
| In-Scope | Same as Total Devices (all tracked devices are considered in-scope) | Weekly compliance xlsx upload |
|
|
||||||
| Compliant | Devices with NO active findings (all their findings are resolved) | Compliance upload resolves findings when devices drop off the report |
|
|
||||||
| Non-Compliant | Devices with at least one active finding | Compliance upload adds new findings; devices stay non-compliant until all findings resolve |
|
|
||||||
| Remediations Required | Same as Non-Compliant (each non-compliant device needs remediation) | Same as Non-Compliant |
|
|
||||||
| Current % | `(Compliant / In-Scope) * 100`, rounded to whole number | Computed from the counts above |
|
|
||||||
| Target % | Organization-defined compliance target (default 95%) | Set via `VCL_TARGET_PCT` environment variable on the backend |
|
|
||||||
|
|
||||||
### Status of Non-Compliant Assets (Donut Chart)
|
|
||||||
|
|
||||||
| Segment | What It Means | What Feeds It |
|
|
||||||
|---|---|---|
|
|
||||||
| Blocked | Non-compliant devices with NO resolution date set — the team has not committed to a remediation timeline | Devices without a `resolution_date` value |
|
|
||||||
| In-Progress | Non-compliant devices WITH a resolution date set — the team has a target fix date | Devices with a `resolution_date` value |
|
|
||||||
|
|
||||||
**How to move devices from Blocked to In-Progress:** Set a resolution date on the device, either by clicking into it on the Compliance page and entering a date, or by using the Bulk Upload with a "Resolution Date" column.
|
|
||||||
|
|
||||||
### Heavy Hitters Table
|
|
||||||
|
|
||||||
| Column | What It Means | What Feeds It |
|
|
||||||
|---|---|---|
|
|
||||||
| Vertical / Team | The team responsible for the non-compliant devices | `team` field on compliance items (set during xlsx upload) |
|
|
||||||
| Non-Compliant | Count of unique hostnames with active findings for that team | Computed from compliance_items |
|
|
||||||
| Compliance Date | The team's stated target for full remediation (e.g., "Q3 2026") | Manually entered on this page (click to edit) |
|
|
||||||
| Notes | Team-level summary of their remediation approach | Manually entered on this page (click to edit) |
|
|
||||||
|
|
||||||
### Vertical Breakdown Table
|
|
||||||
|
|
||||||
| Column | What It Means | What Feeds It |
|
|
||||||
|---|---|---|
|
|
||||||
| Vertical | Team name | `team` field on compliance items |
|
|
||||||
| Compliance % | `(Compliant devices in team / Total devices in team) * 100` | Computed from compliance_items |
|
|
||||||
| Team | Same as Vertical | Same |
|
|
||||||
| Non-Compliant | Count of non-compliant devices for that team | Computed from compliance_items |
|
|
||||||
| Forecast Burndown (monthly columns) | How many devices are expected to be remediated each month | Grouped by the `resolution_date` month on individual devices |
|
|
||||||
| Blockers | Non-compliant devices with NO resolution date (no committed timeline) | Count of devices where `resolution_date` is NULL |
|
|
||||||
| RAs | Risk Acceptances — count of approved exceptions for that team | Manually entered on this page (click to edit) |
|
|
||||||
| Notes | Team-level remediation narrative | Manually entered on this page (click to edit) |
|
|
||||||
|
|
||||||
### Compliance Overview Trend Chart
|
|
||||||
|
|
||||||
| Element | What It Means | What Feeds It |
|
|
||||||
|---|---|---|
|
|
||||||
| Green bars | Count of compliant devices for each month | Monthly snapshots (created automatically on each compliance upload) |
|
|
||||||
| Solid teal line | Actual compliance percentage for each month | Monthly snapshots |
|
|
||||||
| Dashed teal line | Forecasted compliance percentage (projected forward) | Linear regression on the last 3+ months of actual data |
|
|
||||||
| Amber horizontal line | Target compliance threshold | `VCL_TARGET_PCT` environment variable |
|
|
||||||
|
|
||||||
> The trend chart requires at least one compliance upload to create the first snapshot. After 3+ monthly uploads, the forecast line appears.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Feeds the Data
|
|
||||||
|
|
||||||
### Automatic (from compliance uploads)
|
|
||||||
|
|
||||||
These values update automatically when a new weekly compliance xlsx is uploaded:
|
|
||||||
|
|
||||||
- Total Devices, In-Scope, Compliant, Non-Compliant counts
|
|
||||||
- Current Compliance %
|
|
||||||
- Per-team compliance percentages
|
|
||||||
- Monthly trend snapshots (one snapshot per upload)
|
|
||||||
- Devices moving between active/resolved status
|
|
||||||
|
|
||||||
### Manual (entered by engineers or BIs)
|
|
||||||
|
|
||||||
These values are entered by users and persist until changed:
|
|
||||||
|
|
||||||
| Field | Where to Enter It | Scope |
|
|
||||||
|---|---|---|
|
|
||||||
| Resolution Date | Compliance page → click device → Resolution Date field | Per device |
|
|
||||||
| Remediation Plan | Compliance page → click device → Remediation Plan field | Per device |
|
|
||||||
| Compliance Date | VCL Report → Heavy Hitters table → click the cell | Per team |
|
|
||||||
| Notes | VCL Report → Heavy Hitters or Vertical Breakdown → click the cell | Per team |
|
|
||||||
| RAs (Risk Acceptances) | VCL Report → Vertical Breakdown → click the cell | Per team |
|
|
||||||
|
|
||||||
### Bulk Upload
|
|
||||||
|
|
||||||
For updating many devices at once (e.g., 1000 devices), use the **Bulk Upload** button on the VCL Report page:
|
|
||||||
|
|
||||||
1. Prepare an xlsx file with columns: `Hostname`, `Resolution Date`, `Remediation Plan`, `Notes`
|
|
||||||
2. Click Bulk Upload and select the file
|
|
||||||
3. Review the diff preview (shows matched/unmatched/changed/invalid counts)
|
|
||||||
4. Confirm to commit changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How Metrics Adjust Over Time
|
|
||||||
|
|
||||||
### Weekly Compliance Upload Cycle
|
|
||||||
|
|
||||||
Each weekly xlsx upload triggers these changes:
|
|
||||||
|
|
||||||
1. **New findings** appear as active items → Non-Compliant count increases
|
|
||||||
2. **Resolved findings** (devices no longer on the report) get marked resolved → Compliant count increases
|
|
||||||
3. **A monthly snapshot** is created/updated in `compliance_snapshots` → feeds the trend chart
|
|
||||||
4. **Stats bar** reflects the new totals immediately
|
|
||||||
|
|
||||||
### As Teams Set Resolution Dates
|
|
||||||
|
|
||||||
When resolution dates are added to devices:
|
|
||||||
|
|
||||||
1. **Donut chart shifts** — devices move from "Blocked" (red) to "In-Progress" (amber)
|
|
||||||
2. **Forecast burndown columns populate** — showing expected remediations per month per team
|
|
||||||
3. **Blockers count decreases** — fewer devices without a committed timeline
|
|
||||||
|
|
||||||
### As Devices Get Remediated
|
|
||||||
|
|
||||||
When a device drops off the weekly compliance report (finding resolved):
|
|
||||||
|
|
||||||
1. **Non-Compliant count decreases**
|
|
||||||
2. **Compliant count increases**
|
|
||||||
3. **Current % improves**
|
|
||||||
4. **Team compliance % improves**
|
|
||||||
5. **The device's resolution_date no longer contributes to forecast** (it's done)
|
|
||||||
|
|
||||||
### Trend Chart Over Months
|
|
||||||
|
|
||||||
After 3+ monthly compliance uploads:
|
|
||||||
|
|
||||||
1. The trend chart shows actual compliance % per month (solid line)
|
|
||||||
2. A linear regression projects the trend forward 3 months (dashed line)
|
|
||||||
3. You can see whether the organization is on track to hit the target % (amber line)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary: Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Weekly xlsx upload
|
|
||||||
→ compliance_items (active/resolved findings per device)
|
|
||||||
→ compliance_snapshots (monthly aggregate for trend chart)
|
|
||||||
→ Stats bar, donut, heavy hitters, vertical breakdown auto-update
|
|
||||||
|
|
||||||
Engineers set resolution_date on devices (manual or bulk upload)
|
|
||||||
→ Donut shifts from Blocked to In-Progress
|
|
||||||
→ Forecast burndown columns populate
|
|
||||||
→ Blockers count decreases
|
|
||||||
|
|
||||||
BIs edit team-level fields on VCL Report page
|
|
||||||
→ Compliance Date, Notes, RAs saved per team
|
|
||||||
→ Displayed in Heavy Hitters and Vertical Breakdown tables
|
|
||||||
|
|
||||||
Devices remediated (drop off next weekly upload)
|
|
||||||
→ Compliance % improves
|
|
||||||
→ Trend chart shows upward movement
|
|
||||||
→ Forecast adjusts based on new regression
|
|
||||||
```
|
|
||||||
@@ -1,988 +0,0 @@
|
|||||||
# VCL Metric Calculations — Database Reference
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes how every percentage, total, and forecast number on the VCL Report and CCP Metrics pages is computed from the underlying database. It is the single reference for verifying that what you see on the page matches what is in the data.
|
|
||||||
|
|
||||||
Each section answers four questions:
|
|
||||||
|
|
||||||
- **What it shows** — the field name on screen and the data path
|
|
||||||
- **What feeds it** — the table(s) and columns the value is computed from
|
|
||||||
- **How it is calculated** — the exact SQL or formula, plus any rounding rules
|
|
||||||
- **Why it can drift** — known sources of inaccuracy and how the dashboard guards against them
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Data Sources](#data-sources)
|
|
||||||
- [compliance_items](#compliance_items)
|
|
||||||
- [compliance_uploads](#compliance_uploads)
|
|
||||||
- [compliance_snapshots](#compliance_snapshots)
|
|
||||||
- [vcl_multi_vertical_summary](#vcl_multi_vertical_summary)
|
|
||||||
- [VCL Report Page (Single-Vertical / Legacy AEO)](#vcl-report-page-single-vertical--legacy-aeo)
|
|
||||||
- [Stats Bar](#stats-bar)
|
|
||||||
- [Donut Chart — Status of Non-Compliant Assets](#donut-chart--status-of-non-compliant-assets)
|
|
||||||
- [Heavy Hitters Table](#heavy-hitters-table)
|
|
||||||
- [Vertical Breakdown Table](#vertical-breakdown-table)
|
|
||||||
- [Compliance Trend Chart](#compliance-trend-chart)
|
|
||||||
- [CCP Metrics Page (Multi-Vertical)](#ccp-metrics-page-multi-vertical)
|
|
||||||
- [Aggregated Stats Bar](#aggregated-stats-bar)
|
|
||||||
- [Donut — Blocked vs In-Progress](#donut--blocked-vs-in-progress)
|
|
||||||
- [Trend Chart](#trend-chart)
|
|
||||||
- [Aggregated Burndown Forecast](#aggregated-burndown-forecast)
|
|
||||||
- [Metric Table (Cross-Vertical)](#metric-table-cross-vertical)
|
|
||||||
- [Metric Detail View — Per-Vertical Breakdown](#metric-detail-view--per-vertical-breakdown)
|
|
||||||
- [Per-Metric Forecast Burndown Chart](#per-metric-forecast-burndown-chart)
|
|
||||||
- [Per-Vertical Detail and Burndown](#per-vertical-detail-and-burndown)
|
|
||||||
- [Forecast Algorithms](#forecast-algorithms)
|
|
||||||
- [Linear Regression Forecast (Trend)](#linear-regression-forecast-trend)
|
|
||||||
- [Resolution-Date Burndown Forecast](#resolution-date-burndown-forecast)
|
|
||||||
- [Per-Metric Forecast (Historical + Projected)](#per-metric-forecast-historical--projected)
|
|
||||||
- [Cross-Cutting Correctness Rules](#cross-cutting-correctness-rules)
|
|
||||||
- [Verifying Values by Hand](#verifying-values-by-hand)
|
|
||||||
- [Worked Example — Vulns_Aging vs 7.1.1 Forecast Charts](#worked-example--vulns_aging-vs-711-forecast-charts)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Sources
|
|
||||||
|
|
||||||
The VCL pages read from four tables. Knowing what each one stores is the prerequisite for understanding the calculations.
|
|
||||||
|
|
||||||
### compliance_items
|
|
||||||
|
|
||||||
One row per `(hostname, metric_id, vertical)` combination per upload. **Only non-compliant findings are stored here.** Compliant devices never appear in this table — they are inferred from the Summary sheet's totals minus what is in `compliance_items`.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `hostname` | TEXT | Device hostname |
|
|
||||||
| `metric_id` | TEXT | Compliance metric identifier (e.g., `2.3.5`, `7.1.1`) |
|
|
||||||
| `team` | TEXT | Sub-team responsible (`STEAM`, `ACCESS-ENG`, etc.) |
|
|
||||||
| `vertical` | TEXT | Vertical code (`NTS_AEO`, `SDIT_CISO`, `TSI`); `NULL` for legacy AEO uploads |
|
|
||||||
| `status` | TEXT | `'active'` if currently failing, `'resolved'` once the device drops off the next upload |
|
|
||||||
| `resolution_date` | DATE | Target remediation date (manual entry) |
|
|
||||||
| `seen_count` | INTEGER | Number of consecutive uploads this finding has appeared on |
|
|
||||||
| `first_seen_upload_id` / `upload_id` / `resolved_upload_id` | INTEGER | Upload references for first appearance, latest, and resolution |
|
|
||||||
|
|
||||||
> A device is "compliant" when **no** active row exists for it in this table.
|
|
||||||
|
|
||||||
### compliance_uploads
|
|
||||||
|
|
||||||
One row per uploaded xlsx. A multi-vertical upload day produces multiple rows that share the same `report_date`.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `report_date` | TEXT | The reporting period the file covers (`YYYY-MM-DD`) |
|
|
||||||
| `vertical` | TEXT | Same vertical code as `compliance_items.vertical`; `NULL` for legacy AEO |
|
|
||||||
| `new_count` / `recurring_count` / `resolved_count` | INTEGER | Per-upload deltas (vertical-scoped) |
|
|
||||||
| `summary_json` | TEXT | The raw parsed Summary sheet — used as a fallback by `/summary` |
|
|
||||||
|
|
||||||
### compliance_snapshots
|
|
||||||
|
|
||||||
Monthly aggregated snapshot keyed by `(snapshot_month, vertical)`. The trend chart reads exclusively from here. Snapshots are written automatically inside the upload commit transaction.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `snapshot_month` | TEXT | `YYYY-MM` |
|
|
||||||
| `vertical` | TEXT | Vertical code or team name (legacy) |
|
|
||||||
| `total_devices` / `compliant` / `non_compliant` | INTEGER | Counts at month end |
|
|
||||||
| `compliance_pct` | NUMERIC(5,2) | Pre-computed for that month |
|
|
||||||
|
|
||||||
> The `(snapshot_month, vertical)` pair is `UNIQUE`. Re-uploading inside the same calendar month overwrites the row via `ON CONFLICT DO UPDATE`.
|
|
||||||
|
|
||||||
### vcl_multi_vertical_summary
|
|
||||||
|
|
||||||
One row per `(metric_id, team)` pair per upload, populated from the Summary sheet of a multi-vertical xlsx. This is the **source of truth for compliant counts** — `compliance_items` only has non-compliant rows.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `upload_id` | INTEGER | FK → `compliance_uploads` |
|
|
||||||
| `vertical` | TEXT | Vertical code |
|
|
||||||
| `metric_id` | TEXT | Metric identifier |
|
|
||||||
| `team` | TEXT | Either an `ALL: <vertical>` rollup row or a sub-team row (`STEAM`, `ACCESS-ENG`) |
|
|
||||||
| `non_compliant` / `compliant` / `total` | INTEGER | From the Summary sheet |
|
|
||||||
| `compliance_pct` | NUMERIC(5,2) | From the Summary sheet (decimal — `0.95` = 95%) |
|
|
||||||
| `target` | NUMERIC(5,2) | Per-metric target from the spreadsheet |
|
|
||||||
|
|
||||||
> **Critical aggregation rule:** rows where `team LIKE 'ALL:%'` are vertical-level rollups that already include their sub-teams. **Aggregating both rollup and sub-team rows would double-count.** Every cross-vertical query in this codebase filters with `WHERE team LIKE 'ALL:%'`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VCL Report Page (Single-Vertical / Legacy AEO)
|
|
||||||
|
|
||||||
This is the original VCL Report at `/api/compliance/vcl/...`. It aggregates across whatever data exists in `compliance_items` regardless of vertical, and is primarily used for the AEO single-team view.
|
|
||||||
|
|
||||||
Source: `backend/routes/compliance.js` (`router.get('/vcl/stats', ...)` and `router.get('/vcl/trend', ...)`)
|
|
||||||
|
|
||||||
### Stats Bar
|
|
||||||
|
|
||||||
**What it shows:** Total Devices, In-Scope, Compliant, Non-Compliant, Remediations Required, Current %, Target %.
|
|
||||||
|
|
||||||
**What feeds it:** `compliance_items` — every distinct hostname.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
COUNT(DISTINCT hostname) AS total_devices,
|
|
||||||
COUNT(DISTINCT hostname) AS in_scope,
|
|
||||||
COUNT(DISTINCT CASE
|
|
||||||
WHEN hostname NOT IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active')
|
|
||||||
THEN hostname END) AS compliant,
|
|
||||||
COUNT(DISTINCT CASE
|
|
||||||
WHEN hostname IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active')
|
|
||||||
THEN hostname END) AS non_compliant
|
|
||||||
FROM compliance_items;
|
|
||||||
```
|
|
||||||
|
|
||||||
Then in JavaScript:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0
|
|
||||||
remediations_required = non_compliant
|
|
||||||
target_pct = process.env.VCL_TARGET_PCT || 95
|
|
||||||
```
|
|
||||||
|
|
||||||
**Field-by-field:**
|
|
||||||
|
|
||||||
| Field | Definition |
|
|
||||||
|---|---|
|
|
||||||
| Total Devices | Count of unique hostnames that have ever appeared in any upload |
|
|
||||||
| In-Scope | Same as Total Devices — every tracked device is in-scope by definition |
|
|
||||||
| Compliant | Hostnames with **zero** rows where `status = 'active'` |
|
|
||||||
| Non-Compliant | Hostnames with **at least one** row where `status = 'active'` |
|
|
||||||
| Remediations Required | Equals Non-Compliant — every non-compliant device needs at least one fix |
|
|
||||||
| Current % | `ROUND((Compliant / In-Scope) * 100)` — whole-number percent |
|
|
||||||
| Target % | `VCL_TARGET_PCT` env var on the backend, default 95 |
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- If a hostname has both an `active` row in one vertical and a `resolved` row in another, the `IN`/`NOT IN` subqueries above already classify correctly — `active` wins because the `IN` subquery includes any active row.
|
|
||||||
- Compliant devices are inferred. If `compliance_items` is missing rows that the Summary sheet reported (e.g., truncated upload), the count silently undercounts.
|
|
||||||
|
|
||||||
### Donut Chart — Status of Non-Compliant Assets
|
|
||||||
|
|
||||||
**What it shows:** Two slices — Blocked (red) and In-Progress (amber) — with counts and percentages.
|
|
||||||
|
|
||||||
**What feeds it:** `compliance_items` rows where `status = 'active'`, deduplicated to one row per hostname using `MAX(resolution_date)`.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT hostname, MAX(resolution_date) AS resolution_date
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE status = 'active'
|
|
||||||
GROUP BY hostname;
|
|
||||||
```
|
|
||||||
|
|
||||||
Then `categorizeNonCompliant()` partitions:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
blocked = items.filter(i => i.resolution_date == null)
|
|
||||||
in_progress = items.filter(i => i.resolution_date != null)
|
|
||||||
blocked.pct = Math.round((blocked.count / total) * 100)
|
|
||||||
in_progress.pct = Math.round((in_progress.count / total) * 100)
|
|
||||||
```
|
|
||||||
|
|
||||||
> A device with **any** resolution date set on **any** of its active findings is considered In-Progress. Only when every active finding lacks a date is the device counted as Blocked.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- The `MAX(resolution_date)` clause means a device with one dated finding and one undated finding is classified as In-Progress, not Blocked. This is intentional — once one team commits to a date, the device is no longer fully blocked.
|
|
||||||
- Rounding to whole numbers means `blocked.pct + in_progress.pct` may total 99 or 101 in edge cases. The chart still displays the correct underlying counts.
|
|
||||||
|
|
||||||
### Heavy Hitters Table
|
|
||||||
|
|
||||||
**What it shows:** One row per team, sorted by non-compliant device count descending. Columns: Vertical/Team, Non-Compliant, Compliance Date, Notes.
|
|
||||||
|
|
||||||
**What feeds it:** `compliance_items` deduplicated to one team per hostname, plus `vcl_vertical_metadata` for manual fields (Notes, Compliance Date, RAs).
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
WITH device_team AS (
|
|
||||||
SELECT DISTINCT ON (hostname)
|
|
||||||
hostname,
|
|
||||||
COALESCE(team, 'Unknown') AS team,
|
|
||||||
resolution_date
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
|
||||||
)
|
|
||||||
SELECT team,
|
|
||||||
COUNT(DISTINCT hostname)::int AS non_compliant,
|
|
||||||
MAX(resolution_date) AS compliance_date
|
|
||||||
FROM device_team
|
|
||||||
GROUP BY team
|
|
||||||
ORDER BY COUNT(DISTINCT hostname) DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
The CTE picks one representative row per hostname using the `(seen_count DESC, upload_id DESC)` rule — the longest-running, most recently seen team assignment wins. This guarantees `SUM(heavy_hitters[*].non_compliant) == stats.non_compliant`.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- Before the fix tracked under spec `compliance-duplicate-failing-metrics`, a hostname that appeared with different `team` values across verticals was double-counted. The CTE above is the fix — confirmed by Property 3 of that spec.
|
|
||||||
- `compliance_date` here is the latest resolution date across the team's devices, used as a default. The team's manually entered Compliance Date in `vcl_vertical_metadata` overrides it when present.
|
|
||||||
|
|
||||||
### Vertical Breakdown Table
|
|
||||||
|
|
||||||
**What it shows:** Same teams as Heavy Hitters, plus per-team Compliance %, Forecast Burndown columns, Blockers count, RAs, Notes.
|
|
||||||
|
|
||||||
**What feeds it:**
|
|
||||||
- Per-team total devices: same `device_team` CTE as Heavy Hitters but without the `status = 'active'` filter.
|
|
||||||
- Forecast: `compliance_items` with non-null `resolution_date`.
|
|
||||||
- Manual fields: `vcl_vertical_metadata`.
|
|
||||||
|
|
||||||
**How it is calculated (per team):**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Total devices for the team
|
|
||||||
WITH device_team AS (
|
|
||||||
SELECT DISTINCT ON (hostname)
|
|
||||||
hostname,
|
|
||||||
COALESCE(team, 'Unknown') AS team
|
|
||||||
FROM compliance_items
|
|
||||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
|
||||||
)
|
|
||||||
SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1;
|
|
||||||
|
|
||||||
-- Forecast resolution dates for the team
|
|
||||||
SELECT DISTINCT ON (hostname, metric_id) resolution_date
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE status = 'active'
|
|
||||||
AND COALESCE(team, 'Unknown') = $1
|
|
||||||
AND resolution_date IS NOT NULL
|
|
||||||
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
In JavaScript:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
team_compliant = team_total - team_non_compliant
|
|
||||||
compliance_pct = team_total > 0 ? Math.round((team_compliant / team_total) * 100) : 0
|
|
||||||
forecast_burndown = computeForecastBurndown(forecastItems) // YYYY-MM → count
|
|
||||||
blockers = Math.max(team_non_compliant - forecastItems.length, 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
`computeForecastBurndown` buckets each device's resolution date into a `YYYY-MM` key. The result is `{ "2026-06": 12, "2026-07": 8, ... }` — the count of devices expected to resolve each month.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- The `DISTINCT ON (hostname, metric_id)` in the forecast query was added by the duplicate-failing-metrics fix. Without it, a device failing the same metric in two verticals would have its resolution date counted twice and `blockers` would go negative (the `Math.max` clamp protects the UI but masks the inconsistency).
|
|
||||||
- The team total uses **all rows** in `compliance_items` (active and resolved), so a team's `total` here is "every device that has ever been part of this team," not just current devices.
|
|
||||||
|
|
||||||
### Compliance Trend Chart
|
|
||||||
|
|
||||||
**What it shows:** Bar chart of compliant device count per month, plus a solid line (actual %) and a dashed line (forecasted %) on a secondary axis. A horizontal reference line marks the target.
|
|
||||||
|
|
||||||
**What feeds it:** `compliance_snapshots`, aggregated across all verticals.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT snapshot_month,
|
|
||||||
SUM(compliant)::int AS compliant_count,
|
|
||||||
CASE WHEN SUM(total_devices) > 0
|
|
||||||
THEN ROUND((SUM(compliant)::numeric / SUM(total_devices)::numeric) * 100, 1)
|
|
||||||
ELSE 0 END AS compliance_pct
|
|
||||||
FROM compliance_snapshots
|
|
||||||
GROUP BY snapshot_month
|
|
||||||
ORDER BY snapshot_month ASC;
|
|
||||||
```
|
|
||||||
|
|
||||||
The forecast logic is described in [Linear Regression Forecast](#linear-regression-forecast-trend). Snapshots are persisted in `persistUpload()` using the upload's `report_date` month so historical uploads land in the correct bucket. See [Cross-Cutting Correctness Rules](#cross-cutting-correctness-rules) for the snapshot upsert behavior.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- Snapshots are keyed `(snapshot_month, vertical)`, so re-uploading the same month overwrites — only the latest upload's totals are preserved per month.
|
|
||||||
- Pre-fix snapshots from before the duplicate-failing-metrics correction may have `compliant + non_compliant > total_devices` if a hostname had both active and resolved rows across verticals. The fix uses `MIN(status)` inside a CTE so each hostname is classified once. Older snapshots written before the fix should be regenerated by re-running the affected uploads.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CCP Metrics Page (Multi-Vertical)
|
|
||||||
|
|
||||||
The CCP Metrics page is the executive cross-vertical view. It uses the multi-vertical Summary sheet data as the source of truth for totals (since `compliance_items` only contains non-compliant devices).
|
|
||||||
|
|
||||||
Source: `backend/routes/vclMultiVertical.js`. Mounted at `/api/compliance/vcl-multi/...`.
|
|
||||||
|
|
||||||
### Aggregated Stats Bar
|
|
||||||
|
|
||||||
**What it shows:** Total Devices, Compliant, Non-Compliant, Current %, Target % across **every** vertical's latest upload.
|
|
||||||
|
|
||||||
**What feeds it:** `vcl_multi_vertical_summary` filtered to ALL: rollup rows of the latest upload per vertical.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 1. Find the latest upload ID per vertical
|
|
||||||
SELECT DISTINCT ON (vertical) id, vertical
|
|
||||||
FROM compliance_uploads
|
|
||||||
WHERE vertical IS NOT NULL
|
|
||||||
ORDER BY vertical, id DESC;
|
|
||||||
|
|
||||||
-- 2. Sum totals from rollup rows only (avoids double-counting sub-teams)
|
|
||||||
SELECT vertical,
|
|
||||||
SUM(total)::int AS total_devices,
|
|
||||||
SUM(compliant)::int AS compliant,
|
|
||||||
SUM(non_compliant)::int AS non_compliant
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE upload_id = ANY($1) AND team LIKE 'ALL:%'
|
|
||||||
GROUP BY vertical;
|
|
||||||
```
|
|
||||||
|
|
||||||
In JavaScript:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
agg_total = SUM(vertical.total_devices for each vertical)
|
|
||||||
agg_compliant = SUM(vertical.compliant for each vertical)
|
|
||||||
agg_non_compliant = SUM(vertical.non_compliant for each vertical)
|
|
||||||
compliance_pct = agg_total > 0 ? Math.round((agg_compliant / agg_total) * 100) : 0
|
|
||||||
```
|
|
||||||
|
|
||||||
> **The `team LIKE 'ALL:%'` filter is the most important rule on this page.** Each Summary sheet contains one rollup row per metric (`ALL: NTS-AEO`) plus one row per sub-team (`STEAM`, `ACCESS-ENG`). Summing both rollup and sub-team rows would double the totals. Every cross-vertical query enforces this filter.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- If a Summary sheet ever omits the `ALL:` rollup row for a metric, that metric's totals will be missing from the aggregate. The Python parser does not fabricate rollup rows, so this is a function of what the upstream xlsx contains.
|
|
||||||
- Verticals with no rows in `vcl_multi_vertical_summary` (e.g., the legacy AEO data) do not contribute. Their data is visible only on the original VCL Report, not the CCP Metrics page.
|
|
||||||
|
|
||||||
### Donut — Blocked vs In-Progress
|
|
||||||
|
|
||||||
**What it shows:** Same as the legacy donut, scoped to multi-vertical data.
|
|
||||||
|
|
||||||
**What feeds it:** `compliance_items` where `vertical IS NOT NULL`, deduplicated by hostname.
|
|
||||||
|
|
||||||
**How it is calculated:** Identical formula to the [legacy donut](#donut-chart--status-of-non-compliant-assets), but the filter is `WHERE vertical IS NOT NULL AND status = 'active'`.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT hostname, MAX(resolution_date) AS resolution_date
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE vertical IS NOT NULL AND status = 'active'
|
|
||||||
GROUP BY hostname;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Trend Chart
|
|
||||||
|
|
||||||
**What it shows:** Cross-vertical monthly trend of compliant device count and compliance percentage with a 3-month forecast.
|
|
||||||
|
|
||||||
**What feeds it:** `compliance_snapshots` where `vertical IS NOT NULL AND vertical != ''`.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT snapshot_month,
|
|
||||||
SUM(total_devices)::int AS total_devices,
|
|
||||||
SUM(compliant)::int AS compliant,
|
|
||||||
SUM(non_compliant)::int AS non_compliant
|
|
||||||
FROM compliance_snapshots
|
|
||||||
WHERE vertical IS NOT NULL AND vertical != ''
|
|
||||||
GROUP BY snapshot_month
|
|
||||||
ORDER BY snapshot_month ASC;
|
|
||||||
```
|
|
||||||
|
|
||||||
Each row's `compliance_pct` is `ROUND((compliant / total_devices) * 100, 1)` — one decimal place. The forecast then uses the [Linear Regression Forecast](#linear-regression-forecast-trend) logic.
|
|
||||||
|
|
||||||
### Aggregated Burndown Forecast
|
|
||||||
|
|
||||||
**What it shows:** A bar chart of expected device remediations per month across all verticals, plus stat cards for In-Progress, Blockers, and Projected Clear date.
|
|
||||||
|
|
||||||
**What feeds it:** `compliance_items` where `vertical IS NOT NULL AND status = 'active'`.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT hostname, resolution_date, vertical
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE vertical IS NOT NULL AND status = 'active';
|
|
||||||
```
|
|
||||||
|
|
||||||
Then the rows pass through two pure helpers:
|
|
||||||
|
|
||||||
1. `deduplicateByHostname(rows)` — collapses each hostname to one entry, keeping the **earliest non-null** `resolution_date`. A device that fails three metrics with different planned dates is bucketed by its earliest commitment.
|
|
||||||
|
|
||||||
2. `computeAggregatedBurndown(devices)` — computes:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
total = devices.length
|
|
||||||
blockers = devices.filter(d => d.resolution_date == null).length
|
|
||||||
with_dates = total - blockers
|
|
||||||
monthly[m] = count of devices whose resolution_date falls in month m // YYYY-MM
|
|
||||||
projection[m] = { remediated: monthly[m], remaining: running_remainder }
|
|
||||||
projected_clear_date = (blockers === 0 && monthly is non-empty)
|
|
||||||
? last_month_in_monthly_keys
|
|
||||||
: null
|
|
||||||
```
|
|
||||||
|
|
||||||
> Projected Clear is **only computed when `blockers === 0`**. Any device without a resolution date prevents the projection from showing — the dashboard is honest about the fact that an open-ended commitment cannot be projected.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- Devices with multiple `compliance_items` rows (one per failing metric) are deduplicated by hostname before bucketing. Without deduplication, a device with three failing metrics and one resolution date would count three times.
|
|
||||||
- A resolution date in the **past** still buckets into its actual month — `computeAggregatedBurndown` does not roll past-due dates forward. The per-metric chart does roll them forward; see [Per-Metric Forecast](#per-metric-forecast-historical--projected) for that distinction.
|
|
||||||
|
|
||||||
### Metric Table (Cross-Vertical)
|
|
||||||
|
|
||||||
**What it shows:** One row per metric, with non-compliant, compliant, total, compliance %, and target % aggregated across **every** vertical's latest upload. Sorted by non-compliant descending.
|
|
||||||
|
|
||||||
**What feeds it:** `vcl_multi_vertical_summary` rollup rows from the latest upload per vertical.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT metric_id,
|
|
||||||
MAX(metric_desc) AS metric_desc,
|
|
||||||
MAX(category) AS category,
|
|
||||||
SUM(non_compliant)::int AS non_compliant,
|
|
||||||
SUM(compliant)::int AS compliant,
|
|
||||||
SUM(total)::int AS total,
|
|
||||||
ROUND(AVG(target::numeric), 4) AS target
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE upload_id = ANY($1) AND team LIKE 'ALL:%'
|
|
||||||
GROUP BY metric_id
|
|
||||||
ORDER BY non_compliant DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
Then for each row:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
compliance_pct = total > 0 ? compliant / total : 0 // stored as decimal
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend renders the percentage with one decimal: `(compliance_pct * 100).toFixed(1) + '%'`.
|
|
||||||
|
|
||||||
> **`target` is the arithmetic mean across verticals**, not the worst or best. If two verticals report a target of 0.90 and 0.95 for the same metric, the cross-vertical target is 0.925. This is a deliberate choice — the page shows a fleet-wide composite target, not the strictest individual one.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- `MAX(metric_desc)` and `MAX(category)` rely on every Summary sheet using the same description for the same `metric_id`. If two verticals describe the same metric differently, the alphabetically-last description wins.
|
|
||||||
|
|
||||||
### Metric Detail View — Per-Vertical Breakdown
|
|
||||||
|
|
||||||
**What it shows:** For a selected metric, one row per vertical with that metric's numbers, plus a `sub_teams` array per vertical.
|
|
||||||
|
|
||||||
**What feeds it:** `vcl_multi_vertical_summary` for the selected metric, latest upload per vertical, both rollup and sub-team rows.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT vertical, metric_desc, category, team,
|
|
||||||
non_compliant, compliant, total, compliance_pct, target
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE upload_id = ANY($1) AND metric_id = $2
|
|
||||||
ORDER BY vertical, team;
|
|
||||||
```
|
|
||||||
|
|
||||||
The handler then separates rollup rows (`team LIKE 'ALL:%'`) from sub-team rows in JavaScript:
|
|
||||||
|
|
||||||
- The rollup row for each vertical becomes the primary entry.
|
|
||||||
- Each sub-team row is attached to its vertical's `sub_teams` array.
|
|
||||||
- Rows where `team = '(Other)'` are skipped — they are catch-all rows already counted in the rollup.
|
|
||||||
|
|
||||||
`compliance_pct` is read directly from the table (already a decimal — `0.95` = 95%).
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- A sub-team named `(Other)` is used by the spreadsheet for unassignable devices — it is intentionally excluded from the sub-team breakdown to avoid duplication.
|
|
||||||
- The vertical-level `compliance_pct` is what was in the Summary sheet at upload time. It is not recomputed from `compliant / total`. If those numbers ever disagree (Summary rounded differently), the table shows the spreadsheet's number.
|
|
||||||
|
|
||||||
### Per-Metric Forecast Burndown Chart
|
|
||||||
|
|
||||||
**What it shows:** A combined chart with up to 4 historical monthly snapshots (left of the divider) and up to 12 forecast months (right of the divider). Each data point shows total assets, non-compliant count, and compliance %.
|
|
||||||
|
|
||||||
**What feeds it:** Three sources combined:
|
|
||||||
1. `compliance_snapshots` for historical totals (3 months back).
|
|
||||||
2. `vcl_multi_vertical_summary` for the metric's `total` (used as `total_assets`).
|
|
||||||
3. `compliance_items` for current devices and their resolution dates.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Active devices for this metric across every vertical it spans
|
|
||||||
SELECT hostname, resolution_date, vertical
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE metric_id = $1 AND status = 'active' AND vertical IS NOT NULL;
|
|
||||||
|
|
||||||
-- Historical snapshots for those verticals (3 months back)
|
|
||||||
SELECT snapshot_month AS month,
|
|
||||||
SUM(total_devices)::int AS total_assets,
|
|
||||||
SUM(non_compliant)::int AS non_compliant,
|
|
||||||
ROUND((SUM(compliant)::numeric / NULLIF(SUM(total_devices), 0)) * 100, 1) AS compliance_pct
|
|
||||||
FROM compliance_snapshots
|
|
||||||
WHERE vertical = ANY($1) AND snapshot_month >= $2 AND snapshot_month < $3
|
|
||||||
GROUP BY snapshot_month
|
|
||||||
ORDER BY snapshot_month ASC;
|
|
||||||
|
|
||||||
-- The metric's per-metric total assets (from latest summary)
|
|
||||||
SELECT SUM(total)::int AS total
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
|
||||||
AND upload_id IN (
|
|
||||||
SELECT id FROM compliance_uploads
|
|
||||||
WHERE vertical IS NOT NULL
|
|
||||||
ORDER BY id DESC LIMIT 20
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Historical Computation — Ratio Method
|
|
||||||
|
|
||||||
The `compliance_snapshots` table stores **vertical-level** totals, not per-metric. To estimate this metric's historical non-compliant count for a past month, the handler uses a ratio:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
metric_nc_for_month = ROUND(
|
|
||||||
snapshot.non_compliant_for_vertical * (current_metric_nc / current_vertical_total_nc)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
In words: "the metric's share of the vertical's current non-compliant load is assumed constant — apply that ratio to historical snapshot non-compliant counts."
|
|
||||||
|
|
||||||
> The ratio method is an approximation. It assumes the metric's contribution to the vertical's non-compliance is steady over the last three months. If the metric load shifts dramatically month-to-month, the historical bars will not match the actual past.
|
|
||||||
|
|
||||||
The current month is **always** computed from live data (not the ratio):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
current_month_nc = number of distinct active hostnames for this metric
|
|
||||||
current_compliance_pct = ROUND((total_assets - current_month_nc) / total_assets * 1000) / 10 // 1 decimal
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Forecast Computation — Resolution Date Bucketing
|
|
||||||
|
|
||||||
Forward projections are handled by `computeMetricForecastBurndown`. The algorithm:
|
|
||||||
|
|
||||||
1. Partition active devices into `blockers` (no resolution date) and `with_dates`.
|
|
||||||
2. Bucket each dated device by its `resolution_date` month (`YYYY-MM`).
|
|
||||||
3. **Past-due dates roll into the current month.** A device whose date is March when today is May counts as remediating in May, not March.
|
|
||||||
4. Walk forward up to 12 months, decrementing `remaining_non_compliant` by each month's bucket.
|
|
||||||
5. Stop early if `remaining_non_compliant <= blockers` — meaning every device with a date is projected to be done and only blockers remain.
|
|
||||||
|
|
||||||
The compliance percentage at each forecast point is:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
compliance_pct = ROUND((total_assets - remaining_non_compliant) / total_assets * 1000) / 10
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Correctness Properties of the Forecast
|
|
||||||
|
|
||||||
These properties hold for any input (verified by property-based tests in `backend/__tests__/compliance-duplicate-chart-entries.property.test.js` and the forecast spec):
|
|
||||||
|
|
||||||
1. **Partition invariant:** `blockers + with_dates == non_compliant`.
|
|
||||||
2. **Compliance formula:** `compliance_pct == ROUND((total - nc) / total * 1000) / 10` (or 0 when total is 0).
|
|
||||||
3. **Monotonic non-increasing:** each month's `non_compliant` is less than or equal to the previous month's.
|
|
||||||
4. **Horizon bound:** at most 12 forecast points; terminates early when only blockers remain.
|
|
||||||
5. **Past-due treated as current month:** dates in already-passed months are bucketed into the current month for projection purposes.
|
|
||||||
|
|
||||||
**Why it can drift:**
|
|
||||||
|
|
||||||
- The ratio method for historical data is an estimate. Verify by hand using the actual upload's Summary sheet for that month if precise historical numbers matter.
|
|
||||||
- The fallback `totalAssets = metricNcCount` triggers when no Summary data exists for the metric. This produces a `compliance_pct` of 0 because every "asset" is non-compliant. This is correct for the data we have — the chart cannot show compliance percentages for metrics that have only been observed as failures.
|
|
||||||
|
|
||||||
### Per-Vertical Detail and Burndown
|
|
||||||
|
|
||||||
**What it shows:** Stats and burndown for a single vertical (e.g., NTS_AEO).
|
|
||||||
|
|
||||||
**What feeds it:**
|
|
||||||
- Stats: `vcl_multi_vertical_summary` for that vertical, latest upload, with sub-team breakouts.
|
|
||||||
- Burndown: `compliance_items` for that vertical, deduplicated by hostname.
|
|
||||||
|
|
||||||
**How it is calculated:**
|
|
||||||
|
|
||||||
The vertical-level burndown deduplicates per hostname using the **first non-null** resolution date (any one is enough to mark the device In-Progress):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In the route handler, after fetching compliance_items for the vertical:
|
|
||||||
const deviceMap = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!deviceMap[row.hostname]) {
|
|
||||||
deviceMap[row.hostname] = { hostname: row.hostname, resolution_date: row.resolution_date };
|
|
||||||
} else if (row.resolution_date && !deviceMap[row.hostname].resolution_date) {
|
|
||||||
// Promote a null entry to In-Progress when any other row has a date
|
|
||||||
deviceMap[row.hostname].resolution_date = row.resolution_date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const devices = Object.values(deviceMap);
|
|
||||||
const burndown = computeVerticalBurndown(devices);
|
|
||||||
```
|
|
||||||
|
|
||||||
`computeVerticalBurndown` returns the same shape as `computeAggregatedBurndown` but scoped to one vertical.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Forecast Algorithms
|
|
||||||
|
|
||||||
The dashboard uses three different forecasting approaches depending on the data being projected.
|
|
||||||
|
|
||||||
### Linear Regression Forecast (Trend)
|
|
||||||
|
|
||||||
**Used by:** Trend chart on the VCL Report and CCP Metrics pages.
|
|
||||||
|
|
||||||
**Inputs:** Monthly compliance percentages from `compliance_snapshots` (one decimal place).
|
|
||||||
|
|
||||||
**Algorithm:** Least-squares linear regression on the time series.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// X = month index (0, 1, 2, ...), Y = compliance_pct
|
|
||||||
slope = (n * SUM(X*Y) - SUM(X) * SUM(Y)) / (n * SUM(X^2) - SUM(X)^2)
|
|
||||||
intercept = (SUM(Y) - slope * SUM(X)) / n
|
|
||||||
```
|
|
||||||
|
|
||||||
For each future month `i` (1, 2, 3 — three months out):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
forecast_pct = ROUND((slope * (n + i - 1) + intercept) * 10) / 10
|
|
||||||
forecast_pct = Math.min(100, Math.max(0, forecast_pct)) // clamp to [0, 100]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Activation:** Forecast appears only when **3 or more** historical months exist. With fewer points, the regression is unreliable, so the dashed line is omitted.
|
|
||||||
|
|
||||||
**Why it works for compliance trends:** Compliance is bounded [0, 100] and changes slowly across months. A linear fit captures the directional trajectory ("we're trending up two points per month") accurately enough to inform planning, though it can over-predict near the boundaries (the clamp prevents impossible values).
|
|
||||||
|
|
||||||
### Resolution-Date Burndown Forecast
|
|
||||||
|
|
||||||
**Used by:** Aggregated burndown, per-vertical burndown, the deprecated team-level forecast in the legacy VCL Report.
|
|
||||||
|
|
||||||
**Inputs:** Active non-compliant devices and their `resolution_date` values.
|
|
||||||
|
|
||||||
**Algorithm:** Simple monthly bucketing — no math beyond grouping and counting.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
buckets = {} // YYYY-MM → count
|
|
||||||
for each device with a non-null resolution_date:
|
|
||||||
month = first 7 chars of resolution_date // 'YYYY-MM-DD' → 'YYYY-MM'
|
|
||||||
buckets[month] += 1
|
|
||||||
|
|
||||||
remaining = total_with_dates
|
|
||||||
projection = {}
|
|
||||||
for each month (sorted ascending):
|
|
||||||
remaining -= buckets[month]
|
|
||||||
projection[month] = { remediated: buckets[month], remaining }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Projected Clear date:** the last month in `projection` **only if** `blockers === 0`. If any device lacks a date, no projection is shown — there is no honest way to forecast something with no commitment.
|
|
||||||
|
|
||||||
**Why this is preferred over regression for burndown:** Resolution dates are explicit human commitments. Linear regression on past remediation rates would project an average pace that ignores what teams have actually committed to. The bucketing approach reports exactly what has been promised and nothing more.
|
|
||||||
|
|
||||||
### Per-Metric Forecast (Historical + Projected)
|
|
||||||
|
|
||||||
**Used by:** The CCP Metrics per-metric forecast burndown chart.
|
|
||||||
|
|
||||||
**Inputs:** Historical snapshots (vertical-level) + current metric devices (per-metric) + the metric's `total_assets` from the Summary sheet.
|
|
||||||
|
|
||||||
**Algorithm:** Two separate parts joined at the current month.
|
|
||||||
|
|
||||||
**Historical part — Ratio Method:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// For each historical month from compliance_snapshots:
|
|
||||||
metric_share = current_metric_nc / current_vertical_total_nc
|
|
||||||
month.non_compliant = ROUND(snapshot.non_compliant * metric_share)
|
|
||||||
month.compliance_pct = ROUND((total_assets - month.non_compliant) / total_assets * 1000) / 10
|
|
||||||
```
|
|
||||||
|
|
||||||
> The ratio method assumes the metric's share of vertical non-compliance is stable. If a metric was recently introduced or recently fixed at scale, the historical bars will be off. Validate against the source Summary sheet for that month if you need precision.
|
|
||||||
|
|
||||||
**Forecast part — Resolution-Date Bucketing:**
|
|
||||||
|
|
||||||
Identical to [Resolution-Date Burndown Forecast](#resolution-date-burndown-forecast), with one extra rule: **past-due dates roll into the current month**. A device with `resolution_date = '2026-02-15'` when today is May is bucketed into May, not February. Empirically, past-due dates are commitments that slipped — projecting them as remediating "now" reflects reality (the team is overdue and has to act this month) better than leaving them stuck in the past.
|
|
||||||
|
|
||||||
**Termination:** the loop exits as soon as `remaining_non_compliant <= blockers`. Once every dated device is projected to be done, continuing would just show flat blocker count.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-Cutting Correctness Rules
|
|
||||||
|
|
||||||
These rules apply across every metric on both pages. Violations indicate a real bug — the dashboard's tests verify these properties hold.
|
|
||||||
|
|
||||||
**Rule 1: Rollup-only aggregation across verticals.**
|
|
||||||
Every cross-vertical query that touches `vcl_multi_vertical_summary` filters with `WHERE team LIKE 'ALL:%'`. Aggregating both rollup rows and sub-team rows would double-count. (Validated by the `ccp-metrics-view-restructure` spec, Property 1.)
|
|
||||||
|
|
||||||
**Rule 2: Latest upload per vertical.**
|
|
||||||
Cross-vertical queries select `DISTINCT ON (vertical)` from `compliance_uploads ORDER BY vertical, id DESC` to take only the most recent upload per vertical. Older uploads contribute to `compliance_snapshots` but not to current totals.
|
|
||||||
|
|
||||||
**Rule 3: Snapshot deduplication.**
|
|
||||||
`compliance_snapshots` is keyed `UNIQUE(snapshot_month, vertical)` and updated via `ON CONFLICT DO UPDATE`. Re-uploading the same month for a vertical overwrites the earlier snapshot. Snapshots are upserted using the upload's `report_date` month, not the current calendar month, so a backfilled upload for March lands in `2026-03` even if it is uploaded in May.
|
|
||||||
|
|
||||||
**Rule 4: Status classification on duplicate hostnames.**
|
|
||||||
When a hostname has rows in multiple verticals (one active, one resolved), the snapshot logic uses `MIN(status)` inside a CTE — `'active'` lexicographically wins over `'resolved'`, so the device is classified as non-compliant. This guarantees `compliant + non_compliant <= total_devices` for every snapshot row.
|
|
||||||
|
|
||||||
**Rule 5: Per-(hostname, metric_id) deduplication.**
|
|
||||||
Queries that bucket or count active findings use `DISTINCT ON (hostname, metric_id)` with the canonical `ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC`. A device failing the same metric in two verticals contributes one entry, not two. (Validated by the `compliance-duplicate-failing-metrics` spec, Properties 1–5.)
|
|
||||||
|
|
||||||
**Rule 6: Aggregation by `report_date`, not upload ID.**
|
|
||||||
Trends, top-recurring, and category-trend queries `GROUP BY report_date` rather than `GROUP BY id`. A multi-vertical day produces multiple `compliance_uploads` rows sharing one `report_date`. (Validated by the `compliance-duplicate-chart-entries` spec, Properties 1–3.)
|
|
||||||
|
|
||||||
**Rule 7: Decimal vs whole-number percentages.**
|
|
||||||
Two conventions coexist:
|
|
||||||
|
|
||||||
| Source | Format | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| `vcl_multi_vertical_summary.compliance_pct` | Decimal | `0.95` |
|
|
||||||
| `compliance_snapshots.compliance_pct` | Whole-number | `95.00` |
|
|
||||||
| `/vcl/stats` response `stats.compliance_pct` | Whole-number integer | `95` |
|
|
||||||
| `/vcl-multi/stats` response `stats.compliance_pct` | Whole-number integer | `95` |
|
|
||||||
| `/vcl-multi/metrics` response `compliance_pct` | Decimal | `0.95` |
|
|
||||||
|
|
||||||
The frontend handles both — multiply decimals by 100 and call `toFixed(1)` for the metric-table view, or display the whole number directly for stats bars. Mismatching the formats is a common source of "values look 100x off" bugs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verifying Values by Hand
|
|
||||||
|
|
||||||
When you suspect a number is wrong, work through this checklist before opening a bug.
|
|
||||||
|
|
||||||
**1. Check whether the value comes from `compliance_items` or `vcl_multi_vertical_summary`.**
|
|
||||||
|
|
||||||
- `compliance_items` only has non-compliant rows. If a "compliant count" is wrong, the bug is in the Summary sheet path, not item counting.
|
|
||||||
- `vcl_multi_vertical_summary` is the source of truth for both compliant and non-compliant totals on the CCP Metrics page.
|
|
||||||
|
|
||||||
**2. Check the upload date.**
|
|
||||||
|
|
||||||
- Cross-vertical numbers use the **latest upload per vertical**. If you uploaded NTS_AEO yesterday but TSI two weeks ago, the aggregate uses today's NTS_AEO and the two-week-old TSI.
|
|
||||||
- The "Last Upload" column in the vertical breakdown shows the `report_date` of each vertical's most recent upload.
|
|
||||||
|
|
||||||
**3. Check the snapshot.**
|
|
||||||
|
|
||||||
- The trend chart reads from `compliance_snapshots`, not from current `compliance_items`. If you fixed a hostname today, it will not appear in the trend until the next upload writes a new snapshot.
|
|
||||||
- Historical months are frozen — only the current month's snapshot updates on re-upload.
|
|
||||||
|
|
||||||
**4. Check for ALL: rollup vs sub-team aggregation.**
|
|
||||||
|
|
||||||
- If a vertical or cross-vertical total looks roughly 2x too high, you are probably summing rollup AND sub-team rows. Confirm with:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT team, COUNT(*) FROM vcl_multi_vertical_summary
|
|
||||||
WHERE upload_id = <latest> AND metric_id = '<metric>'
|
|
||||||
GROUP BY team;
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see one `ALL: <vertical>` row plus one row per sub-team. Use only the `ALL:` row for cross-vertical totals.
|
|
||||||
|
|
||||||
**5. Check for cross-vertical hostname collisions.**
|
|
||||||
|
|
||||||
- A hostname appearing in two verticals (e.g., it was migrated between teams) needs the deduplication rules in [Cross-Cutting Correctness Rules](#cross-cutting-correctness-rules) to count once. Confirm with:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT hostname, vertical, team, status, seen_count
|
|
||||||
FROM compliance_items
|
|
||||||
WHERE hostname = '<hostname>'
|
|
||||||
ORDER BY hostname, vertical;
|
|
||||||
```
|
|
||||||
|
|
||||||
If you see two rows with different `team` values, the device is counted under the team from its representative row (highest `seen_count`, then most recent `upload_id`).
|
|
||||||
|
|
||||||
**6. Reconcile against the Summary sheet.**
|
|
||||||
|
|
||||||
- Open the source xlsx for the upload, navigate to the Summary tab, and find the `ALL: <vertical>` row for the metric in question. The `Total`, `Compliant`, and `Non-Compliant` columns should match `vcl_multi_vertical_summary` exactly (the parser does not transform these numbers — it copies them verbatim).
|
|
||||||
|
|
||||||
If after these steps the displayed value still does not match the source data, file an issue with the SQL output from steps 4–5 and the relevant Summary sheet rows attached.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worked Example — Vulns_Aging vs 7.1.1 Forecast Charts
|
|
||||||
|
|
||||||
This example walks the playbook through two side-by-side per-metric forecast burndown charts that look very different despite using the same code path. It is the canonical case study for "the chart looks weird but the data is internally consistent."
|
|
||||||
|
|
||||||
### What the charts show
|
|
||||||
|
|
||||||
**Vulns_Aging — 17,628 devices.** Every bar is full-height, predominantly orange. The compliance line reads `0.1%` for March, jumps to `27.3%` in April, then back to `0.0%` for May. The total bar height is `17628` on every month.
|
|
||||||
|
|
||||||
**7.1.1 — 6,149 non-compliant out of ~66,674 total.** Every bar is mostly blue (compliant) with a thin orange slice at the top. The compliance line stays between `90.8%` and `93.3%`. The total bar height is `66674` on every month.
|
|
||||||
|
|
||||||
Both charts are produced by the same endpoint (`GET /api/compliance/vcl-multi/metric/:metricId/forecast-burndown`) and the same helper (`computeMetricForecastBurndown`). The difference is entirely in the input data.
|
|
||||||
|
|
||||||
### Walking the playbook
|
|
||||||
|
|
||||||
**Step 1 — Where does the value come from?**
|
|
||||||
|
|
||||||
Both charts blend two sources: `compliance_items` for active non-compliant devices, and `vcl_multi_vertical_summary` for the metric's `total_assets`. The historical bars also use `compliance_snapshots` (vertical-level totals) reshaped via the ratio method.
|
|
||||||
|
|
||||||
The differing input here is `total_assets`, sourced from this query:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT SUM(total)::int AS total
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
|
||||||
AND upload_id IN (
|
|
||||||
SELECT id FROM compliance_uploads
|
|
||||||
WHERE vertical IS NOT NULL ORDER BY id DESC LIMIT 20
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
For `7.1.1` this returns ~66,674. For `Vulns_Aging` it returns 0 or null, and the route handler falls back to `totalAssets = metricNcCount` — the device count from `compliance_items` instead.
|
|
||||||
|
|
||||||
**Step 2 — Check the upload date.**
|
|
||||||
|
|
||||||
Both metrics are read from the latest 20 uploads, so this is not the cause of the difference. Skip.
|
|
||||||
|
|
||||||
**Step 3 — Check the snapshot.**
|
|
||||||
|
|
||||||
`compliance_snapshots` is vertical-level only — it does not store per-metric totals. Both metrics use the same snapshot rows scaled by the ratio method:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
metricNc = ROUND(snapshot.non_compliant * (currentMetricNc / currentVerticalTotalNc))
|
|
||||||
```
|
|
||||||
|
|
||||||
For `Vulns_Aging` the ratio is large (most of the vertical's non-compliant load is aging vulnerabilities), so historical `metricNc` values are sizeable. April happened to have a snapshot where the vertical's total was smaller — the ratio method produced a metricNc of roughly `17628 * 0.727 ≈ 12810`, leaving `(17628 - 12810) / 17628 * 100 ≈ 27.3%` compliance for that bar.
|
|
||||||
|
|
||||||
That April spike is not a real compliance gain. It is an artifact of the historical snapshot's vertical-level non-compliant count being lower that month, multiplied by the metric's current share.
|
|
||||||
|
|
||||||
**Step 4 — ALL: rollup vs sub-team aggregation.**
|
|
||||||
|
|
||||||
Run the diagnostic query for each metric:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT team, total, compliant, non_compliant
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE metric_id = '7.1.1' AND team LIKE 'ALL:%'
|
|
||||||
ORDER BY upload_id DESC LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
For `7.1.1` this returns rows like `('ALL: NTS-AEO', 66674, 60525, 6149)` — a clean, complete rollup.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT team, total, compliant, non_compliant
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE metric_id = 'Vulns_Aging' AND team LIKE 'ALL:%'
|
|
||||||
ORDER BY upload_id DESC LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
For `Vulns_Aging` this returns **zero rows**. The Summary sheet of the source xlsx does not contain `Vulns_Aging` as a tracked metric. The metric exists only as a detail sheet inside the workbook, never as a Summary row.
|
|
||||||
|
|
||||||
**Step 5 — Cross-vertical hostname collisions.**
|
|
||||||
|
|
||||||
Confirm the device counts come from `compliance_items`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT COUNT(DISTINCT hostname) FROM compliance_items
|
|
||||||
WHERE metric_id = 'Vulns_Aging' AND status = 'active' AND vertical IS NOT NULL;
|
|
||||||
-- Returns 17628
|
|
||||||
```
|
|
||||||
|
|
||||||
This matches the chart's `total_assets` exactly, which is the smoking gun. The chart is showing 17,628 in scope and 17,628 non-compliant — `compliance_pct = 0%` is mathematically correct for the data the chart received.
|
|
||||||
|
|
||||||
**Step 6 — Reconcile against the Summary sheet.**
|
|
||||||
|
|
||||||
Open any recent xlsx upload for any vertical and inspect the Summary tab. Search for `Vulns_Aging` in the `Metric` column — it is not there. The Summary sheet enumerates the standard metric IDs (`2.3.5`, `5.2.4`, `7.1.1`, etc.) and has no row for the aging vulnerability dashboard.
|
|
||||||
|
|
||||||
Now search for `7.1.1` — it appears with rows for both the rollup (`ALL: NTS-AEO`) and each sub-team. The Summary's `Total`, `Compliant`, and `Non-Compliant` columns match `vcl_multi_vertical_summary` exactly.
|
|
||||||
|
|
||||||
### Why the charts look the way they do
|
|
||||||
|
|
||||||
**Vulns_Aging is a tracked-but-uncategorized detail metric.** The Python parser walks every detail sheet of the xlsx and writes one `compliance_items` row per non-compliant device, using the sheet name as the `metric_id`. `Vulns_Aging` is one such sheet. But because `Vulns_Aging` does not appear in the workbook's Summary sheet, no row is written to `vcl_multi_vertical_summary` — there is no rollup, no compliant count, no total population.
|
|
||||||
|
|
||||||
When the per-metric forecast endpoint asks for the metric's `total`, the query returns null. The handler falls back to `totalAssets = metricNcCount`, which is the count of non-compliant devices. The chart's denominator is therefore identical to its numerator, which forces every "current" compliance percentage to 0%. The April 27.3% spike is the historical ratio method projecting a smaller non-compliant count for that month against the same fallback denominator. May returns to 0% because May is the current month and uses the live device count, which always equals the fallback total by construction.
|
|
||||||
|
|
||||||
The chart is **internally consistent with the data the database has**. It is not internally consistent with what an executive expects "compliance" to mean for an aging dashboard, because the source xlsx never reported a population total for that detail. The fix is upstream — either the xlsx Summary sheet needs to include a `Vulns_Aging` row with a population total, or the handler needs a special case to mark metrics with no Summary data as "not measurable for compliance percentage" and render the chart differently (e.g., counts only, no percentage line).
|
|
||||||
|
|
||||||
**7.1.1 is a fully populated standard metric.** It has a sheet for the failing devices, a `metric_categories` mapping (`Logging & Monitoring`), and a Summary row with `Total`, `Compliant`, and `Non-Compliant` numbers. The route gets a real `totalAssets` value (~66,674), the compliance percentage is `(66674 - 6149) / 66674 ≈ 90.8%`, and the bar visualizes the actual ratio of compliant to non-compliant devices. Historical and forecast bars track real population data, not a fallback.
|
|
||||||
|
|
||||||
### What this tells you about the dashboard
|
|
||||||
|
|
||||||
The forecast burndown chart is honest about what it knows. When the source data lacks a population total, the chart degrades gracefully to "every device in scope is non-compliant" — which is a literal reading of the rows that exist. It does not fabricate a denominator. The cost is that metrics without Summary entries look catastrophically non-compliant on the chart even when the underlying business reality may be different.
|
|
||||||
|
|
||||||
The diagnostic flow above is the canonical procedure: when a chart looks wrong, walk down to the Summary sheet rows and ask whether the metric is even represented as a tracked compliance metric in the source file. If the answer is no, the chart is reflecting an upstream data shape, not a calculation bug.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worked Example — Vulns_Aging vs 7.1.1 Forecast Charts
|
|
||||||
|
|
||||||
This example walks the playbook through two side-by-side per-metric forecast burndown charts that look very different despite using the same code path. It is the canonical case study for "the chart looks weird but the data is internally consistent."
|
|
||||||
|
|
||||||
### What the charts show
|
|
||||||
|
|
||||||
**Vulns_Aging — 17,628 devices.** Every bar is full-height, predominantly orange. The compliance line reads `0.1%` for March, jumps to `27.3%` in April, then back to `0.0%` for May. The total bar height is `17628` on every month.
|
|
||||||
|
|
||||||
**7.1.1 — 6,149 non-compliant out of ~66,674 total.** Every bar is mostly blue (compliant) with a thin orange slice at the top. The compliance line stays between `90.8%` and `93.3%`. The total bar height is `66674` on every month.
|
|
||||||
|
|
||||||
Both charts are produced by the same endpoint (`GET /api/compliance/vcl-multi/metric/:metricId/forecast-burndown`) and the same helper (`computeMetricForecastBurndown`). The difference is entirely in the input data.
|
|
||||||
|
|
||||||
### Walking the playbook
|
|
||||||
|
|
||||||
**Step 1 — Where does the value come from?**
|
|
||||||
|
|
||||||
Both charts blend two sources: `compliance_items` for active non-compliant devices, and `vcl_multi_vertical_summary` for the metric's `total_assets`. The historical bars also use `compliance_snapshots` (vertical-level totals) reshaped via the ratio method.
|
|
||||||
|
|
||||||
The differing input here is `total_assets`, sourced from this query:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT SUM(total)::int AS total
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
|
||||||
AND upload_id IN (
|
|
||||||
SELECT id FROM compliance_uploads
|
|
||||||
WHERE vertical IS NOT NULL ORDER BY id DESC LIMIT 20
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
For `7.1.1` this returns ~66,674. For `Vulns_Aging` it returns 0 or null, and the route handler falls back to `totalAssets = metricNcCount` — the device count from `compliance_items` instead.
|
|
||||||
|
|
||||||
**Step 2 — Check the upload date.**
|
|
||||||
|
|
||||||
Both metrics are read from the latest 20 uploads, so this is not the cause of the difference. Skip.
|
|
||||||
|
|
||||||
**Step 3 — Check the snapshot.**
|
|
||||||
|
|
||||||
`compliance_snapshots` is vertical-level only — it does not store per-metric totals. Both metrics use the same snapshot rows scaled by the ratio method:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
metricNc = ROUND(snapshot.non_compliant * (currentMetricNc / currentVerticalTotalNc))
|
|
||||||
```
|
|
||||||
|
|
||||||
For `Vulns_Aging` the ratio is large (most of the vertical's non-compliant load is aging vulnerabilities), so historical `metricNc` values are sizeable. April happened to have a snapshot where the vertical's total non-compliant count was lower — the ratio method produced a `metricNc` of roughly `17628 * 0.727 ≈ 12810`, leaving `(17628 - 12810) / 17628 * 100 ≈ 27.3%` compliance for that bar.
|
|
||||||
|
|
||||||
That April spike is not a real compliance gain. It is an artifact of the historical snapshot's vertical-level non-compliant count being lower that month, multiplied by the metric's current share.
|
|
||||||
|
|
||||||
**Step 4 — ALL: rollup vs sub-team aggregation.**
|
|
||||||
|
|
||||||
Run the diagnostic query for each metric:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT team, total, compliant, non_compliant
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE metric_id = '7.1.1' AND team LIKE 'ALL:%'
|
|
||||||
ORDER BY upload_id DESC LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
For `7.1.1` this returns rows like `('ALL: NTS-AEO', 66674, 60525, 6149)` — a clean, complete rollup.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT team, total, compliant, non_compliant
|
|
||||||
FROM vcl_multi_vertical_summary
|
|
||||||
WHERE metric_id = 'Vulns_Aging' AND team LIKE 'ALL:%'
|
|
||||||
ORDER BY upload_id DESC LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
For `Vulns_Aging` this returns **zero rows**. The Summary sheet of the source xlsx does not contain `Vulns_Aging` as a tracked metric. The metric exists only as a detail sheet inside the workbook, never as a Summary row.
|
|
||||||
|
|
||||||
**Step 5 — Cross-vertical hostname collisions.**
|
|
||||||
|
|
||||||
Confirm the device counts come from `compliance_items`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT COUNT(DISTINCT hostname) FROM compliance_items
|
|
||||||
WHERE metric_id = 'Vulns_Aging' AND status = 'active' AND vertical IS NOT NULL;
|
|
||||||
-- Returns 17628
|
|
||||||
```
|
|
||||||
|
|
||||||
This matches the chart's `total_assets` exactly, which is the smoking gun. The chart is showing 17,628 in scope and 17,628 non-compliant — `compliance_pct = 0%` is mathematically correct for the data the chart received.
|
|
||||||
|
|
||||||
**Step 6 — Reconcile against the Summary sheet.**
|
|
||||||
|
|
||||||
Open any recent xlsx upload for any vertical and inspect the Summary tab. Search for `Vulns_Aging` in the `Metric` column — it is not there. The Summary sheet enumerates the standard metric IDs (`2.3.4i`, `5.2.4`, `7.1.1`, etc.) and has no row for the aging vulnerability dashboard.
|
|
||||||
|
|
||||||
Now search for `7.1.1` — it appears with rows for both the rollup (`ALL: NTS-AEO`) and each sub-team. The Summary's `Total`, `Compliant`, and `Non-Compliant` columns match `vcl_multi_vertical_summary` exactly.
|
|
||||||
|
|
||||||
### Why the charts look the way they do
|
|
||||||
|
|
||||||
**Vulns_Aging is a tracked-but-uncategorized detail metric.** The Python parser walks every detail sheet of the xlsx and writes one `compliance_items` row per non-compliant device, using the sheet name as the `metric_id`. `Vulns_Aging` is one such sheet. But because `Vulns_Aging` does not appear in the workbook's Summary sheet, no row is written to `vcl_multi_vertical_summary` — there is no rollup, no compliant count, no total population.
|
|
||||||
|
|
||||||
When the per-metric forecast endpoint asks for the metric's `total`, the query returns null. The handler falls back to `totalAssets = metricNcCount`, which is the count of non-compliant devices. The chart's denominator is therefore identical to its numerator, which forces every "current" compliance percentage to 0%. The April 27.3% spike is the historical ratio method projecting a smaller non-compliant count for that month against the same fallback denominator. May returns to 0% because May is the current month and uses the live device count, which always equals the fallback total by construction.
|
|
||||||
|
|
||||||
The chart is **internally consistent with the data the database has**. It is not internally consistent with what an executive expects "compliance" to mean for an aging dashboard, because the source xlsx never reported a population total for that detail. The fix is upstream — either the xlsx Summary sheet needs to include a `Vulns_Aging` row with a population total, or the handler needs a special case to mark metrics with no Summary data as "not measurable for compliance percentage" and render the chart differently (e.g., counts only, no percentage line).
|
|
||||||
|
|
||||||
**7.1.1 is a fully populated standard metric.** It has a sheet for the failing devices, a `metric_categories` mapping (`Logging & Monitoring`), and a Summary row with `Total`, `Compliant`, and `Non-Compliant` numbers. The route gets a real `totalAssets` value (~66,674), the compliance percentage is `(66674 - 6149) / 66674 ≈ 90.8%`, and the bar visualizes the actual ratio of compliant to non-compliant devices. Historical and forecast bars track real population data, not a fallback.
|
|
||||||
|
|
||||||
### What this tells you about the dashboard
|
|
||||||
|
|
||||||
The forecast burndown chart is honest about what it knows. When the source data lacks a population total, the chart degrades gracefully to "every device in scope is non-compliant" — which is a literal reading of the rows that exist. It does not fabricate a denominator. The cost is that metrics without Summary entries look catastrophically non-compliant on the chart even when the underlying business reality may be different.
|
|
||||||
|
|
||||||
The diagnostic flow above is the canonical procedure: when a chart looks wrong, walk down to the Summary sheet rows and ask whether the metric is even represented as a tracked compliance metric in the source file. If the answer is no, the chart is reflecting an upstream data shape, not a calculation bug.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Security Audit Tracker — STEAM Security Dashboard
|
# Security Audit Tracker — STEAM Security Dashboard
|
||||||
|
|
||||||
**Last scan:** 2026-06-04
|
**Last scan:** 2026-04-20
|
||||||
**Scope:** Full repository — backend routes, middleware, helpers, scripts, frontend components
|
**Scope:** Full repository — backend routes, middleware, helpers, scripts, frontend components
|
||||||
**Baseline:** `docs/security-audit-2026-04-01.md` (31 findings), `docs/security-remediation-plan.md` (17 prioritised items)
|
**Baseline:** `docs/security-audit-2026-04-01.md` (31 findings), `docs/security-remediation-plan.md` (17 prioritised items)
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
- [Remediation Status — April 1 Audit](#remediation-status--april-1-audit)
|
- [Remediation Status — April 1 Audit](#remediation-status--april-1-audit)
|
||||||
- [New Findings — April 20 Scan](#new-findings--april-20-scan)
|
- [New Findings — April 20 Scan](#new-findings--april-20-scan)
|
||||||
- [New Findings — June 4 Scan](#new-findings--june-4-scan)
|
|
||||||
- [Open Finding Summary](#open-finding-summary)
|
- [Open Finding Summary](#open-finding-summary)
|
||||||
- [Positive Security Observations](#positive-security-observations)
|
- [Positive Security Observations](#positive-security-observations)
|
||||||
- [Scan Metadata](#scan-metadata)
|
- [Scan Metadata](#scan-metadata)
|
||||||
@@ -55,37 +54,37 @@ Cross-reference of the 31 original findings against the current codebase. Status
|
|||||||
| M-3 | No rate limiting on NVD API proxy | **Open** | No server-side cache or per-user rate limit on `/api/nvd/lookup` |
|
| M-3 | No rate limiting on NVD API proxy | **Open** | No server-side cache or per-user rate limit on `/api/nvd/lookup` |
|
||||||
| M-4 | Admin self-demotion check uses loose equality | **Fixed** | `users.js` — uses `String(userId) === String(req.user.id)` |
|
| M-4 | Admin self-demotion check uses loose equality | **Fixed** | `users.js` — uses `String(userId) === String(req.user.id)` |
|
||||||
| M-5 | Missing hostname format validation | **Fixed** | `compliance.js` POST /notes — regex validation `^[a-zA-Z0-9._-]+$` |
|
| M-5 | Missing hostname format validation | **Fixed** | `compliance.js` POST /notes — regex validation `^[a-zA-Z0-9._-]+$` |
|
||||||
| M-6 | Vendor field validated before trim | **Fixed** | `ivantiTodoQueue.js:11-14` — `isValidVendor()` now trims before length check |
|
| M-6 | Vendor field validated before trim | **Open** | `ivantiTodoQueue.js:8` — `isValidVendor()` checks length before trim |
|
||||||
| M-7 | Unsanitized original filename in temp JSON | **Open** | `compliance.js:347` — `req.file.originalname.replace(/[^\w.\-() ]/g, '_')` sanitizes in temp JSON, but line 355 returns raw `req.file.originalname` to client |
|
| M-7 | Unsanitized original filename in temp JSON | **Open** | `compliance.js:344` — `req.file.originalname` passed directly |
|
||||||
| M-8 | Hardcoded frontend IP in CSP header | **Fixed** | `knowledgeBase.js:302` — reads from `CORS_ORIGINS` env var |
|
| M-8 | Hardcoded frontend IP in CSP header | **Fixed** | `knowledgeBase.js:302` — reads from `CORS_ORIGINS` env var |
|
||||||
| M-9 | API error messages forwarded to UI | **Open** | Frontend still uses `alert(err.message)` in App.js, UserManagement.js, KnowledgeBasePage.js, JiraPage.js, and AdminPage.js |
|
| M-9 | API error messages forwarded to UI | **Open** | Frontend still uses `alert(err.message)` in several places |
|
||||||
| M-10 | User data in window.confirm dialogs | **Partial** | App.js replaced with `ConfirmModal`. `JiraPage.js:430` still uses `window.confirm('Delete this Jira ticket record?')` — static string, reduced risk |
|
| M-10 | User data in window.confirm dialogs | **Open** | Frontend still uses `window.confirm` with user-supplied data |
|
||||||
|
|
||||||
### Low / Info Findings
|
### Low / Info Findings
|
||||||
|
|
||||||
| ID | Title | Status | Evidence |
|
| ID | Title | Status | Evidence |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| L-1 | Silent ROLLBACK on transaction failure | **Fixed** | `compliance.js` — no `.catch(() => {})` patterns remain; ROLLBACK is followed by `throw err` |
|
| L-1 | Silent ROLLBACK on transaction failure | **Open** | `compliance.js:167` — `.catch(() => {})` still swallows errors |
|
||||||
| L-2 | Fire-and-forget audit logging | **Partial** | `auditLog.js` — now logs to `console.error` on failure, but no alerting |
|
| L-2 | Fire-and-forget audit logging | **Partial** | `auditLog.js` — now logs to `console.error` on failure, but no alerting |
|
||||||
| L-3 | Async temp file cleanup with no error handling | **Open** | `compliance.js` — `fs.unlink(path, () => {})` still used in 5 locations |
|
| L-3 | Async temp file cleanup with no error handling | **Open** | `compliance.js` — `fs.unlink(path, () => {})` still used |
|
||||||
| L-4 | IVANTI_SKIP_TLS with no startup warning | **Open** | No startup warning when `IVANTI_SKIP_TLS=true` |
|
| L-4 | IVANTI_SKIP_TLS with no startup warning | **Open** | No startup warning when `IVANTI_SKIP_TLS=true` |
|
||||||
| L-5 | console.error in production frontend | **Open** | No environment guard on console.error calls — 30+ instances across frontend |
|
| L-5 | console.error in production frontend | **Open** | No environment guard on console.error calls |
|
||||||
| L-6 | localStorage column config lacks structural validation | **Open** | `ReportingPage.js:65-80` — parses JSON with try/catch but validates only `Array.isArray(saved)`, not element structure |
|
| L-6 | localStorage column config lacks structural validation | **Open** | No change observed |
|
||||||
|
|
||||||
### Remediation Plan Items (not in original 31)
|
### Remediation Plan Items (not in original 31)
|
||||||
|
|
||||||
| ID | Title | Status | Evidence |
|
| ID | Title | Status | Evidence |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| RP-1 | Authenticate /uploads static file access | **Fixed** | `server.js:127` — `requireAuth()` middleware applied before `express.static('uploads')` |
|
| RP-1 | Authenticate /uploads static file access | **Open** | `server.js:127` — `express.static('uploads')` still unauthenticated |
|
||||||
| RP-2 | Sanitize Mermaid SVG output with DOMPurify | **Open** | `KnowledgeBaseViewer.js:38` — `innerHTML = svg` without DOMPurify |
|
| RP-2 | Sanitize Mermaid SVG output with DOMPurify | **Open** | `KnowledgeBaseViewer.js:38` — `innerHTML = svg` without DOMPurify |
|
||||||
| RP-3 | Strip server file paths from compliance preview response | **Fixed** | `compliance.js` and `vclMultiVertical.js` — preview returns only the temp filename; commit handler reconstructs full path via `path.join(TEMP_DIR, path.basename(tempFile))` |
|
| RP-3 | Strip server file paths from compliance preview response | **Open** | `compliance.js:342` — full `tempFilePath` returned to client |
|
||||||
| RP-4 | Add SESSION_SECRET to .env.example | **Fixed** | `.env.example` — `SESSION_SECRET=` with generation comment present |
|
| RP-4 | Add SESSION_SECRET to .env.example | **Open** | `.env.example` — no `SESSION_SECRET` entry |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## New Findings — April 20 Scan
|
## New Findings — April 20 Scan
|
||||||
|
|
||||||
Findings discovered in the April 20 scan that were not present in the April 1 audit.
|
Findings discovered in this scan that were not present in the April 1 audit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,8 +94,8 @@ Findings discovered in the April 20 scan that were not present in the April 1 au
|
|||||||
|
|
||||||
All three `logAudit` calls in the Archer tickets router omit the `username` field:
|
All three `logAudit` calls in the Archer tickets router omit the `username` field:
|
||||||
|
|
||||||
```javascript
|
```js
|
||||||
logAudit({
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
action: 'CREATE_ARCHER_TICKET',
|
action: 'CREATE_ARCHER_TICKET',
|
||||||
// username: req.user.username ← missing
|
// username: req.user.username ← missing
|
||||||
@@ -114,19 +113,60 @@ The `auditLog.js` helper defaults missing username to `'unknown'`, so all Archer
|
|||||||
|
|
||||||
### N-2 — `migrate-to-1.1.js` Contains Hardcoded Admin Password (Medium)
|
### N-2 — `migrate-to-1.1.js` Contains Hardcoded Admin Password (Medium)
|
||||||
|
|
||||||
**Status:** **Fixed** — file removed from codebase (confirmed 2026-06-04)
|
**File:** `backend/migrate-to-1.1.js:246`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
While `setup.js` was fixed to generate random passwords (H-8), the migration script still hardcodes `admin123`. If this migration is run on an existing deployment, it resets the admin password to a known value.
|
||||||
|
|
||||||
|
**Impact:** Running the migration on a production system resets the admin account to a publicly known password.
|
||||||
|
|
||||||
|
**Fix:** Either generate a random password (matching `setup.js` pattern) or skip admin creation if the user already exists.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### N-3 — Compliance Preview Returns Full Server Filesystem Path (Medium) — FIXED
|
### N-3 — Compliance Preview Returns Full Server Filesystem Path (Medium)
|
||||||
|
|
||||||
**Status:** Fixed — preview now returns only the temp filename (`tempFilename`). The commit handler reconstructs the full path server-side via `path.join(TEMP_DIR, path.basename(tempFile))`. Applied to both `compliance.js` and `vclMultiVertical.js`.
|
**File:** `backend/routes/compliance.js:342`
|
||||||
|
|
||||||
|
```js
|
||||||
|
tempFile: tempFilePath,
|
||||||
|
```
|
||||||
|
|
||||||
|
The preview endpoint returns the full server-side path (e.g. `/home/cve-dashboard/backend/uploads/temp/compliance_preview_...json`) to the frontend. The commit endpoint then receives this path back and reads the file. This exposes the server's directory structure to any authenticated user.
|
||||||
|
|
||||||
|
**Impact:** Information disclosure — authenticated users learn the server's absolute filesystem layout, which aids further exploitation.
|
||||||
|
|
||||||
|
**Fix:** Return only the filename. Reconstruct the full path server-side in the commit handler:
|
||||||
|
```js
|
||||||
|
tempFile: tempFilename, // just the basename
|
||||||
|
// In commit handler:
|
||||||
|
const tempFile = path.join(TEMP_DIR, path.basename(req.body.tempFile));
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### N-4 — `/uploads` Static Directory Served Without Authentication (High) — FIXED
|
### N-4 — `/uploads` Static Directory Served Without Authentication (High)
|
||||||
|
|
||||||
**Status:** Fixed — `requireAuth()` middleware is now applied before `express.static('uploads')` in `server.js`. All file access now requires a valid session cookie.
|
**File:** `backend/server.js:127`
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.use('/uploads', express.static('uploads', {
|
||||||
|
dotfiles: 'deny',
|
||||||
|
index: false
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
All uploaded files (CVE documents, compliance data, knowledge base articles) are served as static files without any authentication check. Anyone who knows or guesses a file URL can access sensitive vulnerability documentation, compliance reports, and internal knowledge base content.
|
||||||
|
|
||||||
|
**Impact:** Unauthenticated access to all uploaded documents. File paths are predictable (CVE ID + vendor + timestamp-filename pattern).
|
||||||
|
|
||||||
|
**Fix:** Replace with an authenticated route handler:
|
||||||
|
```js
|
||||||
|
app.use('/uploads', requireAuth(db), express.static('uploads', { ... }));
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -134,7 +174,7 @@ The `auditLog.js` helper defaults missing username to `'unknown'`, so all Archer
|
|||||||
|
|
||||||
**File:** `frontend/src/components/KnowledgeBaseViewer.js:38`
|
**File:** `frontend/src/components/KnowledgeBaseViewer.js:38`
|
||||||
|
|
||||||
```javascript
|
```js
|
||||||
ref.current.innerHTML = svg;
|
ref.current.innerHTML = svg;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -143,7 +183,7 @@ Mermaid-generated SVG is injected directly into the DOM via `innerHTML`. While M
|
|||||||
**Impact:** Stored XSS vector if Mermaid's internal sanitization is bypassed. Any user viewing the article would execute the payload.
|
**Impact:** Stored XSS vector if Mermaid's internal sanitization is bypassed. Any user viewing the article would execute the payload.
|
||||||
|
|
||||||
**Fix:** Sanitize the SVG string before injection:
|
**Fix:** Sanitize the SVG string before injection:
|
||||||
```javascript
|
```js
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
|
ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
|
||||||
```
|
```
|
||||||
@@ -152,7 +192,15 @@ ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } })
|
|||||||
|
|
||||||
### N-6 — `SESSION_SECRET` Not Documented in `.env.example` (Low)
|
### N-6 — `SESSION_SECRET` Not Documented in `.env.example` (Low)
|
||||||
|
|
||||||
**Status:** **Fixed** — `.env.example` now includes `SESSION_SECRET=` with generation instructions (confirmed 2026-06-04)
|
**File:** `backend/.env.example`
|
||||||
|
|
||||||
|
The `SESSION_SECRET` environment variable is required for the server to start (hard-fail added per H-2 fix), but it is not listed in `.env.example`. Fresh deployments will fail with no guidance on what to set.
|
||||||
|
|
||||||
|
**Fix:** Add to `.env.example`:
|
||||||
|
```
|
||||||
|
# Session signing secret — generate with: openssl rand -hex 32
|
||||||
|
SESSION_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -160,7 +208,7 @@ ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } })
|
|||||||
|
|
||||||
**File:** `backend/middleware/auth.js:55-60`
|
**File:** `backend/middleware/auth.js:55-60`
|
||||||
|
|
||||||
```javascript
|
```js
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Insufficient permissions',
|
error: 'Insufficient permissions',
|
||||||
required: allowedGroups,
|
required: allowedGroups,
|
||||||
@@ -171,7 +219,7 @@ return res.status(403).json({
|
|||||||
The 403 response includes both the required groups and the user's current group. This is minor information disclosure — an attacker probing endpoints learns the exact group membership of the compromised account and which groups are needed.
|
The 403 response includes both the required groups and the user's current group. This is minor information disclosure — an attacker probing endpoints learns the exact group membership of the compromised account and which groups are needed.
|
||||||
|
|
||||||
**Fix:** Remove `required` and `current` from the response:
|
**Fix:** Remove `required` and `current` from the response:
|
||||||
```javascript
|
```js
|
||||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -186,7 +234,7 @@ Security headers include `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Pro
|
|||||||
**Impact:** No browser-enforced restriction on script sources. If an XSS vulnerability exists (e.g. N-5), there is no CSP to mitigate it.
|
**Impact:** No browser-enforced restriction on script sources. If an XSS vulnerability exists (e.g. N-5), there is no CSP to mitigate it.
|
||||||
|
|
||||||
**Fix:** Add a baseline CSP header:
|
**Fix:** Add a baseline CSP header:
|
||||||
```javascript
|
```js
|
||||||
res.setHeader('Content-Security-Policy',
|
res.setHeader('Content-Security-Policy',
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||||
"img-src 'self' data:; font-src 'self'; connect-src 'self'");
|
"img-src 'self' data:; font-src 'self'; connect-src 'self'");
|
||||||
@@ -204,62 +252,14 @@ The `sessions` table has no automatic cleanup. Expired sessions accumulate indef
|
|||||||
**Impact:** Performance degradation over time as the sessions table grows. Not directly exploitable, but expired session rows increase the surface for timing attacks on session lookups.
|
**Impact:** Performance degradation over time as the sessions table grows. Not directly exploitable, but expired session rows increase the surface for timing attacks on session lookups.
|
||||||
|
|
||||||
**Fix:** Add a cleanup interval on server startup:
|
**Fix:** Add a cleanup interval on server startup:
|
||||||
```javascript
|
```js
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
pool.query("DELETE FROM sessions WHERE expires_at < NOW()");
|
db.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
||||||
}, 6 * 60 * 60 * 1000); // every 6 hours
|
}, 6 * 60 * 60 * 1000); // every 6 hours
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## New Findings — June 4 Scan
|
|
||||||
|
|
||||||
Findings discovered in the June 4, 2026 scan.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### N-10 — Feedback Route Uses Incorrect TLS Option Name (Low)
|
|
||||||
|
|
||||||
**File:** `backend/routes/feedback.js:92, 221`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const reqOpts = {
|
|
||||||
// ...
|
|
||||||
rejectAuthorized: false,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
The option is spelled `rejectAuthorized` instead of the correct `rejectUnauthorized`. This means the option is silently ignored by Node's `https` module — TLS verification remains **enabled** (the secure default), so this is not a vulnerability in itself. However, it indicates the developer intended to disable TLS verification (likely for Charter's SSL inspection proxy), and the code does not achieve its intent. If the GitLab instance uses an internal CA certificate, feedback submissions will fail with a TLS error when the proxy is active.
|
|
||||||
|
|
||||||
**Impact:** Feedback submissions may fail behind the SSL inspection proxy due to unintended TLS verification. Not a security vulnerability — TLS remains enforced.
|
|
||||||
|
|
||||||
**Fix:** If TLS skip is intentional, use the correct option name and gate it behind an env var:
|
|
||||||
```javascript
|
|
||||||
rejectUnauthorized: process.env.GITLAB_SKIP_TLS === 'true' ? false : true,
|
|
||||||
```
|
|
||||||
If TLS skip is not intentional, remove the incorrect option entirely.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### N-11 — `window.confirm` Persists in JiraPage.js (Low)
|
|
||||||
|
|
||||||
**File:** `frontend/src/components/pages/JiraPage.js:430`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (!window.confirm('Delete this Jira ticket record?')) return;
|
|
||||||
```
|
|
||||||
|
|
||||||
The main `App.js` was migrated to use `ConfirmModal` (a themed replacement for `window.confirm`), but `JiraPage.js` still uses the raw browser `confirm()` dialog. While the confirmation string is static (not user-supplied data), this is inconsistent with the security pattern established elsewhere in the codebase.
|
|
||||||
|
|
||||||
**Impact:** Low — the string is static so there is no XSS vector. This is a consistency issue rather than a vulnerability.
|
|
||||||
|
|
||||||
**Fix:** Replace with the `ConfirmModal` pattern used in `App.js`:
|
|
||||||
```javascript
|
|
||||||
setPendingConfirm({ message: 'Delete this Jira ticket record?', onConfirm: () => doDelete(id) });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Finding Summary
|
## Open Finding Summary
|
||||||
|
|
||||||
Prioritised list of all open findings requiring action.
|
Prioritised list of all open findings requiring action.
|
||||||
@@ -268,7 +268,7 @@ Prioritised list of all open findings requiring action.
|
|||||||
|
|
||||||
| ID | Severity | Title | Source |
|
| ID | Severity | Title | Source |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| — | — | (none — all High findings resolved) | — |
|
| N-4 | High | `/uploads` static directory served without authentication | New |
|
||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
|
|
||||||
@@ -276,21 +276,24 @@ Prioritised list of all open findings requiring action.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| M-1 | Medium | No CSRF token protection | April 1 |
|
| M-1 | Medium | No CSRF token protection | April 1 |
|
||||||
| M-3 | Medium | No rate limiting on NVD API proxy | April 1 |
|
| M-3 | Medium | No rate limiting on NVD API proxy | April 1 |
|
||||||
| N-1 | Medium | Archer ticket audit logs missing `username` field | April 20 |
|
| N-1 | Medium | Archer ticket audit logs missing `username` field | New |
|
||||||
| N-5 / RP-2 | Medium | Mermaid SVG rendered via `innerHTML` without sanitization | April 20 |
|
| N-2 | Medium | `migrate-to-1.1.js` contains hardcoded admin password | New |
|
||||||
| N-8 | Medium | No Content-Security-Policy header on main application | April 20 |
|
| N-3 | Medium | Compliance preview returns full server filesystem path | New |
|
||||||
| M-7 | Medium | Unsanitized original filename returned to client in preview response | April 1 |
|
| N-5 | Medium | Mermaid SVG rendered via `innerHTML` without sanitization | New |
|
||||||
| M-9 | Medium | API error messages forwarded to UI via `alert()` | April 1 |
|
| N-8 | Medium | No Content-Security-Policy header on main application | New |
|
||||||
|
| M-6 | Medium | Vendor field validated before trim | April 1 |
|
||||||
|
| M-7 | Medium | Unsanitized original filename in temp JSON | April 1 |
|
||||||
|
| M-9 | Medium | API error messages forwarded to UI | April 1 |
|
||||||
|
| M-10 | Medium | User data in `window.confirm` dialogs | April 1 |
|
||||||
|
|
||||||
### Low Priority
|
### Low Priority
|
||||||
|
|
||||||
| ID | Severity | Title | Source |
|
| ID | Severity | Title | Source |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| M-10 | Low | User data in `window.confirm` dialogs (partially fixed — JiraPage only) | April 1 |
|
| N-6 | Low | `SESSION_SECRET` not documented in `.env.example` | New |
|
||||||
| N-7 | Low | `requireGroup` error response leaks current user group | April 20 |
|
| N-7 | Low | `requireGroup` error response leaks current user group | New |
|
||||||
| N-9 | Low | Expired sessions not cleaned up automatically | April 20 |
|
| N-9 | Low | Expired sessions not cleaned up automatically | New |
|
||||||
| N-10 | Low | Feedback route uses incorrect TLS option name | June 4 |
|
| L-1 | Low | Silent ROLLBACK on transaction failure | April 1 |
|
||||||
| N-11 | Low | `window.confirm` persists in JiraPage.js | June 4 |
|
|
||||||
| L-3 | Low | Async temp file cleanup with no error handling | April 1 |
|
| L-3 | Low | Async temp file cleanup with no error handling | April 1 |
|
||||||
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | April 1 |
|
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | April 1 |
|
||||||
| L-5 | Low | console.error in production frontend | April 1 |
|
| L-5 | Low | console.error in production frontend | April 1 |
|
||||||
@@ -302,23 +305,19 @@ Prioritised list of all open findings requiring action.
|
|||||||
|
|
||||||
Verified secure patterns that should be preserved:
|
Verified secure patterns that should be preserved:
|
||||||
|
|
||||||
- **SQL injection prevention** — all queries use parameterized statements throughout the entire codebase (PostgreSQL `$1` placeholders)
|
- **SQL injection prevention** — all queries use parameterized statements throughout the entire codebase
|
||||||
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` consistently applied in `server.js`, `compliance.js`, and `knowledgeBase.js`
|
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` consistently applied in `server.js`, `compliance.js`, and `knowledgeBase.js`
|
||||||
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` with argument arrays — no shell injection
|
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` with argument arrays — no shell injection
|
||||||
- **File upload security** — extension allowlist + MIME prefix validation + 10 MB size limit via multer
|
- **File upload security** — extension allowlist + MIME prefix validation + 10 MB size limit via multer
|
||||||
- **Password hashing** — bcrypt with cost factor 10 used for all password storage
|
- **Password hashing** — bcrypt with cost factor 10 used for all password storage
|
||||||
- **Session management** — 32-byte random session IDs via `crypto.randomBytes`, httpOnly cookies, 24h expiry
|
- **Session management** — 32-byte random session IDs via `crypto.randomBytes`, httpOnly cookies, 24h expiry
|
||||||
- **Rate limiting** — login endpoint protected with 20 attempts per 15-minute window; password change limited to 5 per 15 minutes
|
- **Rate limiting** — login endpoint protected with 20 attempts per 15-minute window
|
||||||
- **Audit trail** — comprehensive audit logging on all state-changing operations (with noted exceptions above)
|
- **Audit trail** — comprehensive audit logging on all state-changing operations (with noted exceptions above)
|
||||||
- **Self-modification prevention** — admin cannot demote or deactivate their own account
|
- **Self-modification prevention** — admin cannot demote or deactivate their own account
|
||||||
- **Ownership-scoped deletion** — Standard_User can only delete resources they created
|
- **Ownership-scoped deletion** — Standard_User can only delete resources they created
|
||||||
- **Compliance linkage protection** — deletion blocked when tickets are linked to active compliance reports
|
- **Compliance linkage protection** — deletion blocked when tickets are linked to active compliance reports
|
||||||
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension and `uploads/temp/` directory
|
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension and `uploads/temp/` directory
|
||||||
- **Static file serving** — `dotfiles: 'deny'` and `index: false` prevent directory listing
|
- **Static file serving** — `dotfiles: 'deny'` and `index: false` prevent directory listing
|
||||||
- **Webhook authentication** — GitLab webhook validates `x-gitlab-token` against `GITLAB_WEBHOOK_SECRET` env var
|
|
||||||
- **SESSION_SECRET enforcement** — server hard-fails on startup if `SESSION_SECRET` is not set
|
|
||||||
- **Input validation coverage** — CVE ID, vendor, hostname, metric_id, EXC number, and workflow_type all validated with regex or enum checks
|
|
||||||
- **Error response discipline** — backend routes consistently return `'Internal server error.'` for 500s, avoiding stack trace leaks
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,16 +325,13 @@ Verified secure patterns that should be preserved:
|
|||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Scan date | 2026-06-04 |
|
| Scan date | 2026-04-20 |
|
||||||
| Scan type | Full repository static analysis |
|
| Scan type | Full repository static analysis |
|
||||||
| Scope | `backend/server.js`, `backend/routes/`, `backend/middleware/`, `backend/helpers/`, `backend/scripts/`, `backend/migrations/`, `frontend/src/`, `.env.example` |
|
| Scope | `backend/`, `frontend/src/`, config files |
|
||||||
| Baseline | `docs/security-audit-2026-04-01.md` |
|
| Baseline | `docs/security-audit-2026-04-01.md` |
|
||||||
| Previous findings | 31 (6 Critical, 9 High, 10 Medium, 6 Low/Info) + 9 new (April 20) + 4 remediation plan items |
|
| Previous findings | 31 (6 Critical, 9 High, 10 Medium, 6 Low/Info) |
|
||||||
| Fixed since last scan | 5 (N-2, N-6, M-6, RP-4, L-1) |
|
| Remediated | 20 fully fixed, 2 partially fixed |
|
||||||
| Downgraded | 1 (M-10: Medium → Low, partially fixed) |
|
| Still open (from baseline) | 13 |
|
||||||
| Still open (from baseline) | 10 |
|
| New findings | 9 |
|
||||||
| Still open (from April 20) | 7 |
|
| Total open | 22 (1 High, 11 Medium, 10 Low) |
|
||||||
| New findings (June 4) | 2 |
|
|
||||||
| Total open | 18 (1 High, 8 Medium, 9 Low) |
|
|
||||||
| Regressions | 0 |
|
|
||||||
| Methodology | Static analysis — code review of all route handlers, middleware, helpers, and frontend components |
|
| Methodology | Static analysis — code review of all route handlers, middleware, helpers, and frontend components |
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
# VCL Multi-Vertical Upload — Design Brief
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document summarizes the design decisions and architectural choices for the VCL Multi-Vertical Upload feature. It is intended as a reference for presenting the approach to stakeholders and the compliance team.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What We Are Building
|
|
||||||
|
|
||||||
A new upload flow on the STEAM Security Dashboard that accepts multiple per-vertical compliance xlsx files (one per organizational vertical), ingests them with vertical-scoped resolution logic, and generates an executive-level VCL compliance report across all organizations — with drill-down by vertical and by metric.
|
|
||||||
|
|
||||||
This is a POC. The compliance team currently exports data from CyberMetrics as xlsx files on a 24-hour cycle. This feature lets them upload those files and generate the same reports they currently build manually in PowerPoint/Excel for senior leadership.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Problem It Solves
|
|
||||||
|
|
||||||
Today the compliance team has 14 separate xlsx files — one per vertical (NTS_AEO, SDIT_CISO, TSI, etc.). The existing dashboard upload flow accepts a single consolidated file and treats it as the complete compliance state. If you upload just one vertical's file, the system incorrectly marks every device from the other 13 verticals as "resolved."
|
|
||||||
|
|
||||||
There is no automated way to:
|
|
||||||
- Ingest all 14 files and produce a unified report
|
|
||||||
- Drill down from the organizational view into specific metrics and devices
|
|
||||||
- Generate burndown forecasts across verticals
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Architectural Decisions
|
|
||||||
|
|
||||||
### 1. Vertical-Scoped Resolution
|
|
||||||
|
|
||||||
**Decision:** When a file for vertical X is committed, only items belonging to vertical X are evaluated for resolution. All other verticals are untouched.
|
|
||||||
|
|
||||||
**Why:** This is the fundamental change that makes per-vertical uploads safe. Without it, uploading one file would destroy data from the other 13 verticals.
|
|
||||||
|
|
||||||
**Implication:** Verticals are independent. You can upload NTS_AEO on Monday and SDIT_CISO on Wednesday without interference. This also supports the daily upload cadence the compliance team wants.
|
|
||||||
|
|
||||||
### 2. Vertical Identity Comes From the Filename
|
|
||||||
|
|
||||||
**Decision:** The vertical code is extracted from the filename pattern `<VERTICAL>_YYYY_MM_DD.xlsx`, not from data inside the xlsx.
|
|
||||||
|
|
||||||
**Why:** The internal xlsx structure is identical across verticals — same Summary sheet, same metric detail sheets, same columns. The only differentiator is the filename. This also means the Python parser requires zero changes.
|
|
||||||
|
|
||||||
**Implication:** Filenames must follow the convention. If they don't, the system flags them as "unrecognized" and the user can manually assign a vertical. This is a reasonable tradeoff for a POC.
|
|
||||||
|
|
||||||
### 3. Separate From Existing AEO Upload
|
|
||||||
|
|
||||||
**Decision:** This is a new flow with its own endpoints (`/api/compliance/vcl-multi/...`), its own UI page, and its own nav entry. The existing AEO compliance upload is unchanged.
|
|
||||||
|
|
||||||
**Why:**
|
|
||||||
- The existing flow works for the STEAM/ACCESS-ENG team's day-to-day operations
|
|
||||||
- The compliance team may deploy this on a separate instance to experiment without affecting production
|
|
||||||
- Different user groups with different needs — engineers vs. compliance analysts vs. senior leadership
|
|
||||||
|
|
||||||
**Implication:** There are now two ways to upload compliance data. They coexist via the `vertical` column — existing AEO data has `vertical = NULL`, multi-vertical data has a vertical code. The VCL report page can aggregate either or both.
|
|
||||||
|
|
||||||
### 4. Two-Dimensional Grouping (Vertical + Team)
|
|
||||||
|
|
||||||
**Decision:** `vertical` and `team` are separate fields. Vertical is the organizational unit (NTS_AEO, SDIT_CISO). Team is the sub-team within a vertical (STEAM, ACCESS-ENG, ACCESS-OPS).
|
|
||||||
|
|
||||||
**Why:** NTS_AEO contains multiple sub-teams. Senior leadership wants to see the vertical-level view. The STEAM team wants to see their team-level view. Both are valid groupings on the same data.
|
|
||||||
|
|
||||||
**Implication:** The cross-organizational report groups by vertical. Drilling into NTS_AEO still shows the STEAM/ACCESS-ENG/ACCESS-OPS breakdown because that data exists in the "Team" column inside the xlsx.
|
|
||||||
|
|
||||||
### 5. Summary Sheet Data Stored Separately
|
|
||||||
|
|
||||||
**Decision:** The parsed Summary sheet (metric-level health data) is stored in a dedicated `vcl_multi_vertical_summary` table, not just as JSON on the upload record.
|
|
||||||
|
|
||||||
**Why:** The metric drill-down view needs to query per-metric compliance percentages and targets efficiently. Storing structured rows enables filtering, sorting, and aggregation at the database level rather than parsing JSON blobs in application code.
|
|
||||||
|
|
||||||
**Implication:** Slightly more storage, but enables fast queries like "show me all metrics below target across all verticals" without full-table scans.
|
|
||||||
|
|
||||||
### 6. Batch Upload With Atomic Commit
|
|
||||||
|
|
||||||
**Decision:** All files in a batch are committed in a single database transaction. If any file fails, the entire batch rolls back.
|
|
||||||
|
|
||||||
**Why:** Partial commits would leave the report in an inconsistent state — some verticals updated, others stale. The compliance team uploads all 14 files together as a reporting cycle. It should either all succeed or all fail.
|
|
||||||
|
|
||||||
**Implication:** If one file has a parsing error, the user is shown the error in the preview phase (before commit). They can remove that file from the batch and commit the rest. Once they hit "Commit," it's all-or-nothing.
|
|
||||||
|
|
||||||
### 7. Daily Upload Support (Idempotent)
|
|
||||||
|
|
||||||
**Decision:** Re-uploading the same vertical on the same day produces the same final state as uploading it once. The system doesn't create duplicate records.
|
|
||||||
|
|
||||||
**Why:** CyberMetrics refreshes on a 24-hour cycle. The compliance team may want to upload daily to track movement. They shouldn't have to worry about "did I already upload today?"
|
|
||||||
|
|
||||||
**Implication:** The resolution logic uses `vertical + hostname + metric_id` as the identity key. Recurring items get their `seen_count` incremented and metadata updated. New items are inserted. Missing items are resolved. Same logic as today, just scoped to the vertical.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Drill-Down Hierarchy
|
|
||||||
|
|
||||||
```
|
|
||||||
Executive Overview (all verticals aggregated)
|
|
||||||
│
|
|
||||||
├── Stats: 2.1M devices, 97% compliant, target 95%
|
|
||||||
├── Trend: monthly compliance % with forecast
|
|
||||||
├── Donut: blocked vs in-progress (non-compliant devices)
|
|
||||||
│
|
|
||||||
└── Vertical Breakdown Table
|
|
||||||
│
|
|
||||||
├── NTS_AEO — 99% — 2,163 non-compliant — click to drill down
|
|
||||||
│ │
|
|
||||||
│ ├── Team Filter: [All (Rollup)] [ACCESS-ENG] [ACCESS-OPS] [INTELDEV] [STEAM]
|
|
||||||
│ │
|
|
||||||
│ ├── Metric Breakdown (expandable rows)
|
|
||||||
│ │ ├── ▸ 5.5.4i (Vulnerability Mgmt) — 97.0% — 1,762 NC — target 80%
|
|
||||||
│ │ │ ├── └ ACCESS-ENG: 7 compliant, 1 NC, 8 total — 88.0%
|
|
||||||
│ │ │ ├── └ ACCESS-OPS: 64,051 compliant, 1,746 NC, 65,797 total — 97.0%
|
|
||||||
│ │ │ ├── └ INTELDEV: 233 compliant, 11 NC, 244 total — 95.0%
|
|
||||||
│ │ │ └── └ STEAM: 123 compliant, 4 NC, 127 total — 97.0%
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── Click metric ID → Metric Sub-Team View
|
|
||||||
│ │ │ ├── Stats: total 66,176 | compliant 64,414 | NC 1,762 | 97% | target 80%
|
|
||||||
│ │ │ └── Sub-Team Table:
|
|
||||||
│ │ │ ├── ACCESS-ENG — 8 total — 88.0% → click
|
|
||||||
│ │ │ │ └── Device list (filtered to ACCESS-ENG)
|
|
||||||
│ │ │ ├── ACCESS-OPS — 65,797 total — 97.0% → click
|
|
||||||
│ │ │ │ └── Device list (filtered to ACCESS-OPS)
|
|
||||||
│ │ │ ├── INTELDEV — 244 total — 95.0% → click
|
|
||||||
│ │ │ └── STEAM — 127 total — 97.0% → click
|
|
||||||
│ │ └── ...
|
|
||||||
│ │
|
|
||||||
│ └── Burndown: blockers, with dates, projected clear date
|
|
||||||
│
|
|
||||||
├── SDIT_CISO — 72% — 68 non-compliant
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How Metrics Are Calculated
|
|
||||||
|
|
||||||
### Data Sources
|
|
||||||
|
|
||||||
Each vertical's xlsx file contains two types of data:
|
|
||||||
|
|
||||||
1. **Summary sheet** — one row per metric per sub-team, with pre-calculated totals (compliant, non-compliant, total, compliance %, target). This is the source of truth for aggregate numbers.
|
|
||||||
|
|
||||||
2. **Detail sheets** — one sheet per metric, listing individual non-compliant devices (hostname, IP, device type, team). These feed the device-level drill-down.
|
|
||||||
|
|
||||||
### The Double-Counting Problem (and How We Solve It)
|
|
||||||
|
|
||||||
The Summary sheet contains **two levels of rows** for each metric:
|
|
||||||
|
|
||||||
| Row Type | Example | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| Sub-team rows | ACCESS-OPS, STEAM, INTELDEV | Individual team breakdown |
|
|
||||||
| Rollup row | ALL: NTS-AEO | Sum of all sub-teams for that metric |
|
|
||||||
|
|
||||||
The rollup row already includes all sub-team totals. If you sum all rows naively, you count every device twice.
|
|
||||||
|
|
||||||
**Solution:** All aggregate calculations (stats bar, vertical breakdown, category totals, snapshots) use **only the ALL: rollup rows**. Sub-team rows are stored for drill-down display but never included in totals.
|
|
||||||
|
|
||||||
### What Each Number Means
|
|
||||||
|
|
||||||
| Metric | Source | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| **Total Devices** | Sum of `total` from ALL: rows across all metrics for a vertical | Total device-metric pairs evaluated (a device appears once per metric it's measured against) |
|
|
||||||
| **Compliant** | Sum of `compliant` from ALL: rows | Device-metric pairs that pass the compliance check |
|
|
||||||
| **Non-Compliant** | Sum of `non_compliant` from ALL: rows | Device-metric pairs that fail |
|
|
||||||
| **Compliance %** | `compliant / total * 100` | Percentage of device-metric pairs passing |
|
|
||||||
| **Target %** | Per-metric value from the spreadsheet (e.g., 95%, 80%, 75%) | The threshold set by the compliance program |
|
|
||||||
| **Blockers** | Non-compliant devices in `compliance_items` with no `resolution_date` | Devices with no committed remediation timeline |
|
|
||||||
| **In-Progress** | Non-compliant devices with a `resolution_date` set | Devices with a planned fix date |
|
|
||||||
|
|
||||||
### Important: "Total Devices" Is Not Unique Devices
|
|
||||||
|
|
||||||
A single physical device (hostname) can appear in multiple metrics. For example, one router might be measured against metric 5.5.4i (vulnerability scanning), 7.1.1 (logging), and 2.3.6i (patching). The "Total Devices" count is the sum of all device-metric evaluations, not unique hostnames.
|
|
||||||
|
|
||||||
This matches how CyberMetrics reports — each metric has its own scope of applicable devices, and the overall compliance percentage reflects performance across all metrics.
|
|
||||||
|
|
||||||
### Per-Metric Compliance Percentage
|
|
||||||
|
|
||||||
Each metric row shows its own compliance percentage, which comes directly from the Summary sheet's "Current Compliance" column. This is a decimal between 0 and 1 (displayed as 0–100% in the UI). The target is also per-metric — some metrics have a 95% target, others 80% or 75%, depending on the compliance program's priorities.
|
|
||||||
|
|
||||||
### Category Aggregation
|
|
||||||
|
|
||||||
Metrics are grouped into categories (Logging & Monitoring, Vulnerability Management, Access & MFA, Endpoint Protection, etc.) based on a static mapping in `compliance_config.json`. The category cards in the drill-down view show the aggregate compliance % across all metrics in that category, using only rollup rows.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sub-Team Drill-Down
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
When you click into a vertical (e.g., NTS_AEO), the metrics table shows the **rollup totals** by default — one row per metric with the ALL: numbers. Two mechanisms expose sub-team data:
|
|
||||||
|
|
||||||
**1. Expand/Collapse (▸ arrow)**
|
|
||||||
|
|
||||||
Click the arrow on any metric row to reveal sub-team rows inline beneath it. Each sub-team row shows that team's compliant/non-compliant/total/% for that specific metric. The sub-team rows are visually indented and teal-highlighted.
|
|
||||||
|
|
||||||
This is useful for: "Which team is dragging down metric 5.5.4i?"
|
|
||||||
|
|
||||||
**2. Team Filter Buttons**
|
|
||||||
|
|
||||||
A row of filter buttons appears above the metrics table showing all teams in that vertical (e.g., ACCESS-ENG, ACCESS-OPS, INTELDEV, STEAM). Click one to filter the entire table to show only that team's numbers per metric. The "All (Rollup)" button returns to the aggregated view.
|
|
||||||
|
|
||||||
This is useful for: "Show me STEAM's compliance across all metrics."
|
|
||||||
|
|
||||||
### What "(Other)" Means
|
|
||||||
|
|
||||||
Some metrics have a team value of `(Other)` in the Summary sheet. This represents devices that don't map to a named sub-team. These are included in the ALL: rollup total but are not shown as a separate sub-team in the UI — they're noise for the compliance team's purposes.
|
|
||||||
|
|
||||||
### Device-Level Drill-Down
|
|
||||||
|
|
||||||
Clicking a sub-team row in the metric sub-team view navigates to the device list — individual non-compliant hostnames for that vertical + metric + team combination. The device list is filtered to only show devices belonging to the selected team. This data comes from the detail sheets (not the Summary sheet) and shows:
|
|
||||||
|
|
||||||
- Hostname, IP address, device type, team
|
|
||||||
- Seen count (how many consecutive uploads this device has been non-compliant)
|
|
||||||
- First seen / last seen dates
|
|
||||||
- Resolution date (if set)
|
|
||||||
- Remediation plan (if documented)
|
|
||||||
|
|
||||||
If a metric has no sub-team breakdown (e.g., only an "(Other)" team), a "View All Devices" button is shown instead, which loads the full unfiltered device list for that metric.
|
|
||||||
|
|
||||||
The full navigation path is:
|
|
||||||
|
|
||||||
```
|
|
||||||
Overview → Vertical → Metric (sub-team totals) → Team (device list)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Burndown Forecast
|
|
||||||
|
|
||||||
The burndown forecast answers: "When will this vertical reach compliance?"
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
1. Each non-compliant device can have a `resolution_date` set (target remediation date)
|
|
||||||
2. Devices with dates are bucketed by month → "20 devices expected remediated in June, 35 in July"
|
|
||||||
3. Devices without dates are counted as "blockers" — no committed timeline
|
|
||||||
4. The trend chart uses linear regression on 3+ months of actual data to project a forecast line
|
|
||||||
|
|
||||||
**What feeds it:**
|
|
||||||
- Resolution dates can be set manually (click device → set date) or via bulk upload (xlsx with Hostname + Resolution Date columns)
|
|
||||||
- The existing bulk upload flow on the VCL page already supports this
|
|
||||||
|
|
||||||
**What the compliance team sees:**
|
|
||||||
- Per-vertical: "NTS_AEO has 80 non-compliant, 25 are blockers, 55 have dates, projected clear by August 2026"
|
|
||||||
- Aggregated: trend line showing whether the organization is on track to hit 95% target
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Does NOT Change
|
|
||||||
|
|
||||||
- Existing AEO compliance upload (single file) — unchanged
|
|
||||||
- Existing VCL report page (STEAM/ACCESS-ENG view) — unchanged
|
|
||||||
- Existing compliance_items table structure — only adds a nullable `vertical` column
|
|
||||||
- Python parser — reused as-is, no modifications
|
|
||||||
- Auth model — same groups (Admin, Standard_User) required for upload
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment Options
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|---|---|
|
|
||||||
| Same instance | Add the feature to the existing dashboard. Multi-vertical data coexists with AEO data via the `vertical` column. |
|
|
||||||
| Separate instance | Deploy a fresh instance with its own database. Compliance team experiments freely. No risk to dev/production data. |
|
|
||||||
| Later: API integration | Replace xlsx upload with direct CyberMetrics API calls. Backend endpoints stay the same — just a different client pushing data. |
|
|
||||||
|
|
||||||
The architecture supports all three without code changes. The `vertical` column and scoped resolution logic work regardless of deployment topology.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions for the Meeting
|
|
||||||
|
|
||||||
1. **Vertical list** — Are the 14 verticals in the screenshot the complete set, or do new verticals get added periodically? (Affects whether we hardcode a list or keep it dynamic.)
|
|
||||||
|
|
||||||
2. **Target % per vertical** — Is the 95% target uniform across all verticals, or do different verticals have different targets?
|
|
||||||
|
|
||||||
3. **Access control** — Should the compliance team have their own user accounts with a specific role, or do they use existing Admin/Standard_User groups?
|
|
||||||
|
|
||||||
4. **Naming** — What should this page be called in the nav? "CCP Metrics", "VCL Multi-Vertical", "Compliance Reporting", something else?
|
|
||||||
|
|
||||||
5. **Retention** — How long should historical upload data be kept? (Affects trend chart depth and storage.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Timeline Estimate
|
|
||||||
|
|
||||||
| Phase | Scope | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 1. Migration + backend endpoints | Schema changes, upload flow, scoped resolution, stats/trend/drill-down APIs | 2–3 days |
|
|
||||||
| 2. Frontend — upload modal | Multi-file drop, filename parsing, batch preview, commit | 1–2 days |
|
|
||||||
| 3. Frontend — report page | Stats bar, vertical table, trend chart, donut, drill-down views | 2–3 days |
|
|
||||||
| 4. Frontend — burndown | Per-vertical burndown chart, blocker counts, forecast | 1 day |
|
|
||||||
| 5. Testing + polish | Property tests, edge cases, error handling, loading states | 1 day |
|
|
||||||
|
|
||||||
Total: roughly 7–10 working days for the full POC.
|
|
||||||
294
frontend/package-lock.json
generated
294
frontend/package-lock.json
generated
@@ -20,7 +20,6 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"web-vitals": "^2.1.4",
|
"web-vitals": "^2.1.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
@@ -12717,16 +12716,6 @@
|
|||||||
"tmpl": "1.0.5"
|
"tmpl": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/markdown-table": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "16.4.2",
|
"version": "16.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||||
@@ -12748,34 +12737,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-find-and-replace": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"escape-string-regexp": "^5.0.0",
|
|
||||||
"unist-util-is": "^6.0.0",
|
|
||||||
"unist-util-visit-parents": "^6.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-from-markdown": {
|
"node_modules/mdast-util-from-markdown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||||
@@ -12800,107 +12761,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-gfm": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mdast-util-from-markdown": "^2.0.0",
|
|
||||||
"mdast-util-gfm-autolink-literal": "^2.0.0",
|
|
||||||
"mdast-util-gfm-footnote": "^2.0.0",
|
|
||||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
|
||||||
"mdast-util-gfm-table": "^2.0.0",
|
|
||||||
"mdast-util-gfm-task-list-item": "^2.0.0",
|
|
||||||
"mdast-util-to-markdown": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-gfm-autolink-literal": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"ccount": "^2.0.0",
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"mdast-util-find-and-replace": "^3.0.0",
|
|
||||||
"micromark-util-character": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-gfm-footnote": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"devlop": "^1.1.0",
|
|
||||||
"mdast-util-from-markdown": "^2.0.0",
|
|
||||||
"mdast-util-to-markdown": "^2.0.0",
|
|
||||||
"micromark-util-normalize-identifier": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-gfm-strikethrough": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"mdast-util-from-markdown": "^2.0.0",
|
|
||||||
"mdast-util-to-markdown": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-gfm-table": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"markdown-table": "^3.0.0",
|
|
||||||
"mdast-util-from-markdown": "^2.0.0",
|
|
||||||
"mdast-util-to-markdown": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-gfm-task-list-item": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"mdast-util-from-markdown": "^2.0.0",
|
|
||||||
"mdast-util-to-markdown": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mdast-util-mdx-expression": {
|
"node_modules/mdast-util-mdx-expression": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||||
@@ -13193,127 +13053,6 @@
|
|||||||
"micromark-util-types": "^2.0.0"
|
"micromark-util-types": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromark-extension-gfm": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"micromark-extension-gfm-autolink-literal": "^2.0.0",
|
|
||||||
"micromark-extension-gfm-footnote": "^2.0.0",
|
|
||||||
"micromark-extension-gfm-strikethrough": "^2.0.0",
|
|
||||||
"micromark-extension-gfm-table": "^2.0.0",
|
|
||||||
"micromark-extension-gfm-tagfilter": "^2.0.0",
|
|
||||||
"micromark-extension-gfm-task-list-item": "^2.0.0",
|
|
||||||
"micromark-util-combine-extensions": "^2.0.0",
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-extension-gfm-autolink-literal": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"micromark-util-character": "^2.0.0",
|
|
||||||
"micromark-util-sanitize-uri": "^2.0.0",
|
|
||||||
"micromark-util-symbol": "^2.0.0",
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-extension-gfm-footnote": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"micromark-core-commonmark": "^2.0.0",
|
|
||||||
"micromark-factory-space": "^2.0.0",
|
|
||||||
"micromark-util-character": "^2.0.0",
|
|
||||||
"micromark-util-normalize-identifier": "^2.0.0",
|
|
||||||
"micromark-util-sanitize-uri": "^2.0.0",
|
|
||||||
"micromark-util-symbol": "^2.0.0",
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-extension-gfm-strikethrough": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"micromark-util-chunked": "^2.0.0",
|
|
||||||
"micromark-util-classify-character": "^2.0.0",
|
|
||||||
"micromark-util-resolve-all": "^2.0.0",
|
|
||||||
"micromark-util-symbol": "^2.0.0",
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-extension-gfm-table": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"micromark-factory-space": "^2.0.0",
|
|
||||||
"micromark-util-character": "^2.0.0",
|
|
||||||
"micromark-util-symbol": "^2.0.0",
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-extension-gfm-tagfilter": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-extension-gfm-task-list-item": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"micromark-factory-space": "^2.0.0",
|
|
||||||
"micromark-util-character": "^2.0.0",
|
|
||||||
"micromark-util-symbol": "^2.0.0",
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-factory-destination": {
|
"node_modules/micromark-factory-destination": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||||
@@ -16818,24 +16557,6 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/remark-gfm": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"mdast-util-gfm": "^3.0.0",
|
|
||||||
"micromark-extension-gfm": "^3.0.0",
|
|
||||||
"remark-parse": "^11.0.0",
|
|
||||||
"remark-stringify": "^11.0.0",
|
|
||||||
"unified": "^11.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/remark-parse": {
|
"node_modules/remark-parse": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||||
@@ -16869,21 +16590,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/remark-stringify": {
|
|
||||||
"version": "11.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
|
|
||||||
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"mdast-util-to-markdown": "^2.0.0",
|
|
||||||
"unified": "^11.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/renderkid": {
|
"node_modules/renderkid": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"web-vitals": "^2.1.4",
|
"web-vitals": "^2.1.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
@@ -29,19 +28,7 @@
|
|||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
"react-app/jest"
|
"react-app/jest"
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-unused-vars": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"vars": "all",
|
|
||||||
"args": "after-used",
|
|
||||||
"ignoreRestSiblings": true,
|
|
||||||
"varsIgnorePattern": "^_",
|
|
||||||
"argsIgnorePattern": "^_"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="AEGIS — Advanced Engineering Group Intelligence System"
|
content="Steam CVE Dashboard - Vulnerability tracking and documentation"
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
<!--
|
<!--
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
work correctly both with client-side routing and a non-root public URL.
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
-->
|
-->
|
||||||
<title>AEGIS</title>
|
<title>CVE Dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"short_name": "AEGIS",
|
"short_name": "SCD",
|
||||||
"name": "AEGIS — Advanced Engineering Group Intelligence System",
|
"name": "Steam CVE Dashboard",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 348 KiB |
@@ -17,18 +17,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast notification slide-in */
|
|
||||||
@keyframes toast-slide-in {
|
|
||||||
from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Base Colors - Modern Slate Foundation */
|
/* Base Colors - Modern Slate Foundation */
|
||||||
--intel-darkest: #0F172A;
|
--intel-darkest: #0F172A;
|
||||||
@@ -845,230 +833,3 @@ h3.text-intel-accent {
|
|||||||
color: #CBD5E1;
|
color: #CBD5E1;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
HOME PAGE COMPONENT CLASSES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Panel card — used for right-sidebar panels (Calendar, Tickets, Ivanti) */
|
|
||||||
.panel-card {
|
|
||||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%);
|
|
||||||
border: 2px solid rgba(14, 165, 233, 0.4);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-card--accent { border-left: 3px solid #0EA5E9; }
|
|
||||||
.panel-card--warning { border-left: 3px solid #F59E0B; }
|
|
||||||
.panel-card--teal { border-left: 3px solid #0D9488; }
|
|
||||||
|
|
||||||
/* Section heading — monospace uppercase with glow */
|
|
||||||
.section-heading {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading--accent {
|
|
||||||
color: #0EA5E9;
|
|
||||||
text-shadow: 0 0 12px rgba(14, 165, 233, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading--warning {
|
|
||||||
color: #F59E0B;
|
|
||||||
text-shadow: 0 0 12px rgba(245, 158, 11, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading--teal {
|
|
||||||
color: #0D9488;
|
|
||||||
text-shadow: 0 0 12px rgba(13, 148, 136, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stat card — clickable variant with border color modifiers */
|
|
||||||
.stat-card--clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card--clickable:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card--active {
|
|
||||||
transform: scale(1.03);
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card--warning {
|
|
||||||
border-color: #F59E0B;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(245, 158, 11, 0.15), inset 0 1px 0 rgba(245, 158, 11, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card--warning::before {
|
|
||||||
background: linear-gradient(90deg, transparent, #F59E0B, transparent);
|
|
||||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card--danger {
|
|
||||||
border-color: #EF4444;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(239, 68, 68, 0.15), inset 0 1px 0 rgba(239, 68, 68, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card--danger::before {
|
|
||||||
background: linear-gradient(90deg, transparent, #EF4444, transparent);
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stat card label and value */
|
|
||||||
.stat-card__label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: #CBD5E1;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card__value {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card__value--accent { color: #0EA5E9; text-shadow: 0 0 16px rgba(14, 165, 233, 0.4); }
|
|
||||||
.stat-card__value--neutral { color: #E2E8F0; }
|
|
||||||
.stat-card__value--warning { color: #F59E0B; text-shadow: 0 0 16px rgba(245, 158, 11, 0.4); }
|
|
||||||
.stat-card__value--danger { color: #EF4444; text-shadow: 0 0 16px rgba(239, 68, 68, 0.4); }
|
|
||||||
.stat-card__value--teal { color: #0D9488; text-shadow: 0 0 16px rgba(13, 148, 136, 0.4); }
|
|
||||||
|
|
||||||
/* Glow dot — pulsing indicator */
|
|
||||||
.glow-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-dot--critical { background: #EF4444; box-shadow: 0 0 12px #EF4444, 0 0 6px #EF4444; }
|
|
||||||
.glow-dot--high { background: #F59E0B; box-shadow: 0 0 12px #F59E0B, 0 0 6px #F59E0B; }
|
|
||||||
.glow-dot--medium { background: #0EA5E9; box-shadow: 0 0 12px #0EA5E9, 0 0 6px #0EA5E9; }
|
|
||||||
.glow-dot--low { background: #10B981; box-shadow: 0 0 12px #10B981, 0 0 6px #10B981; }
|
|
||||||
|
|
||||||
/* Severity badge — combined style (replaces inline badge objects) */
|
|
||||||
.severity-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.375rem 0.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border: 2px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge--critical {
|
|
||||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25) 0%, rgba(239, 68, 68, 0.2) 100%);
|
|
||||||
border-color: #EF4444;
|
|
||||||
color: #FCA5A5;
|
|
||||||
text-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
|
||||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge--high {
|
|
||||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.25) 0%, rgba(245, 158, 11, 0.2) 100%);
|
|
||||||
border-color: #F59E0B;
|
|
||||||
color: #FCD34D;
|
|
||||||
text-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
|
|
||||||
box-shadow: 0 0 16px rgba(245, 158, 11, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge--medium {
|
|
||||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%);
|
|
||||||
border-color: #0EA5E9;
|
|
||||||
color: #7DD3FC;
|
|
||||||
text-shadow: 0 0 8px rgba(14, 165, 233, 0.5);
|
|
||||||
box-shadow: 0 0 16px rgba(14, 165, 233, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge--low {
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.25) 0%, rgba(16, 185, 129, 0.2) 100%);
|
|
||||||
border-color: #10B981;
|
|
||||||
color: #6EE7B7;
|
|
||||||
text-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
|
|
||||||
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar ticket item — compact variant */
|
|
||||||
.sidebar-ticket {
|
|
||||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%);
|
|
||||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ivanti workflow item — teal accent */
|
|
||||||
.workflow-item {
|
|
||||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%);
|
|
||||||
border: 1px solid rgba(13, 148, 136, 0.25);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Workflow state badge */
|
|
||||||
.workflow-state-badge {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background: rgba(13, 148, 136, 0.2);
|
|
||||||
border: 1px solid #0D9488;
|
|
||||||
color: #0D9488;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ticket status badge — small variant */
|
|
||||||
.ticket-status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border: 2px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Archive finding item */
|
|
||||||
.archive-item {
|
|
||||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75));
|
|
||||||
border: 1px solid rgba(100, 116, 139, 0.25);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-item--active { border-left: 3px solid #F59E0B; }
|
|
||||||
.archive-item--resolved { border-left: 3px solid #10B981; }
|
|
||||||
|
|
||||||
/* Big counter display — centered stat number */
|
|
||||||
.big-counter {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-counter--warning { color: #F59E0B; text-shadow: 0 0 16px rgba(245, 158, 11, 0.4); }
|
|
||||||
.big-counter--teal { color: #0D9488; text-shadow: 0 0 16px rgba(13, 148, 136, 0.4); }
|
|
||||||
|
|||||||
2396
frontend/src/App.js
2396
frontend/src/App.js
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Color resolution with fallback
|
|
||||||
*
|
|
||||||
* Feature: compliance-nonmetric-filters, Property 4: Color resolution with fallback
|
|
||||||
* **Validates: Requirements 6.2, 6.3**
|
|
||||||
*
|
|
||||||
* For any non-metric category metric_id, the resolved color equals
|
|
||||||
* CATEGORY_COLORS[metricCategoriesConfig[metricId]] when both lookups succeed,
|
|
||||||
* else #94A3B8.
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
import { deriveNonMetricCategories, CATEGORY_COLORS } from '../components/pages/CompliancePage';
|
|
||||||
|
|
||||||
// The color resolution logic extracted for direct testing
|
|
||||||
function resolveColor(metricId, categoriesConfig) {
|
|
||||||
const categoryName = categoriesConfig[metricId] || null;
|
|
||||||
return (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generators
|
|
||||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
|
||||||
|
|
||||||
const knownCategories = Object.keys(CATEGORY_COLORS);
|
|
||||||
const categoryNameArb = fc.oneof(
|
|
||||||
fc.constantFrom(...knownCategories),
|
|
||||||
fc.string({ minLength: 1, maxLength: 20 }) // may not be in CATEGORY_COLORS
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoriesConfigArb = fc.dictionary(metricIdArb, categoryNameArb);
|
|
||||||
|
|
||||||
describe('Compliance Non-Metric Filter — Property 4: Color resolution with fallback', () => {
|
|
||||||
it('color equals CATEGORY_COLORS[config[metricId]] when both lookups succeed, else #94A3B8', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(metricIdArb, categoriesConfigArb, (metricId, config) => {
|
|
||||||
const result = resolveColor(metricId, config);
|
|
||||||
|
|
||||||
const categoryName = config[metricId];
|
|
||||||
if (categoryName && CATEGORY_COLORS[categoryName]) {
|
|
||||||
expect(result).toBe(CATEGORY_COLORS[categoryName]);
|
|
||||||
} else {
|
|
||||||
expect(result).toBe('#94A3B8');
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deriveNonMetricCategories produces correct colors for all returned categories', () => {
|
|
||||||
const deviceWithMetricArb = metricIdArb.map(id => ({
|
|
||||||
hostname: 'host-' + id,
|
|
||||||
failing_metrics: [{ metric_id: id, metric_desc: '', category: '' }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceWithMetricArb, { minLength: 1, maxLength: 15 }),
|
|
||||||
categoriesConfigArb,
|
|
||||||
(devices, config) => {
|
|
||||||
// Empty summary means all device metric_ids are non-metric
|
|
||||||
const result = deriveNonMetricCategories(devices, [], config);
|
|
||||||
|
|
||||||
for (const item of result) {
|
|
||||||
const expectedColor = resolveColor(item.metricId, config);
|
|
||||||
expect(item.color).toBe(expectedColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Non-metric category derivation is the set difference with accurate counts
|
|
||||||
*
|
|
||||||
* Feature: compliance-nonmetric-filters, Property 1: Non-metric category derivation
|
|
||||||
* **Validates: Requirements 1.1, 1.2, 2.2, 2.4**
|
|
||||||
*
|
|
||||||
* For any set of devices and any set of summary entries for a team,
|
|
||||||
* deriveNonMetricCategories returns exactly the metric_ids present in at least
|
|
||||||
* one device's failing_metrics array that do not appear in any summary entry's
|
|
||||||
* metric_id — deduplicated, sorted alphabetically by metricId, and each with a
|
|
||||||
* count equal to the number of devices whose failing_metrics contains that metric_id.
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
import { deriveNonMetricCategories } from '../components/pages/CompliancePage';
|
|
||||||
|
|
||||||
// Generators
|
|
||||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
|
||||||
|
|
||||||
const failingMetricArb = fc.record({
|
|
||||||
metric_id: metricIdArb,
|
|
||||||
metric_desc: fc.constant(''),
|
|
||||||
category: fc.constant(''),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deviceArb = fc.record({
|
|
||||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
|
||||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const summaryEntryArb = fc.record({
|
|
||||||
metric_id: metricIdArb,
|
|
||||||
team: fc.constant('STEAM'),
|
|
||||||
compliance_pct: fc.double({ min: 0, max: 1 }),
|
|
||||||
status: fc.constantFrom('Meets/Exceeds Target', 'Within 15% of Target', 'Below 15% of Target'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const categoriesConfigArb = fc.dictionary(metricIdArb, fc.constantFrom(
|
|
||||||
'Vulnerability Management', 'Access & MFA', 'Logging & Monitoring',
|
|
||||||
'Asset Data Quality', 'Endpoint Protection', 'Application Security',
|
|
||||||
'Disaster Recovery', 'Decommissioned Assets'
|
|
||||||
));
|
|
||||||
|
|
||||||
describe('Compliance Non-Metric Derivation — Property 1: Set difference with accurate counts', () => {
|
|
||||||
it('returned metric_ids are exactly the set difference of device metric_ids minus summary metric_ids', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
|
||||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
|
||||||
categoriesConfigArb,
|
|
||||||
(devices, summaryEntries, config) => {
|
|
||||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
|
||||||
|
|
||||||
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
|
|
||||||
const deviceMetricIds = new Set();
|
|
||||||
for (const d of devices) {
|
|
||||||
for (const m of (d.failing_metrics || [])) {
|
|
||||||
if (m.metric_id) deviceMetricIds.add(m.metric_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const expectedIds = new Set([...deviceMetricIds].filter(id => !summaryIds.has(id)));
|
|
||||||
|
|
||||||
const resultIds = new Set(result.map(r => r.metricId));
|
|
||||||
expect(resultIds).toEqual(expectedIds);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returned list is deduplicated and sorted alphabetically by metricId', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
|
||||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
|
||||||
categoriesConfigArb,
|
|
||||||
(devices, summaryEntries, config) => {
|
|
||||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
|
||||||
|
|
||||||
// Deduplicated
|
|
||||||
const ids = result.map(r => r.metricId);
|
|
||||||
expect(ids.length).toBe(new Set(ids).size);
|
|
||||||
|
|
||||||
// Sorted alphabetically
|
|
||||||
const sorted = [...ids].sort((a, b) => a.localeCompare(b));
|
|
||||||
expect(ids).toEqual(sorted);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('each count equals the number of devices whose failing_metrics contains that metric_id', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
|
||||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
|
||||||
categoriesConfigArb,
|
|
||||||
(devices, summaryEntries, config) => {
|
|
||||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
|
||||||
|
|
||||||
for (const { metricId, count } of result) {
|
|
||||||
const expectedCount = devices.filter(d =>
|
|
||||||
(d.failing_metrics || []).some(m => m.metric_id === metricId)
|
|
||||||
).length;
|
|
||||||
expect(count).toBe(expectedCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Filter predicate correctness
|
|
||||||
*
|
|
||||||
* Feature: compliance-nonmetric-filters, Property 2: Filter predicate correctness
|
|
||||||
* **Validates: Requirements 3.1, 5.2, 5.3, 5.4**
|
|
||||||
*
|
|
||||||
* For any filter state and any set of devices: when null, all devices pass;
|
|
||||||
* when metric, exactly devices with matching metric_id in ids pass;
|
|
||||||
* when nonmetric, exactly devices with matching metric_id pass.
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
|
|
||||||
// Replicate the filter predicate logic from CompliancePage
|
|
||||||
function applyFilter(devices, filterState) {
|
|
||||||
return devices.filter(d => {
|
|
||||||
if (!filterState) return true;
|
|
||||||
if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id));
|
|
||||||
if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generators
|
|
||||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
|
||||||
|
|
||||||
const failingMetricArb = fc.record({
|
|
||||||
metric_id: metricIdArb,
|
|
||||||
metric_desc: fc.constant(''),
|
|
||||||
category: fc.constant(''),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deviceArb = fc.record({
|
|
||||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
|
||||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const devicesArb = fc.array(deviceArb, { minLength: 0, maxLength: 20 });
|
|
||||||
|
|
||||||
const filterStateArb = fc.oneof(
|
|
||||||
fc.constant(null),
|
|
||||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
|
||||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('Compliance Non-Metric Filter — Property 2: Filter predicate correctness', () => {
|
|
||||||
it('when filterState is null, all devices pass', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(devicesArb, (devices) => {
|
|
||||||
const result = applyFilter(devices, null);
|
|
||||||
expect(result.length).toBe(devices.length);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when filterState is metric, exactly devices with a matching metric_id pass', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(devicesArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (devices, ids) => {
|
|
||||||
const filterState = { type: 'metric', ids };
|
|
||||||
const result = applyFilter(devices, filterState);
|
|
||||||
|
|
||||||
const expected = devices.filter(d =>
|
|
||||||
d.failing_metrics.some(m => ids.includes(m.metric_id))
|
|
||||||
);
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when filterState is nonmetric, exactly devices with that metric_id pass', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(devicesArb, metricIdArb, (devices, id) => {
|
|
||||||
const filterState = { type: 'nonmetric', id };
|
|
||||||
const result = applyFilter(devices, filterState);
|
|
||||||
|
|
||||||
const expected = devices.filter(d =>
|
|
||||||
d.failing_metrics.some(m => m.metric_id === id)
|
|
||||||
);
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Filter state mutual exclusivity
|
|
||||||
*
|
|
||||||
* Feature: compliance-nonmetric-filters, Property 3: Filter state mutual exclusivity
|
|
||||||
* **Validates: Requirements 3.4, 3.5, 5.1**
|
|
||||||
*
|
|
||||||
* For any prior filter state and any filter action (metric card click, chip click,
|
|
||||||
* or clear), the resulting state is exactly one of: null, { type: 'metric', ids: [...] },
|
|
||||||
* or { type: 'nonmetric', id: string } — never undefined, never both.
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
|
|
||||||
// Replicate the filter state transition logic from CompliancePage
|
|
||||||
function applyFilterAction(currentState, action) {
|
|
||||||
if (action.type === 'clear') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (action.type === 'metric_click') {
|
|
||||||
const familyIds = action.ids;
|
|
||||||
// If currently active metric with same ids, toggle off
|
|
||||||
if (currentState?.type === 'metric' &&
|
|
||||||
currentState.ids.length === familyIds.length &&
|
|
||||||
familyIds.every(id => currentState.ids.includes(id))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { type: 'metric', ids: familyIds };
|
|
||||||
}
|
|
||||||
if (action.type === 'chip_click') {
|
|
||||||
const metricId = action.id;
|
|
||||||
// If currently active nonmetric with same id, toggle off
|
|
||||||
if (currentState?.type === 'nonmetric' && currentState.id === metricId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { type: 'nonmetric', id: metricId };
|
|
||||||
}
|
|
||||||
return currentState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidFilterState(state) {
|
|
||||||
if (state === null) return true;
|
|
||||||
if (state && state.type === 'metric' && Array.isArray(state.ids) && state.ids.length > 0) return true;
|
|
||||||
if (state && state.type === 'nonmetric' && typeof state.id === 'string' && state.id.length > 0) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generators
|
|
||||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
|
||||||
|
|
||||||
const filterStateArb = fc.oneof(
|
|
||||||
fc.constant(null),
|
|
||||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
|
||||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
|
||||||
);
|
|
||||||
|
|
||||||
const filterActionArb = fc.oneof(
|
|
||||||
fc.constant({ type: 'clear' }),
|
|
||||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric_click', ids })),
|
|
||||||
metricIdArb.map(id => ({ type: 'chip_click', id }))
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('Compliance Non-Metric Filter — Property 3: Filter state mutual exclusivity', () => {
|
|
||||||
it('resulting state is always exactly one of null, metric, or nonmetric — never undefined or combined', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(filterStateArb, filterActionArb, (priorState, action) => {
|
|
||||||
const result = applyFilterAction(priorState, action);
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
expect(isValidFilterState(result)).toBe(true);
|
|
||||||
|
|
||||||
// Never has both metric and nonmetric properties
|
|
||||||
if (result !== null) {
|
|
||||||
if (result.type === 'metric') {
|
|
||||||
expect(result).not.toHaveProperty('id');
|
|
||||||
}
|
|
||||||
if (result.type === 'nonmetric') {
|
|
||||||
expect(result).not.toHaveProperty('ids');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clear action always results in null regardless of prior state', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(filterStateArb, (priorState) => {
|
|
||||||
const result = applyFilterAction(priorState, { type: 'clear' });
|
|
||||||
expect(result).toBeNull();
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('metric click replaces any prior state with metric filter or null (toggle off)', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(filterStateArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (priorState, ids) => {
|
|
||||||
const result = applyFilterAction(priorState, { type: 'metric_click', ids });
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
// Toggled off — prior must have been metric with same ids
|
|
||||||
expect(priorState?.type).toBe('metric');
|
|
||||||
} else {
|
|
||||||
expect(result.type).toBe('metric');
|
|
||||||
expect(result.ids).toEqual(ids);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chip click replaces any prior state with nonmetric filter or null (toggle off)', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(filterStateArb, metricIdArb, (priorState, id) => {
|
|
||||||
const result = applyFilterAction(priorState, { type: 'chip_click', id });
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
// Toggled off — prior must have been nonmetric with same id
|
|
||||||
expect(priorState?.type).toBe('nonmetric');
|
|
||||||
expect(priorState?.id).toBe(id);
|
|
||||||
} else {
|
|
||||||
expect(result.type).toBe('nonmetric');
|
|
||||||
expect(result.id).toBe(id);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Note Count Badge Formatting
|
|
||||||
*
|
|
||||||
* Feature: ivanti-queue-remediation
|
|
||||||
* Property 12: Note count badge formatting
|
|
||||||
*
|
|
||||||
* For any integer count N where N > 0, the badge display SHALL show the string
|
|
||||||
* representation of N when N <= 99, and "99+" when N > 99. For N = 0, no badge
|
|
||||||
* SHALL be displayed.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 6.1, 6.2**
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pure function under test — extracted badge display logic
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines the badge display value for a given note count.
|
|
||||||
* Returns null when no badge should be shown (count = 0).
|
|
||||||
*
|
|
||||||
* @param {number} count - The number of remediation notes
|
|
||||||
* @returns {string|null} The badge text, or null if no badge
|
|
||||||
*/
|
|
||||||
function formatBadgeCount(count) {
|
|
||||||
if (count <= 0) return null;
|
|
||||||
if (count > 99) return '99+';
|
|
||||||
return String(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 12: Note count badge formatting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 12: Note count badge formatting', () => {
|
|
||||||
it('displays the exact count for N where 1 <= N <= 99', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.integer({ min: 1, max: 99 }),
|
|
||||||
(count) => {
|
|
||||||
const badge = formatBadgeCount(count);
|
|
||||||
expect(badge).toBe(String(count));
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays "99+" for any count exceeding 99', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.integer({ min: 100, max: 100000 }),
|
|
||||||
(count) => {
|
|
||||||
const badge = formatBadgeCount(count);
|
|
||||||
expect(badge).toBe('99+');
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null (no badge) for count = 0', () => {
|
|
||||||
const badge = formatBadgeCount(0);
|
|
||||||
expect(badge).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null (no badge) for negative counts', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.integer({ min: -1000, max: 0 }),
|
|
||||||
(count) => {
|
|
||||||
const badge = formatBadgeCount(count);
|
|
||||||
expect(badge).toBeNull();
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Tests: Ivanti Queue Remediation — Description Generation
|
|
||||||
*
|
|
||||||
* Feature: ivanti-queue-remediation
|
|
||||||
*
|
|
||||||
* Property 10: Description generation appends remediation notes iff notes exist
|
|
||||||
* Property 11: Non-Remediate description unchanged
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
import { generateConsolidatedDescription, appendRemediationNotes } from '../utils/jiraConsolidation';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Arbitraries
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const arbUsername = fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0);
|
|
||||||
|
|
||||||
const arbNoteText = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
|
|
||||||
|
|
||||||
const arbDate = fc.integer({ min: 1577836800000, max: 1924905600000 })
|
|
||||||
.map(ts => new Date(ts).toISOString());
|
|
||||||
|
|
||||||
const arbNote = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 100000 }),
|
|
||||||
queue_item_id: fc.integer({ min: 1, max: 10000 }),
|
|
||||||
user_id: fc.integer({ min: 1, max: 1000 }),
|
|
||||||
username: arbUsername,
|
|
||||||
note_text: arbNoteText,
|
|
||||||
created_at: arbDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const arbQueueItem = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 10000 }),
|
|
||||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
|
||||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
|
||||||
vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'),
|
|
||||||
workflow_type: fc.constant('Remediate'),
|
|
||||||
hostname: fc.constantFrom('server01', 'host-a', 'web-prod'),
|
|
||||||
ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'),
|
|
||||||
cves_json: fc.constant(JSON.stringify(['CVE-2024-1234'])),
|
|
||||||
});
|
|
||||||
|
|
||||||
const arbNonRemediateItem = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 10000 }),
|
|
||||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
|
||||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
|
||||||
vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'),
|
|
||||||
workflow_type: fc.constantFrom('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'),
|
|
||||||
hostname: fc.constantFrom('server01', 'host-a', 'web-prod'),
|
|
||||||
ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'),
|
|
||||||
cves_json: fc.constant(JSON.stringify(['CVE-2024-5678'])),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 10: Description generation appends remediation notes iff notes exist
|
|
||||||
// **Validates: Requirements 8.1, 8.2, 8.3**
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 10: Description generation appends remediation notes iff notes exist', () => {
|
|
||||||
it('appends a "Remediation Notes" section when notesMap has at least one note', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(arbQueueItem, { minLength: 1, maxLength: 5 })
|
|
||||||
.map(items => {
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
fc.array(arbNote, { minLength: 1, maxLength: 5 }),
|
|
||||||
(items, notes) => {
|
|
||||||
const baseDescription = generateConsolidatedDescription(items);
|
|
||||||
// Map notes to the first item
|
|
||||||
const notesMap = { [items[0].id]: notes };
|
|
||||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
|
||||||
|
|
||||||
// Should contain the remediation notes section
|
|
||||||
expect(result).toContain('== Remediation Notes ==');
|
|
||||||
// Should still contain the base description
|
|
||||||
expect(result).toContain(baseDescription.trim());
|
|
||||||
// Each note's text should appear
|
|
||||||
for (const note of notes) {
|
|
||||||
expect(result).toContain(note.note_text);
|
|
||||||
expect(result).toContain(note.username);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('notes are listed in chronological order (oldest first) with [YYYY-MM-DD] prefix', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbQueueItem,
|
|
||||||
fc.array(arbNote, { minLength: 2, maxLength: 5 }),
|
|
||||||
(item, notes) => {
|
|
||||||
const baseDescription = generateConsolidatedDescription([item]);
|
|
||||||
const notesMap = { [item.id]: notes };
|
|
||||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
|
||||||
|
|
||||||
// Extract the remediation notes section
|
|
||||||
const section = result.split('== Remediation Notes ==')[1];
|
|
||||||
expect(section).toBeDefined();
|
|
||||||
|
|
||||||
// Verify each note has the [YYYY-MM-DD] format prefix
|
|
||||||
for (const note of notes) {
|
|
||||||
const expectedDate = new Date(note.created_at).toISOString().slice(0, 10);
|
|
||||||
expect(section).toContain(`[${expectedDate}] ${note.username}:`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify chronological order (oldest first)
|
|
||||||
const sortedNotes = [...notes].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
||||||
let lastIndex = -1;
|
|
||||||
for (const note of sortedNotes) {
|
|
||||||
const idx = section.indexOf(note.note_text, lastIndex + 1);
|
|
||||||
expect(idx).toBeGreaterThan(lastIndex);
|
|
||||||
lastIndex = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT append a section when notesMap is empty', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(arbQueueItem, { minLength: 1, maxLength: 3 })
|
|
||||||
.map(items => {
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
(items) => {
|
|
||||||
const baseDescription = generateConsolidatedDescription(items);
|
|
||||||
const result = appendRemediationNotes(baseDescription, {});
|
|
||||||
|
|
||||||
expect(result).toBe(baseDescription);
|
|
||||||
expect(result).not.toContain('== Remediation Notes ==');
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT append a section when notesMap has items with empty arrays', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
arbQueueItem,
|
|
||||||
(item) => {
|
|
||||||
const baseDescription = generateConsolidatedDescription([item]);
|
|
||||||
const notesMap = { [item.id]: [] };
|
|
||||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
|
||||||
|
|
||||||
expect(result).toBe(baseDescription);
|
|
||||||
expect(result).not.toContain('== Remediation Notes ==');
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 11: Non-Remediate description unchanged
|
|
||||||
// **Validates: Requirements 8.4**
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 11: Non-Remediate description unchanged', () => {
|
|
||||||
it('output is identical to generateConsolidatedDescription when no notes exist', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 5 })
|
|
||||||
.map(items => {
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
(items) => {
|
|
||||||
const baseDescription = generateConsolidatedDescription(items);
|
|
||||||
// Empty notesMap — simulates non-Remediate items
|
|
||||||
const result = appendRemediationNotes(baseDescription, {});
|
|
||||||
|
|
||||||
expect(result).toBe(baseDescription);
|
|
||||||
expect(result).not.toContain('Remediation Notes');
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('output is identical when notesMap is null or undefined', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 3 })
|
|
||||||
.map(items => {
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
fc.oneof(fc.constant(null), fc.constant(undefined)),
|
|
||||||
(items, notesMap) => {
|
|
||||||
const baseDescription = generateConsolidatedDescription(items);
|
|
||||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
|
||||||
|
|
||||||
expect(result).toBe(baseDescription);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 50 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Remediate Queue Grouping
|
|
||||||
*
|
|
||||||
* Feature: ivanti-queue-remediation
|
|
||||||
* Property 2: Remediate items grouped into vendor sections, never Inventory
|
|
||||||
*
|
|
||||||
* For any queue item with workflow_type "Remediate", the groupQueueItems function
|
|
||||||
* SHALL place it in a vendor-grouped section (using the item's vendor field, or
|
|
||||||
* "Unknown" if vendor is empty/null) and SHALL NOT place it in the Inventory section.
|
|
||||||
*
|
|
||||||
* **Validates: Requirements 2.1, 2.2, 2.4**
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
import { groupQueueItems } from '../utils/queueGrouping';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Arbitraries
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const arbVendor = fc.oneof(
|
|
||||||
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
|
||||||
fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'ADTRAN', 'VMware')
|
|
||||||
);
|
|
||||||
|
|
||||||
const arbEmptyVendor = fc.oneof(
|
|
||||||
fc.constant(''),
|
|
||||||
fc.constant(null),
|
|
||||||
fc.constant(undefined),
|
|
||||||
fc.constant(' ') // whitespace only
|
|
||||||
);
|
|
||||||
|
|
||||||
const arbRemediateItemWithVendor = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 100000 }),
|
|
||||||
workflow_type: fc.constant('Remediate'),
|
|
||||||
vendor: arbVendor,
|
|
||||||
status: fc.constant('pending'),
|
|
||||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
|
||||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const arbRemediateItemNoVendor = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 100000 }),
|
|
||||||
workflow_type: fc.constant('Remediate'),
|
|
||||||
vendor: arbEmptyVendor,
|
|
||||||
status: fc.constant('pending'),
|
|
||||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
|
||||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const arbInventoryItem = fc.record({
|
|
||||||
id: fc.integer({ min: 100001, max: 200000 }),
|
|
||||||
workflow_type: fc.constantFrom('CARD', 'GRANITE', 'DECOM'),
|
|
||||||
vendor: fc.constant(''),
|
|
||||||
status: fc.constant('pending'),
|
|
||||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
|
||||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 2: Remediate items grouped into vendor sections, never Inventory
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('Feature: ivanti-queue-remediation, Property 2: Remediate items grouped into vendor sections, never Inventory', () => {
|
|
||||||
it('Remediate items with a vendor are placed in vendor-grouped sections, never Inventory', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 20 })
|
|
||||||
.map(items => {
|
|
||||||
// Ensure unique IDs
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(item => {
|
|
||||||
if (seen.has(item.id)) return false;
|
|
||||||
seen.add(item.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
(items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
|
|
||||||
// No Inventory section should exist (Remediate items never go there)
|
|
||||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
|
||||||
expect(inventorySection).toBeUndefined();
|
|
||||||
|
|
||||||
// All items should be in vendor sections
|
|
||||||
const vendorSections = sections.filter(s => s.type === 'vendor');
|
|
||||||
const allGroupedItems = vendorSections.flatMap(s => s.items);
|
|
||||||
expect(allGroupedItems.length).toBe(items.length);
|
|
||||||
|
|
||||||
// Each item should be in its vendor's section
|
|
||||||
for (const item of items) {
|
|
||||||
const expectedVendor = item.vendor?.trim() || 'Unknown';
|
|
||||||
const section = vendorSections.find(s => s.label === expectedVendor);
|
|
||||||
expect(section).toBeDefined();
|
|
||||||
expect(section.items.some(i => i.id === item.id)).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Remediate items with empty/null/whitespace-only vendor land in "Unknown" section', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(arbRemediateItemNoVendor, { minLength: 1, maxLength: 10 })
|
|
||||||
.map(items => {
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(item => {
|
|
||||||
if (seen.has(item.id)) return false;
|
|
||||||
seen.add(item.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
(items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
|
|
||||||
// No Inventory section
|
|
||||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
|
||||||
expect(inventorySection).toBeUndefined();
|
|
||||||
|
|
||||||
// All items should be in the "Unknown" vendor section
|
|
||||||
const unknownSection = sections.find(s => s.label === 'Unknown');
|
|
||||||
expect(unknownSection).toBeDefined();
|
|
||||||
expect(unknownSection.items.length).toBe(items.length);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Remediate items are never placed in the Inventory section even when mixed with inventory items', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 10 })
|
|
||||||
.map(items => {
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(item => {
|
|
||||||
if (seen.has(item.id)) return false;
|
|
||||||
seen.add(item.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
fc.array(arbInventoryItem, { minLength: 1, maxLength: 5 })
|
|
||||||
.map(items => {
|
|
||||||
const seen = new Set();
|
|
||||||
return items.filter(item => {
|
|
||||||
if (seen.has(item.id)) return false;
|
|
||||||
seen.add(item.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.filter(items => items.length > 0),
|
|
||||||
(remediateItems, inventoryItems) => {
|
|
||||||
const allItems = [...remediateItems, ...inventoryItems];
|
|
||||||
const sections = groupQueueItems(allItems);
|
|
||||||
|
|
||||||
// Inventory section exists (from inventory items)
|
|
||||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
|
||||||
expect(inventorySection).toBeDefined();
|
|
||||||
|
|
||||||
// No Remediate items in the inventory section
|
|
||||||
const remediateInInventory = inventorySection.items.filter(
|
|
||||||
i => i.workflow_type === 'Remediate'
|
|
||||||
);
|
|
||||||
expect(remediateInInventory.length).toBe(0);
|
|
||||||
|
|
||||||
// All Remediate items are in vendor sections
|
|
||||||
const vendorSections = sections.filter(s => s.type === 'vendor');
|
|
||||||
const allVendorItems = vendorSections.flatMap(s => s.items);
|
|
||||||
for (const item of remediateItems) {
|
|
||||||
expect(allVendorItems.some(i => i.id === item.id)).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 100 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Section Header Count Accuracy
|
|
||||||
*
|
|
||||||
* Feature: queue-collapsible-sections, Property 4: Section Header Count Accuracy
|
|
||||||
* **Validates: Requirements 3.1, 3.2**
|
|
||||||
*
|
|
||||||
* For every section in the grouped output, the items array length equals the count
|
|
||||||
* that would be displayed in the header. The sum of all section item counts equals
|
|
||||||
* the total input array length.
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
|
|
||||||
// Replicate the grouping logic inline (utility file not yet extracted)
|
|
||||||
function groupQueueItems(visibleItems) {
|
|
||||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
|
||||||
const inventoryItems = [];
|
|
||||||
const vendorMap = new Map();
|
|
||||||
for (const item of visibleItems) {
|
|
||||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
|
||||||
inventoryItems.push(item);
|
|
||||||
} else {
|
|
||||||
const vendor = item.vendor?.trim() || 'Unknown';
|
|
||||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
|
||||||
vendorMap.get(vendor).push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sections = [];
|
|
||||||
if (inventoryItems.length > 0) {
|
|
||||||
sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems });
|
|
||||||
}
|
|
||||||
const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
||||||
for (const [vendor, items] of sortedVendors) {
|
|
||||||
sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items });
|
|
||||||
}
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generator for queue items with realistic workflow types and vendor names
|
|
||||||
const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer');
|
|
||||||
const vendorArb = fc.oneof(
|
|
||||||
fc.constantFrom('Microsoft', 'Adobe', 'Cisco', 'Oracle', 'VMware', 'Apple'),
|
|
||||||
fc.constant(''),
|
|
||||||
fc.constant(null),
|
|
||||||
fc.constant(undefined)
|
|
||||||
);
|
|
||||||
|
|
||||||
const queueItemArb = fc.record({
|
|
||||||
id: fc.uuid(),
|
|
||||||
workflow_type: workflowTypeArb,
|
|
||||||
vendor: vendorArb,
|
|
||||||
hostname: fc.string({ minLength: 1, maxLength: 20 }),
|
|
||||||
status: fc.constant('pending'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const queueItemsArb = fc.array(queueItemArb, { minLength: 1, maxLength: 50 });
|
|
||||||
|
|
||||||
describe('Queue Grouping — Property 4: Section Header Count Accuracy', () => {
|
|
||||||
it('each section items.length is a positive integer (the displayed count)', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(queueItemsArb, (items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
|
|
||||||
for (const section of sections) {
|
|
||||||
// items.length is the count that would be displayed in the header
|
|
||||||
expect(Number.isInteger(section.items.length)).toBe(true);
|
|
||||||
expect(section.items.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 200 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sum of all section item counts equals total input array length', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(queueItemsArb, (items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
|
|
||||||
const totalItemsInSections = sections.reduce(
|
|
||||||
(sum, section) => sum + section.items.length,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(totalItemsInSections).toBe(items.length);
|
|
||||||
}),
|
|
||||||
{ numRuns: 200 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Empty Section Omission
|
|
||||||
*
|
|
||||||
* Feature: queue-collapsible-sections, Property 3: Empty Section Omission
|
|
||||||
* **Validates: Requirements 1.6, 1.7**
|
|
||||||
*
|
|
||||||
* For any array of queue items (including empty arrays), the grouped output
|
|
||||||
* contains no sections with zero items. If no CARD/GRANITE/DECOM items exist
|
|
||||||
* in the input, no section with type 'inventory' appears in the output.
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
|
|
||||||
// Inline the grouping logic since the utility file may not exist yet
|
|
||||||
function groupQueueItems(visibleItems) {
|
|
||||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
|
||||||
const inventoryItems = [];
|
|
||||||
const vendorMap = new Map();
|
|
||||||
for (const item of visibleItems) {
|
|
||||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
|
||||||
inventoryItems.push(item);
|
|
||||||
} else {
|
|
||||||
const vendor = item.vendor?.trim() || 'Unknown';
|
|
||||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
|
||||||
vendorMap.get(vendor).push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sections = [];
|
|
||||||
if (inventoryItems.length > 0) {
|
|
||||||
sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems });
|
|
||||||
}
|
|
||||||
const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
||||||
for (const [vendor, items] of sortedVendors) {
|
|
||||||
sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items });
|
|
||||||
}
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generator for queue items with various workflow types and vendors
|
|
||||||
const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer');
|
|
||||||
const vendorArb = fc.oneof(
|
|
||||||
fc.constant(null),
|
|
||||||
fc.constant(undefined),
|
|
||||||
fc.constant(''),
|
|
||||||
fc.constant(' '),
|
|
||||||
fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/)
|
|
||||||
);
|
|
||||||
|
|
||||||
const queueItemArb = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 100000 }),
|
|
||||||
workflow_type: workflowTypeArb,
|
|
||||||
vendor: vendorArb,
|
|
||||||
hostname: fc.stringMatching(/^[a-z]{3,10}$/),
|
|
||||||
status: fc.constant('pending'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 });
|
|
||||||
|
|
||||||
describe('Queue Grouping — Property 3: Empty Section Omission', () => {
|
|
||||||
it('no section in the output has zero items', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(queueItemsArb, (items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
for (const section of sections) {
|
|
||||||
expect(section.items.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 200 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('if no inventory-type items exist, no inventory section appears', () => {
|
|
||||||
// Generate arrays that explicitly exclude inventory types
|
|
||||||
const nonInventoryTypeArb = fc.constantFrom('FP', 'Archer');
|
|
||||||
const nonInventoryItemArb = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 100000 }),
|
|
||||||
workflow_type: nonInventoryTypeArb,
|
|
||||||
vendor: vendorArb,
|
|
||||||
hostname: fc.stringMatching(/^[a-z]{3,10}$/),
|
|
||||||
status: fc.constant('pending'),
|
|
||||||
});
|
|
||||||
const nonInventoryItemsArb = fc.array(nonInventoryItemArb, { minLength: 0, maxLength: 50 });
|
|
||||||
|
|
||||||
fc.assert(
|
|
||||||
fc.property(nonInventoryItemsArb, (items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
|
||||||
expect(inventorySection).toBeUndefined();
|
|
||||||
}),
|
|
||||||
{ numRuns: 200 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('if no CARD/GRANITE/DECOM items exist in a mixed array, no inventory section appears', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(queueItemsArb, (items) => {
|
|
||||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
|
||||||
const hasInventoryItems = items.some(item => INVENTORY_TYPES.has(item.workflow_type));
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
|
||||||
|
|
||||||
if (!hasInventoryItems) {
|
|
||||||
expect(inventorySection).toBeUndefined();
|
|
||||||
} else {
|
|
||||||
expect(inventorySection).toBeDefined();
|
|
||||||
expect(inventorySection.items.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 200 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property-Based Test: Section Ordering
|
|
||||||
*
|
|
||||||
* Feature: queue-collapsible-sections, Property 2: Section Ordering
|
|
||||||
* **Validates: Requirements 1.3, 1.4**
|
|
||||||
*
|
|
||||||
* For any array of queue items, the Inventory section (if present) is always
|
|
||||||
* the first element, and all vendor sections are sorted alphabetically by label.
|
|
||||||
*/
|
|
||||||
import fc from 'fast-check';
|
|
||||||
|
|
||||||
// Inline grouping logic (mirrors the implementation in IvantiTodoQueuePage)
|
|
||||||
function groupQueueItems(visibleItems) {
|
|
||||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
|
||||||
const inventoryItems = [];
|
|
||||||
const vendorMap = new Map();
|
|
||||||
for (const item of visibleItems) {
|
|
||||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
|
||||||
inventoryItems.push(item);
|
|
||||||
} else {
|
|
||||||
const vendor = item.vendor?.trim() || 'Unknown';
|
|
||||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
|
||||||
vendorMap.get(vendor).push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sections = [];
|
|
||||||
if (inventoryItems.length > 0) {
|
|
||||||
sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems });
|
|
||||||
}
|
|
||||||
const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
||||||
for (const [vendor, items] of sortedVendors) {
|
|
||||||
sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items });
|
|
||||||
}
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generator for queue items with random workflow types and vendors
|
|
||||||
const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer');
|
|
||||||
|
|
||||||
const vendorArb = fc.oneof(
|
|
||||||
fc.constant(null),
|
|
||||||
fc.constant(undefined),
|
|
||||||
fc.constant(''),
|
|
||||||
fc.constant(' '),
|
|
||||||
fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/)
|
|
||||||
);
|
|
||||||
|
|
||||||
const queueItemArb = fc.record({
|
|
||||||
id: fc.integer({ min: 1, max: 100000 }),
|
|
||||||
workflow_type: workflowTypeArb,
|
|
||||||
vendor: vendorArb,
|
|
||||||
hostname: fc.string({ minLength: 1, maxLength: 20 }),
|
|
||||||
status: fc.constant('pending'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 });
|
|
||||||
|
|
||||||
describe('Queue Grouping — Property 2: Section Ordering', () => {
|
|
||||||
it('Inventory section (if present) is always the first element', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(queueItemsArb, (items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
|
|
||||||
const inventoryIndex = sections.findIndex(s => s.type === 'inventory');
|
|
||||||
|
|
||||||
// If inventory section exists, it must be at index 0
|
|
||||||
if (inventoryIndex !== -1) {
|
|
||||||
expect(inventoryIndex).toBe(0);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 500 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Vendor sections are sorted alphabetically by label', () => {
|
|
||||||
fc.assert(
|
|
||||||
fc.property(queueItemsArb, (items) => {
|
|
||||||
const sections = groupQueueItems(items);
|
|
||||||
|
|
||||||
const vendorSections = sections.filter(s => s.type === 'vendor');
|
|
||||||
|
|
||||||
// Each consecutive pair of vendor sections must be in alphabetical order
|
|
||||||
for (let i = 1; i < vendorSections.length; i++) {
|
|
||||||
const prev = vendorSections[i - 1].label;
|
|
||||||
const curr = vendorSections[i].label;
|
|
||||||
expect(prev.localeCompare(curr)).toBeLessThanOrEqual(0);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ numRuns: 500 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user