diff --git a/.env.docker.production.example b/.env.docker.production.example index d8500e5..d51e352 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -4,6 +4,12 @@ API_DOMAIN=api.rentaldrivego.ma PUBLIC_SITE_DOMAIN=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 ────────────────────────────────────────────────────────────────── # Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false) DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1dbeded..e7cfcbf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,8 +4,6 @@ stages: - deploy variables: - DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - DOCKER_IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest DOCKERFILE_PATH: Dockerfile.production # Required: disable TLS so the docker client can reach the dind daemon DOCKER_TLS_CERTDIR: "" @@ -75,10 +73,19 @@ build_image: - name: docker:24-dind alias: docker before_script: - - test -n "$CI_REGISTRY" || { echo "CI_REGISTRY is not set. Enable GitLab Container Registry or provide registry variables."; exit 1; } - - test -n "$CI_REGISTRY_USER" || { echo "CI_REGISTRY_USER is not set."; exit 1; } - - test -n "$CI_REGISTRY_PASSWORD" || { echo "CI_REGISTRY_PASSWORD is not set."; exit 1; } - - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + # Prefer GitLab's built-in registry variables, but allow explicit fallbacks + # so the pipeline can publish to any OCI-compatible registry. + - 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" + - export DOCKER_IMAGE_LATEST="$IMAGE_REPOSITORY:latest" + - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin script: - echo "--- Building Docker Image ---" # 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_LATEST 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 @@ -106,6 +116,15 @@ deploy_to_vps: needs: - build_image 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 # Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY - eval $(ssh-agent -s) @@ -125,15 +144,16 @@ deploy_to_vps: git pull echo '-- Logging into registry --' - test -n '$CI_REGISTRY' || { echo 'CI_REGISTRY is not set.'; exit 1; } - test -n '$CI_REGISTRY_USER' || { echo 'CI_REGISTRY_USER is not set.'; exit 1; } - test -n '$CI_REGISTRY_PASSWORD' || { echo 'CI_REGISTRY_PASSWORD is not set.'; exit 1; } - echo '$CI_REGISTRY_PASSWORD' | docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin + test -n '$REGISTRY_HOST' || { echo 'Registry host is not set.'; exit 1; } + test -n '$REGISTRY_USER' || { echo 'Registry user is not set.'; exit 1; } + test -n '$REGISTRY_PASSWORD' || { echo 'Registry password is not set.'; exit 1; } + echo '$REGISTRY_PASSWORD' | docker login '$REGISTRY_HOST' -u '$REGISTRY_USER' --password-stdin echo '-- Pulling pre-built image --' docker pull $DOCKER_IMAGE echo '-- Restarting services --' + APP_IMAGE=$IMAGE_REPOSITORY \ APP_VERSION=$CI_COMMIT_SHORT_SHA \ docker compose -p rentaldrivego-prod \ --env-file .env.docker.production \ @@ -144,4 +164,6 @@ deploy_to_vps: docker image prune -f " 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 diff --git a/DOCKER.md b/DOCKER.md index ae3c0df..9eb5f32 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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`. +#### 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= +REGISTRY_PASSWORD= +``` + +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 Traefik must be running before the app stack so it can wire up routes at startup. diff --git a/apps/api/src/modules/companies/company.presenter.test.ts b/apps/api/src/modules/companies/company.presenter.test.ts new file mode 100644 index 0000000..f873d62 --- /dev/null +++ b/apps/api/src/modules/companies/company.presenter.test.ts @@ -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() + }) +}) diff --git a/apps/api/src/modules/vehicles/vehicle.test.ts b/apps/api/src/modules/vehicles/vehicle.test.ts index 667c4ca..eed0918 100644 --- a/apps/api/src/modules/vehicles/vehicle.test.ts +++ b/apps/api/src/modules/vehicles/vehicle.test.ts @@ -122,4 +122,27 @@ describe('vehicle.service', () => { 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') + }) + }) }) diff --git a/apps/api/src/tests/integration/companies.test.ts b/apps/api/src/tests/integration/companies.test.ts new file mode 100644 index 0000000..a06a295 --- /dev/null +++ b/apps/api/src/tests/integration/companies.test.ts @@ -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) + }) + }) +}) diff --git a/apps/api/src/tests/integration/payments.test.ts b/apps/api/src/tests/integration/payments.test.ts index bb28016..2a007cb 100644 --- a/apps/api/src/tests/integration/payments.test.ts +++ b/apps/api/src/tests/integration/payments.test.ts @@ -11,12 +11,62 @@ import { signEmployeeToken, } from '../helpers/fixtures' + const app = createApp() function uniqueEmail(prefix: string) { 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', () => { let companyId: string let ownerToken: string diff --git a/apps/api/src/tests/integration/subscriptions.test.ts b/apps/api/src/tests/integration/subscriptions.test.ts index bb79f9f..85dfe8c 100644 --- a/apps/api/src/tests/integration/subscriptions.test.ts +++ b/apps/api/src/tests/integration/subscriptions.test.ts @@ -14,6 +14,51 @@ function uniqueEmail(prefix: string) { 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', () => { let companyId: string let subscriptionId: string diff --git a/apps/api/src/tests/integration/vehicles.test.ts b/apps/api/src/tests/integration/vehicles.test.ts index df67197..9d9728f 100644 --- a/apps/api/src/tests/integration/vehicles.test.ts +++ b/apps/api/src/tests/integration/vehicles.test.ts @@ -1,4 +1,5 @@ import request from 'supertest' +import { prisma } from '../../lib/prisma' import { createApp } from '../../app' import { createCompanyWithEmployee, @@ -143,4 +144,64 @@ describe('Vehicles API', () => { 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) + }) + }) }) diff --git a/docker-compose.production.yml b/docker-compose.production.yml index a070bce..0bafb60 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -27,7 +27,7 @@ services: - redis_prod_data:/data api: - image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} build: context: . dockerfile: Dockerfile.production @@ -66,7 +66,7 @@ services: - traefik.http.services.api.loadbalancer.server.port=4000 marketplace: - image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} build: context: . dockerfile: Dockerfile.production @@ -95,7 +95,7 @@ services: - traefik.http.services.marketplace-svc.loadbalancer.server.port=3000 dashboard: - image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} build: context: . dockerfile: Dockerfile.production @@ -124,7 +124,7 @@ services: - traefik.http.services.dashboard-svc.loadbalancer.server.port=3001 admin: - image: ${CI_REGISTRY_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} + image: ${APP_IMAGE:-rentaldrivego-prod-app}:${APP_VERSION:-latest} build: context: . dockerfile: Dockerfile.production