add tests and registry

This commit is contained in:
root
2026-05-22 02:58:59 -04:00
parent 99c429d69c
commit b9197bcb5d
10 changed files with 480 additions and 16 deletions
+6
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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)
})
})
}) })
+4 -4
View File
@@ -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