add tests and registry
This commit is contained in:
@@ -4,6 +4,12 @@ API_DOMAIN=api.rentaldrivego.ma
|
|||||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||||
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
||||||
|
|
||||||
|
# ── Optional prebuilt image source ────────────────────────────────────────────
|
||||||
|
# Used for pull-based deployments. CI injects APP_IMAGE and APP_VERSION
|
||||||
|
# automatically during registry-backed deploys, so these can stay commented out.
|
||||||
|
# APP_IMAGE=gitlab.rentaldrivego.ma:5050/rental_car/car_management_system
|
||||||
|
# APP_VERSION=latest
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
||||||
DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego
|
DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego
|
||||||
|
|||||||
+34
-12
@@ -4,8 +4,6 @@ stages:
|
|||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
|
||||||
DOCKER_IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
|
|
||||||
DOCKERFILE_PATH: Dockerfile.production
|
DOCKERFILE_PATH: Dockerfile.production
|
||||||
# Required: disable TLS so the docker client can reach the dind daemon
|
# Required: disable TLS so the docker client can reach the dind daemon
|
||||||
DOCKER_TLS_CERTDIR: ""
|
DOCKER_TLS_CERTDIR: ""
|
||||||
@@ -75,10 +73,19 @@ build_image:
|
|||||||
- name: docker:24-dind
|
- name: docker:24-dind
|
||||||
alias: docker
|
alias: docker
|
||||||
before_script:
|
before_script:
|
||||||
- test -n "$CI_REGISTRY" || { echo "CI_REGISTRY is not set. Enable GitLab Container Registry or provide registry variables."; exit 1; }
|
# Prefer GitLab's built-in registry variables, but allow explicit fallbacks
|
||||||
- test -n "$CI_REGISTRY_USER" || { echo "CI_REGISTRY_USER is not set."; exit 1; }
|
# so the pipeline can publish to any OCI-compatible registry.
|
||||||
- test -n "$CI_REGISTRY_PASSWORD" || { echo "CI_REGISTRY_PASSWORD is not set."; exit 1; }
|
- export REGISTRY_HOST="${CI_REGISTRY:-${REGISTRY_HOST:-}}"
|
||||||
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
- export REGISTRY_USER="${CI_REGISTRY_USER:-${REGISTRY_USER:-}}"
|
||||||
|
- export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-${REGISTRY_PASSWORD:-}}"
|
||||||
|
- export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-${REGISTRY_IMAGE:-}}"
|
||||||
|
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
|
||||||
|
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
|
||||||
|
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
|
||||||
|
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
|
||||||
|
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
|
||||||
|
- export DOCKER_IMAGE_LATEST="$IMAGE_REPOSITORY:latest"
|
||||||
|
- echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
|
||||||
script:
|
script:
|
||||||
- echo "--- Building Docker Image ---"
|
- echo "--- Building Docker Image ---"
|
||||||
# NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs
|
# NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs
|
||||||
@@ -95,7 +102,10 @@ build_image:
|
|||||||
- docker push $DOCKER_IMAGE
|
- docker push $DOCKER_IMAGE
|
||||||
- docker push $DOCKER_IMAGE_LATEST
|
- docker push $DOCKER_IMAGE_LATEST
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "develop"
|
# Skip the container publish step unless either the GitLab registry or an
|
||||||
|
# explicit fallback registry configuration is available.
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "develop" && (($CI_REGISTRY && $CI_REGISTRY_USER && $CI_REGISTRY_PASSWORD && $CI_REGISTRY_IMAGE) || ($REGISTRY_HOST && $REGISTRY_USER && $REGISTRY_PASSWORD && $REGISTRY_IMAGE))'
|
||||||
|
- when: never
|
||||||
|
|
||||||
# ====================================
|
# ====================================
|
||||||
# DEPLOY STAGE
|
# DEPLOY STAGE
|
||||||
@@ -106,6 +116,15 @@ deploy_to_vps:
|
|||||||
needs:
|
needs:
|
||||||
- build_image
|
- build_image
|
||||||
before_script:
|
before_script:
|
||||||
|
- export REGISTRY_HOST="${CI_REGISTRY:-${REGISTRY_HOST:-}}"
|
||||||
|
- export REGISTRY_USER="${CI_REGISTRY_USER:-${REGISTRY_USER:-}}"
|
||||||
|
- export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-${REGISTRY_PASSWORD:-}}"
|
||||||
|
- export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-${REGISTRY_IMAGE:-}}"
|
||||||
|
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
|
||||||
|
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
|
||||||
|
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
|
||||||
|
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
|
||||||
|
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
|
||||||
- apk add --no-cache openssh-client
|
- apk add --no-cache openssh-client
|
||||||
# Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY
|
# Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY
|
||||||
- eval $(ssh-agent -s)
|
- eval $(ssh-agent -s)
|
||||||
@@ -125,15 +144,16 @@ deploy_to_vps:
|
|||||||
git pull
|
git pull
|
||||||
|
|
||||||
echo '-- Logging into registry --'
|
echo '-- Logging into registry --'
|
||||||
test -n '$CI_REGISTRY' || { echo 'CI_REGISTRY is not set.'; exit 1; }
|
test -n '$REGISTRY_HOST' || { echo 'Registry host is not set.'; exit 1; }
|
||||||
test -n '$CI_REGISTRY_USER' || { echo 'CI_REGISTRY_USER is not set.'; exit 1; }
|
test -n '$REGISTRY_USER' || { echo 'Registry user is not set.'; exit 1; }
|
||||||
test -n '$CI_REGISTRY_PASSWORD' || { echo 'CI_REGISTRY_PASSWORD is not set.'; exit 1; }
|
test -n '$REGISTRY_PASSWORD' || { echo 'Registry password is not set.'; exit 1; }
|
||||||
echo '$CI_REGISTRY_PASSWORD' | docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin
|
echo '$REGISTRY_PASSWORD' | docker login '$REGISTRY_HOST' -u '$REGISTRY_USER' --password-stdin
|
||||||
|
|
||||||
echo '-- Pulling pre-built image --'
|
echo '-- Pulling pre-built image --'
|
||||||
docker pull $DOCKER_IMAGE
|
docker pull $DOCKER_IMAGE
|
||||||
|
|
||||||
echo '-- Restarting services --'
|
echo '-- Restarting services --'
|
||||||
|
APP_IMAGE=$IMAGE_REPOSITORY \
|
||||||
APP_VERSION=$CI_COMMIT_SHORT_SHA \
|
APP_VERSION=$CI_COMMIT_SHORT_SHA \
|
||||||
docker compose -p rentaldrivego-prod \
|
docker compose -p rentaldrivego-prod \
|
||||||
--env-file .env.docker.production \
|
--env-file .env.docker.production \
|
||||||
@@ -144,4 +164,6 @@ deploy_to_vps:
|
|||||||
docker image prune -f
|
docker image prune -f
|
||||||
"
|
"
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "develop"
|
# Deploy only when both registry access and VPS credentials are available.
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "develop" && ((($CI_REGISTRY && $CI_REGISTRY_USER && $CI_REGISTRY_PASSWORD && $CI_REGISTRY_IMAGE) || ($REGISTRY_HOST && $REGISTRY_USER && $REGISTRY_PASSWORD && $REGISTRY_IMAGE)) && $SSH_PRIVATE_KEY && $VPS_IP && $VPS_USER)'
|
||||||
|
- when: never
|
||||||
|
|||||||
@@ -156,6 +156,26 @@ Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HO
|
|||||||
|
|
||||||
The example file uses `rentaldrivego.ma` for the marketplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.
|
The example file uses `rentaldrivego.ma` for the marketplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.
|
||||||
|
|
||||||
|
#### 4a. Configure the registry for CI deploys
|
||||||
|
|
||||||
|
If you want GitLab CI to build once and deploy by pulling a prebuilt image on the VPS, configure one of these two modes in GitLab `Settings > CI/CD > Variables`:
|
||||||
|
|
||||||
|
- Built-in GitLab Container Registry:
|
||||||
|
Enable the project registry and use GitLab's default `CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD`, and `CI_REGISTRY_IMAGE` variables.
|
||||||
|
- Explicit registry variables:
|
||||||
|
Set `REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASSWORD`, and `REGISTRY_IMAGE` yourself. This works for GitLab Registry, Docker Hub, GHCR, or another OCI registry.
|
||||||
|
|
||||||
|
Example explicit values for GitLab's hosted registry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REGISTRY_HOST=gitlab.rentaldrivego.ma:5050
|
||||||
|
REGISTRY_IMAGE=gitlab.rentaldrivego.ma:5050/rental_car/car_management_system
|
||||||
|
REGISTRY_USER=<deploy-user-or-token-name>
|
||||||
|
REGISTRY_PASSWORD=<personal-access-token-or-deploy-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The production compose file now reads `APP_IMAGE` and `APP_VERSION` for pull-based deploys. The GitLab deploy job injects those values automatically. If you want to pull manually on the server, you can also set `APP_IMAGE` in `.env.docker.production`.
|
||||||
|
|
||||||
#### 5. Start Traefik
|
#### 5. Start Traefik
|
||||||
|
|
||||||
Traefik must be running before the app stack so it can wire up routes at startup.
|
Traefik must be running before the app stack so it can wire up routes at startup.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { presentBrand } from './company.presenter'
|
||||||
|
|
||||||
|
const fullBrand = {
|
||||||
|
id: 'brand_1',
|
||||||
|
companyId: 'comp_1',
|
||||||
|
displayName: 'Test Rentals',
|
||||||
|
subdomain: 'test-rentals',
|
||||||
|
logoUrl: 'https://example.com/logo.jpg',
|
||||||
|
primaryColor: '#1A56DB',
|
||||||
|
defaultLocale: 'en',
|
||||||
|
defaultCurrency: 'MAD',
|
||||||
|
amanpayMerchantId: 'merchant-abc',
|
||||||
|
amanpaySecretKey: 'super-secret-key',
|
||||||
|
paypalEmail: 'paypal@example.com',
|
||||||
|
paypalMerchantId: 'paypal-merchant-xyz',
|
||||||
|
isListedOnMarketplace: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('presentBrand', () => {
|
||||||
|
it('strips amanpaySecretKey from the response', () => {
|
||||||
|
const result = presentBrand(fullBrand)
|
||||||
|
expect(result).not.toHaveProperty('amanpaySecretKey')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips amanpayMerchantId from the response', () => {
|
||||||
|
const result = presentBrand(fullBrand)
|
||||||
|
expect(result).not.toHaveProperty('amanpayMerchantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips paypalEmail from the response', () => {
|
||||||
|
const result = presentBrand(fullBrand)
|
||||||
|
expect(result).not.toHaveProperty('paypalEmail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips paypalMerchantId from the response', () => {
|
||||||
|
const result = presentBrand(fullBrand)
|
||||||
|
expect(result).not.toHaveProperty('paypalMerchantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets amanpayConfigured=true when both amanpay credentials are present', () => {
|
||||||
|
const result = presentBrand(fullBrand)
|
||||||
|
expect(result.amanpayConfigured).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets amanpayConfigured=false when amanpaySecretKey is null', () => {
|
||||||
|
const result = presentBrand({ ...fullBrand, amanpaySecretKey: null })
|
||||||
|
expect(result.amanpayConfigured).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets amanpayConfigured=false when amanpayMerchantId is null', () => {
|
||||||
|
const result = presentBrand({ ...fullBrand, amanpayMerchantId: null })
|
||||||
|
expect(result.amanpayConfigured).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets amanpayConfigured=false when both amanpay fields are null', () => {
|
||||||
|
const result = presentBrand({ ...fullBrand, amanpayMerchantId: null, amanpaySecretKey: null })
|
||||||
|
expect(result.amanpayConfigured).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets paypalConfigured=true when paypalEmail is set', () => {
|
||||||
|
const result = presentBrand({ ...fullBrand, paypalMerchantId: null })
|
||||||
|
expect(result.paypalConfigured).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets paypalConfigured=true when paypalMerchantId is set', () => {
|
||||||
|
const result = presentBrand({ ...fullBrand, paypalEmail: null })
|
||||||
|
expect(result.paypalConfigured).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets paypalConfigured=false when both paypal fields are null', () => {
|
||||||
|
const result = presentBrand({ ...fullBrand, paypalEmail: null, paypalMerchantId: null })
|
||||||
|
expect(result.paypalConfigured).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves all non-credential fields', () => {
|
||||||
|
const result = presentBrand(fullBrand)
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
id: 'brand_1',
|
||||||
|
companyId: 'comp_1',
|
||||||
|
displayName: 'Test Rentals',
|
||||||
|
subdomain: 'test-rentals',
|
||||||
|
logoUrl: 'https://example.com/logo.jpg',
|
||||||
|
primaryColor: '#1A56DB',
|
||||||
|
defaultLocale: 'en',
|
||||||
|
defaultCurrency: 'MAD',
|
||||||
|
isListedOnMarketplace: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when brand is null', () => {
|
||||||
|
expect(presentBrand(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined when brand is undefined', () => {
|
||||||
|
expect(presentBrand(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -122,4 +122,27 @@ describe('vehicle.service', () => {
|
|||||||
expect(result).toEqual({ id: 'block_1' })
|
expect(result).toEqual({ id: 'block_1' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getMaintenanceLogs', () => {
|
||||||
|
it('throws NotFoundError when vehicle does not belong to the requesting company', async () => {
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(null)
|
||||||
|
await expect(service.getMaintenanceLogs('veh_1', 'other_company')).rejects.toThrow('Vehicle not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns maintenance logs when vehicle belongs to the requesting company', async () => {
|
||||||
|
const mockLogs = [{ id: 'log_1', vehicleId: 'veh_1', description: 'Oil change' }]
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(mockVehicle as any)
|
||||||
|
vi.mocked(repo.findMaintenanceLogs).mockResolvedValue(mockLogs as any)
|
||||||
|
const result = await service.getMaintenanceLogs('veh_1', 'comp_1')
|
||||||
|
expect(repo.findById).toHaveBeenCalledWith('veh_1', 'comp_1')
|
||||||
|
expect(result).toEqual(mockLogs)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes companyId to repo.findById for ownership validation', async () => {
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(mockVehicle as any)
|
||||||
|
vi.mocked(repo.findMaintenanceLogs).mockResolvedValue([])
|
||||||
|
await service.getMaintenanceLogs('veh_1', 'comp_1')
|
||||||
|
expect(repo.findById).toHaveBeenCalledWith('veh_1', 'comp_1')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import request from 'supertest'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { createApp } from '../../app'
|
||||||
|
import {
|
||||||
|
authHeader,
|
||||||
|
createCompanyWithEmployee,
|
||||||
|
signEmployeeToken,
|
||||||
|
} from '../helpers/fixtures'
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
|
||||||
|
describe('Companies API — brand credential exposure (VF-01)', () => {
|
||||||
|
let companyId: string
|
||||||
|
let ownerToken: string
|
||||||
|
let agentToken: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||||
|
companyId = company.id
|
||||||
|
ownerToken = signEmployeeToken(employee.id, companyId, 'OWNER')
|
||||||
|
|
||||||
|
const agent = await prisma.employee.create({
|
||||||
|
data: {
|
||||||
|
companyId,
|
||||||
|
clerkUserId: `clerk_brand_${Date.now()}`,
|
||||||
|
firstName: 'Agent',
|
||||||
|
lastName: 'Brand',
|
||||||
|
email: `agent-brand-${Date.now()}@test.com`,
|
||||||
|
role: 'AGENT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
agentToken = signEmployeeToken(agent.id, companyId, 'AGENT')
|
||||||
|
|
||||||
|
// Seed brand with real-looking payment credentials
|
||||||
|
await prisma.brandSettings.create({
|
||||||
|
data: {
|
||||||
|
companyId,
|
||||||
|
displayName: company.name,
|
||||||
|
subdomain: `brand-test-${Date.now()}`,
|
||||||
|
amanpayMerchantId: 'merchant-secret-id',
|
||||||
|
amanpaySecretKey: 'aman-super-secret-key',
|
||||||
|
paypalEmail: 'payments@example.com',
|
||||||
|
paypalMerchantId: 'paypal-merchant-secret',
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/v1/companies/me/brand', () => {
|
||||||
|
it('returns 401 without auth', async () => {
|
||||||
|
const res = await request(app).get('/api/v1/companies/me/brand')
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose amanpaySecretKey in the response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(ownerToken))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data).not.toHaveProperty('amanpaySecretKey')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose amanpayMerchantId in the response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(ownerToken))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data).not.toHaveProperty('amanpayMerchantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose paypalEmail in the response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(ownerToken))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data).not.toHaveProperty('paypalEmail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose paypalMerchantId in the response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(ownerToken))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data).not.toHaveProperty('paypalMerchantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns amanpayConfigured=true when credentials are set', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(ownerToken))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.amanpayConfigured).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns paypalConfigured=true when credentials are set', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(ownerToken))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.paypalConfigured).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is accessible to AGENT role (no role gate on this endpoint)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(agentToken))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data).not.toHaveProperty('amanpaySecretKey')
|
||||||
|
expect(res.body.data).not.toHaveProperty('amanpayMerchantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns amanpayConfigured=false when credentials are absent', async () => {
|
||||||
|
const { company: c2, employee: e2 } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||||
|
const t2 = signEmployeeToken(e2.id, c2.id, 'OWNER')
|
||||||
|
|
||||||
|
await prisma.brandSettings.create({
|
||||||
|
data: {
|
||||||
|
companyId: c2.id,
|
||||||
|
displayName: c2.name,
|
||||||
|
subdomain: `no-creds-${Date.now()}`,
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/companies/me/brand')
|
||||||
|
.set(authHeader(t2))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.amanpayConfigured).toBe(false)
|
||||||
|
expect(res.body.data.paypalConfigured).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,12 +11,62 @@ import {
|
|||||||
signEmployeeToken,
|
signEmployeeToken,
|
||||||
} from '../helpers/fixtures'
|
} from '../helpers/fixtures'
|
||||||
|
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
|
|
||||||
function uniqueEmail(prefix: string) {
|
function uniqueEmail(prefix: string) {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('Payment webhooks — signature bypass fix (VF-02)', () => {
|
||||||
|
describe('POST /api/v1/payments/webhooks/amanpay', () => {
|
||||||
|
it('returns 401 when AmanPay is not configured (env vars absent)', async () => {
|
||||||
|
// In the test environment AMANPAY_MERCHANT_ID and AMANPAY_SECRET_KEY are not set,
|
||||||
|
// so isConfigured() returns false. The fix ensures this returns 401, not 200.
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/payments/webhooks/amanpay')
|
||||||
|
.send({ transaction_id: 'txn_fake', status: 'PAID' })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toBe('invalid_signature')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not process the webhook payload when provider is not configured', async () => {
|
||||||
|
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||||
|
const vehicle = await createVehicle(company.id)
|
||||||
|
const customer = await createCustomer(company.id)
|
||||||
|
const reservation = await createReservation(company.id, vehicle.id, customer.id, {
|
||||||
|
totalAmount: 1000, paidAmount: 0,
|
||||||
|
})
|
||||||
|
const payment = await createRentalPayment(company.id, reservation.id, {
|
||||||
|
status: 'PENDING',
|
||||||
|
amanpayTransactionId: `txn-webhook-${Date.now()}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/payments/webhooks/amanpay')
|
||||||
|
.send({ transaction_id: payment.amanpayTransactionId, status: 'PAID' })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
|
||||||
|
// Reservation must remain unpaid — the handler was not invoked
|
||||||
|
const unchanged = await prisma.reservation.findUniqueOrThrow({ where: { id: reservation.id } })
|
||||||
|
expect(unchanged.paymentStatus).toBe('UNPAID')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/v1/payments/webhooks/paypal', () => {
|
||||||
|
it('returns 401 when PayPal is not configured (env vars absent)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/payments/webhooks/paypal')
|
||||||
|
.send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'pp-fake' } })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toBe('invalid_signature')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Payments API', () => {
|
describe('Payments API', () => {
|
||||||
let companyId: string
|
let companyId: string
|
||||||
let ownerToken: string
|
let ownerToken: string
|
||||||
|
|||||||
@@ -14,6 +14,51 @@ function uniqueEmail(prefix: string) {
|
|||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('Subscription webhooks — signature bypass fix (VF-02)', () => {
|
||||||
|
describe('POST /api/v1/subscriptions/webhooks/amanpay', () => {
|
||||||
|
it('returns 401 when AmanPay is not configured (env vars absent)', async () => {
|
||||||
|
// In the test environment AMANPAY_MERCHANT_ID / SECRET_KEY are not set,
|
||||||
|
// so isConfigured() returns false. The fix ensures this returns 401, not 200.
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/subscriptions/webhooks/amanpay')
|
||||||
|
.send({ transaction_id: 'txn_fake_sub', status: 'PAID' })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toBe('invalid_signature')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not activate a subscription for an unconfigured-provider request', async () => {
|
||||||
|
const { company } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||||
|
const sub = await prisma.subscription.findUniqueOrThrow({ where: { companyId: company.id } })
|
||||||
|
const invoice = await createSubscriptionInvoice(company.id, sub.id, {
|
||||||
|
status: 'PENDING',
|
||||||
|
amanpayTransactionId: `sub-txn-bypass-${Date.now()}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/subscriptions/webhooks/amanpay')
|
||||||
|
.send({ transaction_id: (invoice as any).amanpayTransactionId, status: 'PAID' })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
|
||||||
|
// Invoice must remain PENDING — the handler was not invoked
|
||||||
|
const unchanged = await prisma.subscriptionInvoice.findUniqueOrThrow({ where: { id: invoice.id } })
|
||||||
|
expect(unchanged.status).toBe('PENDING')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/v1/subscriptions/webhooks/paypal', () => {
|
||||||
|
it('returns 401 when PayPal is not configured (env vars absent)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/subscriptions/webhooks/paypal')
|
||||||
|
.send({ event_type: 'BILLING.SUBSCRIPTION.ACTIVATED', resource: { id: 'sub-fake' } })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toBe('invalid_signature')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Subscriptions API', () => {
|
describe('Subscriptions API', () => {
|
||||||
let companyId: string
|
let companyId: string
|
||||||
let subscriptionId: string
|
let subscriptionId: string
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import request from 'supertest'
|
import request from 'supertest'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
import { createApp } from '../../app'
|
import { createApp } from '../../app'
|
||||||
import {
|
import {
|
||||||
createCompanyWithEmployee,
|
createCompanyWithEmployee,
|
||||||
@@ -143,4 +144,64 @@ describe('Vehicles API', () => {
|
|||||||
expect(res.body.data.available).toBe(true)
|
expect(res.body.data.available).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GET /api/v1/vehicles/:id/maintenance — tenant isolation (VF-03)', () => {
|
||||||
|
it('returns 401 without auth', async () => {
|
||||||
|
const vehicle = await createVehicle(companyId)
|
||||||
|
const res = await request(app).get(`/api/v1/vehicles/${vehicle.id}/maintenance`)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns maintenance logs for own company vehicle', async () => {
|
||||||
|
const vehicle = await createVehicle(companyId)
|
||||||
|
await prisma.maintenanceLog.create({
|
||||||
|
data: {
|
||||||
|
vehicleId: vehicle.id,
|
||||||
|
type: 'OIL_CHANGE',
|
||||||
|
description: 'Routine oil change',
|
||||||
|
cost: 250,
|
||||||
|
performedAt: new Date(),
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/v1/vehicles/${vehicle.id}/maintenance`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(Array.isArray(res.body.data)).toBe(true)
|
||||||
|
expect(res.body.data.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when accessing maintenance logs for a vehicle belonging to another company', async () => {
|
||||||
|
const { company: otherCompany } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||||
|
const foreignVehicle = await createVehicle(otherCompany.id, { make: 'BMW', model: 'X5' })
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/v1/vehicles/${foreignVehicle.id}/maintenance`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not leak maintenance logs across companies even when vehicle id is known', async () => {
|
||||||
|
const { company: otherCompany } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||||
|
const foreignVehicle = await createVehicle(otherCompany.id)
|
||||||
|
await prisma.maintenanceLog.create({
|
||||||
|
data: {
|
||||||
|
vehicleId: foreignVehicle.id,
|
||||||
|
type: 'TIRE_ROTATION',
|
||||||
|
description: 'Other company private data',
|
||||||
|
cost: 100,
|
||||||
|
performedAt: new Date(),
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/v1/vehicles/${foreignVehicle.id}/maintenance`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
- redis_prod_data:/data
|
- redis_prod_data:/data
|
||||||
|
|
||||||
api:
|
api:
|
||||||
image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
- traefik.http.services.api.loadbalancer.server.port=4000
|
- traefik.http.services.api.loadbalancer.server.port=4000
|
||||||
|
|
||||||
marketplace:
|
marketplace:
|
||||||
image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
@@ -95,7 +95,7 @@ services:
|
|||||||
- traefik.http.services.marketplace-svc.loadbalancer.server.port=3000
|
- traefik.http.services.marketplace-svc.loadbalancer.server.port=3000
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
@@ -124,7 +124,7 @@ services:
|
|||||||
- traefik.http.services.dashboard-svc.loadbalancer.server.port=3001
|
- traefik.http.services.dashboard-svc.loadbalancer.server.port=3001
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
|
|||||||
Reference in New Issue
Block a user