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=
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export class SearchController {
|
|||||||
dto.page,
|
dto.page,
|
||||||
dto.perPage,
|
dto.perPage,
|
||||||
dto.featured,
|
dto.featured,
|
||||||
|
dto.ward,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user