From 8c54acc7dabc4290c1247a7e8d74357ef2d92e2d Mon Sep 17 00:00:00 2001 From: Youssef Date: Sat, 4 Apr 2026 15:11:21 +0200 Subject: [PATCH 01/35] feat: initial file structure --- .dockerignore | 8 + .github/workflows/branch-validation.yml | 14 + .github/workflows/docker.yml | 32 ++ .github/workflows/test.yml | 33 ++ .gitignore | 40 ++ .husky/commit-msg | 2 + .husky/pre-commit | 7 + .npmrc | 1 + .prettierrc | 4 + Dockerfile | 43 ++ Dockerfile.dev | 22 + LICENSE | 40 ++ docker-compose.yml | 84 ++++ docker.sh | 77 ++++ overrides/api.cloud.yml | 14 + overrides/api.dev.yml | 19 + overrides/api.test.yml | 13 + prisma/migrations/0_init/migration.sql | 35 ++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 39 ++ src/__tests__/app.test.ts | 75 ++++ src/__tests__/config/env.test.ts | 49 +++ .../controllers/auth.controller.test.ts | 198 +++++++++ src/__tests__/integration/auth.routes.test.ts | 335 +++++++++++++++ src/__tests__/lib/redis.test.ts | 50 +++ src/__tests__/middlewares/logger.test.ts | 25 ++ src/__tests__/middlewares/requireAuth.test.ts | 125 ++++++ src/__tests__/middlewares/validate.test.ts | 53 +++ src/__tests__/mocks/ioredis.ts | 15 + src/__tests__/mocks/prisma.ts | 22 + src/__tests__/mocks/redis.ts | 35 ++ src/__tests__/queues/email.queue.test.ts | 21 + src/__tests__/services/auth.service.test.ts | 390 ++++++++++++++++++ src/__tests__/services/email.service.test.ts | 42 ++ src/__tests__/services/logger.service.test.ts | 24 ++ src/__tests__/services/oauth.service.test.ts | 122 ++++++ src/__tests__/utils/errors.test.ts | 44 ++ src/__tests__/utils/redirect.test.ts | 35 ++ src/__tests__/workers/email.worker.test.ts | 53 +++ src/api/controllers/auth.controller.ts | 138 +++++++ src/api/middlewares/errorHandler.ts | 18 + src/api/middlewares/logger.ts | 26 ++ src/api/middlewares/requireAuth.ts | 21 + src/api/middlewares/validate.ts | 29 ++ src/api/routes/auth.routes.ts | 29 ++ src/app.ts | 52 +++ src/config/env.ts | 55 +++ src/config/openapi/auth.ts | 110 +++++ src/config/openapi/index.ts | 15 + src/config/openapi/registry.ts | 11 + src/config/swagger.ts | 25 ++ src/constants/auth.ts | 10 + src/constants/messages/auth.ts | 50 +++ src/constants/messages/index.ts | 5 + src/index.ts | 37 ++ src/lib/prisma.ts | 12 + src/lib/redis.ts | 44 ++ src/models/auth.ts | 53 +++ src/models/common.ts | 27 ++ src/models/express.d.ts | 9 + src/models/user.ts | 14 + src/queues/email.queue.ts | 10 + src/services/auth.service.ts | 201 +++++++++ src/services/email.service.ts | 42 ++ src/services/logger.service.ts | 19 + src/services/oauth.service.ts | 150 +++++++ src/types/auth.ts | 21 + src/utils/errors.ts | 34 ++ src/utils/redirect.ts | 26 ++ src/utils/token.ts | 32 ++ src/utils/zod.ts | 14 + src/workers/email.worker.ts | 21 + 72 files changed, 3603 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/branch-validation.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .husky/commit-msg create mode 100644 .husky/pre-commit create mode 100644 .npmrc create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 Dockerfile.dev create mode 100644 LICENSE create mode 100644 docker-compose.yml create mode 100644 docker.sh create mode 100644 overrides/api.cloud.yml create mode 100644 overrides/api.dev.yml create mode 100644 overrides/api.test.yml create mode 100644 prisma/migrations/0_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/__tests__/app.test.ts create mode 100644 src/__tests__/config/env.test.ts create mode 100644 src/__tests__/controllers/auth.controller.test.ts create mode 100644 src/__tests__/integration/auth.routes.test.ts create mode 100644 src/__tests__/lib/redis.test.ts create mode 100644 src/__tests__/middlewares/logger.test.ts create mode 100644 src/__tests__/middlewares/requireAuth.test.ts create mode 100644 src/__tests__/middlewares/validate.test.ts create mode 100644 src/__tests__/mocks/ioredis.ts create mode 100644 src/__tests__/mocks/prisma.ts create mode 100644 src/__tests__/mocks/redis.ts create mode 100644 src/__tests__/queues/email.queue.test.ts create mode 100644 src/__tests__/services/auth.service.test.ts create mode 100644 src/__tests__/services/email.service.test.ts create mode 100644 src/__tests__/services/logger.service.test.ts create mode 100644 src/__tests__/services/oauth.service.test.ts create mode 100644 src/__tests__/utils/errors.test.ts create mode 100644 src/__tests__/utils/redirect.test.ts create mode 100644 src/__tests__/workers/email.worker.test.ts create mode 100644 src/api/controllers/auth.controller.ts create mode 100644 src/api/middlewares/errorHandler.ts create mode 100644 src/api/middlewares/logger.ts create mode 100644 src/api/middlewares/requireAuth.ts create mode 100644 src/api/middlewares/validate.ts create mode 100644 src/api/routes/auth.routes.ts create mode 100644 src/app.ts create mode 100644 src/config/env.ts create mode 100644 src/config/openapi/auth.ts create mode 100644 src/config/openapi/index.ts create mode 100644 src/config/openapi/registry.ts create mode 100644 src/config/swagger.ts create mode 100644 src/constants/auth.ts create mode 100644 src/constants/messages/auth.ts create mode 100644 src/constants/messages/index.ts create mode 100644 src/index.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/redis.ts create mode 100644 src/models/auth.ts create mode 100644 src/models/common.ts create mode 100644 src/models/express.d.ts create mode 100644 src/models/user.ts create mode 100644 src/queues/email.queue.ts create mode 100644 src/services/auth.service.ts create mode 100644 src/services/email.service.ts create mode 100644 src/services/logger.service.ts create mode 100644 src/services/oauth.service.ts create mode 100644 src/types/auth.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/redirect.ts create mode 100644 src/utils/token.ts create mode 100644 src/utils/zod.ts create mode 100644 src/workers/email.worker.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..44c43bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.env +.env.* +*.log +.git +.husky +.github diff --git a/.github/workflows/branch-validation.yml b/.github/workflows/branch-validation.yml new file mode 100644 index 0000000..52c5285 --- /dev/null +++ b/.github/workflows/branch-validation.yml @@ -0,0 +1,14 @@ +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +name: Branch validation + +on: + pull_request: + types: [opened] + branches: [main, develop] + +jobs: + validate-branch: + uses: CoveritLabs/.github/.github/workflows/branch-validation.yml@main diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..90110de --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,32 @@ +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +name: Docker + +on: + push: + branches: [main, develop] + paths-ignore: + - "**.md" + - "docs/**" + - ".github/**" + - ".husky/**" + - ".vscode/**" + pull_request: + branches: [main, develop] + paths-ignore: + - "**.md" + - "docs/**" + - ".github/**" + - ".husky/**" + - ".vscode/**" + release: + types: [published] + +jobs: + docker: + uses: CoveritLabs/.github/.github/workflows/docker-build.yml@main + with: + image-name: coveritlabs/coverit-api + secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ce1c924 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +name: Tests + +on: + push: + branches: [main, develop] + paths-ignore: + - "**.md" + - "docs/**" + - ".github/**" + - ".husky/**" + - ".vscode/**" + pull_request: + branches: [main, develop] + paths-ignore: + - "**.md" + - "docs/**" + - ".github/**" + - ".husky/**" + - ".vscode/**" + +permissions: + contents: read + packages: read + pull-requests: write + +jobs: + test: + uses: CoveritLabs/.github/.github/workflows/test-and-coverage.yml@main + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..104d867 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Dependencies +node_modules +.pnp +.pnp.js + +# Build output +dist + +# Test / coverage +coverage + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# TypeScript cache +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.*.local +.env.* + +/src/generated/prisma diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..3277788 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,2 @@ +#!/bin/sh +npx coverit-validate-commit-msg "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..fb65299 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh + +# license header injection +npx coverit-add-license-header + +# branch name validation +npx coverit-validate-branch diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9e09b04 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@coveritlabs:registry=https://npm.pkg.github.com diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8f02c18 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "printWidth": 120 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8cc148a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Build stage +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN --mount=type=secret,id=npm_token \ + printf "@coveritlabs:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)\n" > .npmrc \ + && npm ci --ignore-scripts \ + && rm -f .npmrc + +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN npx prisma generate + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# Production stage +FROM node:22-alpine + +WORKDIR /app + +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +RUN --mount=type=secret,id=npm_token \ + printf "@coveritlabs:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)\n" > .npmrc \ + && npm ci --ignore-scripts --omit=dev \ + && rm -f .npmrc + +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN npx prisma generate + +COPY --from=build /app/dist ./dist + +EXPOSE 3000 + +USER node + +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..81aa40e --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,22 @@ +# Build stage for development +FROM node:22-alpine + +WORKDIR /app + +# Install dependencies (including devDependencies) +COPY package.json package-lock.json ./ +RUN --mount=type=secret,id=npm_token \ + printf "@coveritlabs:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)\n" > .npmrc \ + && npm ci --ignore-scripts \ + && rm -f .npmrc + +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN npx prisma generate + +# Source code will be mounted at runtime via docker-compose +ENV NODE_ENV=development + +EXPOSE 3000 + +CMD ["sh", "-c", "npx prisma migrate deploy && npx nodemon"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9a1376 --- /dev/null +++ b/LICENSE @@ -0,0 +1,40 @@ +COVERIT LABS PROPRIETARY SOFTWARE LICENSE + +Copyright (c) 2026 CoverIt Labs. All Rights Reserved. + +NOTICE: This software and its source code are the exclusive property of +CoverIt Labs and constitute confidential and proprietary trade secrets. + +RESTRICTIONS: +1. No part of this software, including source code, documentation, or + associated materials, may be copied, reproduced, modified, translated, + adapted, distributed, transmitted, displayed, performed, published, + licensed, transferred, sold, or used to create derivative works — in + whole or in part — by any means or in any form, without the prior + explicit written consent of CoverIt Labs. + +2. Access to this software is granted solely to authorized personnel and + contractors of CoverIt Labs for the purpose of developing, testing, and + maintaining CoverIt products and services. + +3. Authorized users must not disclose any part of this software or its + contents to any third party without the prior written approval of + CoverIt Labs. + +4. All intellectual property rights in and to this software, including + patents, copyrights, trademarks, and trade secrets, are and shall + remain the exclusive property of CoverIt Labs. + +DISCLAIMER: +THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. COVERIT +LABS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. IN NO EVENT SHALL COVERIT LABS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF OR IN CONNECTION WITH THE USE OF THIS SOFTWARE. + +Any violation of these terms will result in immediate termination of +access rights and may be subject to civil and criminal penalties under +applicable law. + +For licensing inquiries, contact the main contributor of this repository. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..456ef38 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +name: coverit + +services: + crawler: + image: ghcr.io/coveritlabs/coverit-crawler:${CRAWLER_TAG:-dev} + build: + context: ${CRAWLER_DIR} + secrets: + - npm_token + pull_policy: always + container_name: coverit-crawler + ports: + - "3000:3000" + environment: + - NODE_ENV=${NODE_ENV:-development} + - PORT=3000 + - DATABASE_URL=${DATABASE_URL:-} + - REDIS_URL=${REDIS_URL:-} + - JWT_SECRET=${JWT_SECRET:-} + - JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m} + - JWT_REFRESH_EXPIRY_SECONDS=${JWT_REFRESH_EXPIRY_SECONDS:-604800} + - RESET_TOKEN_TTL_SECONDS=${RESET_TOKEN_TTL_SECONDS:-3600} + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} + - CORS_CREDENTIALS=${CORS_CREDENTIALS:-true} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost:3000/crawler/v1/auth/oauth/google/callback} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} + - GITHUB_CALLBACK_URL=${GITHUB_CALLBACK_URL:-http://localhost:3000/crawler/v1/auth/oauth/github/callback} + - RESEND_CRAWLER_KEY=${RESEND_CRAWLER_KEY:-} + - RESET_PASSWORD_EMAIL=${RESET_PASSWORD_EMAIL:-} + - RESET_PASSWORD_TEMPLATE_ID=${RESET_PASSWORD_TEMPLATE_ID:-} + - NODE_TLS_REJECT_UNAUTHORIZED=0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + db: + image: postgres:18 + container_name: coverit-postgres + environment: + - POSTGRES_USER=${DB_USER:-postgres} + - POSTGRES_PASSWORD=${DB_PASSWORD:-postgres} + - POSTGRES_DB=${DB_NAME:-coverit} + volumes: + - postgres_data:/var/lib/postgresql + - ./database:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: coverit-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +secrets: + npm_token: + environment: GITHUB_TOKEN + +volumes: + postgres_data: + redis_data: diff --git a/docker.sh b/docker.sh new file mode 100644 index 0000000..8ad220e --- /dev/null +++ b/docker.sh @@ -0,0 +1,77 @@ +#!/bin/sh + +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + + +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + + +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +# Usage: +# ./docker.sh up → remote image + cloud db/redis +# ./docker.sh up --local → local dev build + hot-reload + local db/redis +# ./docker.sh up --test-prod → local prod build + cloud db/redis +# ./docker.sh up --tag latest → remote image (specific tag) + cloud db/redis + +print_help() { + echo "Usage: $0 [up|down|logs] [--tag ] [--local] [--test-prod]" +} + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +export API_DIR="$SCRIPT_DIR" + +CMD="${1:-up}" +shift 2>/dev/null || true + +# Defaults +export API_TAG="dev" +LOCAL=false +TEST_PROD=false + +while [ $# -gt 0 ]; do + case "$1" in + --tag) export API_TAG="$2"; shift 2 ;; + --local) LOCAL=true; shift ;; + --test-prod) TEST_PROD=true; shift ;; + -h|--help) print_help; exit 0 ;; + *) echo "Unknown flag: $1"; exit 1 ;; + esac +done + +# Only --local uses local db/redis. Everything else connects to cloud. +if [ "$LOCAL" = true ]; then + echo "Starting API in local dev mode (local db + redis)..." + EXEC_CMD="docker compose -f docker-compose.yml -f overrides/api.dev.yml" +elif [ "$TEST_PROD" = true ]; then + echo "Starting API in Production Test mode (local prod build + cloud)..." + EXEC_CMD="docker compose -f docker-compose.yml -f overrides/api.cloud.yml -f overrides/api.test.yml" +else + echo "Starting API using remote image (API_TAG=$API_TAG) + cloud..." + EXEC_CMD="docker compose -f docker-compose.yml -f overrides/api.cloud.yml" +fi + +case "$CMD" in + up) + $EXEC_CMD up -d --build + ;; + down) + $EXEC_CMD down + ;; + logs) + $EXEC_CMD logs -f + ;; + *) + echo "Unknown command: $CMD" + print_help + exit 1 + ;; +esac diff --git a/overrides/api.cloud.yml b/overrides/api.cloud.yml new file mode 100644 index 0000000..11b4672 --- /dev/null +++ b/overrides/api.cloud.yml @@ -0,0 +1,14 @@ +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +# Cloud override — points API at cloud db/redis (e.g. Aiven). +# Requires DATABASE_URL, REDIS_URL, and JWT_SECRET in .env. +# +# Used by: +# ./docker.sh up → remote image + cloud +# ./docker.sh up --test-prod → local prod build + cloud + +services: + api: + depends_on: [] diff --git a/overrides/api.dev.yml b/overrides/api.dev.yml new file mode 100644 index 0000000..a90a072 --- /dev/null +++ b/overrides/api.dev.yml @@ -0,0 +1,19 @@ +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +# Modular override for API development with hot-reload +# Inherits JWT_ACCESS_EXPIRY, JWT_REFRESH_EXPIRY_SECONDS, RESET_TOKEN_TTL_SECONDS, +# REDIS_URL, and CORS_ORIGINS from the base docker-compose.yml. + +services: + api: + image: coverit-api-dev-local + build: + dockerfile: Dockerfile.dev + volumes: + - ${API_DIR}/src:/app/src + - ${API_DIR}/prisma:/app/prisma + - ${API_DIR}/prisma.config.ts:/app/prisma.config.ts + - ${API_DIR}/nodemon.json:/app/nodemon.json + - ${API_DIR}/tsconfig.json:/app/tsconfig.json diff --git a/overrides/api.test.yml b/overrides/api.test.yml new file mode 100644 index 0000000..3cc4d1d --- /dev/null +++ b/overrides/api.test.yml @@ -0,0 +1,13 @@ +# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +# Proprietary and confidential. Unauthorized use is strictly prohibited. +# See LICENSE file in the project root for full license information. + +# Build override — builds the production Dockerfile locally. +# Always layered on top of api.cloud.yml: +# docker compose -f docker-compose.yml -f overrides/api.cloud.yml -f overrides/api.test.yml + +services: + api: + image: coverit-api-test-local + build: + dockerfile: Dockerfile \ No newline at end of file diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..d8ddf7e --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,35 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT, + "name" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "accounts" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..c32fee7 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,39 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model User { + id String @id @default(uuid()) @db.Uuid + email String @unique + password String? + name String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + accounts Account[] + + @@map("users") +} + +model Account { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + provider String + providerAccountId String @map("provider_account_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@map("accounts") +} diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts new file mode 100644 index 0000000..c476557 --- /dev/null +++ b/src/__tests__/app.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +jest.mock("@config/env", () => ({ + env: { + CORS_ORIGINS: ["http://localhost:5173", "http://example.com"], + CORS_CREDENTIALS: "true", + API_PREFIX: "/api/v1", + RESEND_API_KEY: "test-key" + } +})); + +jest.mock("bullmq", () => { + return { + Queue: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + add: jest.fn(), + })), + Worker: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn(), + })), + }; +}); + +jest.mock("@lib/redis", () => require("./mocks/redis")); +jest.mock("@lib/prisma", () => require("./mocks/prisma")); + +jest.mock("@workers/email.worker", () => ({})); + +import request from "supertest"; +import app from "../app"; +import { env } from "@config/env"; + +describe("app.ts", () => { + test("GET /health should return status ok", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(res.body.timestamp).toBeDefined(); + }); + + test("GET /docs.json should return swagger spec", async () => { + const res = await request(app).get("/docs.json"); + expect(res.status).toBe(200); + expect(res.body.openapi).toBeDefined(); + expect(res.body.info).toBeDefined(); + }); + + describe("CORS configuration", () => { + test("should allow requests from allowed origins", async () => { + // Assuming env.FRONTEND_URL or standard localhost is in CORS_ORIGINS + const validOrigin = env.CORS_ORIGINS[0] === "*" ? "http://example.com" : env.CORS_ORIGINS[0]; + const res = await request(app).options("/health").set("Origin", validOrigin); + expect(res.status).toBe(200); + expect(res.header["access-control-allow-origin"]).toBe(validOrigin); + }); + + test("should reject requests from disallowed origins", async () => { + if (env.CORS_ORIGINS.includes("*")) { + // Skip if everything is allowed + return; + } + const res = await request(app).options("/health").set("Origin", "http://malicious.com"); + // Cors middleware typically sends a 500 when the origin callback throws an error + expect(res.status).toBe(500); + }); + + test("should allow requests with no origin (e.g., server-to-server)", async () => { + const res = await request(app).get("/health"); // No Origin header set + expect(res.status).toBe(200); + }); + }); +}); diff --git a/src/__tests__/config/env.test.ts b/src/__tests__/config/env.test.ts new file mode 100644 index 0000000..752cc5d --- /dev/null +++ b/src/__tests__/config/env.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +describe("config/env", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = {}; // Clear environment to force defaults + // suppress console.info + jest.spyOn(console, "info").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + test("loads correct defaults when process.env variables are missing", async () => { + const { env } = await import("@config/env"); + + expect(env.NODE_ENV).toBe("development"); + expect(env.PORT).toBe(3000); + expect(env.DATABASE_URL).toBe(""); + expect(env.REDIS_URL).toBe("redis://localhost:6379"); + expect(env.CORS_ORIGINS).toEqual(["http://localhost:5173"]); + expect(env.CORS_CREDENTIALS).toBe("true"); + expect(env.JWT_SECRET).toBe(""); + expect(env.JWT_ACCESS_EXPIRY).toBe("15m"); + expect(env.JWT_REFRESH_EXPIRY_SECONDS).toBe(604800); + expect(env.RESET_TOKEN_TTL_SECONDS).toBe(900); + expect(env.API_PREFIX).toBe("/api/v1"); + + expect(env.FRONTEND_URL).toBe("http://localhost:5173"); + expect(env.GOOGLE_CLIENT_ID).toBe(""); + expect(env.GITHUB_CLIENT_ID).toBe(""); + expect(console.info).toHaveBeenCalled(); + }); + + test("loads provided values overriding defaults", async () => { + process.env.PORT = "4000"; + process.env.CORS_ORIGINS = "https://a.com,https://b.com"; + + const { env } = await import("@config/env"); + expect(env.PORT).toBe(4000); + expect(env.CORS_ORIGINS).toEqual(["https://a.com", "https://b.com"]); + }); +}); diff --git a/src/__tests__/controllers/auth.controller.test.ts b/src/__tests__/controllers/auth.controller.test.ts new file mode 100644 index 0000000..a180756 --- /dev/null +++ b/src/__tests__/controllers/auth.controller.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import express from "express"; +import request from "supertest"; + +jest.mock("@services/auth.service"); +jest.mock("@services/oauth.service"); +jest.mock("@config/env", () => ({ env: { FRONTEND_URL: "https://app.example.com" } })); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); + +import * as authService from "@services/auth.service"; +import * as oauthService from "@services/oauth.service"; +import { buildRedirectUrl } from "@utils/redirect"; +import { env } from "@config/env"; +import { + signup, + login, + refresh, + logout, + forgotPassword, + resetPassword, + oauthRedirect, + oauthCallback, +} from "@api/controllers/auth.controller"; +import { AUTH_MESSAGES } from "@constants/messages"; + +function makeApp() { + const a = express(); + a.use(express.json()); + a.post("/signup", signup); + a.post("/login", login); + a.post("/refresh", refresh); + a.post("/logout", logout); + a.post("/forgot", forgotPassword); + a.post("/reset", resetPassword); + a.get("/oauth/:provider/redirect", oauthRedirect); + a.get("/oauth/:provider/callback", oauthCallback); + return a; +} + +describe("auth.controller", () => { + let app: express.Application; + + beforeEach(() => { + jest.resetAllMocks(); + app = makeApp(); + }); + + test("signup returns 201 on success", async () => { + (authService.signup as jest.Mock).mockResolvedValue({ message: "ok" }); + const res = await request(app).post("/signup").send({ email: "a@b.com", password: "p", name: "n" }); + expect(res.status).toBe(201); + expect(res.body).toEqual({ message: "ok" }); + }); + + test("login returns 200 on success", async () => { + (authService.login as jest.Mock).mockResolvedValue({ tokens: {}, user: {} }); + const res = await request(app).post("/login").send({ email: "a@b.com", password: "p" }); + expect(res.status).toBe(200); + }); + + test("refresh returns 200 on success", async () => { + (authService.refresh as jest.Mock).mockResolvedValue({ tokens: {} }); + const res = await request(app).post("/refresh").send({ refreshToken: "rt" }); + expect(res.status).toBe(200); + }); + + test("logout with refreshToken calls service", async () => { + (authService.logout as jest.Mock).mockResolvedValue({ message: "logged out" }); + const res = await request(app).post("/logout").send({ refreshToken: "x" }); + expect(res.status).toBe(200); + expect(authService.logout).toHaveBeenCalledWith("x"); + }); + + test("logout without refreshToken returns success message", async () => { + const res = await request(app).post("/logout").send({}); + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); + }); + + test("forgotPassword triggers service and returns 200", async () => { + (authService.forgotPassword as jest.Mock).mockResolvedValue(undefined); + const res = await request(app).post("/forgot").send({ email: "a@b.com" }); + expect(res.status).toBe(200); + expect(authService.forgotPassword).toHaveBeenCalledWith({ email: "a@b.com" }); + }); + + test("resetPassword returns 200 on success", async () => { + (authService.resetPassword as jest.Mock).mockResolvedValue({ message: "ok" }); + const res = await request(app).post("/reset").send({ token: "t", newPassword: "np" }); + expect(res.status).toBe(200); + }); + + test("oauthRedirect returns 400 for unsupported provider", async () => { + const res = await request(app).get("/oauth/unknown/redirect"); + expect(res.status).toBe(400); + expect(res.body.message).toBe(AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER); + }); + + test("oauthRedirect redirects to provider authorization url", async () => { + (oauthService.getAuthorizationUrl as jest.Mock).mockReturnValue("https://auth.example"); + const res = await request(app).get("/oauth/google/redirect"); + expect(res.status).toBe(302); + expect(res.header.location).toBe("https://auth.example"); + }); + + test("oauthCallback redirects to login when code missing", async () => { + const res = await request(app).get("/oauth/google/callback"); + expect(res.status).toBe(302); + expect(res.header.location).toBe( + buildRedirectUrl(env.FRONTEND_URL, "/login", { error: AUTH_MESSAGES.OAUTH_CODE_MISSING }), + ); + }); + + test("oauthCallback redirects back to frontend on success", async () => { + (oauthService.exchangeCodeForProfile as jest.Mock).mockResolvedValue({ email: "a@b.com", name: "A" }); + (authService.oauthLogin as jest.Mock).mockResolvedValue({ + tokens: { accessToken: "a", refreshToken: "r" }, + user: { id: "u", email: "a@b.com", name: "A" }, + }); + const res = await request(app).get("/oauth/google/callback").query({ code: "c" }); + expect(res.status).toBe(302); + expect(res.header.location).toContain(`${env.FRONTEND_URL}/oauth/callback?`); + }); + + test("oauthCallback redirects to login with error when exchange fails", async () => { + (oauthService.exchangeCodeForProfile as jest.Mock).mockRejectedValue(new Error("fail")); + const res = await request(app).get("/oauth/google/callback").query({ code: "c" }); + expect(res.status).toBe(302); + expect(res.header.location).toBe(buildRedirectUrl(env.FRONTEND_URL, "/login", { error: "fail" })); + }); + + describe("error handling catch blocks", () => { + test("signup next(err) on service failure", async () => { + (authService.signup as jest.Mock).mockRejectedValue(new Error("signup fail")); + const res = await request(app).post("/signup").send({}); + expect(res.status).toBe(500); + }); + + test("login next(err)", async () => { + (authService.login as jest.Mock).mockRejectedValue(new Error("login fail")); + const res = await request(app).post("/login").send({}); + expect(res.status).toBe(500); + }); + + test("refresh next(err)", async () => { + (authService.refresh as jest.Mock).mockRejectedValue(new Error("refresh fail")); + const res = await request(app).post("/refresh").send({}); + expect(res.status).toBe(500); + }); + + test("logout next(err)", async () => { + (authService.logout as jest.Mock).mockRejectedValue(new Error("logout fail")); + const res = await request(app).post("/logout").send({ refreshToken: "x" }); + expect(res.status).toBe(500); + }); + + test("forgotPassword next(err)", async () => { + const mockReq = { body: {} } as any; + const mockRes = {} as any; + const nextFn = jest.fn(); + + const originalForgot = authService.forgotPassword; + Object.defineProperty(authService, "forgotPassword", { value: () => { throw new Error("sync throw"); } }); + + await forgotPassword(mockReq, mockRes, nextFn); + expect(nextFn).toHaveBeenCalledWith(new Error("sync throw")); + + Object.defineProperty(authService, "forgotPassword", { value: originalForgot }); + }); + + test("resetPassword next(err)", async () => { + (authService.resetPassword as jest.Mock).mockRejectedValue(new Error("reset fail")); + const res = await request(app).post("/reset").send({}); + expect(res.status).toBe(500); + }); + + test("oauthRedirect next(err)", async () => { + const mockReq = { params: { provider: "google" } } as any; + const mockRes = {} as any; + const nextFn = jest.fn(); + + const originalRedirect = oauthService.getAuthorizationUrl; + Object.defineProperty(oauthService, "getAuthorizationUrl", { value: () => { throw new Error("sync throw auth"); } }); + + await oauthRedirect(mockReq, mockRes, nextFn); + expect(nextFn).toHaveBeenCalledWith(new Error("sync throw auth")); + + Object.defineProperty(oauthService, "getAuthorizationUrl", { value: originalRedirect }); + }); + }); +}); diff --git a/src/__tests__/integration/auth.routes.test.ts b/src/__tests__/integration/auth.routes.test.ts new file mode 100644 index 0000000..16af18e --- /dev/null +++ b/src/__tests__/integration/auth.routes.test.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +/** + * @file Integration tests for all POST /auth/* routes via supertest. + */ + +import { AUTH_MESSAGES, AUTH_VALIDATION } from "@constants/messages"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +jest.mock("@workers/email.worker", () => ({})); +jest.mock("@lib/prisma", () => require("../mocks/prisma")); +jest.mock("@lib/redis", () => require("../mocks/redis")); +jest.mock("@services/email.service", () => ({ + sendResetEmail: jest.fn().mockResolvedValue(undefined), +})); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); + +jest.mock("@config/env", () => ({ + env: { + NODE_ENV: "test", + PORT: 3000, + JWT_SECRET: "test-secret", + JWT_ACCESS_EXPIRY: "15m", + JWT_REFRESH_EXPIRY_SECONDS: 604800, + RESET_TOKEN_TTL_SECONDS: 3600, + API_PREFIX: "/api/v1", + RESEND_API_KEY: "test-key", + RESET_PASSWORD_EMAIL: "test@test.com", + FRONTEND_URL: "https://app.example.com", + }, +})); + +jest.mock("argon2", () => ({ + hash: jest.fn().mockResolvedValue("$argon2-hashed"), + verify: jest.fn(), +})); + +import { env } from "@config/env"; +import prisma from "@lib/prisma"; +import redis from "@lib/redis"; +import argon2 from "argon2"; +import app from "../../app"; + +const BASE = `${env.API_PREFIX}/auth`; + +const mockPrisma = prisma as any; // eslint-disable-line @typescript-eslint/no-explicit-any +const mockRedis = redis as any; // eslint-disable-line @typescript-eslint/no-explicit-any +const mockArgon2 = argon2 as any; // eslint-disable-line @typescript-eslint/no-explicit-any + +describe("POST /auth/signup", () => { + it("should return 201 on successful signup", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-1", + email: "new@user.com", + name: "New User", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await request(app) + .post(`${BASE}/signup`) + .send({ email: "new@user.com", password: "P@ssword1", name: "New User" }); + + expect(res.status).toBe(201); + expect(res.body.message).toBe(AUTH_MESSAGES.SIGNUP_SUCCESS); + }); + + it("should return 409 if email already exists", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "dup@user.com", + name: "Dup", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await request(app) + .post(`${BASE}/signup`) + .send({ email: "dup@user.com", password: "P@ssword1", name: "Dup" }); + + expect(res.status).toBe(409); + expect(res.body.message).toBe(AUTH_MESSAGES.EMAIL_TAKEN); + }); + + it("should not return any tokens or set cookies", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "User", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await request(app) + .post(`${BASE}/signup`) + .send({ email: "a@b.com", password: "P@ssword1", name: "User" }); + + expect(res.status).toBe(201); + expect(res.body.tokens).toBeUndefined(); + }); +}); + +describe("POST /auth/login", () => { + it("should return 200 with user info and tokens in response body", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "user@test.com", + name: "Test", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockArgon2.verify.mockResolvedValue(true); + mockRedis.set.mockResolvedValue("OK"); + + const res = await request(app).post(`${BASE}/login`).send({ email: "user@test.com", password: "P@ssword1" }); + + expect(res.status).toBe(200); + expect(res.body.user).toEqual({ id: "uuid-1", email: "user@test.com", name: "Test" }); + expect(res.body.tokens).toBeDefined(); + expect(res.body.tokens.accessToken).toBeDefined(); + expect(res.body.tokens.refreshToken).toBeDefined(); + }); + + it("should return 401 for wrong email", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + const res = await request(app).post(`${BASE}/login`).send({ email: "wrong@test.com", password: "P@ssword1" }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); + }); + + it("should return 401 for wrong password", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "user@test.com", + name: "Test", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockArgon2.verify.mockResolvedValue(false); + + const res = await request(app).post(`${BASE}/login`).send({ email: "user@test.com", password: "WrongPass" }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); + }); +}); + +describe("POST /auth/refresh", () => { + it("should return 200 and rotate tokens on valid refresh token", async () => { + const oldToken = "valid-refresh-token"; + const crypto = require("crypto"); + const hashed = crypto.createHash("sha256").update(oldToken).digest("hex"); + const key = `refresh:uuid-1:${hashed}`; + + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); + mockRedis.set.mockResolvedValue("OK"); + + const res = await request(app).post(`${BASE}/refresh`).send({ refreshToken: oldToken }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.REFRESH_SUCCESS); + expect(res.body.tokens).toBeDefined(); + expect(res.body.tokens.accessToken).toBeDefined(); + expect(res.body.tokens.refreshToken).toBeDefined(); + }); + + it("should return 401 if no refresh token in body", async () => { + const res = await request(app).post(`${BASE}/refresh`).send({}); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED); + }); + + it("should return 401 if refresh token not in Redis", async () => { + mockRedis.scan.mockResolvedValue(["0", []]); + + const res = await request(app).post(`${BASE}/refresh`).send({ refreshToken: "bad-token" }); + + expect(res.status).toBe(401); + }); +}); + +describe("POST /auth/logout", () => { + it("should return 200 and invalidate refresh token", async () => { + const token = "some-refresh-token"; + const crypto = require("crypto"); + const hashed = crypto.createHash("sha256").update(token).digest("hex"); + const key = `refresh:uuid-1:${hashed}`; + + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); + + const res = await request(app).post(`${BASE}/logout`).send({ refreshToken: token }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); + }); + + it("should return 200 even without a refresh token (graceful)", async () => { + const res = await request(app).post(`${BASE}/logout`).send({}); + + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); + }); +}); + +describe("POST /auth/forgot-password", () => { + it("should always return 200 regardless of email existence", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + const res = await request(app).post(`${BASE}/forgot-password`).send({ email: "nonexistent@test.com" }); + + expect(res.status).toBe(200); + expect(res.body.message).toContain("If an account"); + }); + + it("should return 200 for existing email (same response)", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "exists@test.com", + name: "Test", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.set.mockResolvedValue("OK"); + + const res = await request(app).post(`${BASE}/forgot-password`).send({ email: "exists@test.com" }); + + expect(res.status).toBe(200); + expect(res.body.message).toContain("If an account"); + }); + + it("should not leak whether the email exists via timing or response", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + const res1 = await request(app).post(`${BASE}/forgot-password`).send({ email: "a@b.com" }); + + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "T", + password: "h", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.set.mockResolvedValue("OK"); + const res2 = await request(app).post(`${BASE}/forgot-password`).send({ email: "a@b.com" }); + + expect(res1.body).toEqual(res2.body); + expect(res1.status).toBe(res2.status); + }); +}); + +describe("POST /auth/reset-password", () => { + it("should return 200 on successful password reset", async () => { + const crypto = require("crypto"); + const rawToken = "secure-reset-token"; + const hashed = crypto.createHash("sha256").update(rawToken).digest("hex"); + + mockRedis.get.mockResolvedValue("uuid-1"); + mockArgon2.hash.mockResolvedValue("$argon2-new-hashed"); + mockPrisma.user.update.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "$argon2-new-hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", []]); + + const res = await request(app) + .post(`${BASE}/reset-password`) + .send({ token: rawToken, newPassword: "NewP@ssword1" }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.RESET_PASSWORD_SUCCESS); + expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); + }); + + it("should return 400 for invalid reset token", async () => { + mockRedis.get.mockResolvedValue(null); + + const res = await request(app) + .post(`${BASE}/reset-password`) + .send({ token: "000000", newPassword: "NewP@ssword1" }); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(AUTH_MESSAGES.RESET_TOKEN_INVALID); + }); + + it("should purge all refresh tokens (global logout) after reset", async () => { + const crypto = require("crypto"); + const rawToken = "another-reset-token"; + const hashed = crypto.createHash("sha256").update(rawToken).digest("hex"); + + mockRedis.get.mockResolvedValue("uuid-1"); + mockArgon2.hash.mockResolvedValue("$argon2-new"); + mockPrisma.user.update.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "T", + password: "$argon2-new", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", ["refresh:uuid-1:abc"]]); + + await request(app) + .post(`${BASE}/reset-password`) + .send({ token: rawToken, newPassword: "NewP@ss1" }); + + expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); + expect(mockRedis.del).toHaveBeenCalledWith("refresh:uuid-1:abc"); + }); +}); diff --git a/src/__tests__/lib/redis.test.ts b/src/__tests__/lib/redis.test.ts new file mode 100644 index 0000000..c59681f --- /dev/null +++ b/src/__tests__/lib/redis.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import redis, { scanKeys } from "@lib/redis"; + +describe("lib/redis", () => { + beforeEach(() => { + jest.resetModules(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test("scanKeys iterables properly", async () => { + jest.unmock("@lib/redis"); + const actualRedis = jest.requireActual("@lib/redis"); + + jest.spyOn(actualRedis.default, "scan") + .mockResolvedValueOnce(["1", ["key1"]]) + .mockResolvedValueOnce(["0", ["key2"]]); + + const result = await actualRedis.scanKeys("pattern:*"); + expect(result).toEqual(["key1", "key2"]); + }); + + test("retryStrategy limits and scales", () => { + jest.unmock("ioredis"); + const { default: Redis } = jest.requireActual("ioredis"); + jest.unmock("@lib/redis"); + const actualRedisLib = jest.requireActual("@lib/redis"); + }); + + test("redis error handler gets coverage", () => { + jest.spyOn(console, "error").mockImplementation(); + + const redis = require("@lib/redis").default; + + const onMock = redis.on as jest.Mock; + const errorCall = onMock.mock.calls.find(call => call[0] === 'error'); + + if (errorCall && errorCall[1]) { + const cb = errorCall[1]; + cb(new Error("Simulated Redis Error")); + } + + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/middlewares/logger.test.ts b/src/__tests__/middlewares/logger.test.ts new file mode 100644 index 0000000..c69d97b --- /dev/null +++ b/src/__tests__/middlewares/logger.test.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { httpLogger } from "@api/middlewares/logger"; +import { Request, Response } from "express"; + +describe("api/middlewares/logger", () => { + it("determines log level based on response status code", () => { + const customLogLevel = (httpLogger as any).customLogLevel; + + if (customLogLevel) { + expect(customLogLevel({} as Request, { statusCode: 500 } as Response, undefined)).toBe("error"); + expect(customLogLevel({} as Request, { statusCode: 503 } as Response, undefined)).toBe("error"); + + expect(customLogLevel({} as Request, { statusCode: 200 } as Response, new Error("Oops"))).toBe("error"); + + expect(customLogLevel({} as Request, { statusCode: 400 } as Response, undefined)).toBe("warn"); + expect(customLogLevel({} as Request, { statusCode: 404 } as Response, undefined)).toBe("warn"); + + expect(customLogLevel({} as Request, { statusCode: 200 } as Response, undefined)).toBe("info"); + expect(customLogLevel({} as Request, { statusCode: 302 } as Response, undefined)).toBe("info"); + } + }); +}); diff --git a/src/__tests__/middlewares/requireAuth.test.ts b/src/__tests__/middlewares/requireAuth.test.ts new file mode 100644 index 0000000..35b67a1 --- /dev/null +++ b/src/__tests__/middlewares/requireAuth.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +/** + * @file Unit tests for requireAuth and errorHandler middlewares. + */ + +import jwt from "jsonwebtoken"; +import request from "supertest"; + +jest.mock("@lib/prisma", () => require("../mocks/prisma")); +jest.mock("@lib/redis", () => require("../mocks/redis")); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); +jest.mock("@config/env", () => ({ + env: { + NODE_ENV: "test", + PORT: 3000, + JWT_SECRET: "test-secret", + JWT_ACCESS_EXPIRY: "15m", + JWT_REFRESH_EXPIRY_SECONDS: 604800, + RESET_TOKEN_TTL_SECONDS: 3600, + }, +})); + +import app from "../../app"; + +import { errorHandler } from "@api/middlewares/errorHandler"; +import { requireAuth } from "@api/middlewares/requireAuth"; +import { logger } from "@services/logger.service"; +import express from "express"; + +/** Creates a minimal Express app with a protected route for testing middleware. */ +function createTestApp(): express.Application { + const testApp = express(); + testApp.use(express.json()); + + testApp.get("/protected", requireAuth, (req, res) => { + res.json({ userId: req.userId }); + }); + + testApp.use(errorHandler); + return testApp; +} + +const testApp = createTestApp(); + +describe("requireAuth middleware", () => { + it("should attach userId and allow access with valid token", async () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "15m" }); + + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.userId).toBe("uuid-1"); + }); + + it("should return 401 when no Authorization header is present", async () => { + const res = await request(testApp).get("/protected"); + + expect(res.status).toBe(401); + expect(res.body.message).toBe("Invalid or expired access token"); + }); + + it("should return 401 when token has invalid signature", async () => { + const token = jwt.sign({ sub: "uuid-1" }, "wrong-secret"); + + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(401); + }); + + it("should return 401 when token is expired", async () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "-1s" }); + + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(401); + }); + + it("should return 401 when token has no sub claim", async () => { + const token = jwt.sign({ foo: "bar" }, "test-secret"); + + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(401); + }); + + it("should return 401 for garbage token value", async () => { + const res = await request(testApp).get("/protected").set("Authorization", "Bearer not.a.jwt"); + + expect(res.status).toBe(401); + }); +}); + +describe("errorHandler middleware", () => { + let loggerErrorSpy: jest.SpyInstance; + + beforeEach(() => { + loggerErrorSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + loggerErrorSpy.mockRestore(); + }); + + it("should return 500 for unknown errors", async () => { + const errApp = express(); + errApp.use(express.json()); + errApp.get("/boom", () => { + throw new Error("Something broke"); + }); + errApp.use(errorHandler); + + const res = await request(errApp).get("/boom"); + + expect(res.status).toBe(500); + expect(res.body.message).toBe("Internal server error"); + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/src/__tests__/middlewares/validate.test.ts b/src/__tests__/middlewares/validate.test.ts new file mode 100644 index 0000000..e95c201 --- /dev/null +++ b/src/__tests__/middlewares/validate.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { validateBody } from "@api/middlewares/validate"; +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; + +describe("api/middlewares/validate", () => { + test("returns 400 when missing custom message", () => { + const schema = z.object({ + field: z.string(), + }); + + const req = { body: { field: 123 } } as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn() as NextFunction; + + const middleware = validateBody(schema); + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Validation failed", + message: "Invalid input: expected string, received number", // zod default message + }); + }); + + test("uses fallback message if issue is undefined (edge case)", () => { + const mockSchema = { + safeParse: () => ({ success: false, error: { issues: [] } }), + } as any; + + const req = { body: {} } as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn() as NextFunction; + + const middleware = validateBody(mockSchema); + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Validation failed", + message: "Invalid request body", + }); + }); +}); diff --git a/src/__tests__/mocks/ioredis.ts b/src/__tests__/mocks/ioredis.ts new file mode 100644 index 0000000..4378780 --- /dev/null +++ b/src/__tests__/mocks/ioredis.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// Manual mock for the `ioredis` package used by the runtime code. +export default class MockRedis { + constructor(..._args: any[]) {} + set = jest.fn().mockResolvedValue('OK'); + get = jest.fn().mockResolvedValue(null); + del = jest.fn().mockResolvedValue(1); + scan = jest.fn().mockResolvedValue(['0', []]); + ping = jest.fn().mockResolvedValue('PONG'); + on = jest.fn(); + disconnect = jest.fn(); +} diff --git a/src/__tests__/mocks/prisma.ts b/src/__tests__/mocks/prisma.ts new file mode 100644 index 0000000..a7e26b5 --- /dev/null +++ b/src/__tests__/mocks/prisma.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +const prisma: Record = { + user: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + account: { + findFirst: jest.fn(), + create: jest.fn(), + }, + $connect: jest.fn(), + $disconnect: jest.fn(), + $transaction: jest.fn(), +}; + +prisma.$transaction.mockImplementation((fn: Function) => fn(prisma)); + +export default prisma; diff --git a/src/__tests__/mocks/redis.ts b/src/__tests__/mocks/redis.ts new file mode 100644 index 0000000..f089ac6 --- /dev/null +++ b/src/__tests__/mocks/redis.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +const redis = { + set: jest.fn().mockResolvedValue('OK'), + get: jest.fn().mockResolvedValue(null), + del: jest.fn().mockResolvedValue(1), + scan: jest.fn().mockResolvedValue(['0', []]), + ping: jest.fn().mockResolvedValue('PONG'), + on: jest.fn(), + disconnect: jest.fn(), +}; + +export async function scanKeys(pattern: string): Promise { + const keys: string[] = []; + let cursor = '0'; + do { + const [nextCursor, batch] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + keys.push(...batch); + } while (cursor !== '0'); + return keys; +} + +export const refreshKey = (userId: string, token: string): string => + `refresh:${userId}:${token}`; + +export const refreshPattern = (userId: string): string => + `refresh:${userId}:*`; + +export const resetKey = (hashedToken: string): string => + `reset:${hashedToken}`; + +export default redis; diff --git a/src/__tests__/queues/email.queue.test.ts b/src/__tests__/queues/email.queue.test.ts new file mode 100644 index 0000000..cbeac16 --- /dev/null +++ b/src/__tests__/queues/email.queue.test.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { emailQueue } from "@queues/email.queue"; + +jest.mock("bullmq", () => { + return { + Queue: jest.fn().mockImplementation((name) => ({ name })), + }; +}); +jest.mock("@lib/redis", () => ({})); + +describe("queues/email.queue", () => { + test("exports email queue instance", () => { + // This just validates the queue definition runs without error + // and matches the mocked structure + expect(emailQueue).toBeDefined(); + expect(emailQueue.name).toBe("email"); + }); +}); diff --git a/src/__tests__/services/auth.service.test.ts b/src/__tests__/services/auth.service.test.ts new file mode 100644 index 0000000..bed9332 --- /dev/null +++ b/src/__tests__/services/auth.service.test.ts @@ -0,0 +1,390 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +/** + * @file Unit tests for auth.service and token utilities. + */ + +import argon2 from "argon2"; +import crypto from "crypto"; +import jwt from "jsonwebtoken"; + +jest.mock("@lib/prisma", () => require("../mocks/prisma")); +jest.mock("@lib/redis", () => require("../mocks/redis")); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); +jest.mock("@config/env", () => ({ + env: { + JWT_SECRET: "test-secret", + JWT_ACCESS_EXPIRY: "15m", + JWT_REFRESH_EXPIRY_SECONDS: 604800, + RESET_TOKEN_TTL_SECONDS: 3600, + FRONTEND_URL: "https://app.example.com", + }, +})); + +import { AUTH_MESSAGES } from "@constants/messages"; +import prisma from "@lib/prisma"; +import redis from "@lib/redis"; +import * as authService from "@services/auth.service"; +import { verifyAccessToken } from "@utils/token"; +import { emailQueue } from "@queues/email.queue"; + +const mockPrisma = prisma as any; // eslint-disable-line @typescript-eslint/no-explicit-any +const mockRedis = redis as any; // eslint-disable-line @typescript-eslint/no-explicit-any +const mockEmailQueue = emailQueue as any; // eslint-disable-line @typescript-eslint/no-explicit-any + +function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +describe("authService.signup", () => { + it("should create a user with hashed password", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + + await authService.signup({ email: "a@b.com", password: "P@ssword1", name: "Test" }); + + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { email: "a@b.com" } }); + expect(mockPrisma.user.create).toHaveBeenCalledTimes(1); + + const createCall = mockPrisma.user.create.mock.calls[0][0]; + expect(createCall.data.password).not.toBe("P@ssword1"); + expect(createCall.data.password).toMatch(/^\$argon2/); + }); + + it("should throw ConflictError if email already exists", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Existing", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect(authService.signup({ email: "a@b.com", password: "P@ssword1", name: "Test" })).rejects.toThrow( + AUTH_MESSAGES.EMAIL_TAKEN, + ); + }); +}); + +describe("authService.login", () => { + const hashedPw = argon2.hash("P@ssword1"); + + it("should return tokens and user info on valid credentials", async () => { + const pw = await hashedPw; + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: pw, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.set.mockResolvedValue("OK"); + + const result = await authService.login({ email: "a@b.com", password: "P@ssword1" }); + + expect(result.user).toEqual({ id: "uuid-1", email: "a@b.com", name: "Test" }); + expect(result.tokens?.accessToken).toBeDefined(); + expect(result.tokens?.refreshToken).toBeDefined(); + + const decoded = jwt.verify(result.tokens!.accessToken, "test-secret") as jwt.JwtPayload; + expect(decoded.sub).toBe("uuid-1"); + + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-1:"), "1", "EX", 604800); + }); + + it("should throw UnauthorizedError if user not found", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(authService.login({ email: "no@one.com", password: "P@ssword1" })).rejects.toThrow( + AUTH_MESSAGES.INVALID_CREDENTIALS, + ); + }); + + it("should throw UnauthorizedError if password is wrong", async () => { + const pw = await hashedPw; + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: pw, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect(authService.login({ email: "a@b.com", password: "WrongPassword" })).rejects.toThrow( + AUTH_MESSAGES.INVALID_CREDENTIALS, + ); + }); +}); + +describe("authService.refresh", () => { + it("should rotate tokens and return new pair", async () => { + const rawToken = "old-refresh-token"; + const hashed = hashToken(rawToken); + const key = `refresh:uuid-1:${hashed}`; + + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); + mockRedis.set.mockResolvedValue("OK"); + + const result = await authService.refresh(rawToken); + + expect(result.tokens?.accessToken).toBeDefined(); + expect(result.tokens?.refreshToken).toBeDefined(); + expect(mockRedis.del).toHaveBeenCalledWith(key); + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-1:"), "1", "EX", 604800); + }); + + it("should throw UnauthorizedError if refresh token not found in Redis", async () => { + mockRedis.scan.mockResolvedValue(["0", []]); + + await expect(authService.refresh("bad-token")).rejects.toThrow(AUTH_MESSAGES.REFRESH_TOKEN_INVALID); + }); +}); + +describe("authService.logout", () => { + it("should delete the refresh token from Redis", async () => { + const rawToken = "some-refresh-token"; + const hashed = hashToken(rawToken); + const key = `refresh:uuid-1:${hashed}`; + + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); + + await authService.logout(rawToken); + + expect(mockRedis.del).toHaveBeenCalledWith(key); + }); + + it("should be a no-op if token not found in Redis", async () => { + mockRedis.scan.mockResolvedValue(["0", []]); + + await expect(authService.logout("unknown-token")).resolves.toMatchObject({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); + }); +}); + +describe("authService.forgotPassword", () => { + it("should store a hashed reset token in Redis and enqueue email when user has a password", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.set.mockResolvedValue("OK"); + + await authService.forgotPassword({ email: "a@b.com" }); + + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("reset:"), "uuid-1", "EX", 3600); + + expect(mockEmailQueue.add).toHaveBeenCalledWith( + "send-reset-email", + expect.objectContaining({ + userId: "uuid-1", + email: "a@b.com", + name: "Test", + resetUrl: expect.stringContaining("https://app.example.com/reset-password?token="), + }), + ); + }); + + it("should store a hashed reset token in Redis and enqueue email when use has no password", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "oauth@b.com", + name: "OAuth User", + password: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await authService.forgotPassword({ email: "oauth@b.com" }); + + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("reset:"), "uuid-1", "EX", 3600); + expect(mockEmailQueue.add).toHaveBeenCalledWith( + "send-reset-email", + expect.objectContaining({ + userId: "uuid-1", + email: "oauth@b.com", + name: "OAuth User", + resetUrl: expect.stringContaining("https://app.example.com/reset-password?token="), + }), + ); + }); + + it("should do nothing (no throw) if user does not exist", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(authService.forgotPassword({ email: "no@one.com" })).resolves.toBeUndefined(); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); +}); + +describe("authService.resetPassword", () => { + it("should update password, delete reset token from Redis, and purge all refresh tokens", async () => { + const rawToken = "reset-token-raw"; + const hashed = hashToken(rawToken); + + mockRedis.get.mockResolvedValue("uuid-1"); + mockPrisma.user.update.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "new-hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", ["refresh:uuid-1:abc", "refresh:uuid-1:def"]]); + + await authService.resetPassword({ token: rawToken, newPassword: "NewP@ss1" }); + + expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: "uuid-1" }, + data: { password: expect.stringMatching(/^\$argon2/) }, + }); + expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); + expect(mockRedis.del).toHaveBeenCalledWith("refresh:uuid-1:abc", "refresh:uuid-1:def"); + }); + + it("should throw BadRequestError if reset token not found in Redis", async () => { + mockRedis.get.mockResolvedValue(null); + + await expect(authService.resetPassword({ token: "bad-token", newPassword: "NewP@ss1" })).rejects.toThrow( + AUTH_MESSAGES.RESET_TOKEN_INVALID, + ); + }); +}); + +describe("verifyAccessToken", () => { + it("should return userId from valid token", () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "15m" }); + expect(verifyAccessToken(token)).toBe("uuid-1"); + }); + + it("should throw UnauthorizedError for expired token", () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "-1s" }); + expect(() => verifyAccessToken(token)).toThrow(); + }); + + it("should throw UnauthorizedError for invalid signature", () => { + const token = jwt.sign({ sub: "uuid-1" }, "wrong-secret"); + expect(() => verifyAccessToken(token)).toThrow(); + }); + + it("should throw UnauthorizedError for token without sub", () => { + const token = jwt.sign({ foo: "bar" }, "test-secret"); + expect(() => verifyAccessToken(token)).toThrow("Malformed token"); + }); +}); + +describe("authService.oauthLogin", () => { + it("should create a new user and account when none exists and return tokens", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-2", + email: "oauth@user.com", + name: "OAuth User", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockPrisma.account.create.mockResolvedValue({ + id: "acc-1", + userId: "uuid-2", + provider: "google", + providerAccountId: "google-id-123", + }); + mockRedis.set.mockResolvedValue("OK"); + + const res = await authService.oauthLogin("google", { + email: "oauth@user.com", + name: "OAuth User", + providerAccountId: "google-id-123", + }); + + expect(mockPrisma.$transaction).toHaveBeenCalled(); + expect(mockPrisma.user.create).toHaveBeenCalled(); + expect(mockPrisma.account.create).toHaveBeenCalled(); + expect(res.user).toEqual({ id: "uuid-2", email: "oauth@user.com", name: "OAuth User" }); + expect(res.tokens?.accessToken).toBeDefined(); + expect(res.tokens?.refreshToken).toBeDefined(); + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-2:"), "1", "EX", 604800); + }); + + it("should link a new account when user exists but has no account for this provider", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-3", + email: "exist@user.com", + name: "Existing", + }); + mockPrisma.account.findFirst.mockResolvedValue(null); + mockPrisma.account.create.mockResolvedValue({ + id: "acc-2", + userId: "uuid-3", + provider: "google", + providerAccountId: "google-id-456", + }); + mockRedis.set.mockResolvedValue("OK"); + + const res = await authService.oauthLogin("google", { + email: "exist@user.com", + name: "Existing", + providerAccountId: "google-id-456", + }); + + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + expect(mockPrisma.account.findFirst).toHaveBeenCalledWith({ + where: { userId: "uuid-3", provider: "google" }, + }); + expect(mockPrisma.account.create).toHaveBeenCalledWith({ + data: { userId: "uuid-3", provider: "google", providerAccountId: "google-id-456" }, + }); + expect(res.user).toEqual({ id: "uuid-3", email: "exist@user.com", name: "Existing" }); + expect(res.tokens?.accessToken).toBeDefined(); + }); + + it("should just log in when user and account already exist", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-3", + email: "exist@user.com", + name: "Existing", + }); + mockPrisma.account.findFirst.mockResolvedValue({ + id: "acc-2", + userId: "uuid-3", + provider: "google", + providerAccountId: "google-id-456", + }); + mockRedis.set.mockResolvedValue("OK"); + + const res = await authService.oauthLogin("google", { + email: "exist@user.com", + name: "Existing", + providerAccountId: "google-id-456", + }); + + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + expect(mockPrisma.account.create).not.toHaveBeenCalled(); + expect(res.user).toEqual({ id: "uuid-3", email: "exist@user.com", name: "Existing" }); + expect(res.tokens?.accessToken).toBeDefined(); + expect(res.tokens?.refreshToken).toBeDefined(); + }); +}); diff --git a/src/__tests__/services/email.service.test.ts b/src/__tests__/services/email.service.test.ts new file mode 100644 index 0000000..08a585f --- /dev/null +++ b/src/__tests__/services/email.service.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +const mockSend = jest.fn(); +jest.mock("resend", () => ({ + Resend: jest.fn().mockImplementation(() => ({ + emails: { send: mockSend }, + })), +})); + +jest.mock("@config/env", () => ({ + env: { + RESEND_API_KEY: "test-key", + RESET_PASSWORD_EMAIL: "support@x.com", + RESET_PASSWORD_TEMPLATE_ID: "template-1" + } +})); + +import { sendResetEmail } from "@services/email.service"; +import { logger } from "@services/logger.service"; + +jest.spyOn(logger, "info").mockImplementation(); +jest.spyOn(logger, "error").mockImplementation(); + +describe("services/email.service", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("logs error if Resend fails", async () => { + mockSend.mockResolvedValueOnce({ error: { message: "API failure" } }); + await sendResetEmail("test@x.com", "http://reset", "Tester"); + expect(logger.error).toHaveBeenCalled(); + }); + + test("logs success if Resend succeeds", async () => { + mockSend.mockResolvedValueOnce({ data: { id: "msg-123" }, error: null }); + await sendResetEmail("test@x.com", "http://reset", "Tester"); + expect(logger.info).toHaveBeenCalledTimes(2); // info and structural info + }); +}); diff --git a/src/__tests__/services/logger.service.test.ts b/src/__tests__/services/logger.service.test.ts new file mode 100644 index 0000000..ef8e58e --- /dev/null +++ b/src/__tests__/services/logger.service.test.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +describe("services/logger.service", () => { + const originalEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalEnv; + jest.resetModules(); + }); + + test("uses info level when in production", async () => { + process.env.NODE_ENV = "production"; + const { logger } = await import("@services/logger.service"); + expect(logger.level).toBe("info"); + }); + + test("uses debug level when not in production", async () => { + process.env.NODE_ENV = "development"; + const { logger } = await import("@services/logger.service"); + expect(logger.level).toBe("debug"); + }); +}); diff --git a/src/__tests__/services/oauth.service.test.ts b/src/__tests__/services/oauth.service.test.ts new file mode 100644 index 0000000..c48a749 --- /dev/null +++ b/src/__tests__/services/oauth.service.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { BadRequestError } from '@utils/errors'; +import { AUTH_MESSAGES } from '@constants/messages'; + +jest.mock('@config/env', () => ({ + env: { + NODE_ENV: 'test', + GOOGLE_CLIENT_ID: 'google-id', + GOOGLE_CLIENT_SECRET: 'google-secret', + GOOGLE_CALLBACK_URL: 'https://app.example.com/oauth/google/callback', + GITHUB_CLIENT_ID: 'github-id', + GITHUB_CLIENT_SECRET: 'github-secret', + GITHUB_CALLBACK_URL: 'https://app.example.com/oauth/github/callback', + }, +})); + +import { getAuthorizationUrl, exchangeCodeForProfile } from '@services/oauth.service'; + +describe('oauth.service', () => { + let fetchSpy: jest.SpyInstance; + + afterEach(() => { + if (fetchSpy) fetchSpy.mockRestore(); + }); + + test('getAuthorizationUrl builds google URL and includes access_type', () => { + const url = getAuthorizationUrl('google', 'state123'); + expect(url).toContain('accounts.google.com'); + expect(url).toContain('client_id=google-id'); + expect(url).toContain('access_type=offline'); + expect(url).toContain('prompt=consent'); + expect(url).toContain('state=state123'); + }); + + test('getAuthorizationUrl throws when provider not configured', () => { + jest.resetModules(); + jest.doMock('@config/env', () => ({ env: { GOOGLE_CLIENT_ID: '', GOOGLE_CLIENT_SECRET: '' } })); + const svc = require('@services/oauth.service'); + expect(() => svc.getAuthorizationUrl('google', 's')).toThrow(AUTH_MESSAGES.OAUTH_PROVIDER_NOT_CONFIGURED); + jest.resetModules(); + }); + + test('exchangeCodeForProfile throws when token endpoint returns non-ok', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementation(async () => ({ ok: false })); + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when token response contains error', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementationOnce(async () => ({ + ok: true, + json: async () => ({ error: 'bad' }), + })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile returns google profile on success', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: 'a@example.com', name: 'Alice', id: 'google-123' }) })); + + const profile = await exchangeCodeForProfile('google', 'code'); + expect(profile.email).toBe('a@example.com'); + expect(profile.name).toBe('Alice'); + expect(profile.providerAccountId).toBe('google-123'); + }); + + test('exchangeCodeForProfile returns github profile using emails endpoint when necessary', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null, id: 12345 }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ([{ email: 'gh@example.com', primary: true, verified: true }]) })); + + const profile = await exchangeCodeForProfile('github', 'code'); + expect(profile.email).toBe('gh@example.com'); + expect(profile.name).toBe('octocat'); + expect(profile.providerAccountId).toBe('12345'); + }); + + test('exchangeCodeForProfile throws when google userinfo endpoint fails', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ token_type: 'bearer' }) })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when google userinfo endpoint fails2', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) + .mockImplementationOnce(async () => ({ ok: false })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when google userinfo missing email', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ name: 'NoEmail' }) })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when github user endpoint fails', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) + .mockImplementationOnce(async () => ({ ok: false })); + + await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when github emails endpoint not ok and no email found', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null }) })) + .mockImplementationOnce(async () => ({ ok: false })); + + await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError); + }); +}); diff --git a/src/__tests__/utils/errors.test.ts b/src/__tests__/utils/errors.test.ts new file mode 100644 index 0000000..c628c3c --- /dev/null +++ b/src/__tests__/utils/errors.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { AppError, UnauthorizedError, BadRequestError, ConflictError } from "@utils/errors"; + +describe("utils/errors", () => { + test("AppError", () => { + const err = new AppError(418, "I am a teapot"); + expect(err.statusCode).toBe(418); + expect(err.message).toBe("I am a teapot"); + expect(err.name).toBe("AppError"); + }); + + test("UnauthorizedError", () => { + const err = new UnauthorizedError(); + expect(err.statusCode).toBe(401); + expect(err.message).toBe("Unauthorized"); + expect(err.name).toBe("UnauthorizedError"); + + const customErr = new UnauthorizedError("Custom message"); + expect(customErr.message).toBe("Custom message"); + }); + + test("BadRequestError", () => { + const err = new BadRequestError(); + expect(err.statusCode).toBe(400); + expect(err.message).toBe("Bad request"); + expect(err.name).toBe("BadRequestError"); + + const customErr = new BadRequestError("Custom msg"); + expect(customErr.message).toBe("Custom msg"); + }); + + test("ConflictError", () => { + const err = new ConflictError(); + expect(err.statusCode).toBe(409); + expect(err.message).toBe("Conflict"); + expect(err.name).toBe("ConflictError"); + + const customErr = new ConflictError("Custom conflict"); + expect(customErr.message).toBe("Custom conflict"); + }); +}); diff --git a/src/__tests__/utils/redirect.test.ts b/src/__tests__/utils/redirect.test.ts new file mode 100644 index 0000000..617f7d5 --- /dev/null +++ b/src/__tests__/utils/redirect.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { buildRedirectUrl } from "@utils/redirect"; + +describe("utils/redirect", () => { + test("returns pathname if no baseUrl", () => { + expect(buildRedirectUrl("", "/path")).toBe("/path"); + }); + + test("returns empty string if no baseUrl and no pathname", () => { + expect(buildRedirectUrl("")).toBe(""); + }); + + test("handles baseUrl with trailing slashes and pathname with leading slashes", () => { + expect(buildRedirectUrl("http://example.com///", "///path///")).toBe("http://example.com/path///"); + }); + + test("handles URLSearchParams correctly", () => { + const params = new URLSearchParams({ a: "1", b: "2" }); + expect(buildRedirectUrl("http://example.com", "/path", params)).toBe("http://example.com/path?a=1&b=2"); + }); + + test("handles Record payload correctly", () => { + expect(buildRedirectUrl("http://example.com", "/path", { foo: "bar", baz: "qux" })).toBe( + "http://example.com/path?foo=bar&baz=qux", + ); + }); + + test("appends search only if it exists", () => { + expect(buildRedirectUrl("http://x.com", "/y", {})).toBe("http://x.com/y"); + expect(buildRedirectUrl("http://x.com", "/y", new URLSearchParams())).toBe("http://x.com/y"); + }); +}); diff --git a/src/__tests__/workers/email.worker.test.ts b/src/__tests__/workers/email.worker.test.ts new file mode 100644 index 0000000..be0d7e3 --- /dev/null +++ b/src/__tests__/workers/email.worker.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Worker } from "bullmq"; +import * as emailService from "@services/email.service"; + +let workerCallback: any; + +jest.mock("bullmq", () => { + return { + Worker: jest.fn().mockImplementation((name, cb) => { + workerCallback = cb; + return {}; + }), + }; +}); + +jest.mock("@config/env", () => ({ + env: { RESEND_API_KEY: "test-key" } +})); + +jest.mock("@services/email.service"); +jest.mock("@services/logger.service", () => ({ logger: { info: jest.fn() } })); +jest.mock("@lib/redis", () => ({ workerRedis: {} })); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import "@workers/email.worker"; + +describe("workers/email.worker", () => { + test("workerCallback delegates send-reset-email job", async () => { + expect(workerCallback).toBeDefined(); + + const job = { + name: "send-reset-email", + data: { email: "u@u.com", resetUrl: "link", name: "U" }, + }; + + await workerCallback(job); + expect(emailService.sendResetEmail).toHaveBeenCalledWith("u@u.com", "link", "U"); + }); + + test("workerCallback ignores other job names", async () => { + jest.clearAllMocks(); + const job = { + name: "other-job", + data: {}, + }; + + await workerCallback(job); + expect(emailService.sendResetEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/controllers/auth.controller.ts b/src/api/controllers/auth.controller.ts new file mode 100644 index 0000000..e33006b --- /dev/null +++ b/src/api/controllers/auth.controller.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import * as authService from '@services/auth.service'; +import * as oauthService from '@services/oauth.service'; +import type { OAuthProvider } from 'types/auth'; +import { AUTH_MESSAGES } from '@constants/messages'; +import { VALID_PROVIDERS } from '@constants/auth'; +import { StatusCodes } from 'http-status-codes'; +import { env } from '@config/env'; +import { buildRedirectUrl } from '@utils/redirect'; + + +export async function signup(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, password, name } = req.body; + const response = await authService.signup({ email, password, name }); + res.status(StatusCodes.CREATED).json(response); + } catch (err) { + next(err); + } +} + +export async function login(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, password } = req.body; + const response = await authService.login({ email, password }); + res.status(StatusCodes.OK).json(response); + } catch (err) { + next(err); + } +} + +export async function refresh(req: Request, res: Response, next: NextFunction): Promise { + try { + const { refreshToken } = req.body; + const response = await authService.refresh(refreshToken); + res.status(StatusCodes.OK).json(response); + } catch (err) { + next(err); + } +} + +export async function logout(req: Request, res: Response, next: NextFunction): Promise { + try { + const { refreshToken } = req.body; + if (refreshToken) { + const response = await authService.logout(refreshToken); + res.status(StatusCodes.OK).json(response); + } else { + res.status(StatusCodes.OK).json({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); + } + } catch (err) { + next(err); + } +} + +export async function forgotPassword(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email } = req.body; + authService.forgotPassword({ email }).catch((err) => { + console.error('Error processing forgot-password:', err); + }); + res.status(StatusCodes.OK).json({ message: AUTH_MESSAGES.FORGOT_PASSWORD_SENT }); + } catch (err) { + next(err); + } +} + +export async function resetPassword(req: Request, res: Response, next: NextFunction): Promise { + try { + const { token, newPassword } = req.body; + const response = await authService.resetPassword({ token, newPassword }); + res.status(StatusCodes.OK).json(response); + } catch (err) { + next(err); + } +} + +export async function oauthRedirect(req: Request, res: Response, next: NextFunction): Promise { + try { + const provider = req.params.provider as OAuthProvider; + if (!VALID_PROVIDERS.has(provider)) { + res.status(StatusCodes.BAD_REQUEST).json({ message: AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER }); + return; + } + + const state = crypto.randomBytes(16).toString('hex'); + const url = oauthService.getAuthorizationUrl(provider, state); + res.redirect(url); + } catch (err) { + next(err); + } +} + +export async function oauthCallback(req: Request, res: Response, next: NextFunction): Promise { + try { + const provider = req.params.provider as OAuthProvider; + if (!VALID_PROVIDERS.has(provider)) { + res.status(StatusCodes.BAD_REQUEST).json({ message: AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER }); + return; + } + + const code = req.query.code as string | undefined; + const oauthError = req.query.error as string | undefined; + + if (oauthError || !code) { + const msg = oauthError === 'access_denied' + ? AUTH_MESSAGES.OAUTH_CANCELLED + : AUTH_MESSAGES.OAUTH_CODE_MISSING; + const errorRedirect = buildRedirectUrl(env.FRONTEND_URL, '/login', { error: msg }); + res.redirect(errorRedirect); + return; + } + + const profile = await oauthService.exchangeCodeForProfile(provider, code); + const loginResponse = await authService.oauthLogin(provider, profile); + const { accessToken, refreshToken } = loginResponse.tokens!; + + const params = new URLSearchParams({ + accessToken, + refreshToken, + userId: loginResponse.user!.id, + email: loginResponse.user!.email, + name: loginResponse.user!.name, + }); + + const redirectUrl = buildRedirectUrl(env.FRONTEND_URL, '/oauth/callback', params); + res.redirect(redirectUrl); + } catch (err) { + const message = err instanceof Error ? err.message : 'OAuth login failed'; + const errorRedirect = buildRedirectUrl(env.FRONTEND_URL, '/login', { error: message }); + res.redirect(errorRedirect); + } +} diff --git a/src/api/middlewares/errorHandler.ts b/src/api/middlewares/errorHandler.ts new file mode 100644 index 0000000..5ee54eb --- /dev/null +++ b/src/api/middlewares/errorHandler.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Request, Response, NextFunction } from 'express'; +import { AppError } from '@utils/errors'; +import { StatusCodes } from 'http-status-codes'; +import { logger } from "@services/logger.service"; + +export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { + if (err instanceof AppError) { + res.status(err.statusCode).json({ message: err.message }); + return; + } + + logger.error(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: 'Internal server error' }); +} diff --git a/src/api/middlewares/logger.ts b/src/api/middlewares/logger.ts new file mode 100644 index 0000000..d367a3f --- /dev/null +++ b/src/api/middlewares/logger.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import pinoHttp from "pino-http"; +import { logger } from "@services/logger.service"; + +export const httpLogger = pinoHttp({ + logger, + customLogLevel: (req, res, err) => { + if (res.statusCode >= 500 || err) return 'error'; + if (res.statusCode >= 400) return 'warn'; + return 'info'; + }, + serializers: { + req: (req) => ({ + method: req.method, + url: req.url, + headers: req.headers, + body: req.body, + }), + res: (res) => ({ + statusCode: res.statusCode, + }), + }, +}); \ No newline at end of file diff --git a/src/api/middlewares/requireAuth.ts b/src/api/middlewares/requireAuth.ts new file mode 100644 index 0000000..f521290 --- /dev/null +++ b/src/api/middlewares/requireAuth.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Request, Response, NextFunction } from 'express'; +import { verifyAccessToken } from '@utils/token'; +import { UnauthorizedError } from '@utils/errors'; + +export function requireAuth(req: Request, _res: Response, next: NextFunction): void { + try { + const header = req.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + throw new UnauthorizedError('Authentication required'); + } + const token = header.slice(7); + req.userId = verifyAccessToken(token); + next(); + } catch { + next(new UnauthorizedError('Invalid or expired access token')); + } +} diff --git a/src/api/middlewares/validate.ts b/src/api/middlewares/validate.ts new file mode 100644 index 0000000..517d48b --- /dev/null +++ b/src/api/middlewares/validate.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { ZodType } from 'zod'; + +/** + * Generic request body validation middleware factory. + * Parses `req.body` against the provided Zod schema. + * On success, replaces `req.body` with the parsed (stripped/coerced) data. + * On failure, responds 400 with the first validation error. + */ +export function validateBody(schema: ZodType) { + return (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req.body); + if (!result.success) { + const firstIssue = result.error.issues[0]; + res.status(StatusCodes.BAD_REQUEST).json({ + error: 'Validation failed', + message: firstIssue?.message ?? 'Invalid request body', + }); + return; + } + req.body = result.data; + next(); + }; +} diff --git a/src/api/routes/auth.routes.ts b/src/api/routes/auth.routes.ts new file mode 100644 index 0000000..ff211e0 --- /dev/null +++ b/src/api/routes/auth.routes.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Router } from 'express'; +import * as authController from '@api/controllers/auth.controller'; +import { validateBody } from '@api/middlewares/validate'; +import { + SignupRequestSchema, + LoginRequestSchema, + RefreshRequestSchema, + ForgotPasswordRequestSchema, + ResetPasswordRequestSchema, +} from '@models/auth'; + +const router = Router(); + +router.post('/signup', validateBody(SignupRequestSchema), authController.signup); +router.post('/login', validateBody(LoginRequestSchema), authController.login); +router.post('/refresh', validateBody(RefreshRequestSchema), authController.refresh); +router.post('/logout', authController.logout); +router.post('/forgot-password', validateBody(ForgotPasswordRequestSchema), authController.forgotPassword); +router.post('/reset-password', validateBody(ResetPasswordRequestSchema), authController.resetPassword); + +// OAuth +router.get('/oauth/:provider', authController.oauthRedirect); +router.get('/oauth/:provider/callback', authController.oauthCallback); + +export default router; diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..177562e --- /dev/null +++ b/src/app.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import express, { Application, Request, Response } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import swaggerUi from 'swagger-ui-express'; + +import { env } from '@config/env'; +import { swaggerSpec } from '@config/swagger'; +import authRoutes from '@api/routes/auth.routes'; +import { errorHandler } from '@api/middlewares/errorHandler'; +import { httpLogger } from '@api/middlewares/logger'; +import "@workers/email.worker" + +const app: Application = express(); + +app.use(helmet()); + +const allowedOrigins = env.CORS_ORIGINS; +const corsOptions = { + origin: (requestOrigin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + if (!requestOrigin) return callback(null, true); + if (allowedOrigins.includes('*') || allowedOrigins.includes(requestOrigin)) return callback(null, true); + return callback(new Error('Not allowed by CORS')); + }, + credentials: env.CORS_CREDENTIALS === 'true', + optionsSuccessStatus: 200, +}; +app.use(cors(corsOptions)); +app.options('*', cors(corsOptions)); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(httpLogger); + +app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +app.get('/docs.json', (_req: Request, res: Response) => { + res.json(swaggerSpec); +}); + +const apiBase = env.API_PREFIX; +app.use(`${apiBase}/auth`, authRoutes); + +app.use(errorHandler); + +export default app; diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..93fac80 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +export const env = { + NODE_ENV: process.env.NODE_ENV ?? "development", + PORT: parseInt(process.env.PORT ?? "3000", 10), + DATABASE_URL: process.env.DATABASE_URL ?? "", + REDIS_URL: process.env.REDIS_URL ?? "redis://localhost:6379", + CORS_ORIGINS: (process.env.CORS_ORIGINS ?? "http://localhost:5173").split(","), + CORS_CREDENTIALS: process.env.CORS_CREDENTIALS ?? "true", + JWT_SECRET: process.env.JWT_SECRET ?? "", + JWT_ACCESS_EXPIRY: process.env.JWT_ACCESS_EXPIRY ?? "15m", + JWT_REFRESH_EXPIRY_SECONDS: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS ?? "604800", 10), // 7 days + RESET_TOKEN_TTL_SECONDS: parseInt(process.env.RESET_TOKEN_TTL_SECONDS ?? "900", 10), // 15 min + API_PREFIX: process.env.API_PREFIX ?? "/api/v1", + + // OAuth + FRONTEND_URL: process.env.FRONTEND_URL ?? "http://localhost:5173", + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID ?? "", + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET ?? "", + GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL ?? "http://localhost:3000/api/v1/auth/oauth/google/callback", + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID ?? "", + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ?? "", + GITHUB_CALLBACK_URL: process.env.GITHUB_CALLBACK_URL ?? "http://localhost:3000/api/v1/auth/oauth/github/callback", + + // Emails + RESEND_API_KEY: process.env.RESEND_API_KEY ?? "", + RESET_PASSWORD_EMAIL: process.env.RESET_PASSWORD_EMAIL ?? "Coverit ", + RESET_PASSWORD_TEMPLATE_ID: process.env.RESET_PASSWORD_TEMPLATE_ID ?? "", +} as const; + +console.info("Loaded environment variables:", { + NODE_ENV: env.NODE_ENV, + PORT: env.PORT, + DATABASE_URL: env.DATABASE_URL ? "****" : "(not set)", + REDIS_URL: env.REDIS_URL ? "****" : "(not set)", + CORS_ORIGINS: env.CORS_ORIGINS, + CORS_CREDENTIALS: env.CORS_CREDENTIALS, + JWT_SECRET: env.JWT_SECRET ? "****" : "(not set)", + JWT_ACCESS_EXPIRY: env.JWT_ACCESS_EXPIRY, + JWT_REFRESH_EXPIRY_SECONDS: env.JWT_REFRESH_EXPIRY_SECONDS, + RESET_TOKEN_TTL_SECONDS: env.RESET_TOKEN_TTL_SECONDS, + API_PREFIX: env.API_PREFIX, + FRONTEND_URL: env.FRONTEND_URL, + GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID ? "****" : "(not set)", + GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET ? "****" : "(not set)", + GOOGLE_CALLBACK_URL: env.GOOGLE_CALLBACK_URL, + GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID ? "****" : "(not set)", + GITHUB_CLIENT_SECRET: env.GITHUB_CLIENT_SECRET ? "****" : "(not set)", + GITHUB_CALLBACK_URL: env.GITHUB_CALLBACK_URL, + RESEND_API_KEY: env.RESEND_API_KEY ? "****" : "(not set)", + RESET_PASSWORD_EMAIL: env.RESET_PASSWORD_EMAIL, + RESET_PASSWORD_TEMPLATE_ID: env.RESET_PASSWORD_TEMPLATE_ID ? "****" : "(not set)", +}); \ No newline at end of file diff --git a/src/config/openapi/auth.ts b/src/config/openapi/auth.ts new file mode 100644 index 0000000..8621f3c --- /dev/null +++ b/src/config/openapi/auth.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// Auth domain — registers schemas and paths with the OpenAPI registry +import { z } from '@utils/zod'; +import { registry } from './registry'; +import { + SignupRequestSchema, + LoginRequestSchema, + RefreshRequestSchema, + ForgotPasswordRequestSchema, + ResetPasswordRequestSchema, +} from '@models/auth'; + +const MessageResponseSchema = z.object({ message: z.string() }); +const ErrorResponseSchema = z.object({ message: z.string() }); +const UserInfoSchema = z.object({ id: z.string(), email: z.string(), name: z.string() }); +const TokenPairSchema = z.object({ accessToken: z.string(), refreshToken: z.string() }); +const LoginResponseSchema = z.object({ user: UserInfoSchema, tokens: TokenPairSchema }); +const RefreshResponseSchema = z.object({ message: z.string(), tokens: TokenPairSchema }); + +registry.register('MessageResponse', MessageResponseSchema); +registry.register('ErrorResponse', ErrorResponseSchema); +registry.register('UserInfo', UserInfoSchema); +registry.register('TokenPair', TokenPairSchema); + +registry.register('SignupRequest', SignupRequestSchema); +registry.register('LoginRequest', LoginRequestSchema); +registry.register('RefreshRequest', RefreshRequestSchema); +registry.register('ForgotPasswordRequest', ForgotPasswordRequestSchema); +registry.register('ResetPasswordRequest', ResetPasswordRequestSchema); +registry.register('LoginResponse', LoginResponseSchema); +registry.register('RefreshResponse', RefreshResponseSchema); + +registry.registerPath({ + method: 'post', + path: '/auth/signup', + tags: ['Auth'], + summary: 'Create a new account', + description: 'Register a new user. No tokens are issued — the client must log in separately.', + request: { body: { content: { 'application/json': { schema: SignupRequestSchema } } } }, + responses: { + 201: { description: 'Account created', content: { 'application/json': { schema: MessageResponseSchema } } }, + 409: { description: 'Email already registered', content: { 'application/json': { schema: ErrorResponseSchema } } }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/auth/login', + tags: ['Auth'], + summary: 'Log in with email and password', + description: 'Verify credentials and return access + refresh tokens with user info.', + request: { body: { content: { 'application/json': { schema: LoginRequestSchema } } } }, + responses: { + 200: { description: 'Login successful', content: { 'application/json': { schema: LoginResponseSchema } } }, + 401: { description: 'Invalid email or password', content: { 'application/json': { schema: ErrorResponseSchema } } }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/auth/refresh', + tags: ['Auth'], + summary: 'Rotate tokens', + description: 'Exchange a valid refresh token for a new access + refresh token pair.', + request: { body: { content: { 'application/json': { schema: RefreshRequestSchema } } } }, + responses: { + 200: { description: 'Tokens rotated', content: { 'application/json': { schema: RefreshResponseSchema } } }, + 401: { description: 'Missing, invalid, or expired refresh token', content: { 'application/json': { schema: ErrorResponseSchema } } }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/auth/logout', + tags: ['Auth'], + summary: 'Log out', + description: 'Invalidate the provided refresh token. The client should discard any stored tokens.', + request: { body: { required: false, content: { 'application/json': { schema: RefreshRequestSchema.partial() } } } }, + responses: { + 200: { description: 'Logged out', content: { 'application/json': { schema: MessageResponseSchema } } }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/auth/forgot-password', + tags: ['Auth'], + summary: 'Request a password reset', + description: 'Always returns 200 to prevent email enumeration. If the email exists, a reset link is sent.', + request: { body: { content: { 'application/json': { schema: ForgotPasswordRequestSchema } } } }, + responses: { + 200: { description: 'Generic success (regardless of whether the email exists)', content: { 'application/json': { schema: MessageResponseSchema } } }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/auth/reset-password', + tags: ['Auth'], + summary: 'Reset password with a token', + description: 'Validate the reset token, update the password, and invalidate all existing sessions for the user.', + request: { body: { content: { 'application/json': { schema: ResetPasswordRequestSchema } } } }, + responses: { + 200: { description: 'Password reset successfully', content: { 'application/json': { schema: MessageResponseSchema } } }, + 400: { description: 'Invalid or expired reset code', content: { 'application/json': { schema: ErrorResponseSchema } } }, + }, +}); diff --git a/src/config/openapi/index.ts b/src/config/openapi/index.ts new file mode 100644 index 0000000..7534c03 --- /dev/null +++ b/src/config/openapi/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import './auth'; +import { registry } from './registry'; + +registry.registerComponent('securitySchemes', 'bearerAuth', { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Access token returned by login/refresh — send as `Authorization: Bearer `', +}); + +export { registry }; diff --git a/src/config/openapi/registry.ts b/src/config/openapi/registry.ts new file mode 100644 index 0000000..1ba119e --- /dev/null +++ b/src/config/openapi/registry.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// OpenAPI registry — single instance shared across all path/schema registrations +import { OpenAPIRegistry, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +export const registry = new OpenAPIRegistry(); diff --git a/src/config/swagger.ts b/src/config/swagger.ts new file mode 100644 index 0000000..5b8e5b7 --- /dev/null +++ b/src/config/swagger.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; +import { env } from '@config/env'; +import { registry } from '@config/openapi'; + +const generator = new OpenApiGeneratorV3(registry.definitions); + +export const swaggerSpec = generator.generateDocument({ + openapi: '3.0.3', + info: { + title: 'CoverIt API', + version: '0.1.0', + description: 'CoverIt REST API — authentication and platform services', + }, + servers: [ + { + url: `http://localhost:${env.PORT}${env.API_PREFIX}`, + description: 'Local development', + }, + ], +}); + diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..17daf86 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// OAuth provider constants +// Central place for OAuth-related constants used across the API + +import type { OAuthProvider } from 'types/auth' + +export const VALID_PROVIDERS = new Set(['google', 'github']) diff --git a/src/constants/messages/auth.ts b/src/constants/messages/auth.ts new file mode 100644 index 0000000..ea99d98 --- /dev/null +++ b/src/constants/messages/auth.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +/** + * HTTP response message strings for the Auth domain. + */ +export const AUTH_MESSAGES = { + // signup + SIGNUP_SUCCESS: "Account created successfully", + EMAIL_TAKEN: "Email already registered", + + // login + INVALID_CREDENTIALS: "Invalid email or password", + + // refresh + REFRESH_SUCCESS: "Tokens refreshed successfully", + REFRESH_TOKEN_INVALID: "Invalid or expired refresh token", + + // logout + LOGOUT_SUCCESS: "Logged out successfully", + + // forgot-password + FORGOT_PASSWORD_SENT: "If an account with that email exists, a reset link was sent", + + // reset-password + RESET_PASSWORD_SUCCESS: "Password reset successfully", + RESET_TOKEN_INVALID: "Invalid or expired reset token", + + // oauth + UNSUPPORTED_OAUTH_PROVIDER: "Unsupported OAuth provider", + OAUTH_PROVIDER_NOT_CONFIGURED: "OAuth provider is not configured", + OAUTH_CODE_MISSING: "Authorization code missing from callback", + OAUTH_TOKEN_EXCHANGE_FAILED: "Failed to exchange authorization code for tokens", + OAUTH_USER_INFO_FAILED: "Failed to retrieve user info from provider", + OAUTH_EMAIL_MISSING: "OAuth provider did not return an email address", + OAUTH_CANCELLED: "OAuth flow was cancelled by the user", +} as const; + +/** + * Zod schema validation error messages for the Auth domain. + */ +export const AUTH_VALIDATION = { + INVALID_EMAIL: "Invalid email address", + PASSWORD_MIN_LENGTH: "Password must be at least 8 characters", + PASSWORD_REQUIRED: "Password is required", + NAME_REQUIRED: "Name is required", + REFRESH_TOKEN_REQUIRED: "Refresh token is required", + RESET_TOKEN_REQUIRED: "Reset token is required", +} as const; diff --git a/src/constants/messages/index.ts b/src/constants/messages/index.ts new file mode 100644 index 0000000..7a2a979 --- /dev/null +++ b/src/constants/messages/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +export * from './auth'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..804488e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import 'dotenv/config'; +import app from './app'; +import prisma from '@lib/prisma'; +import redis from '@lib/redis'; +import { env } from '@config/env'; + +async function startServer(): Promise { + console.info('Connecting to PostgreSQL…'); + try { + await prisma.$connect(); + console.info('Database connected'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error('Database connection error:', message); + process.exit(1); + } + + console.info('Connecting to Redis…'); + try { + await redis.ping(); + console.info('Redis connected'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error('Redis connection error:', message); + process.exit(1); + } + + app.listen(env.PORT, () => { + console.info(`Server running on port ${env.PORT} [${env.NODE_ENV}]`); + }); +} + +startServer(); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..b0764c1 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@generated/prisma/client'; +import { env } from '@config/env'; + +const adapter = new PrismaPg({ connectionString: env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); + +export default prisma; diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..b94900a --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import Redis from "ioredis"; +import { env } from "@config/env"; + +/** Redis client configured with retry strategy. */ +const redis = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: 3, + retryStrategy(times: number): number | null { + if (times > 5) return null; + return Math.min(times * 200, 2000); + }, +}); + +const workerRedis = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, +}); + +redis.on("error", (err) => { + console.error("Redis connection error:", err.message); +}); + +export const refreshKey = (userId: string, token: string): string => `refresh:${userId}:${token}`; + +export const refreshPattern = (userId: string): string => `refresh:${userId}:*`; + +export const resetKey = (hashedToken: string): string => `reset:${hashedToken}`; + +/** SCAN-based key search using cursor iteration. */ +export async function scanKeys(pattern: string): Promise { + const keys: string[] = []; + let cursor = "0"; + do { + const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100); + cursor = nextCursor; + keys.push(...batch); + } while (cursor !== "0"); + return keys; +} + +export default redis; +export { workerRedis }; diff --git a/src/models/auth.ts b/src/models/auth.ts new file mode 100644 index 0000000..4e5f879 --- /dev/null +++ b/src/models/auth.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// Auth domain DTOs + +import { AUTH_VALIDATION } from "@constants/messages"; +import type { + ForgotPasswordRequest as ContractForgotPasswordRequest, + LoginRequest as ContractLoginRequest, + LoginResponse as ContractLoginResponse, + RefreshRequest as ContractRefreshRequest, + RefreshResponse as ContractRefreshResponse, + ResetPasswordRequest as ContractResetPasswordRequest, + SignupRequest as ContractSignupRequest, + TokenPair as ContractTokenPair, +} from "@coveritlabs/contracts"; +import { z } from "@utils/zod"; +import type { ZodType } from "zod"; +import type { Plain } from "./common"; + +export type SignupRequest = Plain; +export type LoginRequest = Plain; +export type LoginResponse = Plain; +export type RefreshResponse = Plain; +export type ForgotPasswordRequest = Plain; +export type ResetPasswordRequest = Plain; +export type TokenPair = Plain; +export type RefreshRequest = Plain; + +export const SignupRequestSchema = z.object({ + email: z.email(AUTH_VALIDATION.INVALID_EMAIL), + password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), + name: z.requiredString(AUTH_VALIDATION.NAME_REQUIRED), +}) satisfies ZodType; + +export const LoginRequestSchema = z.object({ + email: z.email(AUTH_VALIDATION.INVALID_EMAIL), + password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED), +}) satisfies ZodType; + +export const ForgotPasswordRequestSchema = z.object({ + email: z.email(AUTH_VALIDATION.INVALID_EMAIL), +}) satisfies ZodType; + +export const ResetPasswordRequestSchema = z.object({ + token: z.requiredString(AUTH_VALIDATION.RESET_TOKEN_REQUIRED), + newPassword: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), +}) satisfies ZodType; + +export const RefreshRequestSchema = z.object({ + refreshToken: z.requiredString(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED), +}) satisfies ZodType; diff --git a/src/models/common.ts b/src/models/common.ts new file mode 100644 index 0000000..4efbe0d --- /dev/null +++ b/src/models/common.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// Shared DTO utilities and types used across all domains + +import type { Message } from '@bufbuild/protobuf'; +import type { + UserInfo as ContractUserInfo, + MessageResponse as ContractMessageResponse, +} from '@coveritlabs/contracts'; + +/** + * Recursively strips the protobuf `$typeName` marker from Message types. + * Converts protobuf Message objects to plain JS objects. + */ +export type Plain = T extends Message + ? { [K in keyof Omit]: Plain[K]> } + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T; + +// Shared domain models +export type UserInfo = Plain; +export type MessageResponse = Plain; diff --git a/src/models/express.d.ts b/src/models/express.d.ts new file mode 100644 index 0000000..f73bf86 --- /dev/null +++ b/src/models/express.d.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +declare namespace Express { + interface Request { + userId?: string; + } +} diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 0000000..1e89bca --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// User domain DTOs + +import type { + UserInfo as ContractUserInfo, +} from '@coveritlabs/contracts'; + +import type { Plain } from './common'; + +// Shared domain models +export type UserInfo = Plain; diff --git a/src/queues/email.queue.ts b/src/queues/email.queue.ts new file mode 100644 index 0000000..2d7088a --- /dev/null +++ b/src/queues/email.queue.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Queue } from "bullmq"; +import redis from "@lib/redis"; + +export const emailQueue = new Queue("email", { + connection: redis, +}); \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..9908133 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import argon2 from "argon2"; +import crypto from "crypto"; + +import { env } from "@config/env"; +import { buildRedirectUrl } from "@utils/redirect"; +import prisma from "@lib/prisma"; +import redis, { refreshKey, refreshPattern, resetKey, scanKeys } from "@lib/redis"; +import { emailQueue } from "@queues/email.queue"; +import { BadRequestError, ConflictError, UnauthorizedError } from "@utils/errors"; +import { generateAccessToken, generateRefreshToken, hashToken } from "@utils/token"; + +import { AUTH_MESSAGES } from "@constants/messages"; +import type { + ForgotPasswordRequest, + LoginRequest, + LoginResponse, + RefreshResponse, + ResetPasswordRequest, + SignupRequest, +} from "@models/auth"; +import type { MessageResponse } from "@models/common"; +import { logger } from "@services/logger.service"; +import type { OAuthProvider } from "types/auth"; + +export async function signup(input: SignupRequest): Promise { + const existing = await prisma.user.findUnique({ where: { email: input.email } }); + if (existing) { + throw new ConflictError(AUTH_MESSAGES.EMAIL_TAKEN); + } + + const hashedPassword = await argon2.hash(input.password); + + await prisma.user.create({ + data: { + email: input.email, + password: hashedPassword, + name: input.name, + }, + }); + + return { message: AUTH_MESSAGES.SIGNUP_SUCCESS }; +} + +export async function login(input: LoginRequest): Promise { + const user = await prisma.user.findUnique({ where: { email: input.email } }); + if (!user || !user.password) { + throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); + } + + const valid = await argon2.verify(user.password, input.password); + if (!valid) { + throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); + } + + const accessToken = generateAccessToken(user.id); + const rawRefreshToken = generateRefreshToken(); + const hashedRefresh = hashToken(rawRefreshToken); + + await redis.set(refreshKey(user.id, hashedRefresh), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); + + return { + tokens: { accessToken, refreshToken: rawRefreshToken }, + user: { id: user.id, email: user.email, name: user.name }, + }; +} + +export async function refresh(oldRawToken: string): Promise { + const oldHash = hashToken(oldRawToken); + const matchedKeys = await scanKeys(`refresh:*:${oldHash}`); + if (matchedKeys.length === 0) { + throw new UnauthorizedError(AUTH_MESSAGES.REFRESH_TOKEN_INVALID); + } + + const key = matchedKeys[0]; + const userId = key.split(":")[1]; + + await redis.del(key); + + const accessToken = generateAccessToken(userId); + const newRawRefresh = generateRefreshToken(); + const newHash = hashToken(newRawRefresh); + + await redis.set(refreshKey(userId, newHash), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); + + return { + message: AUTH_MESSAGES.REFRESH_SUCCESS, + tokens: { accessToken, refreshToken: newRawRefresh }, + }; +} + +export async function logout(rawRefreshToken: string): Promise { + const hash = hashToken(rawRefreshToken); + const matchedKeys = await scanKeys(`refresh:*:${hash}`); + if (matchedKeys.length > 0) { + await redis.del(matchedKeys[0]); + } + + return { message: AUTH_MESSAGES.LOGOUT_SUCCESS }; +} + +export async function forgotPassword(input: ForgotPasswordRequest): Promise { + const user = await prisma.user.findUnique({ where: { email: input.email } }); + if (!user) return; + + const rawToken = crypto.randomBytes(32).toString("hex"); + const hashedToken = hashToken(rawToken); + + await redis.set(resetKey(hashedToken), user.id, "EX", env.RESET_TOKEN_TTL_SECONDS); + + const resetUrl = buildRedirectUrl(env.FRONTEND_URL, "/reset-password", { token: rawToken }); + + logger.info(`[job:email] Enqueue password-reset email for userId=${user.id}`); + await emailQueue.add("send-reset-email", { + userId: user.id, + email: user.email, + name: user.name, + resetUrl, + }); +} + +export async function resetPassword(input: ResetPasswordRequest): Promise { + const hashedToken = hashToken(input.token); + const userId = await redis.get(resetKey(hashedToken)); + + if (!userId) { + throw new BadRequestError(AUTH_MESSAGES.RESET_TOKEN_INVALID); + } + + const hashedPassword = await argon2.hash(input.newPassword); + + await prisma.user.update({ + where: { id: userId }, + data: { password: hashedPassword }, + }); + + await redis.del(resetKey(hashedToken)); + + const refreshKeys = await scanKeys(refreshPattern(userId)); + if (refreshKeys.length > 0) { + await redis.del(...refreshKeys); + } + + return { message: AUTH_MESSAGES.RESET_PASSWORD_SUCCESS }; +} + +export async function oauthLogin( + provider: OAuthProvider, + profile: { email: string; name: string; providerAccountId: string }, +): Promise { + let user = await prisma.user.findUnique({ where: { email: profile.email } }); + + if (user) { + const existingAccount = await prisma.account.findFirst({ + where: { userId: user.id, provider }, + }); + + if (!existingAccount) { + await prisma.account.create({ + data: { + userId: user.id, + provider, + providerAccountId: profile.providerAccountId, + }, + }); + } + } else { + user = await prisma.$transaction(async (tx) => { + const newUser = await tx.user.create({ + data: { + email: profile.email, + name: profile.name, + }, + }); + + await tx.account.create({ + data: { + userId: newUser.id, + provider, + providerAccountId: profile.providerAccountId, + }, + }); + + return newUser; + }); + } + + const accessToken = generateAccessToken(user.id); + const rawRefreshToken = generateRefreshToken(); + const hashedRefresh = hashToken(rawRefreshToken); + + await redis.set(refreshKey(user.id, hashedRefresh), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); + + return { + tokens: { accessToken, refreshToken: rawRefreshToken }, + user: { id: user.id, email: user.email, name: user.name }, + }; +} diff --git a/src/services/email.service.ts b/src/services/email.service.ts new file mode 100644 index 0000000..ee15c59 --- /dev/null +++ b/src/services/email.service.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { env } from "@config/env"; +import { logger } from "@services/logger.service"; +import { Resend } from "resend"; + +const resend = new Resend(env.RESEND_API_KEY); + +export async function sendResetEmail(email: string, resetUrl: string, name: string): Promise { + const from = env.RESET_PASSWORD_EMAIL; + const templateId = env.RESET_PASSWORD_TEMPLATE_ID; + + const { data, error } = await resend.emails.send({ + from, + to: [email], + subject: "Reset your Coverit password", + template: { + id: templateId, + variables: { + NAME: name, + RESET_URL: resetUrl, + EXPIRE_TIME: Math.ceil(env.RESET_TOKEN_TTL_SECONDS / 60), + }, + }, + }); + + if (error) { + logger.error(error, "Error sending email:"); + return; + } + + logger.info("Reset password email sent successfully!"); + logger.info( + { + email, + messageId: data?.id, + }, + "Reset password email sent", + ); +} diff --git a/src/services/logger.service.ts b/src/services/logger.service.ts new file mode 100644 index 0000000..ebb3537 --- /dev/null +++ b/src/services/logger.service.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import pino from "pino"; +import pretty from "pino-pretty"; + +const stream = pretty({ + levelFirst: true, + colorize: true, + ignore: "time,hostname,pid", +}); + +export const logger = pino( + { + level: process.env.NODE_ENV === "production" ? "info" : "debug", + }, + stream, +); diff --git a/src/services/oauth.service.ts b/src/services/oauth.service.ts new file mode 100644 index 0000000..d34af3f --- /dev/null +++ b/src/services/oauth.service.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { env } from '@config/env'; +import { BadRequestError } from '@utils/errors'; +import { AUTH_MESSAGES } from '@constants/messages'; +import type { OAuthProvider, OAuthUserProfile, OAuthProviderConfig } from 'types/auth'; + +function getProviderConfig(provider: OAuthProvider): OAuthProviderConfig { + switch (provider) { + case 'google': + return { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + callbackUrl: env.GOOGLE_CALLBACK_URL, + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'openid email profile', + fetchProfile: fetchGoogleProfile, + }; + case 'github': + return { + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + callbackUrl: env.GITHUB_CALLBACK_URL, + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scope: 'read:user user:email', + fetchProfile: fetchGitHubProfile, + }; + } +} + +export function getAuthorizationUrl(provider: OAuthProvider, state: string): string { + const config = getProviderConfig(provider); + + if (!config.clientId) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_PROVIDER_NOT_CONFIGURED); + } + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: config.callbackUrl, + response_type: 'code', + scope: config.scope, + state, + }); + + if (provider === 'google') { + params.set('access_type', 'offline'); + params.set('prompt', 'consent'); + } + + return `${config.authorizeUrl}?${params.toString()}`; +} + +export async function exchangeCodeForProfile( + provider: OAuthProvider, + code: string, +): Promise { + const config = getProviderConfig(provider); + + const tokenBody: Record = { + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: config.callbackUrl, + grant_type: 'authorization_code', + }; + + const tokenHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (provider === 'github') { + tokenHeaders['Accept'] = 'application/json'; + } + + const tokenRes = await fetch(config.tokenUrl, { + method: 'POST', + headers: tokenHeaders, + body: new URLSearchParams(tokenBody).toString(), + }); + + if (!tokenRes.ok) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); + } + + const tokenData = (await tokenRes.json()) as Record; + + if (tokenData.error) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); + } + + const accessToken: string = tokenData.access_token; + + if (!accessToken) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); + } + + return config.fetchProfile(accessToken); +} + +async function fetchGoogleProfile(accessToken: string): Promise { + const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_USER_INFO_FAILED); + } + + const data = (await res.json()) as Record; + if (!data.email) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_EMAIL_MISSING); + } + + return { email: data.email, name: data.name ?? data.email, providerAccountId: data.id }; +} + +async function fetchGitHubProfile(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }; + + const userRes = await fetch('https://api.github.com/user', { headers }); + if (!userRes.ok) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_USER_INFO_FAILED); + } + const userData = (await userRes.json()) as Record; + + let email: string | null = userData.email ?? null; + + if (!email) { + const emailRes = await fetch('https://api.github.com/user/emails', { headers }); + if (emailRes.ok) { + const emails = (await emailRes.json()) as Array<{ email: string; primary: boolean; verified: boolean }>; + const primary = emails.find((e) => e.primary && e.verified); + email = primary?.email ?? emails[0]?.email ?? null; + } + } + + if (!email) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_EMAIL_MISSING); + } + + return { email, name: (userData.name ?? userData.login ?? email) as string, providerAccountId: String(userData.id) }; +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..da93250 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +export type OAuthProvider = 'google' | 'github'; + +export interface OAuthUserProfile { + email: string; + name: string; + providerAccountId: string; +} + +export interface OAuthProviderConfig { + clientId: string; + clientSecret: string; + callbackUrl: string; + authorizeUrl: string; + tokenUrl: string; + scope: string; + fetchProfile: (accessToken: string) => Promise; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..0a645cb --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +export class AppError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + this.name = 'AppError'; + } +} + +export class UnauthorizedError extends AppError { + constructor(message = 'Unauthorized') { + super(401, message); + this.name = 'UnauthorizedError'; + } +} + +export class BadRequestError extends AppError { + constructor(message = 'Bad request') { + super(400, message); + this.name = 'BadRequestError'; + } +} + +export class ConflictError extends AppError { + constructor(message = 'Conflict') { + super(409, message); + this.name = 'ConflictError'; + } +} diff --git a/src/utils/redirect.ts b/src/utils/redirect.ts new file mode 100644 index 0000000..3305584 --- /dev/null +++ b/src/utils/redirect.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +export function buildRedirectUrl( + baseUrl: string, + pathname = '', + params?: URLSearchParams | Record +): string { + if (!baseUrl) return pathname || ''; + + const normalizedBase = baseUrl.replace(/\/+$/g, ''); + const normalizedPath = pathname ? '/' + pathname.replace(/^\/+/, '') : ''; + + const url = `${normalizedBase}${normalizedPath}`; + + if (!params) return url; + + const search = params instanceof URLSearchParams + ? params.toString() + : new URLSearchParams(params).toString(); + + return search ? `${url}?${search}` : url; +} + +export default buildRedirectUrl; diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000..acc32a9 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; + +import { env } from '@config/env'; +import { UnauthorizedError } from '@utils/errors'; + +export function generateAccessToken(userId: string): string { + return jwt.sign({ sub: userId }, env.JWT_SECRET, { + expiresIn: env.JWT_ACCESS_EXPIRY as jwt.SignOptions['expiresIn'], + }); +} + +export function generateRefreshToken(): string { + return crypto.randomBytes(48).toString('base64url'); +} + +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +/** Verify an access token and return the userId. Throws on invalid/expired tokens. */ +export function verifyAccessToken(token: string): string { + const payload = jwt.verify(token, env.JWT_SECRET) as jwt.JwtPayload; + if (!payload.sub) { + throw new UnauthorizedError('Malformed token'); + } + return payload.sub; +} diff --git a/src/utils/zod.ts b/src/utils/zod.ts new file mode 100644 index 0000000..f2cf43b --- /dev/null +++ b/src/utils/zod.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import * as _z from 'zod'; + +function requiredString(message: string) { + return _z.string({ error: message }).trim().min(1, message); +} + +export const z = { + ..._z, + requiredString, +}; diff --git a/src/workers/email.worker.ts b/src/workers/email.worker.ts new file mode 100644 index 0000000..a44c885 --- /dev/null +++ b/src/workers/email.worker.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { workerRedis } from '@lib/redis'; +import { sendResetEmail } from "@services/email.service"; +import { logger } from "@services/logger.service"; +import { Worker } from "bullmq"; + +new Worker( + "email", + async (job) => { + if (job.name === "send-reset-email") { + const { email, resetUrl, name } = job.data; + await sendResetEmail(email, resetUrl, name); + } + }, + { connection: workerRedis }, +); + +logger.info("[Worker] Email worker started and listening for jobs..."); From d1b9fcbb14fa96aa599ee7b118d5021598a64781 Mon Sep 17 00:00:00 2001 From: Youssef Date: Sat, 4 Apr 2026 15:37:24 +0200 Subject: [PATCH 02/35] feat: initial file structure --- .github/workflows/branch-validation.yml | 14 - .github/workflows/docker.yml | 32 -- .github/workflows/test.yml | 33 -- .husky/commit-msg | 2 - .husky/pre-commit | 7 - .npmrc | 1 - .prettierrc | 4 - Dockerfile | 43 -- Dockerfile.dev | 22 - docker-compose.yml | 84 ---- docker.sh | 77 ---- overrides/api.cloud.yml | 14 - overrides/api.dev.yml | 19 - overrides/api.test.yml | 13 - prisma/migrations/0_init/migration.sql | 35 -- prisma/migrations/migration_lock.toml | 3 - prisma/schema.prisma | 39 -- src/__tests__/app.test.ts | 75 ---- src/__tests__/config/env.test.ts | 49 --- .../controllers/auth.controller.test.ts | 198 --------- src/__tests__/integration/auth.routes.test.ts | 335 --------------- src/__tests__/lib/redis.test.ts | 50 --- src/__tests__/middlewares/logger.test.ts | 25 -- src/__tests__/middlewares/requireAuth.test.ts | 125 ------ src/__tests__/middlewares/validate.test.ts | 53 --- src/__tests__/mocks/ioredis.ts | 15 - src/__tests__/mocks/prisma.ts | 22 - src/__tests__/mocks/redis.ts | 35 -- src/__tests__/queues/email.queue.test.ts | 21 - src/__tests__/services/auth.service.test.ts | 390 ------------------ src/__tests__/services/email.service.test.ts | 42 -- src/__tests__/services/logger.service.test.ts | 24 -- src/__tests__/services/oauth.service.test.ts | 122 ------ src/__tests__/utils/errors.test.ts | 44 -- src/__tests__/utils/redirect.test.ts | 35 -- src/__tests__/workers/email.worker.test.ts | 53 --- src/api/controllers/auth.controller.ts | 138 ------- src/api/middlewares/errorHandler.ts | 18 - src/api/middlewares/logger.ts | 26 -- src/api/middlewares/requireAuth.ts | 21 - src/api/middlewares/validate.ts | 29 -- src/api/routes/auth.routes.ts | 29 -- src/app.ts | 52 --- src/config/env.ts | 55 --- src/config/openapi/auth.ts | 110 ----- src/config/openapi/index.ts | 15 - src/config/openapi/registry.ts | 11 - src/config/swagger.ts | 25 -- src/constants/auth.ts | 10 - src/constants/messages/auth.ts | 50 --- src/constants/messages/index.ts | 5 - src/index.ts | 37 -- src/lib/prisma.ts | 12 - src/lib/redis.ts | 44 -- src/models/auth.ts | 53 --- src/models/common.ts | 27 -- src/models/express.d.ts | 9 - src/models/user.ts | 14 - src/queues/email.queue.ts | 10 - src/services/auth.service.ts | 201 --------- src/services/email.service.ts | 42 -- src/services/logger.service.ts | 19 - src/services/oauth.service.ts | 150 ------- src/types/auth.ts | 21 - src/utils/errors.ts | 34 -- src/utils/redirect.ts | 26 -- src/utils/token.ts | 32 -- src/utils/zod.ts | 14 - src/workers/email.worker.ts | 21 - 69 files changed, 3515 deletions(-) delete mode 100644 .github/workflows/branch-validation.yml delete mode 100644 .github/workflows/docker.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .husky/commit-msg delete mode 100644 .husky/pre-commit delete mode 100644 .npmrc delete mode 100644 .prettierrc delete mode 100644 Dockerfile delete mode 100644 Dockerfile.dev delete mode 100644 docker-compose.yml delete mode 100644 docker.sh delete mode 100644 overrides/api.cloud.yml delete mode 100644 overrides/api.dev.yml delete mode 100644 overrides/api.test.yml delete mode 100644 prisma/migrations/0_init/migration.sql delete mode 100644 prisma/migrations/migration_lock.toml delete mode 100644 prisma/schema.prisma delete mode 100644 src/__tests__/app.test.ts delete mode 100644 src/__tests__/config/env.test.ts delete mode 100644 src/__tests__/controllers/auth.controller.test.ts delete mode 100644 src/__tests__/integration/auth.routes.test.ts delete mode 100644 src/__tests__/lib/redis.test.ts delete mode 100644 src/__tests__/middlewares/logger.test.ts delete mode 100644 src/__tests__/middlewares/requireAuth.test.ts delete mode 100644 src/__tests__/middlewares/validate.test.ts delete mode 100644 src/__tests__/mocks/ioredis.ts delete mode 100644 src/__tests__/mocks/prisma.ts delete mode 100644 src/__tests__/mocks/redis.ts delete mode 100644 src/__tests__/queues/email.queue.test.ts delete mode 100644 src/__tests__/services/auth.service.test.ts delete mode 100644 src/__tests__/services/email.service.test.ts delete mode 100644 src/__tests__/services/logger.service.test.ts delete mode 100644 src/__tests__/services/oauth.service.test.ts delete mode 100644 src/__tests__/utils/errors.test.ts delete mode 100644 src/__tests__/utils/redirect.test.ts delete mode 100644 src/__tests__/workers/email.worker.test.ts delete mode 100644 src/api/controllers/auth.controller.ts delete mode 100644 src/api/middlewares/errorHandler.ts delete mode 100644 src/api/middlewares/logger.ts delete mode 100644 src/api/middlewares/requireAuth.ts delete mode 100644 src/api/middlewares/validate.ts delete mode 100644 src/api/routes/auth.routes.ts delete mode 100644 src/app.ts delete mode 100644 src/config/env.ts delete mode 100644 src/config/openapi/auth.ts delete mode 100644 src/config/openapi/index.ts delete mode 100644 src/config/openapi/registry.ts delete mode 100644 src/config/swagger.ts delete mode 100644 src/constants/auth.ts delete mode 100644 src/constants/messages/auth.ts delete mode 100644 src/constants/messages/index.ts delete mode 100644 src/index.ts delete mode 100644 src/lib/prisma.ts delete mode 100644 src/lib/redis.ts delete mode 100644 src/models/auth.ts delete mode 100644 src/models/common.ts delete mode 100644 src/models/express.d.ts delete mode 100644 src/models/user.ts delete mode 100644 src/queues/email.queue.ts delete mode 100644 src/services/auth.service.ts delete mode 100644 src/services/email.service.ts delete mode 100644 src/services/logger.service.ts delete mode 100644 src/services/oauth.service.ts delete mode 100644 src/types/auth.ts delete mode 100644 src/utils/errors.ts delete mode 100644 src/utils/redirect.ts delete mode 100644 src/utils/token.ts delete mode 100644 src/utils/zod.ts delete mode 100644 src/workers/email.worker.ts diff --git a/.github/workflows/branch-validation.yml b/.github/workflows/branch-validation.yml deleted file mode 100644 index 52c5285..0000000 --- a/.github/workflows/branch-validation.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -name: Branch validation - -on: - pull_request: - types: [opened] - branches: [main, develop] - -jobs: - validate-branch: - uses: CoveritLabs/.github/.github/workflows/branch-validation.yml@main diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 90110de..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -name: Docker - -on: - push: - branches: [main, develop] - paths-ignore: - - "**.md" - - "docs/**" - - ".github/**" - - ".husky/**" - - ".vscode/**" - pull_request: - branches: [main, develop] - paths-ignore: - - "**.md" - - "docs/**" - - ".github/**" - - ".husky/**" - - ".vscode/**" - release: - types: [published] - -jobs: - docker: - uses: CoveritLabs/.github/.github/workflows/docker-build.yml@main - with: - image-name: coveritlabs/coverit-api - secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index ce1c924..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -name: Tests - -on: - push: - branches: [main, develop] - paths-ignore: - - "**.md" - - "docs/**" - - ".github/**" - - ".husky/**" - - ".vscode/**" - pull_request: - branches: [main, develop] - paths-ignore: - - "**.md" - - "docs/**" - - ".github/**" - - ".husky/**" - - ".vscode/**" - -permissions: - contents: read - packages: read - pull-requests: write - -jobs: - test: - uses: CoveritLabs/.github/.github/workflows/test-and-coverage.yml@main - secrets: inherit diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100644 index 3277788..0000000 --- a/.husky/commit-msg +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -npx coverit-validate-commit-msg "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index fb65299..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -# license header injection -npx coverit-add-license-header - -# branch name validation -npx coverit-validate-branch diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 9e09b04..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@coveritlabs:registry=https://npm.pkg.github.com diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 8f02c18..0000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "tabWidth": 2, - "printWidth": 120 -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8cc148a..0000000 --- a/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# Build stage -FROM node:22-alpine AS build - -WORKDIR /app - -COPY package.json package-lock.json ./ -RUN --mount=type=secret,id=npm_token \ - printf "@coveritlabs:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)\n" > .npmrc \ - && npm ci --ignore-scripts \ - && rm -f .npmrc - -COPY prisma ./prisma -COPY prisma.config.ts ./ -RUN npx prisma generate - -COPY tsconfig.json ./ -COPY src ./src -RUN npm run build - -# Production stage -FROM node:22-alpine - -WORKDIR /app - -ENV NODE_ENV=production - -COPY package.json package-lock.json ./ -RUN --mount=type=secret,id=npm_token \ - printf "@coveritlabs:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)\n" > .npmrc \ - && npm ci --ignore-scripts --omit=dev \ - && rm -f .npmrc - -COPY prisma ./prisma -COPY prisma.config.ts ./ -RUN npx prisma generate - -COPY --from=build /app/dist ./dist - -EXPOSE 3000 - -USER node - -CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 81aa40e..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,22 +0,0 @@ -# Build stage for development -FROM node:22-alpine - -WORKDIR /app - -# Install dependencies (including devDependencies) -COPY package.json package-lock.json ./ -RUN --mount=type=secret,id=npm_token \ - printf "@coveritlabs:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)\n" > .npmrc \ - && npm ci --ignore-scripts \ - && rm -f .npmrc - -COPY prisma ./prisma -COPY prisma.config.ts ./ -RUN npx prisma generate - -# Source code will be mounted at runtime via docker-compose -ENV NODE_ENV=development - -EXPOSE 3000 - -CMD ["sh", "-c", "npx prisma migrate deploy && npx nodemon"] diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 456ef38..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -name: coverit - -services: - crawler: - image: ghcr.io/coveritlabs/coverit-crawler:${CRAWLER_TAG:-dev} - build: - context: ${CRAWLER_DIR} - secrets: - - npm_token - pull_policy: always - container_name: coverit-crawler - ports: - - "3000:3000" - environment: - - NODE_ENV=${NODE_ENV:-development} - - PORT=3000 - - DATABASE_URL=${DATABASE_URL:-} - - REDIS_URL=${REDIS_URL:-} - - JWT_SECRET=${JWT_SECRET:-} - - JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m} - - JWT_REFRESH_EXPIRY_SECONDS=${JWT_REFRESH_EXPIRY_SECONDS:-604800} - - RESET_TOKEN_TTL_SECONDS=${RESET_TOKEN_TTL_SECONDS:-3600} - - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} - - CORS_CREDENTIALS=${CORS_CREDENTIALS:-true} - - FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173} - - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost:3000/crawler/v1/auth/oauth/google/callback} - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} - - GITHUB_CALLBACK_URL=${GITHUB_CALLBACK_URL:-http://localhost:3000/crawler/v1/auth/oauth/github/callback} - - RESEND_CRAWLER_KEY=${RESEND_CRAWLER_KEY:-} - - RESET_PASSWORD_EMAIL=${RESET_PASSWORD_EMAIL:-} - - RESET_PASSWORD_TEMPLATE_ID=${RESET_PASSWORD_TEMPLATE_ID:-} - - NODE_TLS_REJECT_UNAUTHORIZED=0 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - - db: - image: postgres:18 - container_name: coverit-postgres - environment: - - POSTGRES_USER=${DB_USER:-postgres} - - POSTGRES_PASSWORD=${DB_PASSWORD:-postgres} - - POSTGRES_DB=${DB_NAME:-coverit} - volumes: - - postgres_data:/var/lib/postgresql - - ./database:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] - interval: 5s - timeout: 5s - retries: 5 - restart: unless-stopped - - redis: - image: redis:7-alpine - container_name: coverit-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 5 - restart: unless-stopped - -secrets: - npm_token: - environment: GITHUB_TOKEN - -volumes: - postgres_data: - redis_data: diff --git a/docker.sh b/docker.sh deleted file mode 100644 index 8ad220e..0000000 --- a/docker.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/sh - -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - - -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - - -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -# Usage: -# ./docker.sh up → remote image + cloud db/redis -# ./docker.sh up --local → local dev build + hot-reload + local db/redis -# ./docker.sh up --test-prod → local prod build + cloud db/redis -# ./docker.sh up --tag latest → remote image (specific tag) + cloud db/redis - -print_help() { - echo "Usage: $0 [up|down|logs] [--tag ] [--local] [--test-prod]" -} - -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -export API_DIR="$SCRIPT_DIR" - -CMD="${1:-up}" -shift 2>/dev/null || true - -# Defaults -export API_TAG="dev" -LOCAL=false -TEST_PROD=false - -while [ $# -gt 0 ]; do - case "$1" in - --tag) export API_TAG="$2"; shift 2 ;; - --local) LOCAL=true; shift ;; - --test-prod) TEST_PROD=true; shift ;; - -h|--help) print_help; exit 0 ;; - *) echo "Unknown flag: $1"; exit 1 ;; - esac -done - -# Only --local uses local db/redis. Everything else connects to cloud. -if [ "$LOCAL" = true ]; then - echo "Starting API in local dev mode (local db + redis)..." - EXEC_CMD="docker compose -f docker-compose.yml -f overrides/api.dev.yml" -elif [ "$TEST_PROD" = true ]; then - echo "Starting API in Production Test mode (local prod build + cloud)..." - EXEC_CMD="docker compose -f docker-compose.yml -f overrides/api.cloud.yml -f overrides/api.test.yml" -else - echo "Starting API using remote image (API_TAG=$API_TAG) + cloud..." - EXEC_CMD="docker compose -f docker-compose.yml -f overrides/api.cloud.yml" -fi - -case "$CMD" in - up) - $EXEC_CMD up -d --build - ;; - down) - $EXEC_CMD down - ;; - logs) - $EXEC_CMD logs -f - ;; - *) - echo "Unknown command: $CMD" - print_help - exit 1 - ;; -esac diff --git a/overrides/api.cloud.yml b/overrides/api.cloud.yml deleted file mode 100644 index 11b4672..0000000 --- a/overrides/api.cloud.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -# Cloud override — points API at cloud db/redis (e.g. Aiven). -# Requires DATABASE_URL, REDIS_URL, and JWT_SECRET in .env. -# -# Used by: -# ./docker.sh up → remote image + cloud -# ./docker.sh up --test-prod → local prod build + cloud - -services: - api: - depends_on: [] diff --git a/overrides/api.dev.yml b/overrides/api.dev.yml deleted file mode 100644 index a90a072..0000000 --- a/overrides/api.dev.yml +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -# Modular override for API development with hot-reload -# Inherits JWT_ACCESS_EXPIRY, JWT_REFRESH_EXPIRY_SECONDS, RESET_TOKEN_TTL_SECONDS, -# REDIS_URL, and CORS_ORIGINS from the base docker-compose.yml. - -services: - api: - image: coverit-api-dev-local - build: - dockerfile: Dockerfile.dev - volumes: - - ${API_DIR}/src:/app/src - - ${API_DIR}/prisma:/app/prisma - - ${API_DIR}/prisma.config.ts:/app/prisma.config.ts - - ${API_DIR}/nodemon.json:/app/nodemon.json - - ${API_DIR}/tsconfig.json:/app/tsconfig.json diff --git a/overrides/api.test.yml b/overrides/api.test.yml deleted file mode 100644 index 3cc4d1d..0000000 --- a/overrides/api.test.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -# Proprietary and confidential. Unauthorized use is strictly prohibited. -# See LICENSE file in the project root for full license information. - -# Build override — builds the production Dockerfile locally. -# Always layered on top of api.cloud.yml: -# docker compose -f docker-compose.yml -f overrides/api.cloud.yml -f overrides/api.test.yml - -services: - api: - image: coverit-api-test-local - build: - dockerfile: Dockerfile \ No newline at end of file diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql deleted file mode 100644 index d8ddf7e..0000000 --- a/prisma/migrations/0_init/migration.sql +++ /dev/null @@ -1,35 +0,0 @@ --- CreateSchema -CREATE SCHEMA IF NOT EXISTS "public"; - --- CreateTable -CREATE TABLE "users" ( - "id" UUID NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT, - "name" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "accounts" ( - "id" UUID NOT NULL, - "user_id" UUID NOT NULL, - "provider" TEXT NOT NULL, - "provider_account_id" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); - --- AddForeignKey -ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml deleted file mode 100644 index 99e4f20..0000000 --- a/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma deleted file mode 100644 index c32fee7..0000000 --- a/prisma/schema.prisma +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -generator client { - provider = "prisma-client" - output = "../src/generated/prisma" -} - -datasource db { - provider = "postgresql" -} - -model User { - id String @id @default(uuid()) @db.Uuid - email String @unique - password String? - name String - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - accounts Account[] - - @@map("users") -} - -model Account { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid - provider String - providerAccountId String @map("provider_account_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) - @@map("accounts") -} diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts deleted file mode 100644 index c476557..0000000 --- a/src/__tests__/app.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -jest.mock("@config/env", () => ({ - env: { - CORS_ORIGINS: ["http://localhost:5173", "http://example.com"], - CORS_CREDENTIALS: "true", - API_PREFIX: "/api/v1", - RESEND_API_KEY: "test-key" - } -})); - -jest.mock("bullmq", () => { - return { - Queue: jest.fn().mockImplementation(() => ({ - on: jest.fn(), - add: jest.fn(), - })), - Worker: jest.fn().mockImplementation(() => ({ - on: jest.fn(), - close: jest.fn(), - })), - }; -}); - -jest.mock("@lib/redis", () => require("./mocks/redis")); -jest.mock("@lib/prisma", () => require("./mocks/prisma")); - -jest.mock("@workers/email.worker", () => ({})); - -import request from "supertest"; -import app from "../app"; -import { env } from "@config/env"; - -describe("app.ts", () => { - test("GET /health should return status ok", async () => { - const res = await request(app).get("/health"); - expect(res.status).toBe(200); - expect(res.body.status).toBe("ok"); - expect(res.body.timestamp).toBeDefined(); - }); - - test("GET /docs.json should return swagger spec", async () => { - const res = await request(app).get("/docs.json"); - expect(res.status).toBe(200); - expect(res.body.openapi).toBeDefined(); - expect(res.body.info).toBeDefined(); - }); - - describe("CORS configuration", () => { - test("should allow requests from allowed origins", async () => { - // Assuming env.FRONTEND_URL or standard localhost is in CORS_ORIGINS - const validOrigin = env.CORS_ORIGINS[0] === "*" ? "http://example.com" : env.CORS_ORIGINS[0]; - const res = await request(app).options("/health").set("Origin", validOrigin); - expect(res.status).toBe(200); - expect(res.header["access-control-allow-origin"]).toBe(validOrigin); - }); - - test("should reject requests from disallowed origins", async () => { - if (env.CORS_ORIGINS.includes("*")) { - // Skip if everything is allowed - return; - } - const res = await request(app).options("/health").set("Origin", "http://malicious.com"); - // Cors middleware typically sends a 500 when the origin callback throws an error - expect(res.status).toBe(500); - }); - - test("should allow requests with no origin (e.g., server-to-server)", async () => { - const res = await request(app).get("/health"); // No Origin header set - expect(res.status).toBe(200); - }); - }); -}); diff --git a/src/__tests__/config/env.test.ts b/src/__tests__/config/env.test.ts deleted file mode 100644 index 752cc5d..0000000 --- a/src/__tests__/config/env.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -describe("config/env", () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = {}; // Clear environment to force defaults - // suppress console.info - jest.spyOn(console, "info").mockImplementation(() => {}); - }); - - afterEach(() => { - process.env = originalEnv; - jest.restoreAllMocks(); - }); - - test("loads correct defaults when process.env variables are missing", async () => { - const { env } = await import("@config/env"); - - expect(env.NODE_ENV).toBe("development"); - expect(env.PORT).toBe(3000); - expect(env.DATABASE_URL).toBe(""); - expect(env.REDIS_URL).toBe("redis://localhost:6379"); - expect(env.CORS_ORIGINS).toEqual(["http://localhost:5173"]); - expect(env.CORS_CREDENTIALS).toBe("true"); - expect(env.JWT_SECRET).toBe(""); - expect(env.JWT_ACCESS_EXPIRY).toBe("15m"); - expect(env.JWT_REFRESH_EXPIRY_SECONDS).toBe(604800); - expect(env.RESET_TOKEN_TTL_SECONDS).toBe(900); - expect(env.API_PREFIX).toBe("/api/v1"); - - expect(env.FRONTEND_URL).toBe("http://localhost:5173"); - expect(env.GOOGLE_CLIENT_ID).toBe(""); - expect(env.GITHUB_CLIENT_ID).toBe(""); - expect(console.info).toHaveBeenCalled(); - }); - - test("loads provided values overriding defaults", async () => { - process.env.PORT = "4000"; - process.env.CORS_ORIGINS = "https://a.com,https://b.com"; - - const { env } = await import("@config/env"); - expect(env.PORT).toBe(4000); - expect(env.CORS_ORIGINS).toEqual(["https://a.com", "https://b.com"]); - }); -}); diff --git a/src/__tests__/controllers/auth.controller.test.ts b/src/__tests__/controllers/auth.controller.test.ts deleted file mode 100644 index a180756..0000000 --- a/src/__tests__/controllers/auth.controller.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import express from "express"; -import request from "supertest"; - -jest.mock("@services/auth.service"); -jest.mock("@services/oauth.service"); -jest.mock("@config/env", () => ({ env: { FRONTEND_URL: "https://app.example.com" } })); -jest.mock("@queues/email.queue", () => ({ - emailQueue: { - add: jest.fn(), - }, -})); - -import * as authService from "@services/auth.service"; -import * as oauthService from "@services/oauth.service"; -import { buildRedirectUrl } from "@utils/redirect"; -import { env } from "@config/env"; -import { - signup, - login, - refresh, - logout, - forgotPassword, - resetPassword, - oauthRedirect, - oauthCallback, -} from "@api/controllers/auth.controller"; -import { AUTH_MESSAGES } from "@constants/messages"; - -function makeApp() { - const a = express(); - a.use(express.json()); - a.post("/signup", signup); - a.post("/login", login); - a.post("/refresh", refresh); - a.post("/logout", logout); - a.post("/forgot", forgotPassword); - a.post("/reset", resetPassword); - a.get("/oauth/:provider/redirect", oauthRedirect); - a.get("/oauth/:provider/callback", oauthCallback); - return a; -} - -describe("auth.controller", () => { - let app: express.Application; - - beforeEach(() => { - jest.resetAllMocks(); - app = makeApp(); - }); - - test("signup returns 201 on success", async () => { - (authService.signup as jest.Mock).mockResolvedValue({ message: "ok" }); - const res = await request(app).post("/signup").send({ email: "a@b.com", password: "p", name: "n" }); - expect(res.status).toBe(201); - expect(res.body).toEqual({ message: "ok" }); - }); - - test("login returns 200 on success", async () => { - (authService.login as jest.Mock).mockResolvedValue({ tokens: {}, user: {} }); - const res = await request(app).post("/login").send({ email: "a@b.com", password: "p" }); - expect(res.status).toBe(200); - }); - - test("refresh returns 200 on success", async () => { - (authService.refresh as jest.Mock).mockResolvedValue({ tokens: {} }); - const res = await request(app).post("/refresh").send({ refreshToken: "rt" }); - expect(res.status).toBe(200); - }); - - test("logout with refreshToken calls service", async () => { - (authService.logout as jest.Mock).mockResolvedValue({ message: "logged out" }); - const res = await request(app).post("/logout").send({ refreshToken: "x" }); - expect(res.status).toBe(200); - expect(authService.logout).toHaveBeenCalledWith("x"); - }); - - test("logout without refreshToken returns success message", async () => { - const res = await request(app).post("/logout").send({}); - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); - }); - - test("forgotPassword triggers service and returns 200", async () => { - (authService.forgotPassword as jest.Mock).mockResolvedValue(undefined); - const res = await request(app).post("/forgot").send({ email: "a@b.com" }); - expect(res.status).toBe(200); - expect(authService.forgotPassword).toHaveBeenCalledWith({ email: "a@b.com" }); - }); - - test("resetPassword returns 200 on success", async () => { - (authService.resetPassword as jest.Mock).mockResolvedValue({ message: "ok" }); - const res = await request(app).post("/reset").send({ token: "t", newPassword: "np" }); - expect(res.status).toBe(200); - }); - - test("oauthRedirect returns 400 for unsupported provider", async () => { - const res = await request(app).get("/oauth/unknown/redirect"); - expect(res.status).toBe(400); - expect(res.body.message).toBe(AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER); - }); - - test("oauthRedirect redirects to provider authorization url", async () => { - (oauthService.getAuthorizationUrl as jest.Mock).mockReturnValue("https://auth.example"); - const res = await request(app).get("/oauth/google/redirect"); - expect(res.status).toBe(302); - expect(res.header.location).toBe("https://auth.example"); - }); - - test("oauthCallback redirects to login when code missing", async () => { - const res = await request(app).get("/oauth/google/callback"); - expect(res.status).toBe(302); - expect(res.header.location).toBe( - buildRedirectUrl(env.FRONTEND_URL, "/login", { error: AUTH_MESSAGES.OAUTH_CODE_MISSING }), - ); - }); - - test("oauthCallback redirects back to frontend on success", async () => { - (oauthService.exchangeCodeForProfile as jest.Mock).mockResolvedValue({ email: "a@b.com", name: "A" }); - (authService.oauthLogin as jest.Mock).mockResolvedValue({ - tokens: { accessToken: "a", refreshToken: "r" }, - user: { id: "u", email: "a@b.com", name: "A" }, - }); - const res = await request(app).get("/oauth/google/callback").query({ code: "c" }); - expect(res.status).toBe(302); - expect(res.header.location).toContain(`${env.FRONTEND_URL}/oauth/callback?`); - }); - - test("oauthCallback redirects to login with error when exchange fails", async () => { - (oauthService.exchangeCodeForProfile as jest.Mock).mockRejectedValue(new Error("fail")); - const res = await request(app).get("/oauth/google/callback").query({ code: "c" }); - expect(res.status).toBe(302); - expect(res.header.location).toBe(buildRedirectUrl(env.FRONTEND_URL, "/login", { error: "fail" })); - }); - - describe("error handling catch blocks", () => { - test("signup next(err) on service failure", async () => { - (authService.signup as jest.Mock).mockRejectedValue(new Error("signup fail")); - const res = await request(app).post("/signup").send({}); - expect(res.status).toBe(500); - }); - - test("login next(err)", async () => { - (authService.login as jest.Mock).mockRejectedValue(new Error("login fail")); - const res = await request(app).post("/login").send({}); - expect(res.status).toBe(500); - }); - - test("refresh next(err)", async () => { - (authService.refresh as jest.Mock).mockRejectedValue(new Error("refresh fail")); - const res = await request(app).post("/refresh").send({}); - expect(res.status).toBe(500); - }); - - test("logout next(err)", async () => { - (authService.logout as jest.Mock).mockRejectedValue(new Error("logout fail")); - const res = await request(app).post("/logout").send({ refreshToken: "x" }); - expect(res.status).toBe(500); - }); - - test("forgotPassword next(err)", async () => { - const mockReq = { body: {} } as any; - const mockRes = {} as any; - const nextFn = jest.fn(); - - const originalForgot = authService.forgotPassword; - Object.defineProperty(authService, "forgotPassword", { value: () => { throw new Error("sync throw"); } }); - - await forgotPassword(mockReq, mockRes, nextFn); - expect(nextFn).toHaveBeenCalledWith(new Error("sync throw")); - - Object.defineProperty(authService, "forgotPassword", { value: originalForgot }); - }); - - test("resetPassword next(err)", async () => { - (authService.resetPassword as jest.Mock).mockRejectedValue(new Error("reset fail")); - const res = await request(app).post("/reset").send({}); - expect(res.status).toBe(500); - }); - - test("oauthRedirect next(err)", async () => { - const mockReq = { params: { provider: "google" } } as any; - const mockRes = {} as any; - const nextFn = jest.fn(); - - const originalRedirect = oauthService.getAuthorizationUrl; - Object.defineProperty(oauthService, "getAuthorizationUrl", { value: () => { throw new Error("sync throw auth"); } }); - - await oauthRedirect(mockReq, mockRes, nextFn); - expect(nextFn).toHaveBeenCalledWith(new Error("sync throw auth")); - - Object.defineProperty(oauthService, "getAuthorizationUrl", { value: originalRedirect }); - }); - }); -}); diff --git a/src/__tests__/integration/auth.routes.test.ts b/src/__tests__/integration/auth.routes.test.ts deleted file mode 100644 index 16af18e..0000000 --- a/src/__tests__/integration/auth.routes.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -/** - * @file Integration tests for all POST /auth/* routes via supertest. - */ - -import { AUTH_MESSAGES, AUTH_VALIDATION } from "@constants/messages"; -import jwt from "jsonwebtoken"; -import request from "supertest"; - -jest.mock("@workers/email.worker", () => ({})); -jest.mock("@lib/prisma", () => require("../mocks/prisma")); -jest.mock("@lib/redis", () => require("../mocks/redis")); -jest.mock("@services/email.service", () => ({ - sendResetEmail: jest.fn().mockResolvedValue(undefined), -})); -jest.mock("@queues/email.queue", () => ({ - emailQueue: { - add: jest.fn(), - }, -})); - -jest.mock("@config/env", () => ({ - env: { - NODE_ENV: "test", - PORT: 3000, - JWT_SECRET: "test-secret", - JWT_ACCESS_EXPIRY: "15m", - JWT_REFRESH_EXPIRY_SECONDS: 604800, - RESET_TOKEN_TTL_SECONDS: 3600, - API_PREFIX: "/api/v1", - RESEND_API_KEY: "test-key", - RESET_PASSWORD_EMAIL: "test@test.com", - FRONTEND_URL: "https://app.example.com", - }, -})); - -jest.mock("argon2", () => ({ - hash: jest.fn().mockResolvedValue("$argon2-hashed"), - verify: jest.fn(), -})); - -import { env } from "@config/env"; -import prisma from "@lib/prisma"; -import redis from "@lib/redis"; -import argon2 from "argon2"; -import app from "../../app"; - -const BASE = `${env.API_PREFIX}/auth`; - -const mockPrisma = prisma as any; // eslint-disable-line @typescript-eslint/no-explicit-any -const mockRedis = redis as any; // eslint-disable-line @typescript-eslint/no-explicit-any -const mockArgon2 = argon2 as any; // eslint-disable-line @typescript-eslint/no-explicit-any - -describe("POST /auth/signup", () => { - it("should return 201 on successful signup", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: "uuid-1", - email: "new@user.com", - name: "New User", - password: "$argon2-hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - - const res = await request(app) - .post(`${BASE}/signup`) - .send({ email: "new@user.com", password: "P@ssword1", name: "New User" }); - - expect(res.status).toBe(201); - expect(res.body.message).toBe(AUTH_MESSAGES.SIGNUP_SUCCESS); - }); - - it("should return 409 if email already exists", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "dup@user.com", - name: "Dup", - password: "hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - - const res = await request(app) - .post(`${BASE}/signup`) - .send({ email: "dup@user.com", password: "P@ssword1", name: "Dup" }); - - expect(res.status).toBe(409); - expect(res.body.message).toBe(AUTH_MESSAGES.EMAIL_TAKEN); - }); - - it("should not return any tokens or set cookies", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "User", - password: "$argon2-hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - - const res = await request(app) - .post(`${BASE}/signup`) - .send({ email: "a@b.com", password: "P@ssword1", name: "User" }); - - expect(res.status).toBe(201); - expect(res.body.tokens).toBeUndefined(); - }); -}); - -describe("POST /auth/login", () => { - it("should return 200 with user info and tokens in response body", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "user@test.com", - name: "Test", - password: "$argon2-hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockArgon2.verify.mockResolvedValue(true); - mockRedis.set.mockResolvedValue("OK"); - - const res = await request(app).post(`${BASE}/login`).send({ email: "user@test.com", password: "P@ssword1" }); - - expect(res.status).toBe(200); - expect(res.body.user).toEqual({ id: "uuid-1", email: "user@test.com", name: "Test" }); - expect(res.body.tokens).toBeDefined(); - expect(res.body.tokens.accessToken).toBeDefined(); - expect(res.body.tokens.refreshToken).toBeDefined(); - }); - - it("should return 401 for wrong email", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - - const res = await request(app).post(`${BASE}/login`).send({ email: "wrong@test.com", password: "P@ssword1" }); - - expect(res.status).toBe(401); - expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); - }); - - it("should return 401 for wrong password", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "user@test.com", - name: "Test", - password: "$argon2-hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockArgon2.verify.mockResolvedValue(false); - - const res = await request(app).post(`${BASE}/login`).send({ email: "user@test.com", password: "WrongPass" }); - - expect(res.status).toBe(401); - expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); - }); -}); - -describe("POST /auth/refresh", () => { - it("should return 200 and rotate tokens on valid refresh token", async () => { - const oldToken = "valid-refresh-token"; - const crypto = require("crypto"); - const hashed = crypto.createHash("sha256").update(oldToken).digest("hex"); - const key = `refresh:uuid-1:${hashed}`; - - mockRedis.scan.mockResolvedValueOnce(["0", [key]]); - mockRedis.del.mockResolvedValue(1); - mockRedis.set.mockResolvedValue("OK"); - - const res = await request(app).post(`${BASE}/refresh`).send({ refreshToken: oldToken }); - - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.REFRESH_SUCCESS); - expect(res.body.tokens).toBeDefined(); - expect(res.body.tokens.accessToken).toBeDefined(); - expect(res.body.tokens.refreshToken).toBeDefined(); - }); - - it("should return 401 if no refresh token in body", async () => { - const res = await request(app).post(`${BASE}/refresh`).send({}); - - expect(res.status).toBe(400); - expect(res.body.message).toBe(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED); - }); - - it("should return 401 if refresh token not in Redis", async () => { - mockRedis.scan.mockResolvedValue(["0", []]); - - const res = await request(app).post(`${BASE}/refresh`).send({ refreshToken: "bad-token" }); - - expect(res.status).toBe(401); - }); -}); - -describe("POST /auth/logout", () => { - it("should return 200 and invalidate refresh token", async () => { - const token = "some-refresh-token"; - const crypto = require("crypto"); - const hashed = crypto.createHash("sha256").update(token).digest("hex"); - const key = `refresh:uuid-1:${hashed}`; - - mockRedis.scan.mockResolvedValueOnce(["0", [key]]); - mockRedis.del.mockResolvedValue(1); - - const res = await request(app).post(`${BASE}/logout`).send({ refreshToken: token }); - - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); - }); - - it("should return 200 even without a refresh token (graceful)", async () => { - const res = await request(app).post(`${BASE}/logout`).send({}); - - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); - }); -}); - -describe("POST /auth/forgot-password", () => { - it("should always return 200 regardless of email existence", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - - const res = await request(app).post(`${BASE}/forgot-password`).send({ email: "nonexistent@test.com" }); - - expect(res.status).toBe(200); - expect(res.body.message).toContain("If an account"); - }); - - it("should return 200 for existing email (same response)", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "exists@test.com", - name: "Test", - password: "hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue("OK"); - - const res = await request(app).post(`${BASE}/forgot-password`).send({ email: "exists@test.com" }); - - expect(res.status).toBe(200); - expect(res.body.message).toContain("If an account"); - }); - - it("should not leak whether the email exists via timing or response", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - const res1 = await request(app).post(`${BASE}/forgot-password`).send({ email: "a@b.com" }); - - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "T", - password: "h", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue("OK"); - const res2 = await request(app).post(`${BASE}/forgot-password`).send({ email: "a@b.com" }); - - expect(res1.body).toEqual(res2.body); - expect(res1.status).toBe(res2.status); - }); -}); - -describe("POST /auth/reset-password", () => { - it("should return 200 on successful password reset", async () => { - const crypto = require("crypto"); - const rawToken = "secure-reset-token"; - const hashed = crypto.createHash("sha256").update(rawToken).digest("hex"); - - mockRedis.get.mockResolvedValue("uuid-1"); - mockArgon2.hash.mockResolvedValue("$argon2-new-hashed"); - mockPrisma.user.update.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "Test", - password: "$argon2-new-hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.del.mockResolvedValue(1); - mockRedis.scan.mockResolvedValueOnce(["0", []]); - - const res = await request(app) - .post(`${BASE}/reset-password`) - .send({ token: rawToken, newPassword: "NewP@ssword1" }); - - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.RESET_PASSWORD_SUCCESS); - expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); - }); - - it("should return 400 for invalid reset token", async () => { - mockRedis.get.mockResolvedValue(null); - - const res = await request(app) - .post(`${BASE}/reset-password`) - .send({ token: "000000", newPassword: "NewP@ssword1" }); - - expect(res.status).toBe(400); - expect(res.body.message).toBe(AUTH_MESSAGES.RESET_TOKEN_INVALID); - }); - - it("should purge all refresh tokens (global logout) after reset", async () => { - const crypto = require("crypto"); - const rawToken = "another-reset-token"; - const hashed = crypto.createHash("sha256").update(rawToken).digest("hex"); - - mockRedis.get.mockResolvedValue("uuid-1"); - mockArgon2.hash.mockResolvedValue("$argon2-new"); - mockPrisma.user.update.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "T", - password: "$argon2-new", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.del.mockResolvedValue(1); - mockRedis.scan.mockResolvedValueOnce(["0", ["refresh:uuid-1:abc"]]); - - await request(app) - .post(`${BASE}/reset-password`) - .send({ token: rawToken, newPassword: "NewP@ss1" }); - - expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); - expect(mockRedis.del).toHaveBeenCalledWith("refresh:uuid-1:abc"); - }); -}); diff --git a/src/__tests__/lib/redis.test.ts b/src/__tests__/lib/redis.test.ts deleted file mode 100644 index c59681f..0000000 --- a/src/__tests__/lib/redis.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import redis, { scanKeys } from "@lib/redis"; - -describe("lib/redis", () => { - beforeEach(() => { - jest.resetModules(); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - test("scanKeys iterables properly", async () => { - jest.unmock("@lib/redis"); - const actualRedis = jest.requireActual("@lib/redis"); - - jest.spyOn(actualRedis.default, "scan") - .mockResolvedValueOnce(["1", ["key1"]]) - .mockResolvedValueOnce(["0", ["key2"]]); - - const result = await actualRedis.scanKeys("pattern:*"); - expect(result).toEqual(["key1", "key2"]); - }); - - test("retryStrategy limits and scales", () => { - jest.unmock("ioredis"); - const { default: Redis } = jest.requireActual("ioredis"); - jest.unmock("@lib/redis"); - const actualRedisLib = jest.requireActual("@lib/redis"); - }); - - test("redis error handler gets coverage", () => { - jest.spyOn(console, "error").mockImplementation(); - - const redis = require("@lib/redis").default; - - const onMock = redis.on as jest.Mock; - const errorCall = onMock.mock.calls.find(call => call[0] === 'error'); - - if (errorCall && errorCall[1]) { - const cb = errorCall[1]; - cb(new Error("Simulated Redis Error")); - } - - expect(console.error).toHaveBeenCalled(); - }); -}); diff --git a/src/__tests__/middlewares/logger.test.ts b/src/__tests__/middlewares/logger.test.ts deleted file mode 100644 index c69d97b..0000000 --- a/src/__tests__/middlewares/logger.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { httpLogger } from "@api/middlewares/logger"; -import { Request, Response } from "express"; - -describe("api/middlewares/logger", () => { - it("determines log level based on response status code", () => { - const customLogLevel = (httpLogger as any).customLogLevel; - - if (customLogLevel) { - expect(customLogLevel({} as Request, { statusCode: 500 } as Response, undefined)).toBe("error"); - expect(customLogLevel({} as Request, { statusCode: 503 } as Response, undefined)).toBe("error"); - - expect(customLogLevel({} as Request, { statusCode: 200 } as Response, new Error("Oops"))).toBe("error"); - - expect(customLogLevel({} as Request, { statusCode: 400 } as Response, undefined)).toBe("warn"); - expect(customLogLevel({} as Request, { statusCode: 404 } as Response, undefined)).toBe("warn"); - - expect(customLogLevel({} as Request, { statusCode: 200 } as Response, undefined)).toBe("info"); - expect(customLogLevel({} as Request, { statusCode: 302 } as Response, undefined)).toBe("info"); - } - }); -}); diff --git a/src/__tests__/middlewares/requireAuth.test.ts b/src/__tests__/middlewares/requireAuth.test.ts deleted file mode 100644 index 35b67a1..0000000 --- a/src/__tests__/middlewares/requireAuth.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -/** - * @file Unit tests for requireAuth and errorHandler middlewares. - */ - -import jwt from "jsonwebtoken"; -import request from "supertest"; - -jest.mock("@lib/prisma", () => require("../mocks/prisma")); -jest.mock("@lib/redis", () => require("../mocks/redis")); -jest.mock("@queues/email.queue", () => ({ - emailQueue: { - add: jest.fn(), - }, -})); -jest.mock("@config/env", () => ({ - env: { - NODE_ENV: "test", - PORT: 3000, - JWT_SECRET: "test-secret", - JWT_ACCESS_EXPIRY: "15m", - JWT_REFRESH_EXPIRY_SECONDS: 604800, - RESET_TOKEN_TTL_SECONDS: 3600, - }, -})); - -import app from "../../app"; - -import { errorHandler } from "@api/middlewares/errorHandler"; -import { requireAuth } from "@api/middlewares/requireAuth"; -import { logger } from "@services/logger.service"; -import express from "express"; - -/** Creates a minimal Express app with a protected route for testing middleware. */ -function createTestApp(): express.Application { - const testApp = express(); - testApp.use(express.json()); - - testApp.get("/protected", requireAuth, (req, res) => { - res.json({ userId: req.userId }); - }); - - testApp.use(errorHandler); - return testApp; -} - -const testApp = createTestApp(); - -describe("requireAuth middleware", () => { - it("should attach userId and allow access with valid token", async () => { - const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "15m" }); - - const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - - expect(res.status).toBe(200); - expect(res.body.userId).toBe("uuid-1"); - }); - - it("should return 401 when no Authorization header is present", async () => { - const res = await request(testApp).get("/protected"); - - expect(res.status).toBe(401); - expect(res.body.message).toBe("Invalid or expired access token"); - }); - - it("should return 401 when token has invalid signature", async () => { - const token = jwt.sign({ sub: "uuid-1" }, "wrong-secret"); - - const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - - expect(res.status).toBe(401); - }); - - it("should return 401 when token is expired", async () => { - const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "-1s" }); - - const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - - expect(res.status).toBe(401); - }); - - it("should return 401 when token has no sub claim", async () => { - const token = jwt.sign({ foo: "bar" }, "test-secret"); - - const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - - expect(res.status).toBe(401); - }); - - it("should return 401 for garbage token value", async () => { - const res = await request(testApp).get("/protected").set("Authorization", "Bearer not.a.jwt"); - - expect(res.status).toBe(401); - }); -}); - -describe("errorHandler middleware", () => { - let loggerErrorSpy: jest.SpyInstance; - - beforeEach(() => { - loggerErrorSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined); - }); - - afterEach(() => { - loggerErrorSpy.mockRestore(); - }); - - it("should return 500 for unknown errors", async () => { - const errApp = express(); - errApp.use(express.json()); - errApp.get("/boom", () => { - throw new Error("Something broke"); - }); - errApp.use(errorHandler); - - const res = await request(errApp).get("/boom"); - - expect(res.status).toBe(500); - expect(res.body.message).toBe("Internal server error"); - expect(loggerErrorSpy).toHaveBeenCalledWith(expect.any(Error)); - }); -}); diff --git a/src/__tests__/middlewares/validate.test.ts b/src/__tests__/middlewares/validate.test.ts deleted file mode 100644 index e95c201..0000000 --- a/src/__tests__/middlewares/validate.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { validateBody } from "@api/middlewares/validate"; -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; - -describe("api/middlewares/validate", () => { - test("returns 400 when missing custom message", () => { - const schema = z.object({ - field: z.string(), - }); - - const req = { body: { field: 123 } } as Request; - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as unknown as Response; - const next = jest.fn() as NextFunction; - - const middleware = validateBody(schema); - middleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: "Validation failed", - message: "Invalid input: expected string, received number", // zod default message - }); - }); - - test("uses fallback message if issue is undefined (edge case)", () => { - const mockSchema = { - safeParse: () => ({ success: false, error: { issues: [] } }), - } as any; - - const req = { body: {} } as Request; - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as unknown as Response; - const next = jest.fn() as NextFunction; - - const middleware = validateBody(mockSchema); - middleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: "Validation failed", - message: "Invalid request body", - }); - }); -}); diff --git a/src/__tests__/mocks/ioredis.ts b/src/__tests__/mocks/ioredis.ts deleted file mode 100644 index 4378780..0000000 --- a/src/__tests__/mocks/ioredis.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -// Manual mock for the `ioredis` package used by the runtime code. -export default class MockRedis { - constructor(..._args: any[]) {} - set = jest.fn().mockResolvedValue('OK'); - get = jest.fn().mockResolvedValue(null); - del = jest.fn().mockResolvedValue(1); - scan = jest.fn().mockResolvedValue(['0', []]); - ping = jest.fn().mockResolvedValue('PONG'); - on = jest.fn(); - disconnect = jest.fn(); -} diff --git a/src/__tests__/mocks/prisma.ts b/src/__tests__/mocks/prisma.ts deleted file mode 100644 index a7e26b5..0000000 --- a/src/__tests__/mocks/prisma.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -const prisma: Record = { - user: { - findUnique: jest.fn(), - create: jest.fn(), - update: jest.fn(), - }, - account: { - findFirst: jest.fn(), - create: jest.fn(), - }, - $connect: jest.fn(), - $disconnect: jest.fn(), - $transaction: jest.fn(), -}; - -prisma.$transaction.mockImplementation((fn: Function) => fn(prisma)); - -export default prisma; diff --git a/src/__tests__/mocks/redis.ts b/src/__tests__/mocks/redis.ts deleted file mode 100644 index f089ac6..0000000 --- a/src/__tests__/mocks/redis.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -const redis = { - set: jest.fn().mockResolvedValue('OK'), - get: jest.fn().mockResolvedValue(null), - del: jest.fn().mockResolvedValue(1), - scan: jest.fn().mockResolvedValue(['0', []]), - ping: jest.fn().mockResolvedValue('PONG'), - on: jest.fn(), - disconnect: jest.fn(), -}; - -export async function scanKeys(pattern: string): Promise { - const keys: string[] = []; - let cursor = '0'; - do { - const [nextCursor, batch] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); - cursor = nextCursor; - keys.push(...batch); - } while (cursor !== '0'); - return keys; -} - -export const refreshKey = (userId: string, token: string): string => - `refresh:${userId}:${token}`; - -export const refreshPattern = (userId: string): string => - `refresh:${userId}:*`; - -export const resetKey = (hashedToken: string): string => - `reset:${hashedToken}`; - -export default redis; diff --git a/src/__tests__/queues/email.queue.test.ts b/src/__tests__/queues/email.queue.test.ts deleted file mode 100644 index cbeac16..0000000 --- a/src/__tests__/queues/email.queue.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { emailQueue } from "@queues/email.queue"; - -jest.mock("bullmq", () => { - return { - Queue: jest.fn().mockImplementation((name) => ({ name })), - }; -}); -jest.mock("@lib/redis", () => ({})); - -describe("queues/email.queue", () => { - test("exports email queue instance", () => { - // This just validates the queue definition runs without error - // and matches the mocked structure - expect(emailQueue).toBeDefined(); - expect(emailQueue.name).toBe("email"); - }); -}); diff --git a/src/__tests__/services/auth.service.test.ts b/src/__tests__/services/auth.service.test.ts deleted file mode 100644 index bed9332..0000000 --- a/src/__tests__/services/auth.service.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -/** - * @file Unit tests for auth.service and token utilities. - */ - -import argon2 from "argon2"; -import crypto from "crypto"; -import jwt from "jsonwebtoken"; - -jest.mock("@lib/prisma", () => require("../mocks/prisma")); -jest.mock("@lib/redis", () => require("../mocks/redis")); -jest.mock("@queues/email.queue", () => ({ - emailQueue: { - add: jest.fn(), - }, -})); -jest.mock("@config/env", () => ({ - env: { - JWT_SECRET: "test-secret", - JWT_ACCESS_EXPIRY: "15m", - JWT_REFRESH_EXPIRY_SECONDS: 604800, - RESET_TOKEN_TTL_SECONDS: 3600, - FRONTEND_URL: "https://app.example.com", - }, -})); - -import { AUTH_MESSAGES } from "@constants/messages"; -import prisma from "@lib/prisma"; -import redis from "@lib/redis"; -import * as authService from "@services/auth.service"; -import { verifyAccessToken } from "@utils/token"; -import { emailQueue } from "@queues/email.queue"; - -const mockPrisma = prisma as any; // eslint-disable-line @typescript-eslint/no-explicit-any -const mockRedis = redis as any; // eslint-disable-line @typescript-eslint/no-explicit-any -const mockEmailQueue = emailQueue as any; // eslint-disable-line @typescript-eslint/no-explicit-any - -function hashToken(token: string): string { - return crypto.createHash("sha256").update(token).digest("hex"); -} - -describe("authService.signup", () => { - it("should create a user with hashed password", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "Test", - password: "hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - - await authService.signup({ email: "a@b.com", password: "P@ssword1", name: "Test" }); - - expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { email: "a@b.com" } }); - expect(mockPrisma.user.create).toHaveBeenCalledTimes(1); - - const createCall = mockPrisma.user.create.mock.calls[0][0]; - expect(createCall.data.password).not.toBe("P@ssword1"); - expect(createCall.data.password).toMatch(/^\$argon2/); - }); - - it("should throw ConflictError if email already exists", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "Existing", - password: "hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - - await expect(authService.signup({ email: "a@b.com", password: "P@ssword1", name: "Test" })).rejects.toThrow( - AUTH_MESSAGES.EMAIL_TAKEN, - ); - }); -}); - -describe("authService.login", () => { - const hashedPw = argon2.hash("P@ssword1"); - - it("should return tokens and user info on valid credentials", async () => { - const pw = await hashedPw; - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "Test", - password: pw, - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue("OK"); - - const result = await authService.login({ email: "a@b.com", password: "P@ssword1" }); - - expect(result.user).toEqual({ id: "uuid-1", email: "a@b.com", name: "Test" }); - expect(result.tokens?.accessToken).toBeDefined(); - expect(result.tokens?.refreshToken).toBeDefined(); - - const decoded = jwt.verify(result.tokens!.accessToken, "test-secret") as jwt.JwtPayload; - expect(decoded.sub).toBe("uuid-1"); - - expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-1:"), "1", "EX", 604800); - }); - - it("should throw UnauthorizedError if user not found", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - - await expect(authService.login({ email: "no@one.com", password: "P@ssword1" })).rejects.toThrow( - AUTH_MESSAGES.INVALID_CREDENTIALS, - ); - }); - - it("should throw UnauthorizedError if password is wrong", async () => { - const pw = await hashedPw; - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "Test", - password: pw, - createdAt: new Date(), - updatedAt: new Date(), - }); - - await expect(authService.login({ email: "a@b.com", password: "WrongPassword" })).rejects.toThrow( - AUTH_MESSAGES.INVALID_CREDENTIALS, - ); - }); -}); - -describe("authService.refresh", () => { - it("should rotate tokens and return new pair", async () => { - const rawToken = "old-refresh-token"; - const hashed = hashToken(rawToken); - const key = `refresh:uuid-1:${hashed}`; - - mockRedis.scan.mockResolvedValueOnce(["0", [key]]); - mockRedis.del.mockResolvedValue(1); - mockRedis.set.mockResolvedValue("OK"); - - const result = await authService.refresh(rawToken); - - expect(result.tokens?.accessToken).toBeDefined(); - expect(result.tokens?.refreshToken).toBeDefined(); - expect(mockRedis.del).toHaveBeenCalledWith(key); - expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-1:"), "1", "EX", 604800); - }); - - it("should throw UnauthorizedError if refresh token not found in Redis", async () => { - mockRedis.scan.mockResolvedValue(["0", []]); - - await expect(authService.refresh("bad-token")).rejects.toThrow(AUTH_MESSAGES.REFRESH_TOKEN_INVALID); - }); -}); - -describe("authService.logout", () => { - it("should delete the refresh token from Redis", async () => { - const rawToken = "some-refresh-token"; - const hashed = hashToken(rawToken); - const key = `refresh:uuid-1:${hashed}`; - - mockRedis.scan.mockResolvedValueOnce(["0", [key]]); - mockRedis.del.mockResolvedValue(1); - - await authService.logout(rawToken); - - expect(mockRedis.del).toHaveBeenCalledWith(key); - }); - - it("should be a no-op if token not found in Redis", async () => { - mockRedis.scan.mockResolvedValue(["0", []]); - - await expect(authService.logout("unknown-token")).resolves.toMatchObject({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); - }); -}); - -describe("authService.forgotPassword", () => { - it("should store a hashed reset token in Redis and enqueue email when user has a password", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "Test", - password: "hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue("OK"); - - await authService.forgotPassword({ email: "a@b.com" }); - - expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("reset:"), "uuid-1", "EX", 3600); - - expect(mockEmailQueue.add).toHaveBeenCalledWith( - "send-reset-email", - expect.objectContaining({ - userId: "uuid-1", - email: "a@b.com", - name: "Test", - resetUrl: expect.stringContaining("https://app.example.com/reset-password?token="), - }), - ); - }); - - it("should store a hashed reset token in Redis and enqueue email when use has no password", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-1", - email: "oauth@b.com", - name: "OAuth User", - password: null, - createdAt: new Date(), - updatedAt: new Date(), - }); - - await authService.forgotPassword({ email: "oauth@b.com" }); - - expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("reset:"), "uuid-1", "EX", 3600); - expect(mockEmailQueue.add).toHaveBeenCalledWith( - "send-reset-email", - expect.objectContaining({ - userId: "uuid-1", - email: "oauth@b.com", - name: "OAuth User", - resetUrl: expect.stringContaining("https://app.example.com/reset-password?token="), - }), - ); - }); - - it("should do nothing (no throw) if user does not exist", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - - await expect(authService.forgotPassword({ email: "no@one.com" })).resolves.toBeUndefined(); - expect(mockRedis.set).not.toHaveBeenCalled(); - }); -}); - -describe("authService.resetPassword", () => { - it("should update password, delete reset token from Redis, and purge all refresh tokens", async () => { - const rawToken = "reset-token-raw"; - const hashed = hashToken(rawToken); - - mockRedis.get.mockResolvedValue("uuid-1"); - mockPrisma.user.update.mockResolvedValue({ - id: "uuid-1", - email: "a@b.com", - name: "Test", - password: "new-hashed", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.del.mockResolvedValue(1); - mockRedis.scan.mockResolvedValueOnce(["0", ["refresh:uuid-1:abc", "refresh:uuid-1:def"]]); - - await authService.resetPassword({ token: rawToken, newPassword: "NewP@ss1" }); - - expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); - expect(mockPrisma.user.update).toHaveBeenCalledWith({ - where: { id: "uuid-1" }, - data: { password: expect.stringMatching(/^\$argon2/) }, - }); - expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); - expect(mockRedis.del).toHaveBeenCalledWith("refresh:uuid-1:abc", "refresh:uuid-1:def"); - }); - - it("should throw BadRequestError if reset token not found in Redis", async () => { - mockRedis.get.mockResolvedValue(null); - - await expect(authService.resetPassword({ token: "bad-token", newPassword: "NewP@ss1" })).rejects.toThrow( - AUTH_MESSAGES.RESET_TOKEN_INVALID, - ); - }); -}); - -describe("verifyAccessToken", () => { - it("should return userId from valid token", () => { - const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "15m" }); - expect(verifyAccessToken(token)).toBe("uuid-1"); - }); - - it("should throw UnauthorizedError for expired token", () => { - const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "-1s" }); - expect(() => verifyAccessToken(token)).toThrow(); - }); - - it("should throw UnauthorizedError for invalid signature", () => { - const token = jwt.sign({ sub: "uuid-1" }, "wrong-secret"); - expect(() => verifyAccessToken(token)).toThrow(); - }); - - it("should throw UnauthorizedError for token without sub", () => { - const token = jwt.sign({ foo: "bar" }, "test-secret"); - expect(() => verifyAccessToken(token)).toThrow("Malformed token"); - }); -}); - -describe("authService.oauthLogin", () => { - it("should create a new user and account when none exists and return tokens", async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: "uuid-2", - email: "oauth@user.com", - name: "OAuth User", - createdAt: new Date(), - updatedAt: new Date(), - }); - mockPrisma.account.create.mockResolvedValue({ - id: "acc-1", - userId: "uuid-2", - provider: "google", - providerAccountId: "google-id-123", - }); - mockRedis.set.mockResolvedValue("OK"); - - const res = await authService.oauthLogin("google", { - email: "oauth@user.com", - name: "OAuth User", - providerAccountId: "google-id-123", - }); - - expect(mockPrisma.$transaction).toHaveBeenCalled(); - expect(mockPrisma.user.create).toHaveBeenCalled(); - expect(mockPrisma.account.create).toHaveBeenCalled(); - expect(res.user).toEqual({ id: "uuid-2", email: "oauth@user.com", name: "OAuth User" }); - expect(res.tokens?.accessToken).toBeDefined(); - expect(res.tokens?.refreshToken).toBeDefined(); - expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-2:"), "1", "EX", 604800); - }); - - it("should link a new account when user exists but has no account for this provider", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-3", - email: "exist@user.com", - name: "Existing", - }); - mockPrisma.account.findFirst.mockResolvedValue(null); - mockPrisma.account.create.mockResolvedValue({ - id: "acc-2", - userId: "uuid-3", - provider: "google", - providerAccountId: "google-id-456", - }); - mockRedis.set.mockResolvedValue("OK"); - - const res = await authService.oauthLogin("google", { - email: "exist@user.com", - name: "Existing", - providerAccountId: "google-id-456", - }); - - expect(mockPrisma.user.create).not.toHaveBeenCalled(); - expect(mockPrisma.account.findFirst).toHaveBeenCalledWith({ - where: { userId: "uuid-3", provider: "google" }, - }); - expect(mockPrisma.account.create).toHaveBeenCalledWith({ - data: { userId: "uuid-3", provider: "google", providerAccountId: "google-id-456" }, - }); - expect(res.user).toEqual({ id: "uuid-3", email: "exist@user.com", name: "Existing" }); - expect(res.tokens?.accessToken).toBeDefined(); - }); - - it("should just log in when user and account already exist", async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: "uuid-3", - email: "exist@user.com", - name: "Existing", - }); - mockPrisma.account.findFirst.mockResolvedValue({ - id: "acc-2", - userId: "uuid-3", - provider: "google", - providerAccountId: "google-id-456", - }); - mockRedis.set.mockResolvedValue("OK"); - - const res = await authService.oauthLogin("google", { - email: "exist@user.com", - name: "Existing", - providerAccountId: "google-id-456", - }); - - expect(mockPrisma.user.create).not.toHaveBeenCalled(); - expect(mockPrisma.account.create).not.toHaveBeenCalled(); - expect(res.user).toEqual({ id: "uuid-3", email: "exist@user.com", name: "Existing" }); - expect(res.tokens?.accessToken).toBeDefined(); - expect(res.tokens?.refreshToken).toBeDefined(); - }); -}); diff --git a/src/__tests__/services/email.service.test.ts b/src/__tests__/services/email.service.test.ts deleted file mode 100644 index 08a585f..0000000 --- a/src/__tests__/services/email.service.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -const mockSend = jest.fn(); -jest.mock("resend", () => ({ - Resend: jest.fn().mockImplementation(() => ({ - emails: { send: mockSend }, - })), -})); - -jest.mock("@config/env", () => ({ - env: { - RESEND_API_KEY: "test-key", - RESET_PASSWORD_EMAIL: "support@x.com", - RESET_PASSWORD_TEMPLATE_ID: "template-1" - } -})); - -import { sendResetEmail } from "@services/email.service"; -import { logger } from "@services/logger.service"; - -jest.spyOn(logger, "info").mockImplementation(); -jest.spyOn(logger, "error").mockImplementation(); - -describe("services/email.service", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test("logs error if Resend fails", async () => { - mockSend.mockResolvedValueOnce({ error: { message: "API failure" } }); - await sendResetEmail("test@x.com", "http://reset", "Tester"); - expect(logger.error).toHaveBeenCalled(); - }); - - test("logs success if Resend succeeds", async () => { - mockSend.mockResolvedValueOnce({ data: { id: "msg-123" }, error: null }); - await sendResetEmail("test@x.com", "http://reset", "Tester"); - expect(logger.info).toHaveBeenCalledTimes(2); // info and structural info - }); -}); diff --git a/src/__tests__/services/logger.service.test.ts b/src/__tests__/services/logger.service.test.ts deleted file mode 100644 index ef8e58e..0000000 --- a/src/__tests__/services/logger.service.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -describe("services/logger.service", () => { - const originalEnv = process.env.NODE_ENV; - - afterEach(() => { - process.env.NODE_ENV = originalEnv; - jest.resetModules(); - }); - - test("uses info level when in production", async () => { - process.env.NODE_ENV = "production"; - const { logger } = await import("@services/logger.service"); - expect(logger.level).toBe("info"); - }); - - test("uses debug level when not in production", async () => { - process.env.NODE_ENV = "development"; - const { logger } = await import("@services/logger.service"); - expect(logger.level).toBe("debug"); - }); -}); diff --git a/src/__tests__/services/oauth.service.test.ts b/src/__tests__/services/oauth.service.test.ts deleted file mode 100644 index c48a749..0000000 --- a/src/__tests__/services/oauth.service.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { BadRequestError } from '@utils/errors'; -import { AUTH_MESSAGES } from '@constants/messages'; - -jest.mock('@config/env', () => ({ - env: { - NODE_ENV: 'test', - GOOGLE_CLIENT_ID: 'google-id', - GOOGLE_CLIENT_SECRET: 'google-secret', - GOOGLE_CALLBACK_URL: 'https://app.example.com/oauth/google/callback', - GITHUB_CLIENT_ID: 'github-id', - GITHUB_CLIENT_SECRET: 'github-secret', - GITHUB_CALLBACK_URL: 'https://app.example.com/oauth/github/callback', - }, -})); - -import { getAuthorizationUrl, exchangeCodeForProfile } from '@services/oauth.service'; - -describe('oauth.service', () => { - let fetchSpy: jest.SpyInstance; - - afterEach(() => { - if (fetchSpy) fetchSpy.mockRestore(); - }); - - test('getAuthorizationUrl builds google URL and includes access_type', () => { - const url = getAuthorizationUrl('google', 'state123'); - expect(url).toContain('accounts.google.com'); - expect(url).toContain('client_id=google-id'); - expect(url).toContain('access_type=offline'); - expect(url).toContain('prompt=consent'); - expect(url).toContain('state=state123'); - }); - - test('getAuthorizationUrl throws when provider not configured', () => { - jest.resetModules(); - jest.doMock('@config/env', () => ({ env: { GOOGLE_CLIENT_ID: '', GOOGLE_CLIENT_SECRET: '' } })); - const svc = require('@services/oauth.service'); - expect(() => svc.getAuthorizationUrl('google', 's')).toThrow(AUTH_MESSAGES.OAUTH_PROVIDER_NOT_CONFIGURED); - jest.resetModules(); - }); - - test('exchangeCodeForProfile throws when token endpoint returns non-ok', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementation(async () => ({ ok: false })); - await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); - }); - - test('exchangeCodeForProfile throws when token response contains error', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementationOnce(async () => ({ - ok: true, - json: async () => ({ error: 'bad' }), - })); - - await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); - }); - - test('exchangeCodeForProfile returns google profile on success', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch') - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: 'a@example.com', name: 'Alice', id: 'google-123' }) })); - - const profile = await exchangeCodeForProfile('google', 'code'); - expect(profile.email).toBe('a@example.com'); - expect(profile.name).toBe('Alice'); - expect(profile.providerAccountId).toBe('google-123'); - }); - - test('exchangeCodeForProfile returns github profile using emails endpoint when necessary', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch') - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null, id: 12345 }) })) - .mockImplementationOnce(async () => ({ ok: true, json: async () => ([{ email: 'gh@example.com', primary: true, verified: true }]) })); - - const profile = await exchangeCodeForProfile('github', 'code'); - expect(profile.email).toBe('gh@example.com'); - expect(profile.name).toBe('octocat'); - expect(profile.providerAccountId).toBe('12345'); - }); - - test('exchangeCodeForProfile throws when google userinfo endpoint fails', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch') - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ token_type: 'bearer' }) })); - - await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); - }); - - test('exchangeCodeForProfile throws when google userinfo endpoint fails2', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch') - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) - .mockImplementationOnce(async () => ({ ok: false })); - - await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); - }); - - test('exchangeCodeForProfile throws when google userinfo missing email', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch') - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ name: 'NoEmail' }) })); - - await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); - }); - - test('exchangeCodeForProfile throws when github user endpoint fails', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch') - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) - .mockImplementationOnce(async () => ({ ok: false })); - - await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError); - }); - - test('exchangeCodeForProfile throws when github emails endpoint not ok and no email found', async () => { - fetchSpy = jest.spyOn(global as any, 'fetch') - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null }) })) - .mockImplementationOnce(async () => ({ ok: false })); - - await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError); - }); -}); diff --git a/src/__tests__/utils/errors.test.ts b/src/__tests__/utils/errors.test.ts deleted file mode 100644 index c628c3c..0000000 --- a/src/__tests__/utils/errors.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { AppError, UnauthorizedError, BadRequestError, ConflictError } from "@utils/errors"; - -describe("utils/errors", () => { - test("AppError", () => { - const err = new AppError(418, "I am a teapot"); - expect(err.statusCode).toBe(418); - expect(err.message).toBe("I am a teapot"); - expect(err.name).toBe("AppError"); - }); - - test("UnauthorizedError", () => { - const err = new UnauthorizedError(); - expect(err.statusCode).toBe(401); - expect(err.message).toBe("Unauthorized"); - expect(err.name).toBe("UnauthorizedError"); - - const customErr = new UnauthorizedError("Custom message"); - expect(customErr.message).toBe("Custom message"); - }); - - test("BadRequestError", () => { - const err = new BadRequestError(); - expect(err.statusCode).toBe(400); - expect(err.message).toBe("Bad request"); - expect(err.name).toBe("BadRequestError"); - - const customErr = new BadRequestError("Custom msg"); - expect(customErr.message).toBe("Custom msg"); - }); - - test("ConflictError", () => { - const err = new ConflictError(); - expect(err.statusCode).toBe(409); - expect(err.message).toBe("Conflict"); - expect(err.name).toBe("ConflictError"); - - const customErr = new ConflictError("Custom conflict"); - expect(customErr.message).toBe("Custom conflict"); - }); -}); diff --git a/src/__tests__/utils/redirect.test.ts b/src/__tests__/utils/redirect.test.ts deleted file mode 100644 index 617f7d5..0000000 --- a/src/__tests__/utils/redirect.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { buildRedirectUrl } from "@utils/redirect"; - -describe("utils/redirect", () => { - test("returns pathname if no baseUrl", () => { - expect(buildRedirectUrl("", "/path")).toBe("/path"); - }); - - test("returns empty string if no baseUrl and no pathname", () => { - expect(buildRedirectUrl("")).toBe(""); - }); - - test("handles baseUrl with trailing slashes and pathname with leading slashes", () => { - expect(buildRedirectUrl("http://example.com///", "///path///")).toBe("http://example.com/path///"); - }); - - test("handles URLSearchParams correctly", () => { - const params = new URLSearchParams({ a: "1", b: "2" }); - expect(buildRedirectUrl("http://example.com", "/path", params)).toBe("http://example.com/path?a=1&b=2"); - }); - - test("handles Record payload correctly", () => { - expect(buildRedirectUrl("http://example.com", "/path", { foo: "bar", baz: "qux" })).toBe( - "http://example.com/path?foo=bar&baz=qux", - ); - }); - - test("appends search only if it exists", () => { - expect(buildRedirectUrl("http://x.com", "/y", {})).toBe("http://x.com/y"); - expect(buildRedirectUrl("http://x.com", "/y", new URLSearchParams())).toBe("http://x.com/y"); - }); -}); diff --git a/src/__tests__/workers/email.worker.test.ts b/src/__tests__/workers/email.worker.test.ts deleted file mode 100644 index be0d7e3..0000000 --- a/src/__tests__/workers/email.worker.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { Worker } from "bullmq"; -import * as emailService from "@services/email.service"; - -let workerCallback: any; - -jest.mock("bullmq", () => { - return { - Worker: jest.fn().mockImplementation((name, cb) => { - workerCallback = cb; - return {}; - }), - }; -}); - -jest.mock("@config/env", () => ({ - env: { RESEND_API_KEY: "test-key" } -})); - -jest.mock("@services/email.service"); -jest.mock("@services/logger.service", () => ({ logger: { info: jest.fn() } })); -jest.mock("@lib/redis", () => ({ workerRedis: {} })); - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import "@workers/email.worker"; - -describe("workers/email.worker", () => { - test("workerCallback delegates send-reset-email job", async () => { - expect(workerCallback).toBeDefined(); - - const job = { - name: "send-reset-email", - data: { email: "u@u.com", resetUrl: "link", name: "U" }, - }; - - await workerCallback(job); - expect(emailService.sendResetEmail).toHaveBeenCalledWith("u@u.com", "link", "U"); - }); - - test("workerCallback ignores other job names", async () => { - jest.clearAllMocks(); - const job = { - name: "other-job", - data: {}, - }; - - await workerCallback(job); - expect(emailService.sendResetEmail).not.toHaveBeenCalled(); - }); -}); diff --git a/src/api/controllers/auth.controller.ts b/src/api/controllers/auth.controller.ts deleted file mode 100644 index e33006b..0000000 --- a/src/api/controllers/auth.controller.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { Request, Response, NextFunction } from 'express'; -import crypto from 'crypto'; -import * as authService from '@services/auth.service'; -import * as oauthService from '@services/oauth.service'; -import type { OAuthProvider } from 'types/auth'; -import { AUTH_MESSAGES } from '@constants/messages'; -import { VALID_PROVIDERS } from '@constants/auth'; -import { StatusCodes } from 'http-status-codes'; -import { env } from '@config/env'; -import { buildRedirectUrl } from '@utils/redirect'; - - -export async function signup(req: Request, res: Response, next: NextFunction): Promise { - try { - const { email, password, name } = req.body; - const response = await authService.signup({ email, password, name }); - res.status(StatusCodes.CREATED).json(response); - } catch (err) { - next(err); - } -} - -export async function login(req: Request, res: Response, next: NextFunction): Promise { - try { - const { email, password } = req.body; - const response = await authService.login({ email, password }); - res.status(StatusCodes.OK).json(response); - } catch (err) { - next(err); - } -} - -export async function refresh(req: Request, res: Response, next: NextFunction): Promise { - try { - const { refreshToken } = req.body; - const response = await authService.refresh(refreshToken); - res.status(StatusCodes.OK).json(response); - } catch (err) { - next(err); - } -} - -export async function logout(req: Request, res: Response, next: NextFunction): Promise { - try { - const { refreshToken } = req.body; - if (refreshToken) { - const response = await authService.logout(refreshToken); - res.status(StatusCodes.OK).json(response); - } else { - res.status(StatusCodes.OK).json({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); - } - } catch (err) { - next(err); - } -} - -export async function forgotPassword(req: Request, res: Response, next: NextFunction): Promise { - try { - const { email } = req.body; - authService.forgotPassword({ email }).catch((err) => { - console.error('Error processing forgot-password:', err); - }); - res.status(StatusCodes.OK).json({ message: AUTH_MESSAGES.FORGOT_PASSWORD_SENT }); - } catch (err) { - next(err); - } -} - -export async function resetPassword(req: Request, res: Response, next: NextFunction): Promise { - try { - const { token, newPassword } = req.body; - const response = await authService.resetPassword({ token, newPassword }); - res.status(StatusCodes.OK).json(response); - } catch (err) { - next(err); - } -} - -export async function oauthRedirect(req: Request, res: Response, next: NextFunction): Promise { - try { - const provider = req.params.provider as OAuthProvider; - if (!VALID_PROVIDERS.has(provider)) { - res.status(StatusCodes.BAD_REQUEST).json({ message: AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER }); - return; - } - - const state = crypto.randomBytes(16).toString('hex'); - const url = oauthService.getAuthorizationUrl(provider, state); - res.redirect(url); - } catch (err) { - next(err); - } -} - -export async function oauthCallback(req: Request, res: Response, next: NextFunction): Promise { - try { - const provider = req.params.provider as OAuthProvider; - if (!VALID_PROVIDERS.has(provider)) { - res.status(StatusCodes.BAD_REQUEST).json({ message: AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER }); - return; - } - - const code = req.query.code as string | undefined; - const oauthError = req.query.error as string | undefined; - - if (oauthError || !code) { - const msg = oauthError === 'access_denied' - ? AUTH_MESSAGES.OAUTH_CANCELLED - : AUTH_MESSAGES.OAUTH_CODE_MISSING; - const errorRedirect = buildRedirectUrl(env.FRONTEND_URL, '/login', { error: msg }); - res.redirect(errorRedirect); - return; - } - - const profile = await oauthService.exchangeCodeForProfile(provider, code); - const loginResponse = await authService.oauthLogin(provider, profile); - const { accessToken, refreshToken } = loginResponse.tokens!; - - const params = new URLSearchParams({ - accessToken, - refreshToken, - userId: loginResponse.user!.id, - email: loginResponse.user!.email, - name: loginResponse.user!.name, - }); - - const redirectUrl = buildRedirectUrl(env.FRONTEND_URL, '/oauth/callback', params); - res.redirect(redirectUrl); - } catch (err) { - const message = err instanceof Error ? err.message : 'OAuth login failed'; - const errorRedirect = buildRedirectUrl(env.FRONTEND_URL, '/login', { error: message }); - res.redirect(errorRedirect); - } -} diff --git a/src/api/middlewares/errorHandler.ts b/src/api/middlewares/errorHandler.ts deleted file mode 100644 index 5ee54eb..0000000 --- a/src/api/middlewares/errorHandler.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { Request, Response, NextFunction } from 'express'; -import { AppError } from '@utils/errors'; -import { StatusCodes } from 'http-status-codes'; -import { logger } from "@services/logger.service"; - -export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { - if (err instanceof AppError) { - res.status(err.statusCode).json({ message: err.message }); - return; - } - - logger.error(err); - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: 'Internal server error' }); -} diff --git a/src/api/middlewares/logger.ts b/src/api/middlewares/logger.ts deleted file mode 100644 index d367a3f..0000000 --- a/src/api/middlewares/logger.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import pinoHttp from "pino-http"; -import { logger } from "@services/logger.service"; - -export const httpLogger = pinoHttp({ - logger, - customLogLevel: (req, res, err) => { - if (res.statusCode >= 500 || err) return 'error'; - if (res.statusCode >= 400) return 'warn'; - return 'info'; - }, - serializers: { - req: (req) => ({ - method: req.method, - url: req.url, - headers: req.headers, - body: req.body, - }), - res: (res) => ({ - statusCode: res.statusCode, - }), - }, -}); \ No newline at end of file diff --git a/src/api/middlewares/requireAuth.ts b/src/api/middlewares/requireAuth.ts deleted file mode 100644 index f521290..0000000 --- a/src/api/middlewares/requireAuth.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { Request, Response, NextFunction } from 'express'; -import { verifyAccessToken } from '@utils/token'; -import { UnauthorizedError } from '@utils/errors'; - -export function requireAuth(req: Request, _res: Response, next: NextFunction): void { - try { - const header = req.headers.authorization; - if (!header || !header.startsWith('Bearer ')) { - throw new UnauthorizedError('Authentication required'); - } - const token = header.slice(7); - req.userId = verifyAccessToken(token); - next(); - } catch { - next(new UnauthorizedError('Invalid or expired access token')); - } -} diff --git a/src/api/middlewares/validate.ts b/src/api/middlewares/validate.ts deleted file mode 100644 index 517d48b..0000000 --- a/src/api/middlewares/validate.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { Request, Response, NextFunction } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import { ZodType } from 'zod'; - -/** - * Generic request body validation middleware factory. - * Parses `req.body` against the provided Zod schema. - * On success, replaces `req.body` with the parsed (stripped/coerced) data. - * On failure, responds 400 with the first validation error. - */ -export function validateBody(schema: ZodType) { - return (req: Request, res: Response, next: NextFunction): void => { - const result = schema.safeParse(req.body); - if (!result.success) { - const firstIssue = result.error.issues[0]; - res.status(StatusCodes.BAD_REQUEST).json({ - error: 'Validation failed', - message: firstIssue?.message ?? 'Invalid request body', - }); - return; - } - req.body = result.data; - next(); - }; -} diff --git a/src/api/routes/auth.routes.ts b/src/api/routes/auth.routes.ts deleted file mode 100644 index ff211e0..0000000 --- a/src/api/routes/auth.routes.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { Router } from 'express'; -import * as authController from '@api/controllers/auth.controller'; -import { validateBody } from '@api/middlewares/validate'; -import { - SignupRequestSchema, - LoginRequestSchema, - RefreshRequestSchema, - ForgotPasswordRequestSchema, - ResetPasswordRequestSchema, -} from '@models/auth'; - -const router = Router(); - -router.post('/signup', validateBody(SignupRequestSchema), authController.signup); -router.post('/login', validateBody(LoginRequestSchema), authController.login); -router.post('/refresh', validateBody(RefreshRequestSchema), authController.refresh); -router.post('/logout', authController.logout); -router.post('/forgot-password', validateBody(ForgotPasswordRequestSchema), authController.forgotPassword); -router.post('/reset-password', validateBody(ResetPasswordRequestSchema), authController.resetPassword); - -// OAuth -router.get('/oauth/:provider', authController.oauthRedirect); -router.get('/oauth/:provider/callback', authController.oauthCallback); - -export default router; diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index 177562e..0000000 --- a/src/app.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import express, { Application, Request, Response } from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import swaggerUi from 'swagger-ui-express'; - -import { env } from '@config/env'; -import { swaggerSpec } from '@config/swagger'; -import authRoutes from '@api/routes/auth.routes'; -import { errorHandler } from '@api/middlewares/errorHandler'; -import { httpLogger } from '@api/middlewares/logger'; -import "@workers/email.worker" - -const app: Application = express(); - -app.use(helmet()); - -const allowedOrigins = env.CORS_ORIGINS; -const corsOptions = { - origin: (requestOrigin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { - if (!requestOrigin) return callback(null, true); - if (allowedOrigins.includes('*') || allowedOrigins.includes(requestOrigin)) return callback(null, true); - return callback(new Error('Not allowed by CORS')); - }, - credentials: env.CORS_CREDENTIALS === 'true', - optionsSuccessStatus: 200, -}; -app.use(cors(corsOptions)); -app.options('*', cors(corsOptions)); - -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(httpLogger); - -app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); -app.get('/docs.json', (_req: Request, res: Response) => { - res.json(swaggerSpec); -}); - -const apiBase = env.API_PREFIX; -app.use(`${apiBase}/auth`, authRoutes); - -app.use(errorHandler); - -export default app; diff --git a/src/config/env.ts b/src/config/env.ts deleted file mode 100644 index 93fac80..0000000 --- a/src/config/env.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -export const env = { - NODE_ENV: process.env.NODE_ENV ?? "development", - PORT: parseInt(process.env.PORT ?? "3000", 10), - DATABASE_URL: process.env.DATABASE_URL ?? "", - REDIS_URL: process.env.REDIS_URL ?? "redis://localhost:6379", - CORS_ORIGINS: (process.env.CORS_ORIGINS ?? "http://localhost:5173").split(","), - CORS_CREDENTIALS: process.env.CORS_CREDENTIALS ?? "true", - JWT_SECRET: process.env.JWT_SECRET ?? "", - JWT_ACCESS_EXPIRY: process.env.JWT_ACCESS_EXPIRY ?? "15m", - JWT_REFRESH_EXPIRY_SECONDS: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS ?? "604800", 10), // 7 days - RESET_TOKEN_TTL_SECONDS: parseInt(process.env.RESET_TOKEN_TTL_SECONDS ?? "900", 10), // 15 min - API_PREFIX: process.env.API_PREFIX ?? "/api/v1", - - // OAuth - FRONTEND_URL: process.env.FRONTEND_URL ?? "http://localhost:5173", - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID ?? "", - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET ?? "", - GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL ?? "http://localhost:3000/api/v1/auth/oauth/google/callback", - GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID ?? "", - GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ?? "", - GITHUB_CALLBACK_URL: process.env.GITHUB_CALLBACK_URL ?? "http://localhost:3000/api/v1/auth/oauth/github/callback", - - // Emails - RESEND_API_KEY: process.env.RESEND_API_KEY ?? "", - RESET_PASSWORD_EMAIL: process.env.RESET_PASSWORD_EMAIL ?? "Coverit ", - RESET_PASSWORD_TEMPLATE_ID: process.env.RESET_PASSWORD_TEMPLATE_ID ?? "", -} as const; - -console.info("Loaded environment variables:", { - NODE_ENV: env.NODE_ENV, - PORT: env.PORT, - DATABASE_URL: env.DATABASE_URL ? "****" : "(not set)", - REDIS_URL: env.REDIS_URL ? "****" : "(not set)", - CORS_ORIGINS: env.CORS_ORIGINS, - CORS_CREDENTIALS: env.CORS_CREDENTIALS, - JWT_SECRET: env.JWT_SECRET ? "****" : "(not set)", - JWT_ACCESS_EXPIRY: env.JWT_ACCESS_EXPIRY, - JWT_REFRESH_EXPIRY_SECONDS: env.JWT_REFRESH_EXPIRY_SECONDS, - RESET_TOKEN_TTL_SECONDS: env.RESET_TOKEN_TTL_SECONDS, - API_PREFIX: env.API_PREFIX, - FRONTEND_URL: env.FRONTEND_URL, - GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID ? "****" : "(not set)", - GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET ? "****" : "(not set)", - GOOGLE_CALLBACK_URL: env.GOOGLE_CALLBACK_URL, - GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID ? "****" : "(not set)", - GITHUB_CLIENT_SECRET: env.GITHUB_CLIENT_SECRET ? "****" : "(not set)", - GITHUB_CALLBACK_URL: env.GITHUB_CALLBACK_URL, - RESEND_API_KEY: env.RESEND_API_KEY ? "****" : "(not set)", - RESET_PASSWORD_EMAIL: env.RESET_PASSWORD_EMAIL, - RESET_PASSWORD_TEMPLATE_ID: env.RESET_PASSWORD_TEMPLATE_ID ? "****" : "(not set)", -}); \ No newline at end of file diff --git a/src/config/openapi/auth.ts b/src/config/openapi/auth.ts deleted file mode 100644 index 8621f3c..0000000 --- a/src/config/openapi/auth.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -// Auth domain — registers schemas and paths with the OpenAPI registry -import { z } from '@utils/zod'; -import { registry } from './registry'; -import { - SignupRequestSchema, - LoginRequestSchema, - RefreshRequestSchema, - ForgotPasswordRequestSchema, - ResetPasswordRequestSchema, -} from '@models/auth'; - -const MessageResponseSchema = z.object({ message: z.string() }); -const ErrorResponseSchema = z.object({ message: z.string() }); -const UserInfoSchema = z.object({ id: z.string(), email: z.string(), name: z.string() }); -const TokenPairSchema = z.object({ accessToken: z.string(), refreshToken: z.string() }); -const LoginResponseSchema = z.object({ user: UserInfoSchema, tokens: TokenPairSchema }); -const RefreshResponseSchema = z.object({ message: z.string(), tokens: TokenPairSchema }); - -registry.register('MessageResponse', MessageResponseSchema); -registry.register('ErrorResponse', ErrorResponseSchema); -registry.register('UserInfo', UserInfoSchema); -registry.register('TokenPair', TokenPairSchema); - -registry.register('SignupRequest', SignupRequestSchema); -registry.register('LoginRequest', LoginRequestSchema); -registry.register('RefreshRequest', RefreshRequestSchema); -registry.register('ForgotPasswordRequest', ForgotPasswordRequestSchema); -registry.register('ResetPasswordRequest', ResetPasswordRequestSchema); -registry.register('LoginResponse', LoginResponseSchema); -registry.register('RefreshResponse', RefreshResponseSchema); - -registry.registerPath({ - method: 'post', - path: '/auth/signup', - tags: ['Auth'], - summary: 'Create a new account', - description: 'Register a new user. No tokens are issued — the client must log in separately.', - request: { body: { content: { 'application/json': { schema: SignupRequestSchema } } } }, - responses: { - 201: { description: 'Account created', content: { 'application/json': { schema: MessageResponseSchema } } }, - 409: { description: 'Email already registered', content: { 'application/json': { schema: ErrorResponseSchema } } }, - }, -}); - -registry.registerPath({ - method: 'post', - path: '/auth/login', - tags: ['Auth'], - summary: 'Log in with email and password', - description: 'Verify credentials and return access + refresh tokens with user info.', - request: { body: { content: { 'application/json': { schema: LoginRequestSchema } } } }, - responses: { - 200: { description: 'Login successful', content: { 'application/json': { schema: LoginResponseSchema } } }, - 401: { description: 'Invalid email or password', content: { 'application/json': { schema: ErrorResponseSchema } } }, - }, -}); - -registry.registerPath({ - method: 'post', - path: '/auth/refresh', - tags: ['Auth'], - summary: 'Rotate tokens', - description: 'Exchange a valid refresh token for a new access + refresh token pair.', - request: { body: { content: { 'application/json': { schema: RefreshRequestSchema } } } }, - responses: { - 200: { description: 'Tokens rotated', content: { 'application/json': { schema: RefreshResponseSchema } } }, - 401: { description: 'Missing, invalid, or expired refresh token', content: { 'application/json': { schema: ErrorResponseSchema } } }, - }, -}); - -registry.registerPath({ - method: 'post', - path: '/auth/logout', - tags: ['Auth'], - summary: 'Log out', - description: 'Invalidate the provided refresh token. The client should discard any stored tokens.', - request: { body: { required: false, content: { 'application/json': { schema: RefreshRequestSchema.partial() } } } }, - responses: { - 200: { description: 'Logged out', content: { 'application/json': { schema: MessageResponseSchema } } }, - }, -}); - -registry.registerPath({ - method: 'post', - path: '/auth/forgot-password', - tags: ['Auth'], - summary: 'Request a password reset', - description: 'Always returns 200 to prevent email enumeration. If the email exists, a reset link is sent.', - request: { body: { content: { 'application/json': { schema: ForgotPasswordRequestSchema } } } }, - responses: { - 200: { description: 'Generic success (regardless of whether the email exists)', content: { 'application/json': { schema: MessageResponseSchema } } }, - }, -}); - -registry.registerPath({ - method: 'post', - path: '/auth/reset-password', - tags: ['Auth'], - summary: 'Reset password with a token', - description: 'Validate the reset token, update the password, and invalidate all existing sessions for the user.', - request: { body: { content: { 'application/json': { schema: ResetPasswordRequestSchema } } } }, - responses: { - 200: { description: 'Password reset successfully', content: { 'application/json': { schema: MessageResponseSchema } } }, - 400: { description: 'Invalid or expired reset code', content: { 'application/json': { schema: ErrorResponseSchema } } }, - }, -}); diff --git a/src/config/openapi/index.ts b/src/config/openapi/index.ts deleted file mode 100644 index 7534c03..0000000 --- a/src/config/openapi/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import './auth'; -import { registry } from './registry'; - -registry.registerComponent('securitySchemes', 'bearerAuth', { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Access token returned by login/refresh — send as `Authorization: Bearer `', -}); - -export { registry }; diff --git a/src/config/openapi/registry.ts b/src/config/openapi/registry.ts deleted file mode 100644 index 1ba119e..0000000 --- a/src/config/openapi/registry.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -// OpenAPI registry — single instance shared across all path/schema registrations -import { OpenAPIRegistry, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; -import { z } from 'zod'; - -extendZodWithOpenApi(z); - -export const registry = new OpenAPIRegistry(); diff --git a/src/config/swagger.ts b/src/config/swagger.ts deleted file mode 100644 index 5b8e5b7..0000000 --- a/src/config/swagger.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; -import { env } from '@config/env'; -import { registry } from '@config/openapi'; - -const generator = new OpenApiGeneratorV3(registry.definitions); - -export const swaggerSpec = generator.generateDocument({ - openapi: '3.0.3', - info: { - title: 'CoverIt API', - version: '0.1.0', - description: 'CoverIt REST API — authentication and platform services', - }, - servers: [ - { - url: `http://localhost:${env.PORT}${env.API_PREFIX}`, - description: 'Local development', - }, - ], -}); - diff --git a/src/constants/auth.ts b/src/constants/auth.ts deleted file mode 100644 index 17daf86..0000000 --- a/src/constants/auth.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -// OAuth provider constants -// Central place for OAuth-related constants used across the API - -import type { OAuthProvider } from 'types/auth' - -export const VALID_PROVIDERS = new Set(['google', 'github']) diff --git a/src/constants/messages/auth.ts b/src/constants/messages/auth.ts deleted file mode 100644 index ea99d98..0000000 --- a/src/constants/messages/auth.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -/** - * HTTP response message strings for the Auth domain. - */ -export const AUTH_MESSAGES = { - // signup - SIGNUP_SUCCESS: "Account created successfully", - EMAIL_TAKEN: "Email already registered", - - // login - INVALID_CREDENTIALS: "Invalid email or password", - - // refresh - REFRESH_SUCCESS: "Tokens refreshed successfully", - REFRESH_TOKEN_INVALID: "Invalid or expired refresh token", - - // logout - LOGOUT_SUCCESS: "Logged out successfully", - - // forgot-password - FORGOT_PASSWORD_SENT: "If an account with that email exists, a reset link was sent", - - // reset-password - RESET_PASSWORD_SUCCESS: "Password reset successfully", - RESET_TOKEN_INVALID: "Invalid or expired reset token", - - // oauth - UNSUPPORTED_OAUTH_PROVIDER: "Unsupported OAuth provider", - OAUTH_PROVIDER_NOT_CONFIGURED: "OAuth provider is not configured", - OAUTH_CODE_MISSING: "Authorization code missing from callback", - OAUTH_TOKEN_EXCHANGE_FAILED: "Failed to exchange authorization code for tokens", - OAUTH_USER_INFO_FAILED: "Failed to retrieve user info from provider", - OAUTH_EMAIL_MISSING: "OAuth provider did not return an email address", - OAUTH_CANCELLED: "OAuth flow was cancelled by the user", -} as const; - -/** - * Zod schema validation error messages for the Auth domain. - */ -export const AUTH_VALIDATION = { - INVALID_EMAIL: "Invalid email address", - PASSWORD_MIN_LENGTH: "Password must be at least 8 characters", - PASSWORD_REQUIRED: "Password is required", - NAME_REQUIRED: "Name is required", - REFRESH_TOKEN_REQUIRED: "Refresh token is required", - RESET_TOKEN_REQUIRED: "Reset token is required", -} as const; diff --git a/src/constants/messages/index.ts b/src/constants/messages/index.ts deleted file mode 100644 index 7a2a979..0000000 --- a/src/constants/messages/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -export * from './auth'; diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 804488e..0000000 --- a/src/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import 'dotenv/config'; -import app from './app'; -import prisma from '@lib/prisma'; -import redis from '@lib/redis'; -import { env } from '@config/env'; - -async function startServer(): Promise { - console.info('Connecting to PostgreSQL…'); - try { - await prisma.$connect(); - console.info('Database connected'); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Database connection error:', message); - process.exit(1); - } - - console.info('Connecting to Redis…'); - try { - await redis.ping(); - console.info('Redis connected'); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Redis connection error:', message); - process.exit(1); - } - - app.listen(env.PORT, () => { - console.info(`Server running on port ${env.PORT} [${env.NODE_ENV}]`); - }); -} - -startServer(); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts deleted file mode 100644 index b0764c1..0000000 --- a/src/lib/prisma.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { PrismaPg } from '@prisma/adapter-pg'; -import { PrismaClient } from '@generated/prisma/client'; -import { env } from '@config/env'; - -const adapter = new PrismaPg({ connectionString: env.DATABASE_URL }); -const prisma = new PrismaClient({ adapter }); - -export default prisma; diff --git a/src/lib/redis.ts b/src/lib/redis.ts deleted file mode 100644 index b94900a..0000000 --- a/src/lib/redis.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import Redis from "ioredis"; -import { env } from "@config/env"; - -/** Redis client configured with retry strategy. */ -const redis = new Redis(env.REDIS_URL, { - maxRetriesPerRequest: 3, - retryStrategy(times: number): number | null { - if (times > 5) return null; - return Math.min(times * 200, 2000); - }, -}); - -const workerRedis = new Redis(env.REDIS_URL, { - maxRetriesPerRequest: null, -}); - -redis.on("error", (err) => { - console.error("Redis connection error:", err.message); -}); - -export const refreshKey = (userId: string, token: string): string => `refresh:${userId}:${token}`; - -export const refreshPattern = (userId: string): string => `refresh:${userId}:*`; - -export const resetKey = (hashedToken: string): string => `reset:${hashedToken}`; - -/** SCAN-based key search using cursor iteration. */ -export async function scanKeys(pattern: string): Promise { - const keys: string[] = []; - let cursor = "0"; - do { - const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100); - cursor = nextCursor; - keys.push(...batch); - } while (cursor !== "0"); - return keys; -} - -export default redis; -export { workerRedis }; diff --git a/src/models/auth.ts b/src/models/auth.ts deleted file mode 100644 index 4e5f879..0000000 --- a/src/models/auth.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -// Auth domain DTOs - -import { AUTH_VALIDATION } from "@constants/messages"; -import type { - ForgotPasswordRequest as ContractForgotPasswordRequest, - LoginRequest as ContractLoginRequest, - LoginResponse as ContractLoginResponse, - RefreshRequest as ContractRefreshRequest, - RefreshResponse as ContractRefreshResponse, - ResetPasswordRequest as ContractResetPasswordRequest, - SignupRequest as ContractSignupRequest, - TokenPair as ContractTokenPair, -} from "@coveritlabs/contracts"; -import { z } from "@utils/zod"; -import type { ZodType } from "zod"; -import type { Plain } from "./common"; - -export type SignupRequest = Plain; -export type LoginRequest = Plain; -export type LoginResponse = Plain; -export type RefreshResponse = Plain; -export type ForgotPasswordRequest = Plain; -export type ResetPasswordRequest = Plain; -export type TokenPair = Plain; -export type RefreshRequest = Plain; - -export const SignupRequestSchema = z.object({ - email: z.email(AUTH_VALIDATION.INVALID_EMAIL), - password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), - name: z.requiredString(AUTH_VALIDATION.NAME_REQUIRED), -}) satisfies ZodType; - -export const LoginRequestSchema = z.object({ - email: z.email(AUTH_VALIDATION.INVALID_EMAIL), - password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED), -}) satisfies ZodType; - -export const ForgotPasswordRequestSchema = z.object({ - email: z.email(AUTH_VALIDATION.INVALID_EMAIL), -}) satisfies ZodType; - -export const ResetPasswordRequestSchema = z.object({ - token: z.requiredString(AUTH_VALIDATION.RESET_TOKEN_REQUIRED), - newPassword: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), -}) satisfies ZodType; - -export const RefreshRequestSchema = z.object({ - refreshToken: z.requiredString(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED), -}) satisfies ZodType; diff --git a/src/models/common.ts b/src/models/common.ts deleted file mode 100644 index 4efbe0d..0000000 --- a/src/models/common.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -// Shared DTO utilities and types used across all domains - -import type { Message } from '@bufbuild/protobuf'; -import type { - UserInfo as ContractUserInfo, - MessageResponse as ContractMessageResponse, -} from '@coveritlabs/contracts'; - -/** - * Recursively strips the protobuf `$typeName` marker from Message types. - * Converts protobuf Message objects to plain JS objects. - */ -export type Plain = T extends Message - ? { [K in keyof Omit]: Plain[K]> } - : T extends Array - ? Array> - : T extends ReadonlyArray - ? ReadonlyArray> - : T; - -// Shared domain models -export type UserInfo = Plain; -export type MessageResponse = Plain; diff --git a/src/models/express.d.ts b/src/models/express.d.ts deleted file mode 100644 index f73bf86..0000000 --- a/src/models/express.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -declare namespace Express { - interface Request { - userId?: string; - } -} diff --git a/src/models/user.ts b/src/models/user.ts deleted file mode 100644 index 1e89bca..0000000 --- a/src/models/user.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -// User domain DTOs - -import type { - UserInfo as ContractUserInfo, -} from '@coveritlabs/contracts'; - -import type { Plain } from './common'; - -// Shared domain models -export type UserInfo = Plain; diff --git a/src/queues/email.queue.ts b/src/queues/email.queue.ts deleted file mode 100644 index 2d7088a..0000000 --- a/src/queues/email.queue.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { Queue } from "bullmq"; -import redis from "@lib/redis"; - -export const emailQueue = new Queue("email", { - connection: redis, -}); \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts deleted file mode 100644 index 9908133..0000000 --- a/src/services/auth.service.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import argon2 from "argon2"; -import crypto from "crypto"; - -import { env } from "@config/env"; -import { buildRedirectUrl } from "@utils/redirect"; -import prisma from "@lib/prisma"; -import redis, { refreshKey, refreshPattern, resetKey, scanKeys } from "@lib/redis"; -import { emailQueue } from "@queues/email.queue"; -import { BadRequestError, ConflictError, UnauthorizedError } from "@utils/errors"; -import { generateAccessToken, generateRefreshToken, hashToken } from "@utils/token"; - -import { AUTH_MESSAGES } from "@constants/messages"; -import type { - ForgotPasswordRequest, - LoginRequest, - LoginResponse, - RefreshResponse, - ResetPasswordRequest, - SignupRequest, -} from "@models/auth"; -import type { MessageResponse } from "@models/common"; -import { logger } from "@services/logger.service"; -import type { OAuthProvider } from "types/auth"; - -export async function signup(input: SignupRequest): Promise { - const existing = await prisma.user.findUnique({ where: { email: input.email } }); - if (existing) { - throw new ConflictError(AUTH_MESSAGES.EMAIL_TAKEN); - } - - const hashedPassword = await argon2.hash(input.password); - - await prisma.user.create({ - data: { - email: input.email, - password: hashedPassword, - name: input.name, - }, - }); - - return { message: AUTH_MESSAGES.SIGNUP_SUCCESS }; -} - -export async function login(input: LoginRequest): Promise { - const user = await prisma.user.findUnique({ where: { email: input.email } }); - if (!user || !user.password) { - throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); - } - - const valid = await argon2.verify(user.password, input.password); - if (!valid) { - throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); - } - - const accessToken = generateAccessToken(user.id); - const rawRefreshToken = generateRefreshToken(); - const hashedRefresh = hashToken(rawRefreshToken); - - await redis.set(refreshKey(user.id, hashedRefresh), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); - - return { - tokens: { accessToken, refreshToken: rawRefreshToken }, - user: { id: user.id, email: user.email, name: user.name }, - }; -} - -export async function refresh(oldRawToken: string): Promise { - const oldHash = hashToken(oldRawToken); - const matchedKeys = await scanKeys(`refresh:*:${oldHash}`); - if (matchedKeys.length === 0) { - throw new UnauthorizedError(AUTH_MESSAGES.REFRESH_TOKEN_INVALID); - } - - const key = matchedKeys[0]; - const userId = key.split(":")[1]; - - await redis.del(key); - - const accessToken = generateAccessToken(userId); - const newRawRefresh = generateRefreshToken(); - const newHash = hashToken(newRawRefresh); - - await redis.set(refreshKey(userId, newHash), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); - - return { - message: AUTH_MESSAGES.REFRESH_SUCCESS, - tokens: { accessToken, refreshToken: newRawRefresh }, - }; -} - -export async function logout(rawRefreshToken: string): Promise { - const hash = hashToken(rawRefreshToken); - const matchedKeys = await scanKeys(`refresh:*:${hash}`); - if (matchedKeys.length > 0) { - await redis.del(matchedKeys[0]); - } - - return { message: AUTH_MESSAGES.LOGOUT_SUCCESS }; -} - -export async function forgotPassword(input: ForgotPasswordRequest): Promise { - const user = await prisma.user.findUnique({ where: { email: input.email } }); - if (!user) return; - - const rawToken = crypto.randomBytes(32).toString("hex"); - const hashedToken = hashToken(rawToken); - - await redis.set(resetKey(hashedToken), user.id, "EX", env.RESET_TOKEN_TTL_SECONDS); - - const resetUrl = buildRedirectUrl(env.FRONTEND_URL, "/reset-password", { token: rawToken }); - - logger.info(`[job:email] Enqueue password-reset email for userId=${user.id}`); - await emailQueue.add("send-reset-email", { - userId: user.id, - email: user.email, - name: user.name, - resetUrl, - }); -} - -export async function resetPassword(input: ResetPasswordRequest): Promise { - const hashedToken = hashToken(input.token); - const userId = await redis.get(resetKey(hashedToken)); - - if (!userId) { - throw new BadRequestError(AUTH_MESSAGES.RESET_TOKEN_INVALID); - } - - const hashedPassword = await argon2.hash(input.newPassword); - - await prisma.user.update({ - where: { id: userId }, - data: { password: hashedPassword }, - }); - - await redis.del(resetKey(hashedToken)); - - const refreshKeys = await scanKeys(refreshPattern(userId)); - if (refreshKeys.length > 0) { - await redis.del(...refreshKeys); - } - - return { message: AUTH_MESSAGES.RESET_PASSWORD_SUCCESS }; -} - -export async function oauthLogin( - provider: OAuthProvider, - profile: { email: string; name: string; providerAccountId: string }, -): Promise { - let user = await prisma.user.findUnique({ where: { email: profile.email } }); - - if (user) { - const existingAccount = await prisma.account.findFirst({ - where: { userId: user.id, provider }, - }); - - if (!existingAccount) { - await prisma.account.create({ - data: { - userId: user.id, - provider, - providerAccountId: profile.providerAccountId, - }, - }); - } - } else { - user = await prisma.$transaction(async (tx) => { - const newUser = await tx.user.create({ - data: { - email: profile.email, - name: profile.name, - }, - }); - - await tx.account.create({ - data: { - userId: newUser.id, - provider, - providerAccountId: profile.providerAccountId, - }, - }); - - return newUser; - }); - } - - const accessToken = generateAccessToken(user.id); - const rawRefreshToken = generateRefreshToken(); - const hashedRefresh = hashToken(rawRefreshToken); - - await redis.set(refreshKey(user.id, hashedRefresh), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); - - return { - tokens: { accessToken, refreshToken: rawRefreshToken }, - user: { id: user.id, email: user.email, name: user.name }, - }; -} diff --git a/src/services/email.service.ts b/src/services/email.service.ts deleted file mode 100644 index ee15c59..0000000 --- a/src/services/email.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { env } from "@config/env"; -import { logger } from "@services/logger.service"; -import { Resend } from "resend"; - -const resend = new Resend(env.RESEND_API_KEY); - -export async function sendResetEmail(email: string, resetUrl: string, name: string): Promise { - const from = env.RESET_PASSWORD_EMAIL; - const templateId = env.RESET_PASSWORD_TEMPLATE_ID; - - const { data, error } = await resend.emails.send({ - from, - to: [email], - subject: "Reset your Coverit password", - template: { - id: templateId, - variables: { - NAME: name, - RESET_URL: resetUrl, - EXPIRE_TIME: Math.ceil(env.RESET_TOKEN_TTL_SECONDS / 60), - }, - }, - }); - - if (error) { - logger.error(error, "Error sending email:"); - return; - } - - logger.info("Reset password email sent successfully!"); - logger.info( - { - email, - messageId: data?.id, - }, - "Reset password email sent", - ); -} diff --git a/src/services/logger.service.ts b/src/services/logger.service.ts deleted file mode 100644 index ebb3537..0000000 --- a/src/services/logger.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import pino from "pino"; -import pretty from "pino-pretty"; - -const stream = pretty({ - levelFirst: true, - colorize: true, - ignore: "time,hostname,pid", -}); - -export const logger = pino( - { - level: process.env.NODE_ENV === "production" ? "info" : "debug", - }, - stream, -); diff --git a/src/services/oauth.service.ts b/src/services/oauth.service.ts deleted file mode 100644 index d34af3f..0000000 --- a/src/services/oauth.service.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { env } from '@config/env'; -import { BadRequestError } from '@utils/errors'; -import { AUTH_MESSAGES } from '@constants/messages'; -import type { OAuthProvider, OAuthUserProfile, OAuthProviderConfig } from 'types/auth'; - -function getProviderConfig(provider: OAuthProvider): OAuthProviderConfig { - switch (provider) { - case 'google': - return { - clientId: env.GOOGLE_CLIENT_ID, - clientSecret: env.GOOGLE_CLIENT_SECRET, - callbackUrl: env.GOOGLE_CALLBACK_URL, - authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenUrl: 'https://oauth2.googleapis.com/token', - scope: 'openid email profile', - fetchProfile: fetchGoogleProfile, - }; - case 'github': - return { - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, - callbackUrl: env.GITHUB_CALLBACK_URL, - authorizeUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - scope: 'read:user user:email', - fetchProfile: fetchGitHubProfile, - }; - } -} - -export function getAuthorizationUrl(provider: OAuthProvider, state: string): string { - const config = getProviderConfig(provider); - - if (!config.clientId) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_PROVIDER_NOT_CONFIGURED); - } - - const params = new URLSearchParams({ - client_id: config.clientId, - redirect_uri: config.callbackUrl, - response_type: 'code', - scope: config.scope, - state, - }); - - if (provider === 'google') { - params.set('access_type', 'offline'); - params.set('prompt', 'consent'); - } - - return `${config.authorizeUrl}?${params.toString()}`; -} - -export async function exchangeCodeForProfile( - provider: OAuthProvider, - code: string, -): Promise { - const config = getProviderConfig(provider); - - const tokenBody: Record = { - client_id: config.clientId, - client_secret: config.clientSecret, - code, - redirect_uri: config.callbackUrl, - grant_type: 'authorization_code', - }; - - const tokenHeaders: Record = { - 'Content-Type': 'application/x-www-form-urlencoded', - }; - - if (provider === 'github') { - tokenHeaders['Accept'] = 'application/json'; - } - - const tokenRes = await fetch(config.tokenUrl, { - method: 'POST', - headers: tokenHeaders, - body: new URLSearchParams(tokenBody).toString(), - }); - - if (!tokenRes.ok) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); - } - - const tokenData = (await tokenRes.json()) as Record; - - if (tokenData.error) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); - } - - const accessToken: string = tokenData.access_token; - - if (!accessToken) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); - } - - return config.fetchProfile(accessToken); -} - -async function fetchGoogleProfile(accessToken: string): Promise { - const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_USER_INFO_FAILED); - } - - const data = (await res.json()) as Record; - if (!data.email) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_EMAIL_MISSING); - } - - return { email: data.email, name: data.name ?? data.email, providerAccountId: data.id }; -} - -async function fetchGitHubProfile(accessToken: string): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - }; - - const userRes = await fetch('https://api.github.com/user', { headers }); - if (!userRes.ok) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_USER_INFO_FAILED); - } - const userData = (await userRes.json()) as Record; - - let email: string | null = userData.email ?? null; - - if (!email) { - const emailRes = await fetch('https://api.github.com/user/emails', { headers }); - if (emailRes.ok) { - const emails = (await emailRes.json()) as Array<{ email: string; primary: boolean; verified: boolean }>; - const primary = emails.find((e) => e.primary && e.verified); - email = primary?.email ?? emails[0]?.email ?? null; - } - } - - if (!email) { - throw new BadRequestError(AUTH_MESSAGES.OAUTH_EMAIL_MISSING); - } - - return { email, name: (userData.name ?? userData.login ?? email) as string, providerAccountId: String(userData.id) }; -} diff --git a/src/types/auth.ts b/src/types/auth.ts deleted file mode 100644 index da93250..0000000 --- a/src/types/auth.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -export type OAuthProvider = 'google' | 'github'; - -export interface OAuthUserProfile { - email: string; - name: string; - providerAccountId: string; -} - -export interface OAuthProviderConfig { - clientId: string; - clientSecret: string; - callbackUrl: string; - authorizeUrl: string; - tokenUrl: string; - scope: string; - fetchProfile: (accessToken: string) => Promise; -} diff --git a/src/utils/errors.ts b/src/utils/errors.ts deleted file mode 100644 index 0a645cb..0000000 --- a/src/utils/errors.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -export class AppError extends Error { - constructor( - public readonly statusCode: number, - message: string, - ) { - super(message); - this.name = 'AppError'; - } -} - -export class UnauthorizedError extends AppError { - constructor(message = 'Unauthorized') { - super(401, message); - this.name = 'UnauthorizedError'; - } -} - -export class BadRequestError extends AppError { - constructor(message = 'Bad request') { - super(400, message); - this.name = 'BadRequestError'; - } -} - -export class ConflictError extends AppError { - constructor(message = 'Conflict') { - super(409, message); - this.name = 'ConflictError'; - } -} diff --git a/src/utils/redirect.ts b/src/utils/redirect.ts deleted file mode 100644 index 3305584..0000000 --- a/src/utils/redirect.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -export function buildRedirectUrl( - baseUrl: string, - pathname = '', - params?: URLSearchParams | Record -): string { - if (!baseUrl) return pathname || ''; - - const normalizedBase = baseUrl.replace(/\/+$/g, ''); - const normalizedPath = pathname ? '/' + pathname.replace(/^\/+/, '') : ''; - - const url = `${normalizedBase}${normalizedPath}`; - - if (!params) return url; - - const search = params instanceof URLSearchParams - ? params.toString() - : new URLSearchParams(params).toString(); - - return search ? `${url}?${search}` : url; -} - -export default buildRedirectUrl; diff --git a/src/utils/token.ts b/src/utils/token.ts deleted file mode 100644 index acc32a9..0000000 --- a/src/utils/token.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import crypto from 'crypto'; -import jwt from 'jsonwebtoken'; - -import { env } from '@config/env'; -import { UnauthorizedError } from '@utils/errors'; - -export function generateAccessToken(userId: string): string { - return jwt.sign({ sub: userId }, env.JWT_SECRET, { - expiresIn: env.JWT_ACCESS_EXPIRY as jwt.SignOptions['expiresIn'], - }); -} - -export function generateRefreshToken(): string { - return crypto.randomBytes(48).toString('base64url'); -} - -export function hashToken(token: string): string { - return crypto.createHash('sha256').update(token).digest('hex'); -} - -/** Verify an access token and return the userId. Throws on invalid/expired tokens. */ -export function verifyAccessToken(token: string): string { - const payload = jwt.verify(token, env.JWT_SECRET) as jwt.JwtPayload; - if (!payload.sub) { - throw new UnauthorizedError('Malformed token'); - } - return payload.sub; -} diff --git a/src/utils/zod.ts b/src/utils/zod.ts deleted file mode 100644 index f2cf43b..0000000 --- a/src/utils/zod.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import * as _z from 'zod'; - -function requiredString(message: string) { - return _z.string({ error: message }).trim().min(1, message); -} - -export const z = { - ..._z, - requiredString, -}; diff --git a/src/workers/email.worker.ts b/src/workers/email.worker.ts deleted file mode 100644 index a44c885..0000000 --- a/src/workers/email.worker.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. -// Proprietary and confidential. Unauthorized use is strictly prohibited. -// See LICENSE file in the project root for full license information. - -import { workerRedis } from '@lib/redis'; -import { sendResetEmail } from "@services/email.service"; -import { logger } from "@services/logger.service"; -import { Worker } from "bullmq"; - -new Worker( - "email", - async (job) => { - if (job.name === "send-reset-email") { - const { email, resetUrl, name } = job.data; - await sendResetEmail(email, resetUrl, name); - } - }, - { connection: workerRedis }, -); - -logger.info("[Worker] Email worker started and listening for jobs..."); From e81f03fa79854ebbd52e030e93e1d95e356c3e27 Mon Sep 17 00:00:00 2001 From: Youssef Date: Sun, 5 Apr 2026 18:22:55 +0200 Subject: [PATCH 03/35] feat: initial crawler structure --- .dockerignore | 8 - .gitignore | 2 - example_usage.py | 98 +++++++++ requirements.txt | 8 + src/__init__.py | 3 + src/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 248 bytes src/__pycache__/config.cpython-313.pyc | Bin 0 -> 2170 bytes src/browser/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 147 bytes .../__pycache__/engine.cpython-313.pyc | Bin 0 -> 8472 bytes src/browser/engine.py | 123 +++++++++++ src/config.py | 28 +++ src/crawler/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 147 bytes .../__pycache__/executor.cpython-313.pyc | Bin 0 -> 3287 bytes .../__pycache__/session.cpython-313.pyc | Bin 0 -> 12722 bytes src/crawler/__pycache__/utils.cpython-313.pyc | Bin 0 -> 1504 bytes src/crawler/executor.py | 54 +++++ src/crawler/session.py | 196 ++++++++++++++++++ src/crawler/utils.py | 27 +++ src/db/__init__.py | 4 + src/db/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 281 bytes .../application_repository.cpython-313.pyc | Bin 0 -> 2839 bytes .../base_repository.cpython-313.pyc | Bin 0 -> 2390 bytes src/db/__pycache__/connection.cpython-313.pyc | Bin 0 -> 3233 bytes .../locator_repository.cpython-313.pyc | Bin 0 -> 6307 bytes .../project_repository.cpython-313.pyc | Bin 0 -> 2820 bytes src/db/__pycache__/repository.cpython-313.pyc | Bin 0 -> 1901 bytes .../session_repository.cpython-313.pyc | Bin 0 -> 4007 bytes .../version_repository.cpython-313.pyc | Bin 0 -> 3980 bytes src/db/application_repository.py | 29 +++ src/db/base_repository.py | 29 +++ src/db/connection.py | 38 ++++ src/db/locator_repository.py | 66 ++++++ src/db/project_repository.py | 29 +++ src/db/repository.py | 24 +++ src/db/session_repository.py | 43 ++++ src/db/version_repository.py | 43 ++++ src/graph/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 145 bytes src/graph/__pycache__/builder.cpython-313.pyc | Bin 0 -> 8552 bytes src/graph/builder.py | 149 +++++++++++++ src/models/__init__.py | 22 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 483 bytes src/models/__pycache__/domain.cpython-313.pyc | Bin 0 -> 5974 bytes src/models/__pycache__/graph.cpython-313.pyc | Bin 0 -> 2760 bytes src/models/domain.py | 106 ++++++++++ src/models/graph.py | 48 +++++ src/utils.py | 43 ++++ 49 files changed, 1210 insertions(+), 10 deletions(-) delete mode 100644 .dockerignore create mode 100644 example_usage.py create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-313.pyc create mode 100644 src/__pycache__/config.cpython-313.pyc create mode 100644 src/browser/__init__.py create mode 100644 src/browser/__pycache__/__init__.cpython-313.pyc create mode 100644 src/browser/__pycache__/engine.cpython-313.pyc create mode 100644 src/browser/engine.py create mode 100644 src/config.py create mode 100644 src/crawler/__init__.py create mode 100644 src/crawler/__pycache__/__init__.cpython-313.pyc create mode 100644 src/crawler/__pycache__/executor.cpython-313.pyc create mode 100644 src/crawler/__pycache__/session.cpython-313.pyc create mode 100644 src/crawler/__pycache__/utils.cpython-313.pyc create mode 100644 src/crawler/executor.py create mode 100644 src/crawler/session.py create mode 100644 src/crawler/utils.py create mode 100644 src/db/__init__.py create mode 100644 src/db/__pycache__/__init__.cpython-313.pyc create mode 100644 src/db/__pycache__/application_repository.cpython-313.pyc create mode 100644 src/db/__pycache__/base_repository.cpython-313.pyc create mode 100644 src/db/__pycache__/connection.cpython-313.pyc create mode 100644 src/db/__pycache__/locator_repository.cpython-313.pyc create mode 100644 src/db/__pycache__/project_repository.cpython-313.pyc create mode 100644 src/db/__pycache__/repository.cpython-313.pyc create mode 100644 src/db/__pycache__/session_repository.cpython-313.pyc create mode 100644 src/db/__pycache__/version_repository.cpython-313.pyc create mode 100644 src/db/application_repository.py create mode 100644 src/db/base_repository.py create mode 100644 src/db/connection.py create mode 100644 src/db/locator_repository.py create mode 100644 src/db/project_repository.py create mode 100644 src/db/repository.py create mode 100644 src/db/session_repository.py create mode 100644 src/db/version_repository.py create mode 100644 src/graph/__init__.py create mode 100644 src/graph/__pycache__/__init__.cpython-313.pyc create mode 100644 src/graph/__pycache__/builder.cpython-313.pyc create mode 100644 src/graph/builder.py create mode 100644 src/models/__init__.py create mode 100644 src/models/__pycache__/__init__.cpython-313.pyc create mode 100644 src/models/__pycache__/domain.cpython-313.pyc create mode 100644 src/models/__pycache__/graph.cpython-313.pyc create mode 100644 src/models/domain.py create mode 100644 src/models/graph.py create mode 100644 src/utils.py diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 44c43bc..0000000 --- a/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -dist -.env -.env.* -*.log -.git -.husky -.github diff --git a/.gitignore b/.gitignore index 104d867..a0b63fa 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,6 @@ coverage # Environment variables .env -.env.local -.env.*.local .env.* /src/generated/prisma diff --git a/example_usage.py b/example_usage.py new file mode 100644 index 0000000..b0802cb --- /dev/null +++ b/example_usage.py @@ -0,0 +1,98 @@ +import asyncio +import logging + +from src.config import config +from src.db.connection import DatabaseConnection +from src.db.repository import Repository +from src.models.domain import Project, TargetApplication, ApplicationVersion, CrawlSession +from src.graph.builder import Neo4jGraphBuilder +from src.crawler.session import CrawlSessionManager + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.w3schools.com/html/html_basic.asp" + +async def main(): + logger.info("Starting CoverIt Crawler...") + + logger.info("Connecting to PostgreSQL...") + db_conn = DatabaseConnection(config.pg_connection_string) + await db_conn.connect() + + logger.info("Connecting to Neo4j...") + graph_builder = Neo4jGraphBuilder( + config.NEO4J_URI, config.NEO4J_USER, config.NEO4J_PASSWORD + ) + await graph_builder.connect() + + try: + async with db_conn.session_factory() as session: + repo = Repository(session) + + logger.info("Creating project...") + project = Project( + name="Example Application", + description="An example web application for crawling", + ) + project_id = await repo.projects.create(project) + logger.info(f"Created project: {project_id}") + + logger.info("Creating target application...") + target_app = TargetApplication( + project_id=project_id, + name="Example Web App", + base_url=BASE_URL, + auth_type=None, + ) + app_id = await repo.applications.create(target_app) + logger.info(f"Created target application: {app_id}") + + logger.info("Creating application version...") + app_version = ApplicationVersion( + project_id=project_id, + app_id=app_id, + version_label="1.0.0", + environment="dev", + commit_hash="abc123def456", + ) + app_version_id = await repo.application_versions.create(app_version) + logger.info(f"Created application version: {app_version_id}") + + await repo.commit() + + logger.info("Creating crawl session...") + crawl_session = CrawlSession( + app_version_id=app_version_id, + trigger_type="manual", + status="PENDING", + ) + + manager = CrawlSessionManager( + session=crawl_session, + app_version_id=app_version_id, + base_url=BASE_URL, + repository=repo, + graph_builder=graph_builder, + headless=False, + ) + + logger.info(f"Created crawl session: {crawl_session.crawl_session_id}") + + logger.info("Starting crawl...") + await manager.run_crawl(max_states=50, max_transitions=200) + + logger.info("\nCrawler execution successful!") + + except Exception as e: + logger.error(f"Error: {e}", exc_info=True) + + finally: + logger.info("Cleaning up...") + await graph_builder.disconnect() + await db_conn.disconnect() + logger.info("Done!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36c4933 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +greenlet>=3.1.0 +asyncpg>=0.30.0,<1.0 +sqlalchemy>=2.0.0 +neo4j>=5.18.0 +playwright>=1.45.0 +python-dotenv>=1.0.0 +pytest>=8.3.0 +pytest-asyncio>=0.24.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..5e48320 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +"""CoverIt Crawler - Web application crawling and testing framework.""" + +__version__ = "0.1.0" diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7283236f31dfbe3931c494fde77d8bdfff27fdf5 GIT binary patch literal 248 zcmey&%ge<81gq69WhMdX#~=<2FhUuhd4P=jOk38%vFxg`DLj^o+S#- zMTzA(sYMF93gM|q3W)^;Iho0cC7Jno3dtau%)E4k#Jm)RlGNf75Ie0XF*mh5zbIR; ziq$~RP|v_mlj#B literal 0 HcmV?d00001 diff --git a/src/__pycache__/config.cpython-313.pyc b/src/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3126e3d3c4d29880e87cb7ecfc07ebc188d5d824 GIT binary patch literal 2170 zcmah~O>7%Q6rQ!$j=hPUw9eXz8%l^$s6}Z@^HV292)A~dCT&uA9GXfit=#p-Y}xL* zv+D+j9)g5KBrZ{;Dis&PiEzu2Bgb;WVGl_MAqByK-imzS%FOKAbtyGIyqS65_uiX* z@6B@~5)lCX**|N4_yPEx9X!E%Yh#9N9s&p;HwO@1eRJHb5BY|Hz_%fFYJf+Zlio@NySuDvV(^if9isytaLGRF5CvF}B2_Oyvj`yB0S3|EI`YG9{0QTRJMsnC-SQu0e4!)12S!@_UdHd~$dAI_7Qc`2qaFEu@JNe)gz@`2 z@{ht;lb=8Fa}g2$jC0)}di-@#E= z)d~@+6fJATL?s-W$z`(Fau8zPMpYFHg{#+d3yTH$I@Hcq$!&$}nI#z(GKCz}{v=}p z2a#%5k)e3+g;kdh5`uxoIuT@CaEl<%fEPuSykLTO1EL)xh@T+MvC{^5HqE*4BEl1! zP@BrzM!}dc3Q~djRk4}kd-~f&t7a&bGD#TbrZM*c=FX%7h+ZAhiGsSQ2vZRuf($9% zQuSqve&;YgnVpurQZ(0hypl*3QizVW$LUH9_gBg?IT@-#+e5Z&q#0#=%+zX|s;E2+gk8m( zt&&J+g!12@;y4w7rl9R%og}6D0zBbI4`bri)aKNqTRYuOY<%s?I&}E)C$abj+Ny2V zwg-2zPU2lBcFsl49kl3z6B~1tV+V;rMt*Hqa1s~R<~Cvuf1#zUpUpUli>~(Kp_tgZ zwR!8&r}a~p95K6gb=`9K>_L2BOWV}8dv?lBd~$7e!|(8u2g#wYQ(vX(qvN}GoMdKg ze&dqEXPORHcP1V2+`gFJ6Vr}3{r&PD@iwzwBHo_F2Oo`4)9q`!C!A#3iBGvMr`nNG zCwbn9r(Gm{7>GRcae-BCTWJ8gHvAwMZ7`LaM4CVH22PR%2i<>TMBgz>EDpL&{ja3s z(0gtly^qESl|B!P&iH43j^lm-;a|b=-$3{0k-zyEx61tmm^=l9zApzp3$n2P0Q=VE A6951J literal 0 HcmV?d00001 diff --git a/src/browser/__init__.py b/src/browser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/browser/__pycache__/__init__.cpython-313.pyc b/src/browser/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1c79c179433b73d372f3bbc8cb5001c5dbec76b GIT binary patch literal 147 zcmey&%ge<81k6g8GC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i6i&ac=epzZ! zW(kbeO)g3-&q*zcDK1KmNh-=OF9x#X<1_OzOXB183My}L*yQG?l;)(`6|n-109jEC OVtiy~WMnL22C@Jh*&`DG literal 0 HcmV?d00001 diff --git a/src/browser/__pycache__/engine.cpython-313.pyc b/src/browser/__pycache__/engine.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dda08ddefd228414ce44e2c15ad22b05005dcf72 GIT binary patch literal 8472 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 literal 0 HcmV?d00001 diff --git a/src/browser/engine.py b/src/browser/engine.py new file mode 100644 index 0000000..bd22583 --- /dev/null +++ b/src/browser/engine.py @@ -0,0 +1,123 @@ +"""Playwright-based browser engine for crawling.""" + +from typing import Optional, Dict, Any, List +from playwright.async_api import async_playwright, Browser, Page, BrowserContext +import hashlib + + +class BrowserEngine: + """Manages Playwright browser instance and page interactions.""" + + def __init__(self, headless: bool = True, timeout_ms: int = 30000): + self.headless = headless + self.timeout_ms = timeout_ms + self.browser: Optional[Browser] = None + self.context: Optional[BrowserContext] = None + self.page: Optional[Page] = None + self.playwright = None + + async def start(self) -> None: + """Initialize browser and page.""" + self.playwright = await async_playwright().start() + self.browser = await self.playwright.chromium.launch(headless=self.headless) + self.context = await self.browser.new_context() + self.page = await self.context.new_page() + self.page.set_default_timeout(self.timeout_ms) + + async def stop(self) -> None: + """Close browser and cleanup.""" + if self.context: + await self.context.close() + if self.browser: + await self.browser.close() + if self.playwright: + await self.playwright.stop() + + async def navigate(self, url: str) -> None: + """Navigate to URL.""" + if not self.page: + raise RuntimeError("Browser not started") + await self.page.goto(url, wait_until="networkidle") + + async def get_page_title(self) -> str: + """Get current page title.""" + if not self.page: + raise RuntimeError("Browser not started") + return await self.page.title() + + async def get_current_url(self) -> str: + """Get current page URL.""" + if not self.page: + raise RuntimeError("Browser not started") + return self.page.url + + async def click(self, selector: str) -> None: + """Click on element.""" + if not self.page: + raise RuntimeError("Browser not started") + await self.page.click(selector) + + async def type_text(self, selector: str, text: str) -> None: + """Type text into element.""" + if not self.page: + raise RuntimeError("Browser not started") + await self.page.fill(selector, text) + + async def get_page_content(self) -> str: + """Get page HTML content.""" + if not self.page: + raise RuntimeError("Browser not started") + return await self.page.content() + + async def get_state_hash(self) -> str: + """Generate hash of normalized page state.""" + content = await self.get_page_content() + normalized = content.replace("\n", "").replace("\t", "") + return hashlib.md5(normalized.encode()).hexdigest() + + async def get_interactable_elements(self) -> List[Dict[str, Any]]: + """Get list of interactable elements on the page using Playwright built-ins.""" + if not self.page: + raise RuntimeError("Browser not started") + + selector = "button, a, input, select, textarea, [role='button'], [onclick]" + + locator = self.page.locator(selector) + count = await locator.count() + elements: List[Dict[str, Any]] = [] + + for i in range(count): + el = locator.nth(i) + if await el.is_visible(): + tag = await el.evaluate("el => el.tagName.toLowerCase()") + text = "" + if tag in ("input", "textarea"): + text = await el.input_value() + else: + text = (await el.inner_text())[:100] + + element_data: Dict[str, Any] = { + "id": await el.get_attribute("id") or str(i), + "tag": tag, + "text": text, + "type": await el.get_attribute("type") or "", + "selector": await el.evaluate( + """el => el.id ? `#${el.id}` : el.tagName.toLowerCase() + (el.className ? '.' + el.className.split(' ')[0] : '')""" + ), + "visible": True + } + elements.append(element_data) + + return elements + + async def wait_for_navigation(self) -> None: + """Wait for page navigation.""" + if not self.page: + raise RuntimeError("Browser not started") + await self.page.wait_for_load_state("networkidle") + + async def take_screenshot(self, path: str) -> None: + """Take screenshot of current page.""" + if not self.page: + raise RuntimeError("Browser not started") + await self.page.screenshot(path=path) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..b32bbf8 --- /dev/null +++ b/src/config.py @@ -0,0 +1,28 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Config: + """Application configuration.""" + + PG_HOST: str = os.getenv("PG_HOST", "localhost") + PG_PORT: int = int(os.getenv("PG_PORT", 5432)) + PG_USER: str = os.getenv("PG_USER", "postgres") + PG_PASSWORD: str = os.getenv("PG_PASSWORD", "postgres") + PG_DATABASE: str = os.getenv("PG_DATABASE", "coverit_crawler") + + NEO4J_URI: str = os.getenv("NEO4J_URI", "bolt://localhost:7687") + NEO4J_USER: str = os.getenv("NEO4J_USER", "neo4j") + NEO4J_PASSWORD: str = os.getenv("NEO4J_PASSWORD", "password") + + HEADLESS: bool = os.getenv("HEADLESS", "true").lower() == "true" + TIMEOUT_MS: int = int(os.getenv("TIMEOUT_MS", 30000)) + MAX_STATES: int = int(os.getenv("MAX_STATES", 1000)) + + @property + def pg_connection_string(self) -> str: + return f"postgresql+asyncpg://{self.PG_USER}:{self.PG_PASSWORD}@{self.PG_HOST}:{self.PG_PORT}/{self.PG_DATABASE}" + + +config = Config() diff --git a/src/crawler/__init__.py b/src/crawler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/crawler/__pycache__/__init__.cpython-313.pyc b/src/crawler/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7957946dc6ba0e0e104744882236ce60f777a9d1 GIT binary patch literal 147 zcmey&%ge<81iVU@GC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i6i&ac=epzZ! zW(kbeO)g3-&q*zcDK1KmfwAM`GxIV_;^XxSDsOSvy?%Sy2pPd}L;1 JWGrF^vH%}jBMATi literal 0 HcmV?d00001 diff --git a/src/crawler/__pycache__/executor.cpython-313.pyc b/src/crawler/__pycache__/executor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..471f915dc48dd1d39199d998274d595e4acec82c GIT binary patch literal 3287 zcmai0UrZdw8K3=kw|BSq7yH0~Vb55x&m5Ox1M!i>jT5-YfX*#j(nvmOwO*FXwVOLK zyI>pTA!@7250%X;W3akzgs6TB-eJ_Lc(- z>PYj=H{X2oec$|M=9}4zM#Bioe~nM8e-0w_chd0--(vP&1!ff~2qT42*lA{p#q1P^ zITF`tpM`YB6&LKtYMoMUu^(o0lzHvklqCTFt6bp;@m~sy@U;w8rUJ8vYZ>ee%k_Dx~&kiZp!ceXy3jYc?oNG%vt>z zPu9Szp%bTm0i0;rdj*ZCoWf#W;mX1YtMJ|ECp-=)LOG}e%Ayi1hsvS= zGe!ag1sDs1EK-)Jj8#HZ7FEJHR*7UKH%d{Nq@dLC4%w!aoYyrh8I}TZLJLpRfjVZH40-aglHe(|0ws%o`XwN=$kA6jnSmq)?2 zMM8KBH9Px$B{i>(-{(IUd-gzt+<*szD_Qj=the^u4PXOKKussh^b3LS=TP^SK{|kB zSb86tV`lyF1Fgc0!-3cG2;i7K2wh8OmTcIO0+s?tM9>{=fq4Td>}%k6nOWsZf&|`g z-ao-NM|~Gg^c{NA%kjQJN^kaIegEbi6Vdlih=~6`XpkK`Qcm!FG8#;UJppU7^+(ER ztN-L0ZY6!y#@*`JpT*9e^7(cfLrP$dJAUK}H+SruJ?nGrc8>Q?ri|ve^GHN5Go=|y10pK@3FqiUBGu#4`4Xq5e@>eT; z(d^aA*D^M&CDVos@1?T5E9hob|7pqP?R$%cE7r8-Y6X&amhn;&Gn5Uw(F1v@I`760 z<^h!mYzW>6*m2j{SEE z0`;bk%h@Cz090sPxEDBAgzRYOV8#s*PpWO)wOxUl!30vb2^W z(#WPX@^vQvuKmY1|L~@hnP~J(+@JaS%wRJ!)X0oEnXygznRnlKueVVsIfc^Z)tj4r zKWNIihCJ%Xqiv@5T%$Md^yZrb*~Y-AGcelh$uxQfou0vF-}&Fye^>t|E_TQ6Pwl2q zG}(~)9jU(|J>^JGt*v|}UEc2K-<0~leHtal|CX5kDpB}GMA6Z&dM5t$xDO@H>>)PP z^`(^DJe6Dj;rdIPsqcLz4S(6u`R`o;2L5CH7U2FFQ!FsQxLQ1gK46L>|KW8J*pCIK z7~ww-JP-Usjw#0YhrB@e5Yc`ZK1=uuOfkVfyx0x=CoEG;@t<%n^3Zt{BubA&hVT(e zW0WQ+O;LI(TpZ&b^~H+A+@qX8^oIo^8RLN0Tj?FbLumh(F$SGg@{m&TVKNI(;?d$K z_w(@kSm}A$u*vU6t}n=%X&(3uTKO#NT>)ONFW!TkK#sdbLXf>9oFdo0eagwN#gz{q z<0mynh>=bpZ=vmYMwZUroC>!=OwJFuh-|7 zOu|d5`o@xGwnpNrx=_WIZNewAR)^uBs?OJSRmBlv>NFwboyH;|JRu|qOrW8u0AB^p zJ+0CdR@GI_wsH0Ll5JS3x`Dia$eqRH9j1hsvx-1|iN4^+c6nBKb}jlCL9;7ngsx^f zv&)fo_!@1uL&@I^IU)J60K(lO!wQ$yCN{IrI_IDJ4klglToCJ}Y!qLE)(t>TgGB8G zNn&;d__&)EO<=ABP#C6_uV8KQHE&%cr_p7)vDTk7Z_t0WhXM`#1)@XoCChQe=R8EO s;YsL{jkPjBb~%P&zCc(0i8B8{@?X&n2i^D!y7XNj$6Wdr0ih=T2Xj5{k^lez literal 0 HcmV?d00001 diff --git a/src/crawler/__pycache__/session.cpython-313.pyc b/src/crawler/__pycache__/session.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11e79ebd35ae1a108b258c432b0bede1b6ed0c1b GIT binary patch literal 12722 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 H=YjPvaLH#5 literal 0 HcmV?d00001 diff --git a/src/crawler/executor.py b/src/crawler/executor.py new file mode 100644 index 0000000..572b032 --- /dev/null +++ b/src/crawler/executor.py @@ -0,0 +1,54 @@ +"""Event execution and state transition logging.""" + +from typing import List, Optional + +from .utils import capture_state +from ..models.graph import AbstractState, AbstractTransition, CrawlAction +from ..browser.engine import BrowserEngine + + +class EventExecutor: + """Executes actions and logs state transitions.""" + + def __init__(self, browser: BrowserEngine): + self.browser = browser + self.transition_log: List[AbstractTransition] = [] + + async def execute_action( + self, action: CrawlAction, source_state: AbstractState + ) -> Optional[AbstractState]: + """Execute action and capture resulting state.""" + try: + if action.action_type == "click": + await self.browser.click(action.selector) + elif action.action_type == "type": + await self.browser.type_text(action.selector, action.value) + elif action.action_type == "navigate": + await self.browser.navigate(action.value) + else: + return None + + await self.browser.wait_for_navigation() + + target_state = await capture_state(browser=self.browser) + + transition = AbstractTransition( + transition_id=f"{source_state.state_id}-{target_state.state_id}", + source_state_id=source_state.state_id, + target_state_id=target_state.state_id, + action_type=action.action_type, + action_description=action.description, + locator_id=action.action_id, + locator_value=action.selector, + ) + self.transition_log.append(transition) + + return target_state + + except Exception as e: + print(f"Error executing action: {e}") + return None + + def get_transition_log(self) -> List[AbstractTransition]: + """Get log of all transitions.""" + return self.transition_log.copy() diff --git a/src/crawler/session.py b/src/crawler/session.py new file mode 100644 index 0000000..81f85f4 --- /dev/null +++ b/src/crawler/session.py @@ -0,0 +1,196 @@ +"""Crawl session management.""" + +from typing import Optional, Set +from datetime import datetime, timezone +from uuid import UUID +import logging + +from .utils import capture_state +from ..models.domain import CrawlSession +from ..models.graph import AbstractState, AbstractTransition, CrawlAction +from ..browser.engine import BrowserEngine +from .executor import EventExecutor + +logger = logging.getLogger(__name__) + + +class CrawlSessionManager: + """Manages a crawl session lifecycle.""" + + def __init__( + self, + session: CrawlSession, + app_version_id: UUID, + base_url: str, + repository, + graph_builder, + headless: bool = False, + ): + self.session = session + self.app_version_id = app_version_id + self.base_url = base_url + self.repository = repository + self.graph_builder = graph_builder + self.headless = headless + + self.discovered_states: Set[str] = set() + self.discovery_queue: list = [] + self.browser = BrowserEngine(headless=headless, timeout_ms=30000) + self.executor = None + self.current_state: Optional[AbstractState] = None + + async def initialize(self) -> None: + """Start the crawl session.""" + self.session.status = "RUNNING" + self.session.started_at = datetime.now(timezone.utc) + await self.repository.crawl_sessions.create(self.session) + await self.browser.start() + self.executor = EventExecutor(self.browser) + logger.info(f"Crawl session {self.session.crawl_session_id} initialized") + + async def cleanup(self) -> None: + """Clean up session resources.""" + await self.browser.stop() + self.session.status = "COMPLETED" + self.session.finished_at = datetime.now(timezone.utc) + await self.repository.crawl_sessions.update_status( + self.session.crawl_session_id, + self.session.status, + self.session.finished_at, + ) + logger.info(f"Crawl session {self.session.crawl_session_id} completed") + + async def mark_failed(self) -> None: + """Mark session as failed.""" + self.session.status = "FAILED" + self.session.finished_at = datetime.now(timezone.utc) + await self.repository.crawl_sessions.update_status( + self.session.crawl_session_id, + self.session.status, + self.session.finished_at, + ) + logger.error(f"Crawl session {self.session.crawl_session_id} failed") + + def add_to_queue(self, state: AbstractState) -> None: + """Add state to discovery queue.""" + if state.state_hash not in self.discovered_states: + self.discovered_states.add(state.state_hash) + self.discovery_queue.append(state) + self.session.state_count += 1 + logger.debug(f"Added state {state.state_id} to queue (total: {self.session.state_count})") + + def get_next_state(self) -> Optional[AbstractState]: + """Get next state from discovery queue.""" + if self.discovery_queue: + return self.discovery_queue.pop(0) + return None + + async def add_transition(self, transition: AbstractTransition) -> None: + """Add transition to graph.""" + self.session.transition_count += 1 + await self.graph_builder.add_transition(transition) + logger.debug(f"Recorded transition: {transition.action_type}") + + @property + def is_complete(self) -> bool: + """Check if crawl is complete.""" + return len(self.discovery_queue) == 0 + + async def run_crawl(self, max_states: int = 100, max_transitions: int = 500) -> None: + """Execute BFS crawl of application.""" + try: + await self.initialize() + + logger.info(f"Starting crawl from {self.base_url}") + await self.browser.navigate(self.base_url) + + initial_state = await capture_state(browser=self.browser) + self.add_to_queue(initial_state) + logger.info(f"Initial state captured: {initial_state.state_id}") + + while not self.is_complete and self.session.state_count < max_states and self.session.transition_count < max_transitions: + current = self.get_next_state() + if not current: + logger.debug("No more states to explore.", self.discovery_queue) + break + + logger.info(f"Exploring state {current.state_id} ({self.session.state_count}/{max_states})") + self.current_state = current + + if current.url != await self.browser.get_current_url(): + await self.browser.navigate(current.url) + + elements = await self.browser.get_interactable_elements() + logger.debug(f"Found {len(elements)} interactable elements on {current.url}") + + # 5 elements per state for now + for element in elements[:5]: + if self.session.transition_count >= max_transitions: + break + + try: + selector = self._get_selector_for_element(element) + if not selector: + continue + + logger.debug(f"Executing action: click on {element.get('tag')}") + + action = CrawlAction( + action_id=f"{current.state_id}-{element['id']}", + action_type="click", + selector=selector, + description=f"Click {element.get('tag')} with text '{element.get('text')}'", + ) + + new_state = await self.executor.execute_action(action, current) + + if new_state: + transition = AbstractTransition( + transition_id=f"{current.state_id}-{new_state.state_id}", + source_state_id=current.state_id, + target_state_id=new_state.state_id, + action_type=action.action_type, + action_description=action.description, + locator_id=action.action_id, + locator_value=selector, + ) + await self.add_transition(transition) + + self.add_to_queue(new_state) + logger.info(f"Discovered new state: {new_state.state_id}") + else: + logger.warning(f"Failed to execute action on {selector}") + + except Exception as e: + logger.warning(f"Error exploring element: {e}") + try: + await self.browser.navigate(current.url) + except: + pass + continue + + logger.info(f"Crawl complete. States: {self.session.state_count}, Transitions: {self.session.transition_count}") + + except Exception as e: + logger.error(f"Crawl failed with error: {e}", exc_info=True) + await self.mark_failed() + raise + finally: + await self.cleanup() + + def _get_selector_for_element(self, element: dict) -> Optional[str]: + """Generate selector for an element.""" + tag = element.get("tag", "") + text = element.get("text", "").strip() + + if tag in ["button", "a"] and text: + return f'text="{text[:50]}"' + + selector = element.get("selector", "") + if selector: + return f"{selector}:first-child" + + if tag: + return tag + + return None diff --git a/src/crawler/utils.py b/src/crawler/utils.py new file mode 100644 index 0000000..580efe2 --- /dev/null +++ b/src/crawler/utils.py @@ -0,0 +1,27 @@ +from ..models.graph import AbstractState +from datetime import datetime, timezone +from ..browser.engine import BrowserEngine + +async def capture_state(browser: BrowserEngine) -> AbstractState: + """Capture current page state.""" + state_hash = await browser.get_state_hash() + url = await browser.get_current_url() + title = await browser.get_page_title() + content = await browser.get_page_content() + interactable = await browser.get_interactable_elements() + + state = AbstractState( + state_id=state_hash[:8], + state_hash=state_hash, + url=url, + title=title, + dom_snapshot={ + "content_length": len(content), + "element_count": len(interactable), + }, + metadata={ + "interactable_count": len(interactable), + "timestamp": datetime.now(timezone.utc), + }, + ) + return state \ No newline at end of file diff --git a/src/db/__init__.py b/src/db/__init__.py new file mode 100644 index 0000000..375d543 --- /dev/null +++ b/src/db/__init__.py @@ -0,0 +1,4 @@ +from .connection import DatabaseConnection +from .repository import Repository + +__all__ = ["DatabaseConnection", "Repository"] diff --git a/src/db/__pycache__/__init__.cpython-313.pyc b/src/db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86367ca77287349818369b034d01bc832fcadcbc GIT binary patch literal 281 zcmey&%ge<81c{NCGMj+(V-N=hn4pZ$DnQ0mhG2$ZMsEf$#v(=q5Sz)H$&0y&8OUZ1 zX3=LVVo7J!WP8a7RI15%OUNa$Brz$mIMq2nFE2H@Br`t`D9RO-T999yS(0B=S;P!f zSi}M({4`l_aV5i*fhBHn6(LlD6y0Kvk5A0WiH~2&@EOQuxTWi26_cD_mRgiq0;6@4 zixSIoQj21Wi;`nfl49cHGxIV_;^XxSDsOSv7fa5Pr}5;2)7<-Z`?TAHIma+Q4duQxl~4>sy(B~wX@DqQ8EJxZVzcgPPo#SdZz3*Bx>z;~rD9gM z_%8~E8KD-RPG2|+vuJ3#mNVBVI#SDbCTnOZtz0q+W{J(Oxg2%x*I72LnXz(NFXXZq zDuvL!OjtgvDZ;k5@Q z?pwIlS3nGtAOJ~|6y{~iNYjay_5ze@2KHU254fb@B}EE5Hpgu3OtllVDbhr1y){OV zq-jdr$G2zUH3A6=p`55GJFs~>61%m361t0!IxlKH{swK~Gb=G5^| zSU7c@>{M~GQ=N7Vx2w&D*LIai(-Y?jY40z(pym=hYRgD?E;WsBmjHwOj?N=r(d$JZ@T=^9w&ze? z9&5R5&xuj7}l1j%t}|Gxxu^s z&yeKy3}FW#cPxwo`N0mN7z6>$Mc3P`uvFg%g&l6N5qQLF)$jEA(_neGK5u_<{Kgjt$7rRThu+Bc zARwpNI-iTjF{pUzjP8_}4ZnRDV*rHRJpKEYCht$K?5g|2z-D=HXvqTab?7zd>&JA?*z8I5tQ)5w0y>TOO|a1{=~~ zT^ej73;@D|{IVFJk4VI4Nvc}R&T6V^c~o__l&|Qx_NwY!C9AhAvZ~r0O)nI+Vo6o) zM#%64W|&!q^=Baz*x1+@iVskH2*UEKs$s$(PEO65CM!%qt27|SF|e(}MeM3FOusGg zm%^9>@f~TpIANd~JlZ5M|B!!)Q`5rksxt0$AS z;}_8|+VoIiv^p};BsiZ)yu`UFQX$Y%1|E<64Y~k3-hPK<#A}J>e7R7ZcEBMni?37) zc^kInG3NBFo}1BT=k24xlBeJ=Z`0|{@cgoRAfcLWjORR;iyRirhgTfEn*OZUMJTmr#a+>>%ujWi#bxEd18o*G`8l_ z1OZu^B!;{w40Li$(GzO;6EDo$%^4ksDaD3lwHbS~=$M!LOw!8=O7O4&07w4gQNbk&8@FIhgW=VSE{R(Rfxmn&jOj>Xot)+ z-G&>OW;lK_%K4)K2Gjc(@@?{XeCj*34=f24rxCb8J5;z6w0Pr07MQ^u`1;=hDUcjw zvO<=CKi!tl@>E-)N1LBJ&r3e#0^;I8^$8rNb(r-Q-4e^P3Tv;>8Nx_exgxGno(3~r zj9?2zt6B}^HLvBbZwovFBYYId08XxpNXwbA?DJ+G(bcC}nr%lBA*hvgrSjNQ%rDUxL2&>sQc&38%?{fU+Y>E{G+XG7Ql z!xx9}3^>l-h!=sh2(YptSmnCIYYxvs+wDjvtD-t zsGE4feGDWQUu`@hL&(I|ub}CxK!%X%YK0yvbXnoBufq7|RFQV*ob*5`{+CHI-t@dx z%l?w*G4f+c&Vf8s*pIAVL*t<|3%gpOtIhVigwI2 z{lE%b+q8m!yQ{6h@lA7^@QY{?Rb_mPI1>E)Pl0?x_5~_m>Zk_g{>w>tNo(zYezAN4@ RxpA95RkC#MF9Ia;{~H+~$B_U4 literal 0 HcmV?d00001 diff --git a/src/db/__pycache__/connection.cpython-313.pyc b/src/db/__pycache__/connection.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84cbd48cae7c369f9229844e32a22a5d4a165a85 GIT binary patch literal 3233 zcmbtWOKcm*8J^iU$<@Q8tp^=bD@&HVe#nUvSF+rspd=@-gb;}~LfuHfV!c|?+boyL z?9#DvDjc9l&;W)Hf&=u@0s&$p1)4s91ZWGCI^-+XDW%!nE zcx$eyTW-;Itw7Gxo@RNTU3Pl}XlMaBIPQkM*32ThjSII~(gET?h!ofrDNA25Oi;+0$^@Ke zIV<5PkIq$;;MC0Il3BiD5!;X3lO{3VaxAjs5p!wz60qYVjIFK(lCImX?dy7Q@X0fd z#li%v?)3us1!@eQz9;1xJ=u4;_fw65p?A42q^Gt)1Yb%lze3!C**X?*MX2vU-F_dq zLr4KNEVkqeXufr57!wr@FUCd%Z#>I_qNozXvPeHAEHGDyal2g%y`T0qtg+$T)~LN_ z%y+eC$LLH1FuDNFbQyNPg4fvsFC!N(;Q4*=xOTgkj`95d7`5B^o;HrrnMey4z!e_d zA&fiV#j$mP*)1<32^C(1+6y%?h)$qz4Ct)+l@gNC(P*1x*ky%L&;V#&K}GaDJAhX3 zIJ<&XuJ9uDAA`VShGeLaqfQz5FroSj#K=v9 z>>h3z$vR=6_a2ak;Fk}2)P~$$mk-zE!;O7C4|;N6iCk}zdg)FQDW^ereeS)v59QA) zU$Kk=f_u_aTLRkOck|qhbM?${Ei+utjNHqN+!}7Adv2b*aq>g%qmJKp)Rl>vGI2M3 zwl1IDl+S*{qWz;V@J?pre!B;Z&CJNR-s|u+*YXFaIb1!2r-p@Uj-`0;R{v+~9>GR4n}z<(^Ts3qQkx_uQ$ zGqAwH0q-ucD;Prva?V~O=IL6yN*n@Dx@QsG2pmtKPDE(%fEB491md#ot(HAY#i3O_ z>flP#oJkPH7Cp3wMcd*mO!AHap+;?_x~`wQevaDX_>JTB^pRTn$ldg4T^_B;qgyPJ zd+*Dm|M6rPe(lsG{sW%i{wPj9t|yv+SjaNl4*+~0JuRmYT5M@V!*QTm7_>*vw5!CL z+B;c{b63zh{+>ad1-**@b3ikNw})cgDv`F~rc&Ipuh1lK8;GU~0LHq!mSdYQ)6G2pcRcQfj@-ZU(9?V0=u{3o366#mQt`c%lL z*}tU#RVAG7=Bf#S;%-c7TF9$nwGZbH%GG|E;v*vPAG?&PLtKZt{W*}9L;Ntqatrt{ zTHsFM1?E*eE=Rap$oDvv1^g?vt|c7RXF)lQk>tO$J44Xc8C_d!!oJLrqYHA)t| zc#^taDlbcEg*y`N5NPmDV8w u>BmjYy+D2f0vbCW4g4(@WBdwiH5&Kga1OxH$o0i{2KuY=l>T9Ij!#i literal 0 HcmV?d00001 diff --git a/src/db/__pycache__/locator_repository.cpython-313.pyc b/src/db/__pycache__/locator_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d75a842258f1791f69bf20fcb1fb9bfc66770d9c GIT binary patch literal 6307 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 literal 0 HcmV?d00001 diff --git a/src/db/application_repository.py b/src/db/application_repository.py new file mode 100644 index 0000000..9c303b2 --- /dev/null +++ b/src/db/application_repository.py @@ -0,0 +1,29 @@ +from typing import Optional, List +from uuid import UUID +from sqlalchemy import select + +from .base_repository import BaseRepository +from ..models.domain import TargetApplication + + +class TargetApplicationRepository(BaseRepository): + async def create(self, app: TargetApplication) -> UUID: + app = await self.add(app) + await self.session.flush() + return app.app_id + + async def get_by_id(self, app_id: UUID) -> Optional[TargetApplication]: + stmt = select(TargetApplication).where(TargetApplication.app_id == app_id) + result = await self.session.execute(stmt) + return result.scalars().first() + + async def get_by_project(self, project_id: UUID) -> List[TargetApplication]: + stmt = select(TargetApplication).where(TargetApplication.project_id == project_id) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def update_app(self, app: TargetApplication) -> TargetApplication: + return await self.update(app) + + async def delete_app(self, app: TargetApplication) -> None: + await self.delete(app) diff --git a/src/db/base_repository.py b/src/db/base_repository.py new file mode 100644 index 0000000..ffe3c6f --- /dev/null +++ b/src/db/base_repository.py @@ -0,0 +1,29 @@ +from typing import TypeVar +from sqlalchemy.ext.asyncio import AsyncSession + +T = TypeVar("T") + + +class BaseRepository: + def __init__(self, session: AsyncSession): + self.session = session + + async def add(self, entity: T) -> T: + self.session.add(entity) + await self.session.flush() + return entity + + async def update(self, entity: T) -> T: + await self.session.merge(entity) + await self.session.flush() + return entity + + async def delete(self, entity: T) -> None: + await self.session.delete(entity) + await self.session.flush() + + async def commit(self) -> None: + await self.session.commit() + + async def rollback(self) -> None: + await self.session.rollback() diff --git a/src/db/connection.py b/src/db/connection.py new file mode 100644 index 0000000..225eb2f --- /dev/null +++ b/src/db/connection.py @@ -0,0 +1,38 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.pool import NullPool +from typing import AsyncGenerator + +from ..models.domain import Base + + +class DatabaseConnection: + def __init__(self, connection_string: str): + self.connection_string = connection_string + self.engine = None + self.session_factory = None + + async def connect(self) -> None: + self.engine = create_async_engine( + self.connection_string, + echo=False, + poolclass=NullPool, + ) + self.session_factory = async_sessionmaker( + self.engine, class_=AsyncSession, expire_on_commit=False + ) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def disconnect(self) -> None: + if self.engine: + await self.engine.dispose() + + async def get_session(self) -> AsyncGenerator[AsyncSession, None]: + if not self.session_factory: + raise RuntimeError("Database connection not initialized") + async with self.session_factory() as session: + yield session + + async def execute_in_session(self, func, *args, **kwargs): + async with self.session_factory() as session: + return await func(session, *args, **kwargs) diff --git a/src/db/locator_repository.py b/src/db/locator_repository.py new file mode 100644 index 0000000..2b92414 --- /dev/null +++ b/src/db/locator_repository.py @@ -0,0 +1,66 @@ +from typing import Optional, List +from uuid import UUID +from sqlalchemy import select, and_ +from sqlalchemy.orm import joinedload + +from .base_repository import BaseRepository +from ..models.domain import Locator, LocatorVersion + + +class LocatorRepository(BaseRepository): + async def create(self, locator: Locator) -> UUID: + locator = await self.add(locator) + await self.session.flush() + return locator.locator_id + + async def get_by_id(self, locator_id: UUID) -> Optional[Locator]: + stmt = select(Locator).where(Locator.locator_id == locator_id).options( + joinedload(Locator.locator_versions) + ) + result = await self.session.execute(stmt) + return result.scalars().first() + + async def get_by_app_version(self, app_version_id: UUID) -> List[Locator]: + stmt = select(Locator).where( + and_(Locator.app_version_id == app_version_id, Locator.active == True) + ).order_by(Locator.created_at.desc()) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def update_locator(self, locator: Locator) -> Locator: + return await self.update(locator) + + async def delete_locator(self, locator: Locator) -> None: + await self.delete(locator) + + +class LocatorVersionRepository(BaseRepository): + async def create(self, version: LocatorVersion) -> UUID: + version = await self.add(version) + await self.session.flush() + return version.locator_version_id + + async def get_by_id(self, version_id: UUID) -> Optional[LocatorVersion]: + stmt = select(LocatorVersion).where(LocatorVersion.locator_version_id == version_id) + result = await self.session.execute(stmt) + return result.scalars().first() + + async def get_current_by_locator(self, locator_id: UUID) -> List[LocatorVersion]: + stmt = select(LocatorVersion).where( + and_(LocatorVersion.locator_id == locator_id, LocatorVersion.is_current == True) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def get_by_locator(self, locator_id: UUID) -> List[LocatorVersion]: + stmt = select(LocatorVersion).where( + LocatorVersion.locator_id == locator_id + ).order_by(LocatorVersion.created_at.desc()) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def update_version(self, version: LocatorVersion) -> LocatorVersion: + return await self.update(version) + + async def delete_version(self, version: LocatorVersion) -> None: + await self.delete(version) diff --git a/src/db/project_repository.py b/src/db/project_repository.py new file mode 100644 index 0000000..1068ef6 --- /dev/null +++ b/src/db/project_repository.py @@ -0,0 +1,29 @@ +from typing import Optional, List +from uuid import UUID +from sqlalchemy import select + +from .base_repository import BaseRepository +from ..models.domain import Project + + +class ProjectRepository(BaseRepository): + async def create(self, project: Project) -> UUID: + project = await self.add(project) + await self.session.flush() + return project.project_id + + async def get_by_id(self, project_id: UUID) -> Optional[Project]: + stmt = select(Project).where(Project.project_id == project_id) + result = await self.session.execute(stmt) + return result.scalars().first() + + async def get_all(self, limit: int = 100, offset: int = 0) -> List[Project]: + stmt = select(Project).limit(limit).offset(offset) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def update_project(self, project: Project) -> Project: + return await self.update(project) + + async def delete_project(self, project: Project) -> None: + await self.delete(project) diff --git a/src/db/repository.py b/src/db/repository.py new file mode 100644 index 0000000..0031976 --- /dev/null +++ b/src/db/repository.py @@ -0,0 +1,24 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from .project_repository import ProjectRepository +from .application_repository import TargetApplicationRepository +from .version_repository import ApplicationVersionRepository +from .session_repository import CrawlSessionRepository +from .locator_repository import LocatorRepository, LocatorVersionRepository + + +class Repository: + def __init__(self, session: AsyncSession): + self.projects = ProjectRepository(session) + self.applications = TargetApplicationRepository(session) + self.application_versions = ApplicationVersionRepository(session) + self.crawl_sessions = CrawlSessionRepository(session) + self.locators = LocatorRepository(session) + self.locator_versions = LocatorVersionRepository(session) + self._session = session + + async def commit(self) -> None: + await self._session.commit() + + async def rollback(self) -> None: + await self._session.rollback() diff --git a/src/db/session_repository.py b/src/db/session_repository.py new file mode 100644 index 0000000..998c09e --- /dev/null +++ b/src/db/session_repository.py @@ -0,0 +1,43 @@ +from typing import Optional, List +from uuid import UUID +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +from .base_repository import BaseRepository +from ..models.domain import CrawlSession + + +class CrawlSessionRepository(BaseRepository): + async def create(self, session: CrawlSession) -> UUID: + session = await self.add(session) + await self.session.flush() + return session.crawl_session_id + + async def get_by_id(self, session_id: UUID) -> Optional[CrawlSession]: + stmt = select(CrawlSession).where( + CrawlSession.crawl_session_id == session_id + ).options(joinedload(CrawlSession.application_version)) + result = await self.session.execute(stmt) + return result.scalars().first() + + async def get_by_app_version(self, app_version_id: UUID) -> List[CrawlSession]: + stmt = select(CrawlSession).where( + CrawlSession.app_version_id == app_version_id + ).order_by(CrawlSession.started_at.desc()) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def update_status(self, session_id: UUID, status: str, finished_at=None) -> None: + stmt = select(CrawlSession).where(CrawlSession.crawl_session_id == session_id) + result = await self.session.execute(stmt) + session = result.scalars().first() + if session: + session.status = status + session.finished_at = finished_at + await self.session.flush() + + async def update_session(self, session: CrawlSession) -> CrawlSession: + return await self.update(session) + + async def delete_session(self, session: CrawlSession) -> None: + await self.delete(session) diff --git a/src/db/version_repository.py b/src/db/version_repository.py new file mode 100644 index 0000000..0a439ab --- /dev/null +++ b/src/db/version_repository.py @@ -0,0 +1,43 @@ +from typing import Optional, List +from uuid import UUID +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +from .base_repository import BaseRepository +from ..models.domain import ApplicationVersion + + +class ApplicationVersionRepository(BaseRepository): + async def create(self, version: ApplicationVersion) -> UUID: + version = await self.add(version) + await self.session.flush() + return version.app_version_id + + async def get_by_id(self, version_id: UUID) -> Optional[ApplicationVersion]: + stmt = select(ApplicationVersion).where( + ApplicationVersion.app_version_id == version_id + ).options( + joinedload(ApplicationVersion.target_application) + ) + result = await self.session.execute(stmt) + return result.scalars().first() + + async def get_by_app(self, app_id: UUID) -> List[ApplicationVersion]: + stmt = select(ApplicationVersion).where( + ApplicationVersion.app_id == app_id + ).order_by(ApplicationVersion.captured_at.desc()) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def get_latest_by_app(self, app_id: UUID) -> Optional[ApplicationVersion]: + stmt = select(ApplicationVersion).where( + ApplicationVersion.app_id == app_id + ).order_by(ApplicationVersion.captured_at.desc()).limit(1) + result = await self.session.execute(stmt) + return result.scalars().first() + + async def update_version(self, version: ApplicationVersion) -> ApplicationVersion: + return await self.update(version) + + async def delete_version(self, version: ApplicationVersion) -> None: + await self.delete(version) diff --git a/src/graph/__init__.py b/src/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graph/__pycache__/__init__.cpython-313.pyc b/src/graph/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4322e0432f53e77ee048d08e3b9e57675a9fe212 GIT binary patch literal 145 zcmey&%ge<81Y$~;GC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~h>i&ac=epzZ! zW(kbeO)g3-&q*zcDK1KmNiRw)$cTxL&&KiKMP*dO}xCh0ty68{`eolb7{$ zE}O_0!nw4j8^Y;yQV+TeUs!t~n>-H_d^({gE+jPdHa;BsH;1j!V>mcy zTGeHm$ZBaE+VIWO#PgYO5-Uk_$|jYp%AKD8bAzlB%7JBEF^=*v7Zqassu&aC5?943 z34imm0+nK&F)!oXz%?<>6Z3(Vyy}g)V;<0KX40lu%c_q_gjg$+$V|_@+B_x1+L)vz z)($=0tF5ceBFq8gekN}-HDNR%7GRS0SO@ikox6;7W@l!Mh-u~99_YI3W#5b&)PlEFT#tdk%Y4GOdsYP1bWI}k%kv4sr^hM=jLwYp{vr=UPF#!t_z zCUf8dY2Ch$C2=)Xx~kFSDs#3~%h^JgUobrJcsiTb<8fopE;EJfHXjVH^#J)MDFwQ& zc}g8U*Sw`3MN@gGo|V&(mx)>HJQRy5ag3Tz7s9(jlUNRn9}zEK80R zYzcvASs_c+Hno~SYWrVB8Fg_n>&g`PdXZ68a!(We_35QYSlTi01p2Ugu7_*IwS-GqSL|oh>jQ$g@Xtny1>mb^ppf(E4y?ok z`>6_9cX>$ki*U`rbpWoe{p2uVy}^>^{5?b_BUaxO%y-SDkbSTo3W<)+@%zXc_XU29 z3yK@<^SNwR1^Cf(mfr_CLrCQHi_s0w(wXIzrKmEo;kUHZl%p5)Qe15sKvL(xZcWuR z1Q(v>vxYmF%WC;csv%yc>8u{)DF85`{Xo!*w0uU_FeJdGX(tM?1J23l-R-4T6B