docs(env): canonicalize FIELD_ENCRYPTION_KEY in .env.example (GOO-238)

Replace KYC_ENCRYPTION_KEY/KYC_ENCRYPTION_KEY_VERSION in .env.example with
the canonical FIELD_ENCRYPTION_KEY/FIELD_ENCRYPTION_KEY_VERSION used by
env-validation.ts and the rotation runbook. Update bootstrap.sh sed line to
substitute the canonical name. Runtime still reads the legacy KYC_* vars as
a deprecated fallback for existing operators.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 13:38:05 +07:00
parent 9af9e1d84a
commit e97a89c3f1
13 changed files with 101 additions and 29 deletions

View File

@@ -202,14 +202,19 @@ SENTRY_ORG=
SENTRY_PROJECT=
# -----------------------------------------------------------------------------
# KYC Field Encryption (REQUIRED in production)
# Field Encryption (REQUIRED in production)
#
# AES-256-GCM key for encrypting sensitive KYC data at rest.
# AES-256-GCM key for encrypting sensitive PII (KYC and other user fields) at rest.
# Must be exactly 64 hex characters (32 bytes).
# openssl rand -hex 32
#
# Canonical names: FIELD_ENCRYPTION_KEY / FIELD_ENCRYPTION_KEY_VERSION.
# The runtime still reads legacy KYC_ENCRYPTION_KEY / KYC_ENCRYPTION_KEY_VERSION
# as a deprecated fallback (see field-encryption.service.ts); new deployments
# should set FIELD_ENCRYPTION_KEY only.
# -----------------------------------------------------------------------------
KYC_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
KYC_ENCRYPTION_KEY_VERSION=1
FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
FIELD_ENCRYPTION_KEY_VERSION=1
# -----------------------------------------------------------------------------
# Logging

View File

@@ -29,12 +29,15 @@ describe('PrismaAVMService', () => {
});
it('returns zero confidence when fewer than 3 comparables', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
]);
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
]);
// First $queryRaw call: property location lookup
// Second $queryRaw call: findComparables (parameterized after refactor in 6774914)
mockPrisma.$queryRaw
.mockResolvedValueOnce([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
])
.mockResolvedValueOnce([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
]);
const result = await service.estimateValue({ propertyId: 'prop-1' });
@@ -44,14 +47,15 @@ describe('PrismaAVMService', () => {
});
it('calculates weighted valuation with sufficient comparables', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
]);
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
]);
mockPrisma.$queryRaw
.mockResolvedValueOnce([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
])
.mockResolvedValueOnce([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
]);
const result = await service.estimateValue({ propertyId: 'prop-1' });
@@ -63,7 +67,8 @@ describe('PrismaAVMService', () => {
});
it('uses coordinates directly when no propertyId', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
// coords-only path: no property lookup, $queryRaw used for comparables directly
mockPrisma.$queryRaw.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
@@ -78,18 +83,20 @@ describe('PrismaAVMService', () => {
expect(result.confidence).toBeGreaterThan(0);
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
// coords-only path: $queryRaw is called for comparables, $queryRawUnsafe is not
expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled();
});
});
describe('getComparables', () => {
it('returns comparables for a property', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
]);
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
]);
mockPrisma.$queryRaw
.mockResolvedValueOnce([
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
])
.mockResolvedValueOnce([
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
]);
const result = await service.getComparables('prop-1', 3000);

View File

@@ -144,4 +144,45 @@ describe('SearchPropertiesHandler', () => {
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
expect(searchCall.filterBy).not.toContain('isFeatured');
});
it('emits ward clause when ward is provided', async () => {
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
const query = new SearchPropertiesQuery(
undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, 'Tan Phong',
);
await handler.execute(query);
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
expect(searchCall.filterBy).toContain('ward:=Tan Phong');
});
it('omits ward clause when ward is not provided', async () => {
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
await handler.execute(new SearchPropertiesQuery('anything'));
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
expect(searchCall.filterBy).not.toContain('ward:=');
});
it('round-trips ward through parseFilterBy', async () => {
const { parseFilterBy } = await import(
'../../infrastructure/services/search-filter-parser'
);
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
const query = new SearchPropertiesQuery(
undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, 'Tan Phong',
);
await handler.execute(query);
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
const parsed = parseFilterBy(searchCall.filterBy);
expect(parsed.ward).toBe('Tan Phong');
});
});

View File

@@ -46,6 +46,9 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
if (query.district) {
filters.push(`district:=${query.district}`);
}
if (query.ward) {
filters.push(`ward:=${query.ward}`);
}
if (query.city) {
filters.push(`city:=${query.city}`);
}
@@ -70,6 +73,7 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
query.transactionType,
query.district,
query.city,
query.ward,
query.page,
query.perPage,
query.priceMin,

View File

@@ -14,5 +14,6 @@ export class SearchPropertiesQuery {
public readonly page?: number,
public readonly perPage?: number,
public readonly featured?: boolean,
public readonly ward?: string,
) {}
}

View File

@@ -10,6 +10,7 @@ interface SearchFilterProps {
areaMax?: number;
bedrooms?: number;
district?: string;
ward?: string;
city?: string;
sortBy?: 'price_asc' | 'price_desc' | 'date_desc' | 'relevance' | 'distance';
page?: number;
@@ -53,6 +54,10 @@ export class SearchFilter extends ValueObject<SearchFilterProps> {
return this.props.district;
}
get ward(): string | undefined {
return this.props.ward;
}
get city(): string | undefined {
return this.props.city;
}

View File

@@ -16,6 +16,7 @@ export interface ParsedFilters {
areaMax?: number;
bedrooms?: number;
district?: string;
ward?: string;
city?: string;
}
@@ -49,6 +50,7 @@ export function parseFilterBy(filterStr: string): ParsedFilters {
if (field === 'propertyType') result.propertyType = val;
else if (field === 'transactionType') result.transactionType = val;
else if (field === 'district') result.district = val;
else if (field === 'ward') result.ward = val;
else if (field === 'city') result.city = val;
else if (field === 'status') { /* handled separately */ }
continue;

View File

@@ -52,6 +52,7 @@ export class SearchController {
dto.page,
dto.perPage,
dto.featured,
dto.ward,
),
);
}

View File

@@ -74,6 +74,11 @@ export class SearchPropertiesDto {
@IsString()
district?: string;
@ApiPropertyOptional({ description: 'Ward name', example: 'Tan Phong' })
@IsOptional()
@IsString()
ward?: string;
@ApiPropertyOptional({ description: 'City name', example: 'Ho Chi Minh' })
@IsOptional()
@IsString()

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { InquiryReadDto } from '@/lib/inquiries-api';
import { InquiryDetailDialog } from '../inquiry-detail-dialog';
import type { InquiryReadDto } from '@/lib/inquiries-api';
// Mock the hook
const mockMarkReadMutate = vi.fn();

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { LeadReadDto } from '@/lib/leads-api';
import { LeadDetailDialog } from '../lead-detail-dialog';
import type { LeadReadDto } from '@/lib/leads-api';
// Mock hooks
const mockUpdateMutate = vi.fn();

View File

@@ -5,6 +5,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
test: {
root: path.resolve(__dirname, '.'),
include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'],
exclude: ['**/node_modules/**'],
environment: 'jsdom',

View File

@@ -163,7 +163,7 @@ else
"${SED_INPLACE[@]}" "s|PGBOUNCER_STATS_PASSWORD=CHANGE_ME|PGBOUNCER_STATS_PASSWORD=pgbouncer_stats|" .env
"${SED_INPLACE[@]}" "s|JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET_VAL|" .env
"${SED_INPLACE[@]}" "s|JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=$JWT_REFRESH_VAL|" .env
"${SED_INPLACE[@]}" "s|KYC_ENCRYPTION_KEY=.*|KYC_ENCRYPTION_KEY=$KYC_KEY_VAL|" .env
"${SED_INPLACE[@]}" "s|FIELD_ENCRYPTION_KEY=.*|FIELD_ENCRYPTION_KEY=$KYC_KEY_VAL|" .env
else
warn "openssl not found — .env copied but secrets are placeholders. Update them manually."
fi