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:
13
.env.example
13
.env.example
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,5 +14,6 @@ export class SearchPropertiesQuery {
|
||||
public readonly page?: number,
|
||||
public readonly perPage?: number,
|
||||
public readonly featured?: boolean,
|
||||
public readonly ward?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -52,6 +52,7 @@ export class SearchController {
|
||||
dto.page,
|
||||
dto.perPage,
|
||||
dto.featured,
|
||||
dto.ward,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user