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= 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). # Must be exactly 64 hex characters (32 bytes).
# openssl rand -hex 32 # 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> FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
KYC_ENCRYPTION_KEY_VERSION=1 FIELD_ENCRYPTION_KEY_VERSION=1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging

View File

@@ -29,12 +29,15 @@ describe('PrismaAVMService', () => {
}); });
it('returns zero confidence when fewer than 3 comparables', async () => { it('returns zero confidence when fewer than 3 comparables', async () => {
mockPrisma.$queryRaw.mockResolvedValue([ // First $queryRaw call: property location lookup
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, // Second $queryRaw call: findComparables (parameterized after refactor in 6774914)
]); mockPrisma.$queryRaw
mockPrisma.$queryRawUnsafe.mockResolvedValue([ .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() }, { 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' }); const result = await service.estimateValue({ propertyId: 'prop-1' });
@@ -44,14 +47,15 @@ describe('PrismaAVMService', () => {
}); });
it('calculates weighted valuation with sufficient comparables', async () => { it('calculates weighted valuation with sufficient comparables', async () => {
mockPrisma.$queryRaw.mockResolvedValue([ mockPrisma.$queryRaw
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, .mockResolvedValueOnce([
]); { 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() }, .mockResolvedValueOnce([
{ 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: '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: '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() }, { 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' }); const result = await service.estimateValue({ propertyId: 'prop-1' });
@@ -63,7 +67,8 @@ describe('PrismaAVMService', () => {
}); });
it('uses coordinates directly when no propertyId', async () => { 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: '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: '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() }, { 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(result.confidence).toBeGreaterThan(0);
expect(Number(result.estimatedPrice)).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', () => { describe('getComparables', () => {
it('returns comparables for a property', async () => { it('returns comparables for a property', async () => {
mockPrisma.$queryRaw.mockResolvedValue([ mockPrisma.$queryRaw
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, .mockResolvedValueOnce([
]); { 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() }, .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); const result = await service.getComparables('prop-1', 3000);

View File

@@ -144,4 +144,45 @@ describe('SearchPropertiesHandler', () => {
const searchCall = mockSearchRepo.search.mock.calls[0]![0]; const searchCall = mockSearchRepo.search.mock.calls[0]![0];
expect(searchCall.filterBy).not.toContain('isFeatured'); 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) { if (query.district) {
filters.push(`district:=${query.district}`); filters.push(`district:=${query.district}`);
} }
if (query.ward) {
filters.push(`ward:=${query.ward}`);
}
if (query.city) { if (query.city) {
filters.push(`city:=${query.city}`); filters.push(`city:=${query.city}`);
} }
@@ -70,6 +73,7 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
query.transactionType, query.transactionType,
query.district, query.district,
query.city, query.city,
query.ward,
query.page, query.page,
query.perPage, query.perPage,
query.priceMin, query.priceMin,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: { test: {
root: path.resolve(__dirname, '.'),
include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'], include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'],
exclude: ['**/node_modules/**'], exclude: ['**/node_modules/**'],
environment: 'jsdom', 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|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_SECRET=.*|JWT_SECRET=$JWT_SECRET_VAL|" .env
"${SED_INPLACE[@]}" "s|JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=$JWT_REFRESH_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 else
warn "openssl not found — .env copied but secrets are placeholders. Update them manually." warn "openssl not found — .env copied but secrets are placeholders. Update them manually."
fi fi