diff --git a/.github/workflows/deploy-apis-to-staging.yml b/.github/workflows/deploy-apis-to-staging.yml index 8cec968c72..3b4dd78829 100644 --- a/.github/workflows/deploy-apis-to-staging.yml +++ b/.github/workflows/deploy-apis-to-staging.yml @@ -56,6 +56,7 @@ jobs: run_incentives: ${{ steps.check_files.outputs.run_incentives }} # incentives run_insights: ${{ steps.check_files.outputs.run_insights }} # incentives run_spatial: ${{ steps.check_files.outputs.run_spatial }} # spatial + run_website: ${{ steps.check_files.outputs.run_website }} # website run_kafka_connectors: ${{ steps.check_files.outputs.run_kafka_connectors }} # kafka connectors run_nginx: ${{ steps.check_files.outputs.run_nginx }} # nginx ingress @@ -98,6 +99,7 @@ jobs: echo "run_incentives=false" >>$GITHUB_OUTPUT echo "run_insights=false" >>$GITHUB_OUTPUT echo "run_spatial=false" >>$GITHUB_OUTPUT + echo "run_website=false" >>$GITHUB_OUTPUT echo "run_kafka_connectors=false" >>$GITHUB_OUTPUT echo "run_nginx=false" >>$GITHUB_OUTPUT @@ -271,6 +273,14 @@ jobs: if [[ $file == k8s/spatial/* ]]; then echo "run_spatial=true" >>$GITHUB_OUTPUT + fi + + if [[ $file == src/website/* ]]; then + echo "run_website=true" >>$GITHUB_OUTPUT + fi + + if [[ $file == k8s/website/* ]]; then + echo "run_website=true" >>$GITHUB_OUTPUT fi if [[ $file == k8s/nginx/staging/* ]]; then @@ -296,6 +306,7 @@ jobs: echo "run_incentives=true" >>$GITHUB_OUTPUT echo "run_insights=true" >>$GITHUB_OUTPUT echo "run_spatial=true" >>$GITHUB_OUTPUT + echo "run_website=true" >>$GITHUB_OUTPUT echo "run_view=true" >>$GITHUB_OUTPUT echo "run_kafka_connectors=true" >>$GITHUB_OUTPUT echo "run_nginx=true" >>$GITHUB_OUTPUT @@ -1723,6 +1734,66 @@ jobs: gcloud secrets versions access latest --secret="sta-key-analytics-service-account" > google_application_credentials.json kubectl create configmap --dry-run=client -o yaml --from-file=google_application_credentials.json stage-spatial-api-config-files | kubectl replace -f - -n staging + + ### website ### + website: + name: build-push-deploy-website + needs: [check, image-tag] + if: needs.check.outputs.run_website == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3.5.3 + + - name: Login to GCR + uses: docker/login-action@v2.2.0 + with: + registry: ${{ env.REGISTRY_URL }} + username: _json_key + password: ${{ secrets.GCR_CONFIG }} + + - name: Login to K8S + uses: azure/k8s-set-context@v3.0 + with: + method: kubeconfig + kubeconfig: ${{ secrets.K8S_CONFIG_STAGE }} + + - name: Build and Push Docker Image + run: | + cd src/website/ + docker build --tag ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:${{ needs.image-tag.outputs.build_id }} . + docker tag ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:${{ needs.image-tag.outputs.build_id }} ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:latest + docker push ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:${{ needs.image-tag.outputs.build_id }} + docker push ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:latest + + - name: Update corresponding helm values file(with retry) + uses: Wandalen/wretry.action@v1.0.36 # Retries action on fail + with: + action: fjogeleit/yaml-update-action@main # Action to retry + with: | + valueFile: "k8s/website/values-stage.yaml" + propertyPath: "image.tag" + value: ${{ needs.image-tag.outputs.build_id }} + branch: ${{ env.DEPLOY_BRANCH }} + token: ${{ secrets.YAML_UPDATER_TOKEN }} + message: "Update website staging image tag to ${{ needs.image-tag.outputs.build_id }}" + + - name: Login to GCP + uses: google-github-actions/auth@v1.1.1 + with: + credentials_json: ${{ secrets.GCP_SA_CREDENTIALS }} + + - name: Setup Cloud SDK + uses: google-github-actions/setup-gcloud@v1.1.1 + + - name: Update the corresponding k8s configmap(s) + run: | + cd src/website/ + gcloud secrets versions access latest --secret="sta-env-website-backend" > .env + kubectl create configmap --dry-run=client -o yaml --from-env-file=.env stage-website-api-config | kubectl replace -f - -n staging + gcloud secrets versions access latest --secret="sta-key-analytics-service-account" > google_application_credentials.json + kubectl create configmap --dry-run=client -o yaml --from-file=google_application_credentials.json stage-website-api-config-files | kubectl replace -f - -n staging + ### apply nginx ### nginx: name: apply-nginx diff --git a/.github/workflows/deploy-previews.yml b/.github/workflows/deploy-previews.yml index 7ed9f5bc9d..ae12f1a6d9 100644 --- a/.github/workflows/deploy-previews.yml +++ b/.github/workflows/deploy-previews.yml @@ -37,6 +37,7 @@ jobs: run_calibrate: ${{ steps.check_files.outputs.run_calibrate }} # calibrate run_incentives: ${{ steps.check_files.outputs.run_incentives }} # incentives run_spatial: ${{ steps.check_files.outputs.run_spatial }} # spatial + run_website: ${{ steps.check_files.outputs.run_website }} # website runs-on: ubuntu-latest steps: - name: checkout code @@ -67,6 +68,7 @@ jobs: echo "run_calibrate=false" >>$GITHUB_OUTPUT echo "run_incentives=false" >>$GITHUB_OUTPUT echo "run_spatial=false" >>$GITHUB_OUTPUT + echo "run_website=false" >>$GITHUB_OUTPUT while IFS= read -r file do @@ -123,6 +125,10 @@ jobs: echo "run_spatial=true" >>$GITHUB_OUTPUT fi + if [[ $file == src/website/* ]]; then + echo "run_website=true" >>$GITHUB_OUTPUT + fi + done < files.txt @@ -1139,3 +1145,81 @@ jobs: repo: context.repo.repo, body: 'Spatial changes in this PR available for preview [here](${{ needs.spatial.outputs.url }})' }) + + ### website ### + website: + name: build-push-deploy-website + needs: [check, branch-name] + if: needs.check.outputs.run_website == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + outputs: + url: ${{ steps.preview-url.outputs.url }} + steps: + - name: Checkout + uses: actions/checkout@v3.5.3 + + - name: Google Auth + id: auth + uses: google-github-actions/auth@v1.1.1 + with: + credentials_json: ${{ secrets.GCP_SA_CREDENTIALS }} + + - name: Setup Cloud SDK + uses: google-github-actions/setup-gcloud@v1.1.1 + + - name: Docker Auth + id: docker-auth + uses: docker/login-action@v2.2.0 + with: + registry: ${{ env.REGISTRY_URL }} + username: _json_key + password: ${{ secrets.GCP_SA_CREDENTIALS }} + + - name: Build and Push Container + run: | + cd src/website/ + docker build --tag ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/pr-previews/website-pr-previews:${{ github.sha }} ./ + docker push ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/pr-previews/website-pr-previews:${{ github.sha }} + + - name: Deploy to Cloud Run + run: |- + gcloud run deploy ${{ needs.branch-name.outputs.lowercase }}-website-preview \ + --region=${{ secrets.REGION }} \ + --max-instances=10 \ + --timeout=60 \ + --concurrency=10 \ + --image=${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/pr-previews/website-pr-previews:${{ github.sha }} \ + --port=8000 \ + --cpu=1000m \ + --memory=1024Mi \ + --update-secrets=/etc/env/.env=sta-env-website-backend:latest,/etc/config/google_application_credentials.json=sta-key-analytics-service-account:latest \ + --command="/bin/sh","-c","cat /etc/env/.env >> /app/.env; /app/entrypoint.sh" \ + --allow-unauthenticated + + - name: Get preview service url + id: preview-url + run: | + read service_url < <(gcloud run services describe ${{ needs.branch-name.outputs.lowercase }}-website-preview \ + --format='value(status.url)' \ + --platform managed \ + --region ${{ secrets.REGION }}) + echo "url=${service_url}" >>$GITHUB_OUTPUT + + website-pr-comment: + name: website-preview-link-comment + if: needs.check.outputs.run_website == 'true' + needs: [website] + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v6 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'website changes in this PR available for preview [here](${{ needs.website.outputs.url }})' + }) \ No newline at end of file diff --git a/.github/workflows/remove-deploy-previews.yml b/.github/workflows/remove-deploy-previews.yml index 77a5f07a22..361e10b2f1 100644 --- a/.github/workflows/remove-deploy-previews.yml +++ b/.github/workflows/remove-deploy-previews.yml @@ -35,6 +35,7 @@ jobs: remove_preview__calibrate: ${{ steps.check_files.outputs.remove_preview__calibrate }} # calibrate remove_preview__incentives: ${{ steps.check_files.outputs.remove_preview__incentives }} #incentives remove_preview__spatial: ${{ steps.check_files.outputs.remove_preview__spatial }} #spatial + remove_preview__website: ${{ steps.check_files.outputs.remove_preview__website }} #website runs-on: ubuntu-latest steps: @@ -66,6 +67,7 @@ jobs: echo "remove_preview__calibrate=false" >>$GITHUB_OUTPUT echo "remove_preview__incentives=false" >>$GITHUB_OUTPUT echo "remove_preview__spatial=false" >>$GITHUB_OUTPUT + echo "remove_preview__website=false" >>$GITHUB_OUTPUT while IFS= read -r file do @@ -120,7 +122,11 @@ jobs: if [[ $file == src/spatial/* ]]; then echo "remove_preview__spatial=true" >>$GITHUB_OUTPUT - fi + fi + + if [[ $file == src/website/* ]]; then + echo "remove_preview__website=true" >>$GITHUB_OUTPUT + fi done < files.txt @@ -409,3 +415,25 @@ jobs: gcloud run services delete ${{ needs.branch-name.outputs.lowercase }}-spatial-preview \ --region=${{ secrets.REGION }} \ --quiet + + ### website ### + website: + name: build-push-website + needs: [check, branch-name] + if: needs.check.outputs.remove_preview__website == 'true' + runs-on: ubuntu-latest + steps: + - name: Google Auth + id: auth + uses: google-github-actions/auth@v1.1.1 + with: + credentials_json: "${{ secrets.GCP_SA_CREDENTIALS }}" + + - name: Setup Cloud SDK + uses: "google-github-actions/setup-gcloud@v1.1.1" + + - name: Delete PR deploy preview + run: |- + gcloud run services delete ${{ needs.branch-name.outputs.lowercase }}-website-preview \ + --region=${{ secrets.REGION }} \ + --quiet diff --git a/k8s/analytics/values-prod.yaml b/k8s/analytics/values-prod.yaml index a45db7049c..5ac058bce7 100644 --- a/k8s/analytics/values-prod.yaml +++ b/k8s/analytics/values-prod.yaml @@ -8,7 +8,7 @@ images: celeryWorker: eu.gcr.io/airqo-250220/airqo-analytics-celery-worker reportJob: eu.gcr.io/airqo-250220/airqo-analytics-report-job devicesSummaryJob: eu.gcr.io/airqo-250220/airqo-analytics-devices-summary-job - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 api: name: airqo-analytics-api label: analytics-api diff --git a/k8s/auth-service/values-prod.yaml b/k8s/auth-service/values-prod.yaml index 0d0a9aed03..7c5b7f13cf 100644 --- a/k8s/auth-service/values-prod.yaml +++ b/k8s/auth-service/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-auth-api - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/auth-service/values-stage.yaml b/k8s/auth-service/values-stage.yaml index 29d2599830..b6d2d289b5 100644 --- a/k8s/auth-service/values-stage.yaml +++ b/k8s/auth-service/values-stage.yaml @@ -6,7 +6,7 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-auth-api - tag: stage-96bf4723-1731358545 + tag: stage-e0815cc8-1731824148 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/calibrate/values-prod.yaml b/k8s/calibrate/values-prod.yaml index 91938071bc..f2358e5f1f 100644 --- a/k8s/calibrate/values-prod.yaml +++ b/k8s/calibrate/values-prod.yaml @@ -6,11 +6,11 @@ app: initContainer: image: repository: eu.gcr.io/airqo-250220/airqo-calibrate-pickle-file - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-calibrate-api - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/device-registry/values-prod.yaml b/k8s/device-registry/values-prod.yaml index f7bb98e91c..3a08fe5f0a 100644 --- a/k8s/device-registry/values-prod.yaml +++ b/k8s/device-registry/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-device-registry-api - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/device-registry/values-stage.yaml b/k8s/device-registry/values-stage.yaml index b348b43901..d1aaa22a88 100644 --- a/k8s/device-registry/values-stage.yaml +++ b/k8s/device-registry/values-stage.yaml @@ -6,7 +6,7 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-device-registry-api - tag: stage-d40838b5-1731501583 + tag: stage-4292371f-1731851989 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/exceedance/values-prod-airqo.yaml b/k8s/exceedance/values-prod-airqo.yaml index aaa00c00f7..1cd8bd092d 100644 --- a/k8s/exceedance/values-prod-airqo.yaml +++ b/k8s/exceedance/values-prod-airqo.yaml @@ -4,6 +4,6 @@ app: configmap: env-exceedance-production image: repository: eu.gcr.io/airqo-250220/airqo-exceedance-job - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 nameOverride: '' fullnameOverride: '' diff --git a/k8s/exceedance/values-prod-kcca.yaml b/k8s/exceedance/values-prod-kcca.yaml index b95fe04389..57a49574db 100644 --- a/k8s/exceedance/values-prod-kcca.yaml +++ b/k8s/exceedance/values-prod-kcca.yaml @@ -4,6 +4,6 @@ app: configmap: env-exceedance-production image: repository: eu.gcr.io/airqo-250220/kcca-exceedance-job - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 nameOverride: '' fullnameOverride: '' diff --git a/k8s/locate/values-prod.yaml b/k8s/locate/values-prod.yaml index 8ae069d653..d96723327a 100644 --- a/k8s/locate/values-prod.yaml +++ b/k8s/locate/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-locate-api - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/nginx/staging/analytics-vs.yaml b/k8s/nginx/staging/analytics-vs.yaml index b17e1b192b..e43c78b3da 100644 --- a/k8s/nginx/staging/analytics-vs.yaml +++ b/k8s/nginx/staging/analytics-vs.yaml @@ -190,5 +190,6 @@ spec: - name: Content-Type value: application/json + diff --git a/k8s/nginx/staging/api-vs.yaml b/k8s/nginx/staging/api-vs.yaml index e256d59f88..3d52eaeff8 100644 --- a/k8s/nginx/staging/api-vs.yaml +++ b/k8s/nginx/staging/api-vs.yaml @@ -53,7 +53,7 @@ spec: port: 8080 - name: spatial service: airqo-spatial-notification-api-svc - port: 5000 + port: 5000 routes: - path: ~ /api\/v[1-2]\/users action: @@ -183,3 +183,4 @@ spec: add: - name: Content-Type value: application/json + diff --git a/k8s/nginx/staging/netmanager-vs.yaml b/k8s/nginx/staging/netmanager-vs.yaml index a08d9b200e..68e7636523 100644 --- a/k8s/nginx/staging/netmanager-vs.yaml +++ b/k8s/nginx/staging/netmanager-vs.yaml @@ -56,7 +56,7 @@ spec: port: 8080 - name: spatial service: airqo-stage-spatial-api-svc - port: 5000 + port: 5000 routes: - path: / action: @@ -188,4 +188,5 @@ spec: responseHeaders: add: - name: Content-Type - value: application/json \ No newline at end of file + value: application/json + diff --git a/k8s/nginx/staging/platform-vs.yaml b/k8s/nginx/staging/platform-vs.yaml index 5ac93a347b..caa374337e 100644 --- a/k8s/nginx/staging/platform-vs.yaml +++ b/k8s/nginx/staging/platform-vs.yaml @@ -63,8 +63,25 @@ spec: subroutes: - path: /inventory action: - pass: inventory + pass: inventory +--- +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: website + namespace: staging +spec: + host: staging-platform.airqo.net + upstreams: + - name: website + service: airqo-stage-website-api-svc + port: 8000 + subroutes: + - path: /website + action: + pass: website + --- apiVersion: k8s.nginx.org/v1 kind: VirtualServerRoute @@ -141,7 +158,7 @@ spec: port: 8080 - name: spatial service: airqo-stage-spatial-api-svc - port: 5000 + port: 5000 routes: - path: / action: @@ -158,6 +175,8 @@ spec: route: staging/inventory - path: /reports route: staging/reports + - path: /website + route: staging/website - path: ~ /api\/v[1-2]\/users action: proxy: @@ -285,4 +304,5 @@ spec: responseHeaders: add: - name: Content-Type - value: application/json \ No newline at end of file + value: application/json + \ No newline at end of file diff --git a/k8s/predict/values-prod.yaml b/k8s/predict/values-prod.yaml index 6b67acf0e5..c6e89c3f43 100644 --- a/k8s/predict/values-prod.yaml +++ b/k8s/predict/values-prod.yaml @@ -7,7 +7,7 @@ images: predictJob: eu.gcr.io/airqo-250220/airqo-predict-job trainJob: eu.gcr.io/airqo-250220/airqo-train-job predictPlaces: eu.gcr.io/airqo-250220/airqo-predict-places-air-quality - tag: prod-c1581d38-1731588486 + tag: prod-24272a02-1731937745 api: name: airqo-prediction-api label: prediction-api diff --git a/k8s/website/.helmignore b/k8s/website/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/k8s/website/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/k8s/website/Chart.yaml b/k8s/website/Chart.yaml new file mode 100644 index 0000000000..35698bf2d8 --- /dev/null +++ b/k8s/website/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: airqo-website-api +description: AirQo Website Backend Helm Chart +version: 0.1.0 +appVersion: "1.16.0" +home: https://airqo.net +maintainers: + - name: AirQo + email: support@airqo.net + url: https://airqo.net diff --git a/k8s/website/templates/NOTES.txt b/k8s/website/templates/NOTES.txt new file mode 100644 index 0000000000..002709a4f7 --- /dev/null +++ b/k8s/website/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "airqo-website-api.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "airqo-website-api.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "airqo-website-api.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "airqo-website-api.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/k8s/website/templates/_helpers.tpl b/k8s/website/templates/_helpers.tpl new file mode 100644 index 0000000000..48f256e660 --- /dev/null +++ b/k8s/website/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "airqo-website-api.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "airqo-website-api.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "airqo-website-api.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "airqo-website-api.labels" -}} +helm.sh/chart: {{ include "airqo-website-api.chart" . }} +{{ include "airqo-website-api.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "airqo-website-api.selectorLabels" -}} +app.kubernetes.io/name: {{ include "airqo-website-api.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "airqo-website-api.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "airqo-website-api.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/k8s/website/templates/deployment.yaml b/k8s/website/templates/deployment.yaml new file mode 100644 index 0000000000..f94462da87 --- /dev/null +++ b/k8s/website/templates/deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.app.name }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "airqo-website-api.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + app: {{ .Values.app.label }} + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + minReadySeconds: 5 + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: {{ .Values.app.label }} + spec: + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 10 + preference: + matchExpressions: + - key: node-type + operator: In + values: + - high-memory + - weight: 1 + preference: + matchExpressions: + - key: node-type + operator: In + values: + - general-purpose + containers: + - name: {{ .Values.app.label }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: IfNotPresent + ports: + - containerPort: {{ .Values.service.targetPort }} + name: {{ .Values.app.label }} + envFrom: + - configMapRef: + name: {{ .Values.app.configmap }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/k8s/website/templates/hpa.yaml b/k8s/website/templates/hpa.yaml new file mode 100644 index 0000000000..fa77e05da5 --- /dev/null +++ b/k8s/website/templates/hpa.yaml @@ -0,0 +1,31 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: '{{ .Values.app.name }}-hpa' + namespace: {{ .Values.app.namespace }} + labels: + {{- include "airqo-website-api.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ .Values.app.name }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} diff --git a/k8s/website/templates/service.yaml b/k8s/website/templates/service.yaml new file mode 100644 index 0000000000..7419db2819 --- /dev/null +++ b/k8s/website/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: '{{ .Values.app.name }}-svc' + labels: {{- include "airqo-website-api.labels" . | nindent 4 }} + namespace: {{ .Values.app.namespace }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: {{ .Values.service.protocol }} + nodePort: {{ .Values.service.nodePort }} + selector: + app: {{ .Values.app.label }} diff --git a/k8s/website/templates/tests/test-connection.yaml b/k8s/website/templates/tests/test-connection.yaml new file mode 100644 index 0000000000..77f330a3ca --- /dev/null +++ b/k8s/website/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "airqo-website-api.fullname" . }}-test-connection" + labels: +{{ include "airqo-website-api.labels" . | indent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "airqo-website-api.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/k8s/website/values-prod.yaml b/k8s/website/values-prod.yaml new file mode 100644 index 0000000000..18bb722274 --- /dev/null +++ b/k8s/website/values-prod.yaml @@ -0,0 +1,38 @@ +app: + name: airqo-website-api + label: website-api + namespace: production + configmap: prod-website-api-config +replicaCount: 3 +image: + repository: eu.gcr.io/airqo-250220/airqo-website-api + tag: latest +nameOverride: '' +fullnameOverride: '' +podAnnotations: {} +resources: + limits: + cpu: 100m + memory: 350Mi + requests: + cpu: 10m + memory: 250Mi +volumeMounts: + - name: config-volume + mountPath: /etc/config +volumes: + - name: config-volume + configMap: + name: prod-website-api-config-files +ingress: + enabled: false +service: + type: NodePort + port: 8000 + protocol: TCP + targetPort: 8000 + nodePort: 30020 +autoscaling: + minReplicas: 1 + maxReplicas: 3 + targetMemoryUtilizationPercentage: 70 \ No newline at end of file diff --git a/k8s/website/values-stage.yaml b/k8s/website/values-stage.yaml new file mode 100644 index 0000000000..45a3fa7f09 --- /dev/null +++ b/k8s/website/values-stage.yaml @@ -0,0 +1,38 @@ +app: + name: airqo-stage-website-api + label: sta-website-api + namespace: staging + configmap: stage-website-api-config +replicaCount: 2 +image: + repository: eu.gcr.io/airqo-250220/airqo-stage-website-api + tag: stage-18f9b58d-1731945154 +nameOverride: '' +fullnameOverride: '' +podAnnotations: {} +resources: + limits: + cpu: 100m + memory: 400Mi + requests: + cpu: 10m + memory: 180Mi +volumeMounts: + - name: config-volume + mountPath: /etc/config +volumes: + - name: config-volume + configMap: + name: stage-website-api-config-files +ingress: + enabled: false +service: + type: NodePort + port: 8000 + protocol: TCP + targetPort: 8000 + nodePort: 31020 +autoscaling: + minReplicas: 1 + maxReplicas: 2 + targetMemoryUtilizationPercentage: 80 diff --git a/src/auth-service/package.json b/src/auth-service/package.json index f4520d9c4b..b81b72da74 100644 --- a/src/auth-service/package.json +++ b/src/auth-service/package.json @@ -1,109 +1,110 @@ { - "name": "auth-service", - "version": "0.0.0", - "private": true, - "main": "./bin", - "scripts": { - "start": "NODE_ENV=production node ./bin", - "stage": "NODE_ENV=staging node ./bin", - "dev-mac": "NODE_ENV=development nodemon --trace-warnings ./bin", - "stage-mac": "NODE_ENV=staging nodemon ./bin", - "prod-mac": "NODE_ENV=production nodemon ./bin", - "dev-pc": "set NODE_ENV=development&&nodemon ./bin", - "stage-pc": "set NODE_ENV=staging&&nodemon ./bin", - "prod-pc": "set NODE_ENV=production&&nodemon ./bin", - "create-env": "printenv > .env", - "test": "nyc --reporter=cobertura mocha ./**/test/ut_*.js --exit" - }, - "_moduleAliases": { - "@root": ".", - "@models": "models", - "@utils": "utils", - "@controllers": "controllers", - "@config": "config", - "@middleware": "middleware", - "@scripts": "scripts", - "@routes": "routes", - "@bin": "bin" - }, - "dependencies": { - "@google-cloud/datastore": "^5.1.0", - "@google-cloud/debug-agent": "^4.0.4", - "@log4js-node/slack": "^1.0.0", - "@mailchimp/mailchimp_marketing": "^3.0.75", - "app-module-path": "^2.2.0", - "async": "^3.2.5", - "async-retry": "^1.3.3", - "axios": "^1.4.0", - "bcrypt": "^5.0.0", - "body-parser": "^1.20.2", - "clean-deep": "^3.4.0", - "company-email-validator": "^1.0.6", - "compression": "^1.7.4", - "connect-mongo": "^3.2.0", - "continuation-local-storage": "^3.2.1", - "cookie-parser": "~1.4.3", - "crypto": "^1.0.1", - "debug": "~2.6.9", - "dotenv": "^8.2.0", - "email-existence": "^0.1.6", - "express": "^4.18.1", - "express-fileupload": "^1.2.1", - "express-formidable": "^1.2.0", - "express-session": "^1.17.3", - "express-validation": "^1.0.3", - "express-validator": "^6.12.1", - "firebase-admin": "^11.10.1", - "firebase-functions": "^4.2.1", - "generate-password": "^1.5.1", - "gstore-node": "^7.2.4", - "helmet": "^3.21.1", - "http-status": "^1.4.0", - "ioredis": "^5.3.2", - "ip-range-check": "^0.2.0", - "is-empty": "^1.2.0", - "jade": "~1.11.0", - "joi": "^14.3.1", - "jsonrepair": "^3.0.2", - "jsonwebtoken": "^8.5.1", - "kafkajs": "^2.2.3", - "log4js": "^6.7.0", - "md5": "^2.3.0", - "module-alias": "^2.2.2", - "moment-timezone": "^0.5.41", - "mongoose": "^5.12.14", - "mongoose-unique-validator": "^2.0.3", - "morgan": "~1.9.0", - "node-cron": "^3.0.2", - "nodemailer": "^6.6.1", - "nyc": "^15.1.0", - "passport": "^0.6.0", - "passport-auth-token": "^1.0.1", - "passport-google-oauth20": "^2.0.0", - "passport-jwt": "^4.0.0", - "passport-local": "^1.0.0", - "redis": "^4.6.7", - "saslprep": "^1.0.3", - "serve-favicon": "~2.4.5", - "tslib": "^2.4.0", - "validator": "^11.1.0", - "winston": "^3.8.2", - "winston-mongodb": "^5.1.1", - "xoauth2": "^1.2.0" - }, - "devDependencies": { - "chai": "^4.3.4", - "chai-as-promised": "^7.1.1", - "chai-http": "^4.3.0", - "faker": "^5.5.3", - "js-yaml": "^4.1.0", - "mocha": "^8.4.0", - "mock-req-res": "^1.2.1", - "nodemon": "^1.19.3", - "proxyquire": "^2.1.3", - "rewire": "^6.0.0", - "sinon": "^11.1.1", - "sinon-chai": "^3.7.0", - "supertest": "^6.3.3" - } + "name": "auth-service", + "version": "0.0.0", + "private": true, + "main": "./bin", + "scripts": { + "start": "NODE_ENV=production node ./bin", + "stage": "NODE_ENV=staging node ./bin", + "dev-mac": "NODE_ENV=development nodemon --trace-warnings ./bin", + "stage-mac": "NODE_ENV=staging nodemon ./bin", + "prod-mac": "NODE_ENV=production nodemon ./bin", + "dev-pc": "set NODE_ENV=development&&nodemon ./bin", + "stage-pc": "set NODE_ENV=staging&&nodemon ./bin", + "prod-pc": "set NODE_ENV=production&&nodemon ./bin", + "create-env": "printenv > .env", + "test": "nyc --reporter=cobertura mocha ./**/test/ut_*.js --exit" + }, + "_moduleAliases": { + "@root": ".", + "@models": "models", + "@utils": "utils", + "@controllers": "controllers", + "@config": "config", + "@middleware": "middleware", + "@scripts": "scripts", + "@routes": "routes", + "@bin": "bin", + "@validators": "validators" + }, + "dependencies": { + "@google-cloud/datastore": "^5.1.0", + "@google-cloud/debug-agent": "^4.0.4", + "@log4js-node/slack": "^1.0.0", + "@mailchimp/mailchimp_marketing": "^3.0.75", + "app-module-path": "^2.2.0", + "async": "^3.2.5", + "async-retry": "^1.3.3", + "axios": "^1.4.0", + "bcrypt": "^5.0.0", + "body-parser": "^1.20.2", + "clean-deep": "^3.4.0", + "company-email-validator": "^1.0.6", + "compression": "^1.7.4", + "connect-mongo": "^3.2.0", + "continuation-local-storage": "^3.2.1", + "cookie-parser": "~1.4.3", + "crypto": "^1.0.1", + "debug": "~2.6.9", + "dotenv": "^8.2.0", + "email-existence": "^0.1.6", + "express": "^4.18.1", + "express-fileupload": "^1.2.1", + "express-formidable": "^1.2.0", + "express-session": "^1.17.3", + "express-validation": "^1.0.3", + "express-validator": "^6.12.1", + "firebase-admin": "^11.10.1", + "firebase-functions": "^4.2.1", + "generate-password": "^1.5.1", + "gstore-node": "^7.2.4", + "helmet": "^3.21.1", + "http-status": "^1.4.0", + "ioredis": "^5.3.2", + "ip-range-check": "^0.2.0", + "is-empty": "^1.2.0", + "jade": "~1.11.0", + "joi": "^14.3.1", + "jsonrepair": "^3.0.2", + "jsonwebtoken": "^8.5.1", + "kafkajs": "^2.2.3", + "log4js": "^6.7.0", + "md5": "^2.3.0", + "module-alias": "^2.2.2", + "moment-timezone": "^0.5.41", + "mongoose": "^5.12.14", + "mongoose-unique-validator": "^2.0.3", + "morgan": "~1.9.0", + "node-cron": "^3.0.2", + "nodemailer": "^6.6.1", + "nyc": "^15.1.0", + "passport": "^0.6.0", + "passport-auth-token": "^1.0.1", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.0", + "passport-local": "^1.0.0", + "redis": "^4.6.7", + "saslprep": "^1.0.3", + "serve-favicon": "~2.4.5", + "tslib": "^2.4.0", + "validator": "^11.1.0", + "winston": "^3.8.2", + "winston-mongodb": "^5.1.1", + "xoauth2": "^1.2.0" + }, + "devDependencies": { + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "chai-http": "^4.3.0", + "faker": "^5.5.3", + "js-yaml": "^4.1.0", + "mocha": "^8.4.0", + "mock-req-res": "^1.2.1", + "nodemon": "^1.19.3", + "proxyquire": "^2.1.3", + "rewire": "^6.0.0", + "sinon": "^11.1.1", + "sinon-chai": "^3.7.0", + "supertest": "^6.3.3" + } } diff --git a/src/auth-service/routes/v2/test/ut_tokens.js b/src/auth-service/routes/v2/test/ut_tokens.js index 4fac83606e..4455251d17 100644 --- a/src/auth-service/routes/v2/test/ut_tokens.js +++ b/src/auth-service/routes/v2/test/ut_tokens.js @@ -1,74 +1,335 @@ require("module-alias/register"); -const express = require("express"); -const { expect } = require("chai"); -const sinon = require("sinon"); const request = require("supertest"); -const app = express(); +const router = require("@routes/tokens"); +const { + validateTenant, + validateTokenParam, + validateTokenUpdate, +} = require("@validators/token.validators"); -// Import route file -const router = require("../tokens"); +describe("Tokens Router", () => { + let app; -// Import controllers and middleware -const createTokenController = require("@controllers/create-token"); -const { setJWTAuth, authJWT } = require("@middleware/passport"); + beforeEach(() => { + app = express(); + app.use(router); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /", () => { + it("should list all tokens", async () => { + const res = await request(app) + .get("/") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("POST /", () => { + it("should create a new token", async () => { + const res = await request(app) + .post("/") + .send({ + /* token details */ + }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(res.body).toHaveProperty("id"); + }); + }); + + describe("PUT /:token/regenerate", () => { + it("should regenerate a token", async () => { + const res = await request(app) + .put("/valid-token/regenerate") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toHaveProperty("id"); + }); + }); + + describe("DELETE /:token", () => { + it("should delete a token", async () => { + const res = await request(app) + .delete("/valid-token") + .set("Authorization", "Bearer valid-token") + .expect(204); + }); + }); + + describe("GET /:token", () => { + it("should verify a token", async () => { + const res = await request(app) + .get("/valid-token") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toHaveProperty("id"); + }); + }); + + describe("GET /expired", () => { + it("should list expired tokens", async () => { + const res = await request(app) + .get("/expired") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("GET /expiring", () => { + it("should list expiring tokens", async () => { + const res = await request(app) + .get("/expiring") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("GET /unknown-ip", () => { + it("should list tokens with unknown IPs", async () => { + const res = await request(app) + .get("/unknown-ip") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("POST /blacklist-ip", () => { + it("should blacklist a single IP", async () => { + const res = await request(app) + .post("/blacklist-ip") + .send({ ip: "192.168.1.1" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toHaveProperty("id"); + }); + }); + + describe("POST /blacklist-ips", () => { + it("should blacklist multiple IPs", async () => { + const res = await request(app) + .post("/blacklist-ips") + .send([{ ip: "192.168.1.1" }, { ip: "192.168.1.2" }]) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("DELETE /blacklist-ip/:ip", () => { + it("should remove a blacklisted IP", async () => { + const res = await request(app) + .delete("/blacklist-ip/192.168.1.1") + .set("Authorization", "Bearer valid-token") + .expect(204); + }); + }); + + describe("GET /blacklist-ip", () => { + it("should list blacklisted IPs", async () => { + const res = await request(app) + .get("/blacklist-ip") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("POST /blacklist-ip-range", () => { + it("should blacklist an IP range", async () => { + const res = await request(app) + .post("/blacklist-ip-range") + .send({ range: "192.168.0.0-192.168.255.255" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toHaveProperty("id"); + }); + }); + + describe("POST /blacklist-ip-range/bulk", () => { + it("should bulk insert blacklisted IP ranges", async () => { + const res = await request(app) + .post("/blacklist-ip-range/bulk") + .send([ + { range: "192.168.0.0-192.168.255.255" }, + { range: "10.0.0.0-10.255.255.255" }, + ]) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("DELETE /blacklist-ip-range/:id", () => { + it("should remove a blacklisted IP range", async () => { + const res = await request(app) + .delete("/blacklist-ip-range/valid-id") + .set("Authorization", "Bearer valid-token") + .expect(204); + }); + }); + + describe("GET /blacklist-ip-range", () => { + it("should list blacklisted IP ranges", async () => { + const res = await request(app) + .get("/blacklist-ip-range") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("POST /whitelist-ip", () => { + it("should whitelist a single IP", async () => { + const res = await request(app) + .post("/whitelist-ip") + .send({ ip: "192.168.1.1" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toHaveProperty("id"); + }); + }); + + describe("POST /bulk-whitelist-ip", () => { + it("should bulk whitelist IPs", async () => { + const res = await request(app) + .post("/bulk-whitelist-ip") + .send([{ ip: "192.168.1.1" }, { ip: "192.168.1.2" }]) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + describe("DELETE /whitelist-ip/:ip", () => { + it("should remove a whitelisted IP", async () => { + const res = await request(app) + .delete("/whitelist-ip/192.168.1.1") + .set("Authorization", "Bearer valid-token") + .expect(204); + }); + }); + + describe("GET /whitelist-ip", () => { + it("should list whitelisted IPs", async () => { + const res = await request(app) + .get("/whitelist-ip") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); -// Sample token for testing -const SAMPLE_TOKEN = "sample_token"; + describe("POST /ip-prefix", () => { + it("should add an IP prefix", async () => { + const res = await request(app) + .post("/ip-prefix") + .send({ prefix: "/24" }) + .set("Authorization", "Bearer valid-token") + .expect(201); -describe("Routes: /tokens", () => { - describe("Middleware: Headers", () => { - it("should add appropriate headers", (done) => { - // Implement test for headers middleware here - // Use Sinon to stub the 'next' function and check response headers - done(); + expect(res.body).toHaveProperty("id"); }); }); - describe("GET /tokens", () => { - it("should return a list of tokens", (done) => { - // Implement test for GET all tokens here - // Use Sinon to stub the 'createTokenController.list' function and check response - done(); + describe("POST /ip-prefix/bulk", () => { + it("should bulk add IP prefixes", async () => { + const res = await request(app) + .post("/ip-prefix/bulk") + .send([{ prefix: "/24" }, { prefix: "/16" }]) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(res.body).toBeInstanceOf(Array); }); }); - describe("POST /tokens", () => { - it("should create a new token", (done) => { - // Implement test for creating a new token here - // Use Sinon to stub the 'createTokenController.create' function and check response - done(); + describe("DELETE /ip-prefix/:id", () => { + it("should remove an IP prefix", async () => { + const res = await request(app) + .delete("/ip-prefix/valid-id") + .set("Authorization", "Bearer valid-token") + .expect(204); }); }); - describe("PUT /tokens/:token", () => { - it("should update a token", (done) => { - // Implement test for updating a token here - // Use Sinon to stub the 'createTokenController.update' function and check response - done(); + describe("GET /ip-prefix", () => { + it("should list IP prefixes", async () => { + const res = await request(app) + .get("/ip-prefix") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); }); }); - describe("DELETE /tokens/:token", () => { - it("should delete a token", (done) => { - // Implement test for deleting a token here - // Use Sinon to stub the 'createTokenController.delete' function and check response - done(); + describe("POST /blacklist-ip-prefix", () => { + it("should blacklist an IP prefix", async () => { + const res = await request(app) + .post("/blacklist-ip-prefix") + .send({ prefix: "/24" }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(res.body).toHaveProperty("id"); }); }); - describe("GET /tokens/:token/verify", () => { - it("should verify a token", (done) => { - // Implement test for verifying a token here - // Use Sinon to stub the 'createTokenController.verify' function and check response - done(); + describe("POST /blacklist-ip-prefix/bulk", () => { + it("should bulk insert blacklisted IP prefixes", async () => { + const res = await request(app) + .post("/blacklist-ip-prefix/bulk") + .send([{ prefix: "/24" }, { prefix: "/16" }]) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(res.body).toBeInstanceOf(Array); }); }); - describe("GET /tokens/:token", () => { - it("should get a token by ID", (done) => { - // Implement test for getting a token by ID here - // Use Sinon to stub the 'createTokenController.list' function with a specific token and check response - done(); + describe("DELETE /blacklist-ip-prefix/:id", () => { + it("should remove a blacklisted IP prefix", async () => { + const res = await request(app) + .delete("/blacklist-ip-prefix/valid-id") + .set("Authorization", "Bearer valid-token") + .expect(204); }); }); + + describe("GET /blacklist-ip-prefix", () => { + it("should list blacklisted IP prefixes", async () => { + const res = await request(app) + .get("/blacklist-ip-prefix") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(res.body).toBeInstanceOf(Array); + }); + }); + + // Add more test cases as needed for other routes }); diff --git a/src/auth-service/routes/v2/tokens.js b/src/auth-service/routes/v2/tokens.js index 5a0b5b1930..80fa63226e 100644 --- a/src/auth-service/routes/v2/tokens.js +++ b/src/auth-service/routes/v2/tokens.js @@ -1,19 +1,25 @@ const express = require("express"); const router = express.Router(); const createTokenController = require("@controllers/create-token"); -const { check, oneOf, query, body, param } = require("express-validator"); const { setJWTAuth, authJWT } = require("@middleware/passport"); -const mongoose = require("mongoose"); -const ObjectId = mongoose.Types.ObjectId; const rateLimitMiddleware = require("@middleware/rate-limit"); - -const validatePagination = (req, res, next) => { - const limit = parseInt(req.query.limit, 10); - const skip = parseInt(req.query.skip, 10); - req.query.limit = Number.isNaN(limit) || limit < 1 ? 100 : limit; - req.query.skip = Number.isNaN(skip) || skip < 0 ? 0 : skip; - next(); -}; +const { + validateTenant, + validateAirqoTenantOnly, + validateTokenParam, + validateTokenCreate, + validateTokenUpdate, + validateSingleIp, + validateMultipleIps, + validatePagination, + validateIpRange, + validateMultipleIpRanges, + validateIpRangeIdParam, + validateIpParam, + validateIpPrefix, + validateMultipleIpPrefixes, + validateIdParam, +} = require("@validators/token.validators"); const headers = (req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); @@ -24,407 +30,132 @@ const headers = (req, res, next) => { res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); next(); }; + +// Apply common middleware router.use(headers); router.use(validatePagination); /******************** tokens ***********************************/ +// List all tokens router.get( "/", - oneOf([ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.list ); +// List expired tokens router.get( "/expired", - oneOf([ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.listExpired ); +// List expiring tokens router.get( "/expiring", - oneOf([ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.listExpiring ); +// List tokens with unknown IPs +router.get( + "/unknown-ip", + validateTenant, + setJWTAuth, + authJWT, + createTokenController.listUnknownIPs +); + +// Create new token router.post( "/", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - body("name") - .exists() - .withMessage("the name is missing in your request") - .trim(), - body("client_id") - .exists() - .withMessage( - "a token requirement is missing in request, consider using the client_id" - ) - .bail() - .notEmpty() - .withMessage("this client_id cannot be empty") - .bail() - .trim() - .isMongoId() - .withMessage("client_id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ]), - oneOf([ - [ - body("expires") - .optional() - .notEmpty() - .withMessage("expires cannot be empty if provided") - .bail() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("expires must be a valid datetime.") - .bail() - .isAfter(new Date().toISOString().slice(0, 10)) - .withMessage("the date should not be before the current date") - .trim(), - ], - ]), + validateTenant, + validateTokenCreate, setJWTAuth, authJWT, createTokenController.create ); + +// Regenerate token router.put( "/:token/regenerate", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("token") - .exists() - .withMessage("the token parameter is missing in the request") - .bail() - .notEmpty() - .withMessage("token must not be empty") - .trim(), - ]), - oneOf([ - [ - body("expires") - .optional() - .trim() - .notEmpty() - .withMessage("expires cannot be empty if provided") - .bail() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("expires must be a valid datetime.") - .bail() - .isAfter(new Date().toISOString().slice(0, 10)) - .withMessage("the date should not be before the current date") - .trim(), - ], - ]), + validateTenant, + validateTokenParam, + validateTokenUpdate, setJWTAuth, authJWT, createTokenController.regenerate ); + +// Update token router.put( "/:token/update", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("token") - .exists() - .withMessage("the token parameter is missing in the request") - .bail() - .notEmpty() - .withMessage("token must not be empty") - .trim(), - ]), - oneOf([ - [ - body("expires") - .optional() - .trim() - .notEmpty() - .withMessage("expires cannot be empty if provided") - .bail() - .isISO8601({ strict: true, strictSeparator: true }) - .withMessage("expires must be a valid datetime.") - .bail() - .isAfter(new Date().toISOString().slice(0, 10)) - .withMessage("the date should not be before the current date") - .trim(), - ], - ]), + validateTenant, + validateTokenParam, + validateTokenUpdate, setJWTAuth, authJWT, createTokenController.update ); + +// Delete token router.delete( "/:token", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("token") - .exists() - .withMessage("the token parameter is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the token must not be empty"), - ]), + validateTenant, + validateTokenParam, setJWTAuth, authJWT, createTokenController.delete ); + +// Verify token router.get( "/:token/verify", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - param("token") - .exists() - .withMessage("the token param is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the token must not be empty"), - ], - ]), + validateTenant, + validateTokenParam, // rateLimitMiddleware, createTokenController.verify ); -/******************** unknown IP addresses *************************/ -router.get( - "/unknown-ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - setJWTAuth, - authJWT, - createTokenController.listUnknownIPs -); /******************** blacklisted IP addresses *********************/ +// Blacklist single IP router.post( "/blacklist-ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - body("ip") - .exists() - .withMessage("the ip is missing in your request body") - .bail() - .notEmpty() - .withMessage("the ip should not be empty if provided") - .trim() - .bail() - .isIP() - .withMessage("Invalid IP address"), - ]), + validateTenant, + validateSingleIp, setJWTAuth, authJWT, createTokenController.blackListIp ); + +// Blacklist multiple IPs router.post( "/blacklist-ips", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("ips") - .exists() - .withMessage("the ips are missing in your request body") - .bail() - .notEmpty() - .withMessage("the ips should not be empty in the request body") - .bail() - .notEmpty() - .withMessage("the ips should not be empty") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the ips should be an array"), - body("ips.*") - .notEmpty() - .withMessage("Provided ips should NOT be empty") - .bail() - .isIP() - .withMessage("IP address provided must be a valid IP address"), - ], - ]), + validateAirqoTenantOnly, + validateMultipleIps, setJWTAuth, authJWT, createTokenController.blackListIps ); + router.delete( "/blacklist-ip/:ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("ip") - .exists() - .withMessage("the ip parameter is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the ip must not be empty") - .bail() - .isIP() - .withMessage("Invalid IP address"), - ]), + validateTenant, + validateIpParam, setJWTAuth, authJWT, createTokenController.removeBlacklistedIp ); + router.get( "/blacklist-ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.listBlacklistedIp @@ -433,119 +164,31 @@ router.get( /******************** blacklisted IP address RANGES *********************/ router.post( "/blacklist-ip-range", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - body("range") - .exists() - .withMessage("the range is missing in your request body") - .bail() - .notEmpty() - .withMessage("the range should not be empty if provided") - .trim(), - ]), + validateTenant, + validateIpRange, setJWTAuth, authJWT, createTokenController.blackListIpRange ); router.post( "/blacklist-ip-range/bulk", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("ranges") - .exists() - .withMessage("the ranges are missing in your request body") - .bail() - .notEmpty() - .withMessage("the ranges should not be empty in the request body") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the ranges should be an array"), - body("ranges.*") - .notEmpty() - .withMessage("Provided range should NOT be empty"), - ], - ]), + validateTenant, + validateMultipleIpRanges, setJWTAuth, authJWT, createTokenController.bulkInsertBlacklistIpRanges ); router.delete( "/blacklist-ip-range/:id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("id") - .exists() - .withMessage("the id param is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the id cannot be empty when provided") - .bail() - .isMongoId() - .withMessage("the id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ]), + validateTenant, + validateIpRangeIdParam, setJWTAuth, authJWT, createTokenController.removeBlacklistedIpRange ); router.get( "/blacklist-ip-range", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.listBlacklistedIpRange @@ -554,31 +197,8 @@ router.get( /******************** whitelisted IP addresses ************************/ router.post( "/whitelist-ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - body("ip") - .exists() - .withMessage("the ip is missing in your request body") - .bail() - .notEmpty() - .withMessage("the ip should not be empty if provided") - .trim() - .bail() - .isIP() - .withMessage("Invalid IP address"), - ]), + validateTenant, + validateSingleIp, setJWTAuth, authJWT, createTokenController.whiteListIp @@ -586,31 +206,8 @@ router.post( router.post( "/bulk-whitelist-ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - body("ips") - .exists() - .withMessage("the ips array is missing in your request body") - .bail() - .custom((value) => Array.isArray(value)) - .withMessage("the ipds should be an array") - .bail() - .notEmpty() - .withMessage("the ips should not be empty"), - body("ips.*").isIP().withMessage("Invalid IP address provided"), - ]), + validateTenant, + validateMultipleIps, setJWTAuth, authJWT, createTokenController.bulkWhiteListIps @@ -618,50 +215,15 @@ router.post( router.delete( "/whitelist-ip/:ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("ip") - .exists() - .withMessage("the ip parameter is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the ip must not be empty") - .bail() - .isIP() - .withMessage("Invalid IP address"), - ]), + validateTenant, + validateIpParam, setJWTAuth, authJWT, createTokenController.removeWhitelistedIp ); router.get( "/whitelist-ip", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.listWhitelistedIp @@ -670,119 +232,34 @@ router.get( /******************** ip prefixes ***************************************/ router.post( "/ip-prefix", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - body("prefix") - .exists() - .withMessage("the prefix is missing in your request body") - .bail() - .notEmpty() - .withMessage("the prefix should not be empty if provided") - .trim(), - ]), + validateTenant, + validateIpPrefix, setJWTAuth, authJWT, createTokenController.ipPrefix ); + router.post( "/ip-prefix/bulk", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("prefixes") - .exists() - .withMessage("the prefixes are missing in your request body") - .bail() - .notEmpty() - .withMessage("the prefixes should not be empty in the request body") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the prefixes should be an array"), - body("prefixes.*") - .notEmpty() - .withMessage("Provided prefix should NOT be empty"), - ], - ]), + validateTenant, + validateMultipleIpPrefixes, setJWTAuth, authJWT, createTokenController.bulkInsertIpPrefix ); + router.delete( "/ip-prefix/:id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("id") - .exists() - .withMessage("the id param is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the id cannot be empty when provided") - .bail() - .isMongoId() - .withMessage("the id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ]), + validateTenant, + validateIdParam, setJWTAuth, authJWT, createTokenController.removeIpPrefix ); + router.get( "/ip-prefix", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.listIpPrefix @@ -791,152 +268,41 @@ router.get( /******************** blacklisted ip prefixes ****************************/ router.post( "/blacklist-ip-prefix", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - body("prefix") - .exists() - .withMessage("the prefix is missing in your request body") - .bail() - .notEmpty() - .withMessage("the prefix should not be empty if provided") - .trim(), - ]), + validateTenant, + validateIpPrefix, setJWTAuth, authJWT, createTokenController.blackListIpPrefix ); router.post( "/blacklist-ip-prefix/bulk", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - body("prefixes") - .exists() - .withMessage("the prefixes are missing in your request body") - .bail() - .notEmpty() - .withMessage("the prefixes should not be empty in the request body") - .bail() - .custom((value) => { - return Array.isArray(value); - }) - .withMessage("the ranges should be an array"), - body("prefixes.*") - .notEmpty() - .withMessage("Provided prefix should NOT be empty"), - ], - ]), + validateTenant, + validateMultipleIpPrefixes, setJWTAuth, authJWT, createTokenController.bulkInsertBlacklistIpPrefix ); router.delete( "/blacklist-ip-prefix/:id", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .bail() - .trim() - .toLowerCase() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - param("id") - .exists() - .withMessage("the id param is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the id cannot be empty when provided") - .bail() - .isMongoId() - .withMessage("the id must be an object ID") - .bail() - .customSanitizer((value) => { - return ObjectId(value); - }), - ]), + validateTenant, + validateIdParam, setJWTAuth, authJWT, createTokenController.removeBlacklistedIpPrefix ); router.get( "/blacklist-ip-prefix", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), + validateTenant, setJWTAuth, authJWT, createTokenController.listBlacklistedIpPrefix ); /*************************** Get TOKEN's information ********************* */ - router.get( "/:token", - oneOf([ - [ - query("tenant") - .optional() - .notEmpty() - .withMessage("tenant should not be empty if provided") - .trim() - .toLowerCase() - .bail() - .isIn(["kcca", "airqo"]) - .withMessage("the tenant value is not among the expected ones"), - ], - ]), - oneOf([ - [ - param("token") - .exists() - .withMessage("the token param is missing in the request") - .bail() - .trim() - .notEmpty() - .withMessage("the token must not be empty"), - ], - ]), + validateTenant, + validateTokenParam, setJWTAuth, authJWT, createTokenController.list diff --git a/src/auth-service/routes/v2/tokens.old.js b/src/auth-service/routes/v2/tokens.old.js new file mode 100644 index 0000000000..5a0b5b1930 --- /dev/null +++ b/src/auth-service/routes/v2/tokens.old.js @@ -0,0 +1,945 @@ +const express = require("express"); +const router = express.Router(); +const createTokenController = require("@controllers/create-token"); +const { check, oneOf, query, body, param } = require("express-validator"); +const { setJWTAuth, authJWT } = require("@middleware/passport"); +const mongoose = require("mongoose"); +const ObjectId = mongoose.Types.ObjectId; +const rateLimitMiddleware = require("@middleware/rate-limit"); + +const validatePagination = (req, res, next) => { + const limit = parseInt(req.query.limit, 10); + const skip = parseInt(req.query.skip, 10); + req.query.limit = Number.isNaN(limit) || limit < 1 ? 100 : limit; + req.query.skip = Number.isNaN(skip) || skip < 0 ? 0 : skip; + next(); +}; + +const headers = (req, res, next) => { + res.header("Access-Control-Allow-Origin", "*"); + res.header( + "Access-Control-Allow-Headers", + "Origin, X-Requested-With, Content-Type, Accept, Authorization" + ); + res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); + next(); +}; +router.use(headers); +router.use(validatePagination); + +/******************** tokens ***********************************/ +router.get( + "/", + oneOf([ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ]), + setJWTAuth, + authJWT, + createTokenController.list +); + +router.get( + "/expired", + oneOf([ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ]), + setJWTAuth, + authJWT, + createTokenController.listExpired +); + +router.get( + "/expiring", + oneOf([ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ]), + setJWTAuth, + authJWT, + createTokenController.listExpiring +); + +router.post( + "/", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + body("name") + .exists() + .withMessage("the name is missing in your request") + .trim(), + body("client_id") + .exists() + .withMessage( + "a token requirement is missing in request, consider using the client_id" + ) + .bail() + .notEmpty() + .withMessage("this client_id cannot be empty") + .bail() + .trim() + .isMongoId() + .withMessage("client_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ]), + oneOf([ + [ + body("expires") + .optional() + .notEmpty() + .withMessage("expires cannot be empty if provided") + .bail() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("expires must be a valid datetime.") + .bail() + .isAfter(new Date().toISOString().slice(0, 10)) + .withMessage("the date should not be before the current date") + .trim(), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.create +); +router.put( + "/:token/regenerate", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("token") + .exists() + .withMessage("the token parameter is missing in the request") + .bail() + .notEmpty() + .withMessage("token must not be empty") + .trim(), + ]), + oneOf([ + [ + body("expires") + .optional() + .trim() + .notEmpty() + .withMessage("expires cannot be empty if provided") + .bail() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("expires must be a valid datetime.") + .bail() + .isAfter(new Date().toISOString().slice(0, 10)) + .withMessage("the date should not be before the current date") + .trim(), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.regenerate +); +router.put( + "/:token/update", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("token") + .exists() + .withMessage("the token parameter is missing in the request") + .bail() + .notEmpty() + .withMessage("token must not be empty") + .trim(), + ]), + oneOf([ + [ + body("expires") + .optional() + .trim() + .notEmpty() + .withMessage("expires cannot be empty if provided") + .bail() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("expires must be a valid datetime.") + .bail() + .isAfter(new Date().toISOString().slice(0, 10)) + .withMessage("the date should not be before the current date") + .trim(), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.update +); +router.delete( + "/:token", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("token") + .exists() + .withMessage("the token parameter is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the token must not be empty"), + ]), + setJWTAuth, + authJWT, + createTokenController.delete +); +router.get( + "/:token/verify", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + param("token") + .exists() + .withMessage("the token param is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the token must not be empty"), + ], + ]), + // rateLimitMiddleware, + createTokenController.verify +); +/******************** unknown IP addresses *************************/ +router.get( + "/unknown-ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.listUnknownIPs +); + +/******************** blacklisted IP addresses *********************/ +router.post( + "/blacklist-ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + body("ip") + .exists() + .withMessage("the ip is missing in your request body") + .bail() + .notEmpty() + .withMessage("the ip should not be empty if provided") + .trim() + .bail() + .isIP() + .withMessage("Invalid IP address"), + ]), + setJWTAuth, + authJWT, + createTokenController.blackListIp +); +router.post( + "/blacklist-ips", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + body("ips") + .exists() + .withMessage("the ips are missing in your request body") + .bail() + .notEmpty() + .withMessage("the ips should not be empty in the request body") + .bail() + .notEmpty() + .withMessage("the ips should not be empty") + .bail() + .custom((value) => { + return Array.isArray(value); + }) + .withMessage("the ips should be an array"), + body("ips.*") + .notEmpty() + .withMessage("Provided ips should NOT be empty") + .bail() + .isIP() + .withMessage("IP address provided must be a valid IP address"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.blackListIps +); +router.delete( + "/blacklist-ip/:ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("ip") + .exists() + .withMessage("the ip parameter is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the ip must not be empty") + .bail() + .isIP() + .withMessage("Invalid IP address"), + ]), + setJWTAuth, + authJWT, + createTokenController.removeBlacklistedIp +); +router.get( + "/blacklist-ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.listBlacklistedIp +); + +/******************** blacklisted IP address RANGES *********************/ +router.post( + "/blacklist-ip-range", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + body("range") + .exists() + .withMessage("the range is missing in your request body") + .bail() + .notEmpty() + .withMessage("the range should not be empty if provided") + .trim(), + ]), + setJWTAuth, + authJWT, + createTokenController.blackListIpRange +); +router.post( + "/blacklist-ip-range/bulk", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + body("ranges") + .exists() + .withMessage("the ranges are missing in your request body") + .bail() + .notEmpty() + .withMessage("the ranges should not be empty in the request body") + .bail() + .custom((value) => { + return Array.isArray(value); + }) + .withMessage("the ranges should be an array"), + body("ranges.*") + .notEmpty() + .withMessage("Provided range should NOT be empty"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.bulkInsertBlacklistIpRanges +); +router.delete( + "/blacklist-ip-range/:id", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("id") + .exists() + .withMessage("the id param is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the id cannot be empty when provided") + .bail() + .isMongoId() + .withMessage("the id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ]), + setJWTAuth, + authJWT, + createTokenController.removeBlacklistedIpRange +); +router.get( + "/blacklist-ip-range", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.listBlacklistedIpRange +); + +/******************** whitelisted IP addresses ************************/ +router.post( + "/whitelist-ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + body("ip") + .exists() + .withMessage("the ip is missing in your request body") + .bail() + .notEmpty() + .withMessage("the ip should not be empty if provided") + .trim() + .bail() + .isIP() + .withMessage("Invalid IP address"), + ]), + setJWTAuth, + authJWT, + createTokenController.whiteListIp +); + +router.post( + "/bulk-whitelist-ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + body("ips") + .exists() + .withMessage("the ips array is missing in your request body") + .bail() + .custom((value) => Array.isArray(value)) + .withMessage("the ipds should be an array") + .bail() + .notEmpty() + .withMessage("the ips should not be empty"), + body("ips.*").isIP().withMessage("Invalid IP address provided"), + ]), + setJWTAuth, + authJWT, + createTokenController.bulkWhiteListIps +); + +router.delete( + "/whitelist-ip/:ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("ip") + .exists() + .withMessage("the ip parameter is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the ip must not be empty") + .bail() + .isIP() + .withMessage("Invalid IP address"), + ]), + setJWTAuth, + authJWT, + createTokenController.removeWhitelistedIp +); +router.get( + "/whitelist-ip", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.listWhitelistedIp +); + +/******************** ip prefixes ***************************************/ +router.post( + "/ip-prefix", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + body("prefix") + .exists() + .withMessage("the prefix is missing in your request body") + .bail() + .notEmpty() + .withMessage("the prefix should not be empty if provided") + .trim(), + ]), + setJWTAuth, + authJWT, + createTokenController.ipPrefix +); +router.post( + "/ip-prefix/bulk", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + body("prefixes") + .exists() + .withMessage("the prefixes are missing in your request body") + .bail() + .notEmpty() + .withMessage("the prefixes should not be empty in the request body") + .bail() + .custom((value) => { + return Array.isArray(value); + }) + .withMessage("the prefixes should be an array"), + body("prefixes.*") + .notEmpty() + .withMessage("Provided prefix should NOT be empty"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.bulkInsertIpPrefix +); +router.delete( + "/ip-prefix/:id", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("id") + .exists() + .withMessage("the id param is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the id cannot be empty when provided") + .bail() + .isMongoId() + .withMessage("the id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ]), + setJWTAuth, + authJWT, + createTokenController.removeIpPrefix +); +router.get( + "/ip-prefix", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.listIpPrefix +); + +/******************** blacklisted ip prefixes ****************************/ +router.post( + "/blacklist-ip-prefix", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + body("prefix") + .exists() + .withMessage("the prefix is missing in your request body") + .bail() + .notEmpty() + .withMessage("the prefix should not be empty if provided") + .trim(), + ]), + setJWTAuth, + authJWT, + createTokenController.blackListIpPrefix +); +router.post( + "/blacklist-ip-prefix/bulk", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + body("prefixes") + .exists() + .withMessage("the prefixes are missing in your request body") + .bail() + .notEmpty() + .withMessage("the prefixes should not be empty in the request body") + .bail() + .custom((value) => { + return Array.isArray(value); + }) + .withMessage("the ranges should be an array"), + body("prefixes.*") + .notEmpty() + .withMessage("Provided prefix should NOT be empty"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.bulkInsertBlacklistIpPrefix +); +router.delete( + "/blacklist-ip-prefix/:id", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + param("id") + .exists() + .withMessage("the id param is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the id cannot be empty when provided") + .bail() + .isMongoId() + .withMessage("the id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ]), + setJWTAuth, + authJWT, + createTokenController.removeBlacklistedIpPrefix +); +router.get( + "/blacklist-ip-prefix", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.listBlacklistedIpPrefix +); + +/*************************** Get TOKEN's information ********************* */ + +router.get( + "/:token", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + param("token") + .exists() + .withMessage("the token param is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the token must not be empty"), + ], + ]), + setJWTAuth, + authJWT, + createTokenController.list +); + +module.exports = router; diff --git a/src/auth-service/validators/test/ut_token.validators.js b/src/auth-service/validators/test/ut_token.validators.js new file mode 100644 index 0000000000..df7bfac387 --- /dev/null +++ b/src/auth-service/validators/test/ut_token.validators.js @@ -0,0 +1,495 @@ +require("module-alias/register"); +const chai = require("chai"); +const sinon = require("sinon"); +const sinonChai = require("sinon-chai"); +const { expect } = chai; +const { + validateTenant, + validateAirqoTenantOnly, + validateTokenParam, + validateTokenCreate, + validateTokenUpdate, + validateSingleIp, + validateMultipleIps, + validatePagination, + validateIpRange, + validateMultipleIpRanges, + validateIpRangeIdParam, + validateIpParam, + validateIpPrefix, + validateMultipleIpPrefixes, + validateIdParam, +} = require("@validators/token.validators"); + +chai.use(sinonChai); + +describe("Validation Functions", () => { + describe("validateTenant", () => { + it("should validate KCCA tenant", () => { + const result = validateTenant({ tenant: "KCCA" }); + expect(result).to.be.undefined; + }); + + it("should reject invalid tenants", () => { + const result = validateTenant({ tenant: "invalid" }); + expect(result) + .to.have.property("msg") + .that.equals("the tenant value is not among the expected ones"); + }); + }); + + describe("validateAirqoTenantOnly", () => { + it("should validate AIRQO tenant", () => { + const result = validateAirqoTenantOnly({ tenant: "AIRQO" }); + expect(result).to.be.undefined; + }); + + it("should reject invalid tenants", () => { + const result = validateAirqoTenantOnly({ tenant: "invalid" }); + expect(result) + .to.have.property("msg") + .that.equals("the tenant value is not among the expected ones"); + }); + }); + + describe("validateTokenParam", () => { + it("should validate token parameter", () => { + const result = validateTokenParam({ token: "valid-token" }); + expect(result).to.be.undefined; + }); + + it("should reject missing token", () => { + const result = validateTokenParam({}); + expect(result) + .to.have.property("msg") + .that.equals("the token parameter is missing in the request"); + }); + + it("should reject empty token", () => { + const result = validateTokenParam({ token: "" }); + expect(result) + .to.have.property("msg") + .that.equals("token must not be empty"); + }); + }); + + describe("validateTokenCreate", () => { + it("should validate name field", () => { + const result = validateTokenCreate([{ name: "Valid Name" }]); + expect(result).to.be.undefined; + }); + + it("should reject missing name", () => { + const result = validateTokenCreate([]); + expect(result) + .to.have.property("msg") + .that.equals("the name is missing in your request"); + }); + + it("should validate client_id", () => { + const result = validateTokenCreate([ + { client_id: "1234567890abcdef1234567890abcdef" }, + ]); + expect(result).to.be.undefined; + }); + + it("should reject invalid client_id", () => { + const result = validateTokenCreate([{ client_id: "invalid" }]); + expect(result) + .to.have.property("msg") + .that.equals("this client_id cannot be empty"); + }); + + it("should sanitize client_id", () => { + const result = validateTokenCreate([ + { client_id: "1234567890abcdef1234567890abcdef" }, + ]); + expect(result[0].client_id).to.equal("1234567890abcdef1234567890abcdef"); + }); + + it("should validate expires field", () => { + const result = validateTokenCreate([{ expires: "2023-12-31T23:59:59Z" }]); + expect(result).to.be.undefined; + }); + + it("should reject invalid expires format", () => { + const result = validateTokenCreate([{ expires: "invalid-date" }]); + expect(result) + .to.have.property("msg") + .that.equals("expires must be a valid datetime."); + }); + + it("should reject future dates", () => { + const result = validateTokenCreate([{ expires: "2050-01-01T00:00:00Z" }]); + expect(result) + .to.have.property("msg") + .that.equals("the date should not be before the current date"); + }); + }); + + describe("validateTokenUpdate", () => { + it("should validate expires field", () => { + const result = validateTokenUpdate([{ expires: "2023-12-31T23:59:59Z" }]); + expect(result).to.be.undefined; + }); + + it("should reject invalid expires format", () => { + const result = validateTokenUpdate([{ expires: "invalid-date" }]); + expect(result) + .to.have.property("msg") + .that.equals("expires must be a valid datetime."); + }); + + it("should reject future dates", () => { + const result = validateTokenUpdate([{ expires: "2050-01-01T00:00:00Z" }]); + expect(result) + .to.have.property("msg") + .that.equals("the date should not be before the current date"); + }); + }); + + describe("validateSingleIp", () => { + it("should validate single IP", () => { + const result = validateSingleIp({ ip: "192.168.1.1" }); + expect(result).to.be.undefined; + }); + + it("should reject missing IP", () => { + const result = validateSingleIp({}); + expect(result) + .to.have.property("msg") + .that.equals("the ip is missing in your request body"); + }); + + it("should reject empty IP", () => { + const result = validateSingleIp({ ip: "" }); + expect(result) + .to.have.property("msg") + .that.equals("the ip should not be empty if provided"); + }); + + it("should reject invalid IP", () => { + const result = validateSingleIp({ ip: "256.256.256.256" }); + expect(result).to.have.property("msg").that.equals("Invalid IP address"); + }); + }); + + describe("validateMultipleIps", () => { + it("should validate multiple IPs", () => { + const result = validateMultipleIps({ ips: ["192.168.1.1", "10.0.0.1"] }); + expect(result).to.be.undefined; + }); + + it("should reject missing IPs", () => { + const result = validateMultipleIps({}); + expect(result) + .to.have.property("msg") + .that.equals("the ips are missing in your request body"); + }); + + it("should reject empty IPs", () => { + const result = validateMultipleIps({ ips: [] }); + expect(result) + .to.have.property("msg") + .that.equals("the ips should not be empty in the request body"); + }); + + it("should reject non-array IPs", () => { + const result = validateMultipleIps({ ips: "not-an-array" }); + expect(result) + .to.have.property("msg") + .that.equals("the ips should be an array"); + }); + + it("should reject individual empty IPs", () => { + const result = validateMultipleIps({ ips: ["", "192.168.1.1"] }); + expect(result) + .to.have.property("msg") + .that.equals("Provided ips should NOT be empty"); + }); + + it("should reject invalid IP", () => { + const result = validateMultipleIps({ + ips: ["192.168.1.1", "256.256.256.256"], + }); + expect(result) + .to.have.property("msg") + .that.equals("IP address provided must be a valid IP address"); + }); + }); + + describe("validatePagination", () => { + it("should set default values for pagination", () => { + const req = {}; + validatePagination(req, {}, () => {}); + expect(req.query.limit).to.equal(100); + expect(req.query.skip).to.equal(0); + }); + + it("should set custom limit", () => { + const req = { query: { limit: 50 } }; + validatePagination(req, {}, () => {}); + expect(req.query.limit).to.equal(50); + }); + + it("should set custom skip", () => { + const req = { query: { skip: 25 } }; + validatePagination(req, {}, () => {}); + expect(req.query.skip).to.equal(25); + }); + + it("should clamp limit below 1", () => { + const req = { query: { limit: 0 } }; + validatePagination(req, {}, () => {}); + expect(req.query.limit).to.equal(1); + }); + + it("should clamp skip below 0", () => { + const req = { query: { skip: -5 } }; + validatePagination(req, {}, () => {}); + expect(req.query.skip).to.equal(0); + }); + }); + + describe("validateIpParam", () => { + it("should validate IP parameter", () => { + const result = validateIpParam({ ip: "192.168.1.1" }); + expect(result).to.be.undefined; + }); + + it("should reject missing IP", () => { + const result = validateIpParam({}); + expect(result) + .to.have.property("msg") + .that.equals("the ip parameter is missing in the request"); + }); + + it("should reject empty IP", () => { + const result = validateIpParam({ ip: "" }); + expect(result) + .to.have.property("msg") + .that.equals("the ip must not be empty"); + }); + + it("should reject invalid IP", () => { + const result = validateIpParam({ ip: "256.256.256.256" }); + expect(result).to.have.property("msg").that.equals("Invalid IP address"); + }); + }); + + describe("validateIpRange", () => { + it("should validate IP range", () => { + const result = validateIpRange({ range: "192.168.0.0-192.168.255.255" }); + expect(result).to.be.undefined; + }); + + it("should reject missing range", () => { + const result = validateIpRange({}); + expect(result) + .to.have.property("msg") + .that.equals("the range is missing in your request body"); + }); + + it("should reject empty range", () => { + const result = validateIpRange({ range: "" }); + expect(result) + .to.have.property("msg") + .that.equals("the range should not be empty if provided"); + }); + + it("should reject invalid range format", () => { + const result = validateIpRange({ range: "invalid-range" }); + expect(result).to.have.property("msg").that.equals("Invalid IP address"); + }); + }); + + describe("validateMultipleIpRanges", () => { + it("should validate multiple IP ranges", () => { + const result = validateMultipleIpRanges({ + ranges: ["192.168.0.0-192.168.255.255", "10.0.0.0-10.255.255.255"], + }); + expect(result).to.be.undefined; + }); + + it("should reject missing ranges", () => { + const result = validateMultipleIpRanges({}); + expect(result) + .to.have.property("msg") + .that.equals("the ranges are missing in your request body"); + }); + + it("should reject empty ranges", () => { + const result = validateMultipleIpRanges({ ranges: [] }); + expect(result) + .to.have.property("msg") + .that.equals("the ranges should not be empty in the request body"); + }); + + it("should reject non-array ranges", () => { + const result = validateMultipleIpRanges({ ranges: "not-an-array" }); + expect(result) + .to.have.property("msg") + .that.equals("the ranges should be an array"); + }); + + it("should reject individual empty ranges", () => { + const result = validateMultipleIpRanges({ + ranges: ["", "192.168.0.0-192.168.255.255"], + }); + expect(result) + .to.have.property("msg") + .that.equals("Provided range should NOT be empty"); + }); + + it("should reject invalid IP range", () => { + const result = validateMultipleIpRanges({ + ranges: ["192.168.0.0-192.168.255.255", "256.256.256.256-257.0.0.0"], + }); + expect(result) + .to.have.property("msg") + .that.equals("IP address provided must be a valid IP address"); + }); + }); + + describe("validateIpRangeIdParam", () => { + it("should validate IP range ID parameter", () => { + const result = validateIpRangeIdParam({ + id: "1234567890abcdef1234567890abcdef", + }); + expect(result).to.be.undefined; + }); + + it("should reject missing ID", () => { + const result = validateIpRangeIdParam({}); + expect(result) + .to.have.property("msg") + .that.equals("the id param is missing in the request"); + }); + + it("should reject empty ID", () => { + const result = validateIpRangeIdParam({ id: "" }); + expect(result) + .to.have.property("msg") + .that.equals("the id cannot be empty when provided"); + }); + + it("should reject invalid ID", () => { + const result = validateIpRangeIdParam({ id: "invalid" }); + expect(result) + .to.have.property("msg") + .that.equals("the id must be an object ID"); + }); + + it("should sanitize ID", () => { + const result = validateIpRangeIdParam({ + id: "1234567890abcdef1234567890abcdef", + }); + expect(result.id).to.equal("1234567890abcdef1234567890abcdef"); + }); + }); + + describe("validateIpPrefix", () => { + it("should validate IP prefix", () => { + const result = validateIpPrefix({ prefix: "/24" }); + expect(result).to.be.undefined; + }); + + it("should reject missing prefix", () => { + const result = validateIpPrefix({}); + expect(result) + .to.have.property("msg") + .that.equals("the prefix is missing in your request body"); + }); + + it("should reject empty prefix", () => { + const result = validateIpPrefix({ prefix: "" }); + expect(result) + .to.have.property("msg") + .that.equals("the prefix should not be empty if provided"); + }); + + it("should reject invalid prefix format", () => { + const result = validateIpPrefix({ prefix: "invalid-prefix" }); + expect(result).to.have.property("msg").that.equals("Invalid IP address"); + }); + }); + + describe("validateMultipleIpPrefixes", () => { + it("should validate multiple IP prefixes", () => { + const result = validateMultipleIpPrefixes({ prefixes: ["/24", "/16"] }); + expect(result).to.be.undefined; + }); + + it("should reject missing prefixes", () => { + const result = validateMultipleIpPrefixes({}); + expect(result) + .to.have.property("msg") + .that.equals("the prefixes are missing in your request body"); + }); + + it("should reject empty prefixes", () => { + const result = validateMultipleIpPrefixes({ prefixes: [] }); + expect(result) + .to.have.property("msg") + .that.equals("the prefixes should not be empty in the request body"); + }); + + it("should reject non-array prefixes", () => { + const result = validateMultipleIpPrefixes({ prefixes: "not-an-array" }); + expect(result) + .to.have.property("msg") + .that.equals("the prefixes should be an array"); + }); + + it("should reject individual empty prefixes", () => { + const result = validateMultipleIpPrefixes({ prefixes: ["", "/24"] }); + expect(result) + .to.have.property("msg") + .that.equals("Provided prefix should NOT be empty"); + }); + + it("should reject invalid IP prefix", () => { + const result = validateMultipleIpPrefixes({ prefixes: ["/", "/24"] }); + expect(result) + .to.have.property("msg") + .that.equals("IP address provided must be a valid IP address"); + }); + }); + + describe("validateIdParam", () => { + it("should validate ID parameter", () => { + const result = validateIdParam({ + id: "1234567890abcdef1234567890abcdef", + }); + expect(result).to.be.undefined; + }); + + it("should reject missing ID", () => { + const result = validateIdParam({}); + expect(result) + .to.have.property("msg") + .that.equals("the id param is missing in the request"); + }); + + it("should reject empty ID", () => { + const result = validateIdParam({ id: "" }); + expect(result) + .to.have.property("msg") + .that.equals("the id cannot be empty when provided"); + }); + + it("should reject invalid ID", () => { + const result = validateIdParam({ id: "invalid" }); + expect(result) + .to.have.property("msg") + .that.equals("the id must be an object ID"); + }); + + it("should sanitize ID", () => { + const result = validateIdParam({ + id: "1234567890abcdef1234567890abcdef", + }); + expect(result.id).to.equal("1234567890abcdef1234567890abcdef"); + }); + }); +}); diff --git a/src/auth-service/validators/token.validators.js b/src/auth-service/validators/token.validators.js new file mode 100644 index 0000000000..7e15a5992e --- /dev/null +++ b/src/auth-service/validators/token.validators.js @@ -0,0 +1,268 @@ +const { query, body, param, oneOf } = require("express-validator"); +const constants = require("@config/constants"); +const mongoose = require("mongoose"); +const ObjectId = mongoose.Types.ObjectId; + +const validateTenant = oneOf([ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), +]); + +const validateAirqoTenantOnly = oneOf([ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant should not be empty if provided") + .trim() + .toLowerCase() + .bail() + .isIn(["airqo"]) + .withMessage("the tenant value is not among the expected ones"), +]); + +const validateTokenParam = oneOf([ + param("token") + .exists() + .withMessage("the token parameter is missing in the request") + .bail() + .notEmpty() + .withMessage("token must not be empty") + .trim(), +]); + +const validateTokenCreate = [ + oneOf([ + [ + body("name") + .exists() + .withMessage("the name is missing in your request") + .trim(), + body("client_id") + .exists() + .withMessage( + "a token requirement is missing in request, consider using the client_id" + ) + .bail() + .notEmpty() + .withMessage("this client_id cannot be empty") + .bail() + .trim() + .isMongoId() + .withMessage("client_id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ], + ]), + oneOf([ + [ + body("expires") + .optional() + .notEmpty() + .withMessage("expires cannot be empty if provided") + .bail() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("expires must be a valid datetime.") + .bail() + .isAfter(new Date().toISOString().slice(0, 10)) + .withMessage("the date should not be before the current date") + .trim(), + ], + ]), +]; + +const validateTokenUpdate = [ + oneOf([ + [ + body("expires") + .optional() + .trim() + .notEmpty() + .withMessage("expires cannot be empty if provided") + .bail() + .isISO8601({ strict: true, strictSeparator: true }) + .withMessage("expires must be a valid datetime.") + .bail() + .isAfter(new Date().toISOString().slice(0, 10)) + .withMessage("the date should not be before the current date") + .trim(), + ], + ]), +]; + +const validateSingleIp = oneOf([ + body("ip") + .exists() + .withMessage("the ip is missing in your request body") + .bail() + .notEmpty() + .withMessage("the ip should not be empty if provided") + .trim() + .bail() + .isIP() + .withMessage("Invalid IP address"), +]); + +const validateMultipleIps = oneOf([ + [ + body("ips") + .exists() + .withMessage("the ips are missing in your request body") + .bail() + .notEmpty() + .withMessage("the ips should not be empty in the request body") + .bail() + .notEmpty() + .withMessage("the ips should not be empty") + .bail() + .custom((value) => { + return Array.isArray(value); + }) + .withMessage("the ips should be an array"), + body("ips.*") + .notEmpty() + .withMessage("Provided ips should NOT be empty") + .bail() + .isIP() + .withMessage("IP address provided must be a valid IP address"), + ], +]); + +const validatePagination = (req, res, next) => { + const limit = parseInt(req.query.limit, 10); + const skip = parseInt(req.query.skip, 10); + req.query.limit = Number.isNaN(limit) || limit < 1 ? 100 : limit; + req.query.skip = Number.isNaN(skip) || skip < 0 ? 0 : skip; + next(); +}; + +const validateIpParam = oneOf([ + param("ip") + .exists() + .withMessage("the ip parameter is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the ip must not be empty") + .bail() + .isIP() + .withMessage("Invalid IP address"), +]); + +const validateIpRange = oneOf([ + body("range") + .exists() + .withMessage("the range is missing in your request body") + .bail() + .notEmpty() + .withMessage("the range should not be empty if provided") + .trim(), +]); + +const validateMultipleIpRanges = oneOf([ + [ + body("ranges") + .exists() + .withMessage("the ranges are missing in your request body") + .bail() + .notEmpty() + .withMessage("the ranges should not be empty in the request body") + .bail() + .custom((value) => { + return Array.isArray(value); + }) + .withMessage("the ranges should be an array"), + body("ranges.*") + .notEmpty() + .withMessage("Provided range should NOT be empty"), + ], +]); + +const validateIpRangeIdParam = oneOf([ + param("id") + .exists() + .withMessage("the id param is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the id cannot be empty when provided") + .bail() + .isMongoId() + .withMessage("the id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), +]); + +const validateIpPrefix = oneOf([ + body("prefix") + .exists() + .withMessage("the prefix is missing in your request body") + .bail() + .notEmpty() + .withMessage("the prefix should not be empty if provided") + .trim(), +]); + +const validateMultipleIpPrefixes = oneOf([ + [ + body("prefixes") + .exists() + .withMessage("the prefixes are missing in your request body") + .bail() + .notEmpty() + .withMessage("the prefixes should not be empty in the request body") + .bail() + .custom((value) => { + return Array.isArray(value); + }) + .withMessage("the prefixes should be an array"), + body("prefixes.*") + .notEmpty() + .withMessage("Provided prefix should NOT be empty"), + ], +]); + +const validateIdParam = oneOf([ + param("id") + .exists() + .withMessage("the id param is missing in the request") + .bail() + .trim() + .notEmpty() + .withMessage("the id cannot be empty when provided") + .bail() + .isMongoId() + .withMessage("the id must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), +]); + +module.exports = { + validateTenant, + validateAirqoTenantOnly, + validateTokenParam, + validateTokenCreate, + validateTokenUpdate, + validateSingleIp, + validateMultipleIps, + validatePagination, + validateIpRange, + validateMultipleIpRanges, + validateIpRangeIdParam, + validateIpParam, + validateIpPrefix, + validateMultipleIpPrefixes, + validateIdParam, +}; diff --git a/src/device-registry/controllers/create-site.js b/src/device-registry/controllers/create-site.js index 2ee04dbc72..8405801f11 100644 --- a/src/device-registry/controllers/create-site.js +++ b/src/device-registry/controllers/create-site.js @@ -714,12 +714,11 @@ const manageSite = { ); return; } - const { - latitude, - longitude, - approximate_distance_in_km, - bearing, - } = req.body; + const { latitude, longitude, approximate_distance_in_km, bearing } = { + ...req.body, + ...req.query, + ...req.params, + }; const result = createSiteUtil.createApproximateCoordinates( { latitude, longitude, approximate_distance_in_km, bearing }, diff --git a/src/device-registry/models/Event.js b/src/device-registry/models/Event.js index 682031e718..b2cdec92aa 100644 --- a/src/device-registry/models/Event.js +++ b/src/device-registry/models/Event.js @@ -23,6 +23,15 @@ const DEFAULT_SKIP = 0; const DEFAULT_PAGE = 1; const UPTIME_CHECK_THRESHOLD = 168; +const AQI_RANGES = { + good: { min: 0, max: 9.0 }, + moderate: { min: 9.1, max: 35.4 }, + u4sg: { min: 35.5, max: 55.4 }, + unhealthy: { min: 55.5, max: 125.4 }, + very_unhealthy: { min: 125.5, max: 225.4 }, + hazardous: { min: 225.5, max: null }, +}; + const valueSchema = new Schema({ time: { type: Date, @@ -856,6 +865,9 @@ async function fetchData(model, filter) { "site_image.airqloud_id": 0, }) .project(projection) + .addFields({ + aqi_ranges: AQI_RANGES, + }) .facet({ total: [{ $count: "device" }], data: [ @@ -868,8 +880,8 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "00e400", @@ -877,8 +889,12 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { + $gte: ["$pm2_5.value", "$aqi_ranges.moderate.min"], + }, + { + $lte: ["$pm2_5.value", "$aqi_ranges.moderate.max"], + }, ], }, then: "ffff00", @@ -886,8 +902,8 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "ff7e00", @@ -895,8 +911,12 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { + $gte: ["$pm2_5.value", "$aqi_ranges.unhealthy.min"], + }, + { + $lte: ["$pm2_5.value", "$aqi_ranges.unhealthy.max"], + }, ], }, then: "ff0000", @@ -904,14 +924,26 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "8f3f97", }, { - case: { $gt: ["$pm2_5.value", 225.4] }, + case: { + $gte: ["$pm2_5.value", "$aqi_ranges.hazardous.min"], + }, then: "7e0023", }, ], @@ -925,8 +957,8 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "Good", @@ -934,8 +966,12 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { + $gte: ["$pm2_5.value", "$aqi_ranges.moderate.min"], + }, + { + $lte: ["$pm2_5.value", "$aqi_ranges.moderate.max"], + }, ], }, then: "Moderate", @@ -943,8 +979,8 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "Unhealthy for Sensitive Groups", @@ -952,8 +988,12 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { + $gte: ["$pm2_5.value", "$aqi_ranges.unhealthy.min"], + }, + { + $lte: ["$pm2_5.value", "$aqi_ranges.unhealthy.max"], + }, ], }, then: "Unhealthy", @@ -961,15 +1001,29 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "Very Unhealthy", }, { case: { - $and: [{ $gt: ["$pm2_5.value", 225.4] }], + $and: [ + { + $gte: ["$pm2_5.value", "$aqi_ranges.hazardous.min"], + }, + ], }, then: "Hazardous", }, @@ -983,8 +1037,8 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "Green", @@ -992,8 +1046,12 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { + $gte: ["$pm2_5.value", "$aqi_ranges.moderate.min"], + }, + { + $lte: ["$pm2_5.value", "$aqi_ranges.moderate.max"], + }, ], }, then: "Yellow", @@ -1001,8 +1059,8 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "Orange", @@ -1010,8 +1068,12 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { + $gte: ["$pm2_5.value", "$aqi_ranges.unhealthy.min"], + }, + { + $lte: ["$pm2_5.value", "$aqi_ranges.unhealthy.max"], + }, ], }, then: "Red", @@ -1019,15 +1081,29 @@ async function fetchData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "Purple", }, { case: { - $and: [{ $gt: ["$pm2_5.value", 225.4] }], + $and: [ + { + $gte: ["$pm2_5.value", "$aqi_ranges.hazardous.min"], + }, + ], }, then: "Maroon", }, @@ -1035,6 +1111,7 @@ async function fetchData(model, filter) { default: "Unknown", }, }, + aqi_ranges: "$aqi_ranges", }, }, ], @@ -1466,6 +1543,9 @@ async function signalData(model, filter) { "site_image.airqloud_id": 0, }) .project(projection) + .addFields({ + aqi_ranges: AQI_RANGES, + }) .facet({ total: [{ $count: "device" }], data: [ @@ -1478,8 +1558,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "00e400", @@ -1487,8 +1567,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.moderate.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.moderate.max"] }, ], }, then: "ffff00", @@ -1496,8 +1576,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "ff7e00", @@ -1505,8 +1585,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.unhealthy.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.unhealthy.max"] }, ], }, then: "ff0000", @@ -1514,14 +1594,26 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "8f3f97", }, { - case: { $gt: ["$pm2_5.value", 225.4] }, + case: { + $gte: ["$pm2_5.value", "$aqi_ranges.hazardous.min"], + }, then: "7e0023", }, ], @@ -1535,8 +1627,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "Good", @@ -1544,8 +1636,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.moderate.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.moderate.max"] }, ], }, then: "Moderate", @@ -1553,8 +1645,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "Unhealthy for Sensitive Groups", @@ -1562,8 +1654,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.unhealthy.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.unhealthy.max"] }, ], }, then: "Unhealthy", @@ -1571,15 +1663,27 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "Very Unhealthy", }, { case: { - $and: [{ $gt: ["$pm2_5.value", 225.4] }], + $and: [ + { $gte: ["$pm2_5.value", "$aqi_ranges.hazardous.min"] }, + ], }, then: "Hazardous", }, @@ -1593,8 +1697,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "Green", @@ -1602,8 +1706,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.moderate.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.moderate.max"] }, ], }, then: "Yellow", @@ -1611,8 +1715,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "Orange", @@ -1620,8 +1724,8 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.unhealthy.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.unhealthy.max"] }, ], }, then: "Red", @@ -1629,15 +1733,27 @@ async function signalData(model, filter) { { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "Purple", }, { case: { - $and: [{ $gt: ["$pm2_5.value", 225.4] }], + $and: [ + { $gte: ["$pm2_5.value", "$aqi_ranges.hazardous.min"] }, + ], }, then: "Maroon", }, @@ -1645,6 +1761,7 @@ async function signalData(model, filter) { default: "Unknown", }, }, + aqi_ranges: "$aqi_ranges", }, }, ], @@ -2106,6 +2223,9 @@ eventSchema.statics.list = async function( "site_image.airqloud_id": 0, }) .project(projection) + .addFields({ + aqi_ranges: AQI_RANGES, + }) .facet({ total: [{ $count: "device" }], data: [ @@ -2118,8 +2238,8 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "00e400", @@ -2127,8 +2247,18 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.moderate.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.moderate.max", + ], + }, ], }, then: "ffff00", @@ -2136,8 +2266,8 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "ff7e00", @@ -2145,8 +2275,18 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.unhealthy.max", + ], + }, ], }, then: "ff0000", @@ -2154,29 +2294,40 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "8f3f97", }, { - case: { $gt: ["$pm2_5.value", 225.4] }, + case: { + $gte: ["$pm2_5.value", "$aqi_ranges.hazardous.min"], + }, then: "7e0023", }, ], default: "Unknown", }, }, - aqi_category: { $switch: { branches: [ { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "Good", @@ -2184,8 +2335,18 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.moderate.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.moderate.max", + ], + }, ], }, then: "Moderate", @@ -2193,8 +2354,8 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "Unhealthy for Sensitive Groups", @@ -2202,8 +2363,18 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.unhealthy.max", + ], + }, ], }, then: "Unhealthy", @@ -2211,15 +2382,32 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "Very Unhealthy", }, { case: { - $and: [{ $gt: ["$pm2_5.value", 225.4] }], + $and: [ + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.hazardous.min", + ], + }, + ], }, then: "Hazardous", }, @@ -2233,8 +2421,8 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gte: ["$pm2_5.value", 0] }, - { $lte: ["$pm2_5.value", 9.0] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.good.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.good.max"] }, ], }, then: "Green", @@ -2242,8 +2430,18 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 9.0] }, - { $lte: ["$pm2_5.value", 35.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.moderate.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.moderate.max", + ], + }, ], }, then: "Yellow", @@ -2251,8 +2449,8 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 35.4] }, - { $lte: ["$pm2_5.value", 55.4] }, + { $gte: ["$pm2_5.value", "$aqi_ranges.u4sg.min"] }, + { $lte: ["$pm2_5.value", "$aqi_ranges.u4sg.max"] }, ], }, then: "Orange", @@ -2260,8 +2458,18 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 55.4] }, - { $lte: ["$pm2_5.value", 125.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.unhealthy.max", + ], + }, ], }, then: "Red", @@ -2269,15 +2477,32 @@ eventSchema.statics.list = async function( { case: { $and: [ - { $gt: ["$pm2_5.value", 125.4] }, - { $lte: ["$pm2_5.value", 225.4] }, + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.min", + ], + }, + { + $lte: [ + "$pm2_5.value", + "$aqi_ranges.very_unhealthy.max", + ], + }, ], }, then: "Purple", }, { case: { - $and: [{ $gt: ["$pm2_5.value", 225.4] }], + $and: [ + { + $gte: [ + "$pm2_5.value", + "$aqi_ranges.hazardous.min", + ], + }, + ], }, then: "Maroon", }, @@ -2285,6 +2510,7 @@ eventSchema.statics.list = async function( default: "Unknown", }, }, + aqi_ranges: "$aqi_ranges", }, }, ], diff --git a/src/device-registry/models/Reading.js b/src/device-registry/models/Reading.js index 8e42727c89..28e9de1c57 100644 --- a/src/device-registry/models/Reading.js +++ b/src/device-registry/models/Reading.js @@ -46,6 +46,36 @@ const SiteDetailsSchema = new Schema( { _id: false } ); +const AqiRangeSchema = new Schema( + { + good: { + min: { type: Number }, + max: { type: Number }, + }, + moderate: { + min: { type: Number }, + max: { type: Number }, + }, + u4sg: { + min: { type: Number }, + max: { type: Number }, + }, + unhealthy: { + min: { type: Number }, + max: { type: Number }, + }, + very_unhealthy: { + min: { type: Number }, + max: { type: Number }, + }, + hazardous: { + min: { type: Number }, + max: { type: Number }, // max can be null + }, + }, + { _id: false } +); + const ReadingsSchema = new Schema( { device: String, @@ -59,6 +89,7 @@ const ReadingsSchema = new Schema( frequency: String, no2: { value: Number }, siteDetails: SiteDetailsSchema, + aqi_ranges: AqiRangeSchema, timeDifferenceHours: Number, aqi_color: String, aqi_category: String, @@ -102,6 +133,7 @@ ReadingsSchema.methods = { no2: this.no2, siteDetails: this.siteDetails, timeDifferenceHours: this.timeDifferenceHours, + aqi_ranges: this.aqi_ranges, aqi_color: this.aqi_color, aqi_category: this.aqi_category, aqi_color_name: this.aqi_color_name, diff --git a/src/device-registry/models/Site.js b/src/device-registry/models/Site.js index 77482f757e..17191e4d02 100644 --- a/src/device-registry/models/Site.js +++ b/src/device-registry/models/Site.js @@ -92,6 +92,7 @@ const siteSchema = new Schema( trim: true, unique: true, required: [true, "generated name is required!"], + immutable: true, }, airqloud_id: { type: ObjectId, @@ -112,6 +113,7 @@ const siteSchema = new Schema( trim: true, unique: true, required: [true, "lat_long is required!"], + immutable: true, }, description: { type: String, @@ -127,6 +129,7 @@ const siteSchema = new Schema( latitude: { type: Number, required: [true, "latitude is required!"], + immutable: true, }, approximate_latitude: { type: Number, @@ -135,6 +138,7 @@ const siteSchema = new Schema( longitude: { type: Number, required: [true, "longitude is required!"], + immutable: true, }, approximate_longitude: { type: Number, @@ -360,60 +364,64 @@ const siteSchema = new Schema( } ); -siteSchema.post("save", async function(doc) {}); - -siteSchema.pre("save", function(next) { - if (this.isModified("latitude")) { - delete this.latitude; - } - if (this.isModified("longitude")) { - delete this.longitude; - } - if (this.isModified("_id")) { - delete this._id; - } - if (this.isModified("generated_name")) { - delete this.generated_name; - } - - this.site_codes = [this._id, this.name, this.generated_name, this.lat_long]; - if (this.search_name) { - this.site_codes.push(this.search_name); - } - if (this.location_name) { - this.site_codes.push(this.location_name); - } - if (this.formatted_name) { - this.site_codes.push(this.formatted_name); - } - - // Check for duplicate values in the grids array - const duplicateValues = this.grids.filter( - (value, index, self) => self.indexOf(value) !== index - ); - if (duplicateValues.length > 0) { - const error = new Error("Duplicate values found in grids array."); - return next(error); - } +siteSchema.pre( + ["updateOne", "findOneAndUpdate", "updateMany", "update", "save"], + function(next) { + if (this.getUpdate) { + const updates = this.getUpdate(); + if (updates) { + if (updates.latitude) delete updates.latitude; + if (updates.longitude) delete updates.longitude; + + if (updates.$set) { + if (updates.$set.latitude || updates.$set.longitude) { + return next( + new HttpError( + "Cannot modify latitude or longitude after creation", + httpStatus.BAD_REQUEST, + { + message: "Cannot modify latitude or longitude after creation", + } + ) + ); + } + delete updates.$set.latitude; + delete updates.$set.longitude; + } + + if (updates.$push) { + delete updates.$push.latitude; + delete updates.$push.longitude; + } + } + } - return next(); -}); + if (this.isNew) { + if (this.isModified("latitude")) delete this.latitude; + if (this.isModified("longitude")) delete this.longitude; + if (this.isModified("_id")) delete this._id; + if (this.isModified("generated_name")) delete this.generated_name; + this.site_codes = [ + this._id, + this.name, + this.generated_name, + this.lat_long, + ]; + if (this.search_name) this.site_codes.push(this.search_name); + if (this.location_name) this.site_codes.push(this.location_name); + if (this.formatted_name) this.site_codes.push(this.formatted_name); + + const duplicateValues = this.grids.filter( + (value, index, self) => self.indexOf(value) !== index + ); + if (duplicateValues.length > 0) { + return next(new Error("Duplicate values found in grids array.")); + } + } -siteSchema.pre("update", function(next) { - if (this.isModified("latitude")) { - delete this.latitude; + next(); } - if (this.isModified("longitude")) { - delete this.longitude; - } - if (this.isModified("_id")) { - delete this._id; - } - if (this.isModified("generated_name")) { - delete this.generated_name; - } - return next(); -}); +); siteSchema.index({ lat_long: 1 }, { unique: true }); siteSchema.index({ generated_name: 1 }, { unique: true }); diff --git a/src/device-registry/routes/v2/sites.js b/src/device-registry/routes/v2/sites.js index d4e1a46796..8d3198e89e 100644 --- a/src/device-registry/routes/v2/sites.js +++ b/src/device-registry/routes/v2/sites.js @@ -944,6 +944,86 @@ router.delete( ]), siteController.delete ); +router.post( + "/approximate", + oneOf([ + [ + body("latitude") + .exists() + .withMessage("the latitude should be provided") + .bail() + .matches(constants.LATITUDE_REGEX, "i") + .withMessage("please provide valid latitude value") + .bail() + .custom((value) => { + let dp = decimalPlaces(value); + if (dp < 2) { + return Promise.reject( + "the latitude must have 2 or more decimal places" + ); + } + return Promise.resolve("latitude validation test has passed"); + }), + body("longitude") + .exists() + .withMessage("the longitude is is missing in your request") + .bail() + .matches(constants.LONGITUDE_REGEX, "i") + .withMessage("please provide valid longitude value") + .bail() + .custom((value) => { + let dp = decimalPlaces(value); + if (dp < 2) { + return Promise.reject( + "the longitude must have 2 or more decimal places" + ); + } + return Promise.resolve("longitude validation test has passed"); + }), + ], + ]), + siteController.createApproximateCoordinates +); +router.get( + "/approximate", + oneOf([ + [ + query("latitude") + .exists() + .withMessage("the latitude should be provided") + .bail() + .matches(constants.LATITUDE_REGEX, "i") + .withMessage("please provide valid latitude value") + .bail() + .custom((value) => { + let dp = decimalPlaces(value); + if (dp < 2) { + return Promise.reject( + "the latitude must have 2 or more decimal places" + ); + } + return Promise.resolve("latitude validation test has passed"); + }), + query("longitude") + .exists() + .withMessage("the longitude is is missing in your request") + .bail() + .matches(constants.LONGITUDE_REGEX, "i") + .withMessage("please provide valid longitude value") + .bail() + .custom((value) => { + let dp = decimalPlaces(value); + if (dp < 2) { + return Promise.reject( + "the longitude must have 2 or more decimal places" + ); + } + return Promise.resolve("longitude validation test has passed"); + }), + ], + ]), + siteController.createApproximateCoordinates +); router.get( "/nearest", oneOf([ diff --git a/src/website/Dockerfile b/src/website/Dockerfile index 541794f91b..d337097193 100644 --- a/src/website/Dockerfile +++ b/src/website/Dockerfile @@ -21,9 +21,6 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy the rest of the backend code into the container COPY . . -# Ensure environment variables are read correctly -COPY .env .env - # Expose the port the Django app will run on EXPOSE 8000 diff --git a/src/website/README.md b/src/website/README.md new file mode 100644 index 0000000000..d50f5ebfdb --- /dev/null +++ b/src/website/README.md @@ -0,0 +1 @@ +# New Website Backend diff --git a/src/website/core/urls.py b/src/website/core/urls.py index 978bffbd62..3d3c910ca5 100644 --- a/src/website/core/urls.py +++ b/src/website/core/urls.py @@ -34,33 +34,33 @@ def healthcheck(request): ) urlpatterns = [ - path('admin/', admin.site.urls), + path('website/admin/', admin.site.urls), # API routes from custom apps in the 'apps' folder - path('api/', include('apps.press.urls')), - path('api/', include('apps.impact.urls')), - path('api/', include('apps.event.urls')), - path('api/', include('apps.highlights.urls')), - path('api/', include('apps.career.urls')), - path('api/', include('apps.publications.urls')), - path('api/', include('apps.team.urls')), - path('api/', include('apps.board.urls')), - path('api/', include('apps.externalTeam.urls')), - path('api/', include('apps.partners.urls')), - path('api/', include('apps.cleanair.urls')), - path('api/', include('apps.FAQ.urls')), - path('api/', include('apps.africancities.urls')), + path('website/', include('apps.press.urls')), + path('website/', include('apps.impact.urls')), + path('website/', include('apps.event.urls')), + path('website/', include('apps.highlights.urls')), + path('website/', include('apps.career.urls')), + path('website/', include('apps.publications.urls')), + path('website/', include('apps.team.urls')), + path('website/', include('apps.board.urls')), + path('website/', include('apps.externalTeam.urls')), + path('website/', include('apps.partners.urls')), + path('website/', include('apps.cleanair.urls')), + path('website/', include('apps.FAQ.urls')), + path('website/', include('apps.africancities.urls')), # Swagger URLs re_path(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), - path('swagger/', schema_view.with_ui('swagger', + path('website/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('redoc/', schema_view.with_ui('redoc', + path('website/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), # Healthcheck route for Docker container readiness - path('healthcheck/', healthcheck, name='healthcheck'), + path('website/healthcheck/', healthcheck, name='healthcheck'), ] # Serve media files during development