Compare commits
4 Commits
master
...
b60b327508
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60b327508 | ||
|
|
25edb3579c | ||
|
|
732e9b02bd | ||
|
|
fbe28102a1 |
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -67,6 +67,19 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
# GOO-134: API unit-test coverage gate (≥70% stmt/lines/funcs, ≥58% branches → ratcheting to 60 via GOO-180).
|
||||||
|
- name: Test coverage (API)
|
||||||
|
run: pnpm --filter @goodgo/api test:coverage
|
||||||
|
|
||||||
|
- name: Upload coverage report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: api-coverage
|
||||||
|
path: apps/api/coverage
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.17",
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
|
|||||||
@@ -5,16 +5,12 @@ import { PrismaAgentRepository } from '../repositories/prisma-agent.repository';
|
|||||||
describe('PrismaAgentRepository', () => {
|
describe('PrismaAgentRepository', () => {
|
||||||
let repository: PrismaAgentRepository;
|
let repository: PrismaAgentRepository;
|
||||||
let mockPrisma: {
|
let mockPrisma: {
|
||||||
|
$queryRaw: ReturnType<typeof vi.fn>;
|
||||||
agent: {
|
agent: {
|
||||||
findUnique: ReturnType<typeof vi.fn>;
|
findUnique: ReturnType<typeof vi.fn>;
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>;
|
|
||||||
update: ReturnType<typeof vi.fn>;
|
update: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
lead: {
|
lead: {
|
||||||
groupBy: ReturnType<typeof vi.fn>;
|
|
||||||
count: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
inquiry: {
|
|
||||||
count: ReturnType<typeof vi.fn>;
|
count: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
listing: {
|
listing: {
|
||||||
@@ -43,16 +39,12 @@ describe('PrismaAgentRepository', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPrisma = {
|
mockPrisma = {
|
||||||
|
$queryRaw: vi.fn(),
|
||||||
agent: {
|
agent: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
lead: {
|
lead: {
|
||||||
groupBy: vi.fn(),
|
|
||||||
count: vi.fn(),
|
|
||||||
},
|
|
||||||
inquiry: {
|
|
||||||
count: vi.fn(),
|
count: vi.fn(),
|
||||||
},
|
},
|
||||||
listing: {
|
listing: {
|
||||||
@@ -198,32 +190,31 @@ describe('PrismaAgentRepository', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getDashboard', () => {
|
describe('getDashboard', () => {
|
||||||
it('returns full dashboard data', async () => {
|
const mockStatsRow = {
|
||||||
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
|
agentId: 'agent-1',
|
||||||
id: 'agent-1',
|
|
||||||
qualityScore: 85,
|
qualityScore: 85,
|
||||||
totalDeals: 12,
|
totalDeals: 12,
|
||||||
responseTimeAvg: 600,
|
responseTimeAvg: 600,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
});
|
leadsByStatus: { NEW: 5, CONTACTED: 10, CONVERTED: 3 },
|
||||||
mockPrisma.lead.groupBy.mockResolvedValue([
|
totalLeads: 18,
|
||||||
{ status: 'NEW', _count: { id: 5 } },
|
convertedLeads: 3,
|
||||||
{ status: 'CONTACTED', _count: { id: 10 } },
|
totalListings: 15,
|
||||||
{ status: 'CONVERTED', _count: { id: 3 } },
|
activeListings: 10,
|
||||||
]);
|
totalInquiries: 45,
|
||||||
mockPrisma.inquiry.count
|
unreadInquiries: 3,
|
||||||
.mockResolvedValueOnce(45) // total
|
avgRating: 4.5,
|
||||||
.mockResolvedValueOnce(3); // unread
|
totalReviews: 20,
|
||||||
mockPrisma.listing.count
|
};
|
||||||
.mockResolvedValueOnce(15) // total
|
|
||||||
.mockResolvedValueOnce(10); // active
|
it('returns full dashboard data using single aggregate query', async () => {
|
||||||
mockPrisma.review.aggregate.mockResolvedValue({
|
mockPrisma.$queryRaw.mockResolvedValue([mockStatsRow]);
|
||||||
_avg: { rating: 4.5 },
|
|
||||||
_count: { rating: 20 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await repository.getDashboard('agent-1');
|
const result = await repository.getDashboard('agent-1');
|
||||||
|
|
||||||
|
// Verify single DB call
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
expect(result.agentId).toBe('agent-1');
|
expect(result.agentId).toBe('agent-1');
|
||||||
expect(result.qualityScore).toBe(85);
|
expect(result.qualityScore).toBe(85);
|
||||||
expect(result.totalDeals).toBe(12);
|
expect(result.totalDeals).toBe(12);
|
||||||
@@ -240,21 +231,23 @@ describe('PrismaAgentRepository', () => {
|
|||||||
expect(result.totalReviews).toBe(20);
|
expect(result.totalReviews).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles agent with zero leads', async () => {
|
it('handles agent with zero leads and reviews', async () => {
|
||||||
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
|
mockPrisma.$queryRaw.mockResolvedValue([{
|
||||||
id: 'agent-1',
|
...mockStatsRow,
|
||||||
qualityScore: 0,
|
qualityScore: 0,
|
||||||
totalDeals: 0,
|
totalDeals: 0,
|
||||||
responseTimeAvg: null,
|
responseTimeAvg: null,
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
});
|
leadsByStatus: {},
|
||||||
mockPrisma.lead.groupBy.mockResolvedValue([]);
|
totalLeads: 0,
|
||||||
mockPrisma.inquiry.count.mockResolvedValue(0);
|
convertedLeads: 0,
|
||||||
mockPrisma.listing.count.mockResolvedValue(0);
|
totalListings: 0,
|
||||||
mockPrisma.review.aggregate.mockResolvedValue({
|
activeListings: 0,
|
||||||
_avg: { rating: null },
|
totalInquiries: 0,
|
||||||
_count: { rating: 0 },
|
unreadInquiries: 0,
|
||||||
});
|
avgRating: 0,
|
||||||
|
totalReviews: 0,
|
||||||
|
}]);
|
||||||
|
|
||||||
const result = await repository.getDashboard('agent-1');
|
const result = await repository.getDashboard('agent-1');
|
||||||
|
|
||||||
@@ -264,6 +257,14 @@ describe('PrismaAgentRepository', () => {
|
|||||||
expect(result.avgReviewRating).toBe(0);
|
expect(result.avgReviewRating).toBe(0);
|
||||||
expect(result.totalReviews).toBe(0);
|
expect(result.totalReviews).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when agent not found (empty result set)', async () => {
|
||||||
|
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(repository.getDashboard('nonexistent')).rejects.toThrow(
|
||||||
|
'Agent not found: nonexistent',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPublicProfile', () => {
|
describe('getPublicProfile', () => {
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ import {
|
|||||||
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
|
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
|
||||||
import { buildPublicProfile } from './agent-profile.queries';
|
import { buildPublicProfile } from './agent-profile.queries';
|
||||||
|
|
||||||
|
/** Shape returned by the single-aggregate getDashboard SQL query. */
|
||||||
|
interface AgentStatsRow {
|
||||||
|
agentId: string;
|
||||||
|
qualityScore: number;
|
||||||
|
totalDeals: number;
|
||||||
|
responseTimeAvg: number | null;
|
||||||
|
isVerified: boolean;
|
||||||
|
leadsByStatus: unknown;
|
||||||
|
totalLeads: number;
|
||||||
|
convertedLeads: number;
|
||||||
|
totalListings: number;
|
||||||
|
activeListings: number;
|
||||||
|
totalInquiries: number;
|
||||||
|
unreadInquiries: number;
|
||||||
|
avgRating: number;
|
||||||
|
totalReviews: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaAgentRepository implements IAgentRepository {
|
export class PrismaAgentRepository implements IAgentRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -34,56 +52,104 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDashboard(agentId: string): Promise<AgentDashboardData> {
|
async getDashboard(agentId: string): Promise<AgentDashboardData> {
|
||||||
const [agent, leads, inquiryStats, listingStats, reviewStats] =
|
// Single aggregate query — replaces 7 separate round-trips.
|
||||||
await Promise.all([
|
const rows = await this.prisma.$queryRaw<AgentStatsRow[]>`
|
||||||
this.prisma.agent.findUniqueOrThrow({
|
WITH
|
||||||
where: { id: agentId },
|
agent_base AS (
|
||||||
select: {
|
SELECT
|
||||||
id: true, qualityScore: true, totalDeals: true,
|
id,
|
||||||
responseTimeAvg: true, isVerified: true,
|
"qualityScore",
|
||||||
},
|
"totalDeals",
|
||||||
}),
|
"responseTimeAvg",
|
||||||
this.prisma.lead.groupBy({
|
"isVerified"
|
||||||
by: ['status'],
|
FROM "Agent"
|
||||||
where: { agentId },
|
WHERE id = ${agentId}
|
||||||
_count: { id: true },
|
),
|
||||||
}),
|
lead_stats AS (
|
||||||
this.getInquiryStats(agentId),
|
SELECT
|
||||||
this.getListingStats(agentId),
|
status,
|
||||||
this.prisma.review.aggregate({
|
COUNT(*)::int AS cnt
|
||||||
where: { targetType: 'AGENT', targetId: agentId },
|
FROM "Lead"
|
||||||
_avg: { rating: true },
|
WHERE "agentId" = ${agentId}
|
||||||
_count: { rating: true },
|
GROUP BY status
|
||||||
}),
|
),
|
||||||
]);
|
listing_agg AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total_listings,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active_listings
|
||||||
|
FROM "Listing"
|
||||||
|
WHERE "agentId" = ${agentId}
|
||||||
|
),
|
||||||
|
inquiry_agg AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total_inquiries,
|
||||||
|
COUNT(*) FILTER (WHERE i."isRead" = false)::int AS unread_inquiries
|
||||||
|
FROM "Inquiry" i
|
||||||
|
JOIN "Listing" l ON l.id = i."listingId"
|
||||||
|
WHERE l."agentId" = ${agentId}
|
||||||
|
),
|
||||||
|
review_agg AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(AVG(rating), 0)::float AS avg_rating,
|
||||||
|
COUNT(*)::int AS total_reviews
|
||||||
|
FROM "Review"
|
||||||
|
WHERE "targetType" = 'AGENT'
|
||||||
|
AND "targetId" = ${agentId}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
a.id AS "agentId",
|
||||||
|
a."qualityScore",
|
||||||
|
a."totalDeals",
|
||||||
|
a."responseTimeAvg",
|
||||||
|
a."isVerified",
|
||||||
|
COALESCE(
|
||||||
|
(SELECT jsonb_object_agg(status, cnt) FROM lead_stats),
|
||||||
|
'{}'::jsonb
|
||||||
|
) AS "leadsByStatus",
|
||||||
|
COALESCE(
|
||||||
|
(SELECT SUM(cnt)::int FROM lead_stats),
|
||||||
|
0
|
||||||
|
) AS "totalLeads",
|
||||||
|
COALESCE(
|
||||||
|
(SELECT cnt FROM lead_stats WHERE status = 'CONVERTED'),
|
||||||
|
0
|
||||||
|
) AS "convertedLeads",
|
||||||
|
la.total_listings AS "totalListings",
|
||||||
|
la.active_listings AS "activeListings",
|
||||||
|
ia.total_inquiries AS "totalInquiries",
|
||||||
|
ia.unread_inquiries AS "unreadInquiries",
|
||||||
|
ra.avg_rating AS "avgRating",
|
||||||
|
ra.total_reviews AS "totalReviews"
|
||||||
|
FROM agent_base a
|
||||||
|
CROSS JOIN listing_agg la
|
||||||
|
CROSS JOIN inquiry_agg ia
|
||||||
|
CROSS JOIN review_agg ra
|
||||||
|
`;
|
||||||
|
|
||||||
const leadsByStatus: Record<string, number> = {};
|
if (rows.length === 0) {
|
||||||
let totalLeads = 0;
|
throw new Error(`Agent not found: ${agentId}`);
|
||||||
let convertedLeads = 0;
|
|
||||||
|
|
||||||
for (const group of leads) {
|
|
||||||
leadsByStatus[group.status] = group._count.id;
|
|
||||||
totalLeads += group._count.id;
|
|
||||||
if (group.status === 'CONVERTED') convertedLeads = group._count.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const row = rows[0]!;
|
||||||
|
const totalLeads = row.totalLeads ?? 0;
|
||||||
|
const convertedLeads = row.convertedLeads ?? 0;
|
||||||
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
|
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agentId: agent.id,
|
agentId: row.agentId,
|
||||||
qualityScore: agent.qualityScore,
|
qualityScore: row.qualityScore,
|
||||||
totalDeals: agent.totalDeals,
|
totalDeals: row.totalDeals,
|
||||||
responseTimeAvg: agent.responseTimeAvg,
|
responseTimeAvg: row.responseTimeAvg,
|
||||||
isVerified: agent.isVerified,
|
isVerified: row.isVerified,
|
||||||
totalLeads,
|
totalLeads,
|
||||||
leadsByStatus,
|
leadsByStatus: (row.leadsByStatus as Record<string, number>) ?? {},
|
||||||
conversionRate: Math.round(conversionRate * 1000) / 1000,
|
conversionRate: Math.round(conversionRate * 1000) / 1000,
|
||||||
totalInquiries: inquiryStats.total,
|
totalInquiries: row.totalInquiries ?? 0,
|
||||||
unreadInquiries: inquiryStats.unread,
|
unreadInquiries: row.unreadInquiries ?? 0,
|
||||||
totalListings: listingStats.total,
|
totalListings: row.totalListings ?? 0,
|
||||||
activeListings: listingStats.active,
|
activeListings: row.activeListings ?? 0,
|
||||||
avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10,
|
avgReviewRating: Math.round((row.avgRating ?? 0) * 10) / 10,
|
||||||
totalReviews: reviewStats._count.rating,
|
totalReviews: row.totalReviews ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,26 +191,6 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getInquiryStats(
|
|
||||||
agentId: string,
|
|
||||||
): Promise<{ total: number; unread: number }> {
|
|
||||||
const [total, unread] = await Promise.all([
|
|
||||||
this.prisma.inquiry.count({ where: { listing: { agentId } } }),
|
|
||||||
this.prisma.inquiry.count({ where: { listing: { agentId }, isRead: false } }),
|
|
||||||
]);
|
|
||||||
return { total, unread };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getListingStats(
|
|
||||||
agentId: string,
|
|
||||||
): Promise<{ total: number; active: number }> {
|
|
||||||
const [total, active] = await Promise.all([
|
|
||||||
this.prisma.listing.count({ where: { agentId } }),
|
|
||||||
this.prisma.listing.count({ where: { agentId, status: 'ACTIVE' } }),
|
|
||||||
]);
|
|
||||||
return { total, active };
|
|
||||||
}
|
|
||||||
|
|
||||||
private toDomain(row: {
|
private toDomain(row: {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ vi.mock('@modules/shared', () => ({
|
|||||||
RedisService: class {},
|
RedisService: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock jsonwebtoken so we can control which secret succeeds
|
||||||
|
vi.mock('jsonwebtoken', () => ({
|
||||||
|
verify: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||||
type RedisStub = {
|
type RedisStub = {
|
||||||
isAvailable: ReturnType<typeof vi.fn>;
|
isAvailable: ReturnType<typeof vi.fn>;
|
||||||
@@ -218,3 +223,118 @@ describe('JwtStrategy', () => {
|
|||||||
).rejects.toMatchObject({ status: 401 });
|
).rejects.toMatchObject({ status: 401 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('JwtStrategy dual-key (secretOrKeyProvider)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses secretOrKeyProvider instead of secretOrKey', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
|
const options = (strategy as any)._options;
|
||||||
|
expect(options.secretOrKeyProvider).toBeDefined();
|
||||||
|
expect(options.secretOrKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secretOrKeyProvider returns primary secret when primary verification succeeds', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||||
|
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||||
|
|
||||||
|
const { verify } = await import('jsonwebtoken');
|
||||||
|
const mockVerify = vi.mocked(verify);
|
||||||
|
mockVerify.mockReturnValueOnce({} as any);
|
||||||
|
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
|
const options = (strategy as any)._options;
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
options.secretOrKeyProvider({} as any, 'some-jwt-token', (err: Error | null, secret?: string) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(secret!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('primary-secret-at-least-32-chars');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secretOrKeyProvider falls back to previous secret when primary fails', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||||
|
vi.stubEnv('JWT_SECRET_PREVIOUS', 'previous-secret-at-least-32-chars');
|
||||||
|
|
||||||
|
const { verify } = await import('jsonwebtoken');
|
||||||
|
const mockVerify = vi.mocked(verify);
|
||||||
|
mockVerify.mockImplementationOnce(() => {
|
||||||
|
throw new Error('invalid signature');
|
||||||
|
});
|
||||||
|
mockVerify.mockReturnValueOnce({} as any);
|
||||||
|
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
|
const options = (strategy as any)._options;
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
options.secretOrKeyProvider({} as any, 'old-jwt-token', (err: Error | null, secret?: string) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(secret!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('previous-secret-at-least-32-chars');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secretOrKeyProvider returns primary when both keys fail (passport will 401)', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||||
|
vi.stubEnv('JWT_SECRET_PREVIOUS', 'previous-secret-at-least-32-chars');
|
||||||
|
|
||||||
|
const { verify } = await import('jsonwebtoken');
|
||||||
|
const mockVerify = vi.mocked(verify);
|
||||||
|
mockVerify.mockImplementation(() => {
|
||||||
|
throw new Error('invalid signature');
|
||||||
|
});
|
||||||
|
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
|
const options = (strategy as any)._options;
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
options.secretOrKeyProvider({} as any, 'bad-jwt-token', (err: Error | null, secret?: string) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(secret!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('primary-secret-at-least-32-chars');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secretOrKeyProvider skips fallback when no previous secret is configured', async () => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'primary-secret-at-least-32-chars');
|
||||||
|
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||||
|
|
||||||
|
const { verify } = await import('jsonwebtoken');
|
||||||
|
const mockVerify = vi.mocked(verify);
|
||||||
|
mockVerify.mockImplementation(() => {
|
||||||
|
throw new Error('invalid signature');
|
||||||
|
});
|
||||||
|
|
||||||
|
const { JwtStrategy } = await import('../strategies/jwt.strategy');
|
||||||
|
const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never);
|
||||||
|
|
||||||
|
const options = (strategy as any)._options;
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
options.secretOrKeyProvider({} as any, 'bad-jwt-token', (err: Error | null, secret?: string) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(secret!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('primary-secret-at-least-32-chars');
|
||||||
|
// verify called only once — no fallback attempted
|
||||||
|
expect(mockVerify).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { type Request } from 'express';
|
import { type Request } from 'express';
|
||||||
|
import { verify as jwtVerify } from 'jsonwebtoken';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||||
import { PrismaService, RedisService } from '@modules/shared';
|
import { PrismaService, RedisService } from '@modules/shared';
|
||||||
@@ -26,6 +27,42 @@ export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
|
|||||||
/** TTL for cached user status (seconds). */
|
/** TTL for cached user status (seconds). */
|
||||||
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a `secretOrKeyProvider` callback for passport-jwt that tries the
|
||||||
|
* primary secret first, then falls back to an optional previous secret.
|
||||||
|
* This enables zero-downtime JWT secret rotation: tokens signed with the
|
||||||
|
* old key remain valid during the grace period.
|
||||||
|
*
|
||||||
|
* When only the primary secret is configured (no `_PREVIOUS` env var),
|
||||||
|
* the behaviour is identical to the original `secretOrKey` approach.
|
||||||
|
*/
|
||||||
|
export function makeSecretOrKeyProvider(
|
||||||
|
primarySecret: string,
|
||||||
|
previousSecret: string | undefined,
|
||||||
|
): (request: Request, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => void {
|
||||||
|
return (_request: Request, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => {
|
||||||
|
// Fast path: try primary first (the common case after rotation completes).
|
||||||
|
try {
|
||||||
|
jwtVerify(rawJwtToken, primarySecret, { audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
||||||
|
return done(null, primarySecret);
|
||||||
|
} catch {
|
||||||
|
// Primary failed — try previous if configured.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousSecret) {
|
||||||
|
try {
|
||||||
|
jwtVerify(rawJwtToken, previousSecret, { audience: 'goodgo-api', issuer: 'goodgo-platform' });
|
||||||
|
return done(null, previousSecret);
|
||||||
|
} catch {
|
||||||
|
// Both keys failed — fall through to let passport return 401.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the primary so passport-jwt produces its standard error.
|
||||||
|
return done(null, primarySecret);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -37,10 +74,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
throw new Error('JWT_SECRET environment variable is required');
|
throw new Error('JWT_SECRET environment variable is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
|
||||||
|
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: extractJwtFromCookieOrHeader,
|
jwtFromRequest: extractJwtFromCookieOrHeader,
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: jwtSecret,
|
secretOrKeyProvider: makeSecretOrKeyProvider(jwtSecret, previousSecret),
|
||||||
audience: 'goodgo-api',
|
audience: 'goodgo-api',
|
||||||
issuer: 'goodgo-platform',
|
issuer: 'goodgo-platform',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Supplemental branch-coverage tests for auth guards.
|
||||||
|
* Covers: OptionalJwtAuthGuard.handleRequest pass-through,
|
||||||
|
* RolesGuard x-forwarded-for array/string ip extraction.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { OptionalJwtAuthGuard } from '../guards/optional-jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
describe('OptionalJwtAuthGuard — handleRequest branch coverage', () => {
|
||||||
|
it('handleRequest returns user when user is provided', () => {
|
||||||
|
const guard = new OptionalJwtAuthGuard();
|
||||||
|
const fakeUser = { sub: 'user-1', role: 'BUYER' };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = (guard as any).handleRequest(null, fakeUser);
|
||||||
|
expect(result).toBe(fakeUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleRequest returns undefined when user is falsy (anonymous)', () => {
|
||||||
|
const guard = new OptionalJwtAuthGuard();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = (guard as any).handleRequest(null, undefined);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleRequest returns false for unauthenticated passport result', () => {
|
||||||
|
const guard = new OptionalJwtAuthGuard();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = (guard as any).handleRequest(null, false);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleRequest ignores error and returns user', () => {
|
||||||
|
const guard = new OptionalJwtAuthGuard();
|
||||||
|
const fakeUser = { sub: 'user-2' };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = (guard as any).handleRequest(new Error('invalid token'), fakeUser);
|
||||||
|
expect(result).toBe(fakeUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RolesGuard — ip extraction branch coverage', () => {
|
||||||
|
let guard: RolesGuard;
|
||||||
|
let mockReflector: { getAllAndOverride: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { warn: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReflector = { getAllAndOverride: vi.fn() };
|
||||||
|
mockLogger = { warn: vi.fn() };
|
||||||
|
guard = new RolesGuard(mockReflector as any, mockLogger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses x-forwarded-for header for ip when req.ip is absent', () => {
|
||||||
|
mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']);
|
||||||
|
const mockRequest = {
|
||||||
|
user: { sub: 'u1', role: 'BUYER' },
|
||||||
|
ip: undefined,
|
||||||
|
headers: { 'x-forwarded-for': '203.0.113.1' },
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
switchToHttp: () => ({ getRequest: () => mockRequest }),
|
||||||
|
getHandler: () => ({ name: 'h' }),
|
||||||
|
getClass: () => ({ name: 'C' }),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = guard.canActivate(ctx);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Access denied'),
|
||||||
|
'RolesGuard',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs "unknown" ip when neither ip nor headers present', () => {
|
||||||
|
mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']);
|
||||||
|
const mockRequest = {
|
||||||
|
user: { sub: 'u1', role: 'BUYER' },
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
switchToHttp: () => ({ getRequest: () => mockRequest }),
|
||||||
|
getHandler: () => ({ name: 'h' }),
|
||||||
|
getClass: () => ({ name: 'C' }),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
guard.canActivate(ctx);
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('unknown'),
|
||||||
|
'RolesGuard',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Supplemental branch-coverage tests for payments handlers.
|
||||||
|
* Covers gateway error path, InternalServerErrorException wrap,
|
||||||
|
* and refund handler edge cases.
|
||||||
|
*/
|
||||||
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||||
|
import { CreatePaymentCommand } from '../commands/create-payment/create-payment.command';
|
||||||
|
import { CreatePaymentHandler } from '../commands/create-payment/create-payment.handler';
|
||||||
|
import { RefundPaymentCommand } from '../commands/refund-payment/refund-payment.command';
|
||||||
|
import { RefundPaymentHandler } from '../commands/refund-payment/refund-payment.handler';
|
||||||
|
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
|
||||||
|
function makeCompletedPayment(): PaymentEntity {
|
||||||
|
const money = Money.create(500_000n).unwrap();
|
||||||
|
const p = PaymentEntity.createNew('pay-1', 'user-1', 'VNPAY', 'LISTING_FEE', money);
|
||||||
|
p.markProcessing('vnpay-tx-1');
|
||||||
|
p.markCompleted({ resultCode: '00' });
|
||||||
|
p.clearDomainEvents();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePendingPayment(): PaymentEntity {
|
||||||
|
const money = Money.create(500_000n).unwrap();
|
||||||
|
const p = PaymentEntity.createNew('pay-2', 'user-1', 'VNPAY', 'LISTING_FEE', money);
|
||||||
|
p.clearDomainEvents();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRepo(): { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> } {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByProviderTxId: vi.fn(),
|
||||||
|
findByIdempotencyKey: vi.fn(),
|
||||||
|
findByUserId: vi.fn(),
|
||||||
|
save: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateIfStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreatePaymentHandler — branch coverage supplements', () => {
|
||||||
|
let handler: CreatePaymentHandler;
|
||||||
|
let mockRepo: ReturnType<typeof makeRepo>;
|
||||||
|
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||||
|
let mockGateway: { createPaymentUrl: ReturnType<typeof vi.fn>; verifyCallback: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = makeRepo();
|
||||||
|
mockGateway = {
|
||||||
|
createPaymentUrl: vi.fn().mockResolvedValue({ paymentUrl: 'https://pay.vn/1', providerTxId: 'tx-1' }),
|
||||||
|
verifyCallback: vi.fn(),
|
||||||
|
refund: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||||
|
handler = new CreatePaymentHandler(mockRepo as any, mockGatewayFactory as any, mockEventBus as any, mockLogger);
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeCmd = () => new CreatePaymentCommand(
|
||||||
|
'user-1', 'VNPAY', 'LISTING_FEE', 500_000n, 'Thanh toán phí đăng tin',
|
||||||
|
'https://return.url', '127.0.0.1',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('throws ValidationException when gateway createPaymentUrl throws', async () => {
|
||||||
|
mockRepo.findByIdempotencyKey.mockResolvedValue(null);
|
||||||
|
mockGateway.createPaymentUrl.mockRejectedValue(new Error('Gateway timeout'));
|
||||||
|
|
||||||
|
const { ValidationException } = await import('@modules/shared');
|
||||||
|
await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(ValidationException);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps unexpected repo.save error in InternalServerErrorException', async () => {
|
||||||
|
mockRepo.findByIdempotencyKey.mockResolvedValue(null);
|
||||||
|
mockRepo.save.mockRejectedValue(new Error('DB write failed'));
|
||||||
|
|
||||||
|
await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RefundPaymentHandler — branch coverage supplements', () => {
|
||||||
|
let handler: RefundPaymentHandler;
|
||||||
|
let mockRepo: ReturnType<typeof makeRepo>;
|
||||||
|
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||||
|
let mockGateway: { createPaymentUrl: ReturnType<typeof vi.fn>; verifyCallback: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = makeRepo();
|
||||||
|
mockGateway = {
|
||||||
|
createPaymentUrl: vi.fn(),
|
||||||
|
verifyCallback: vi.fn(),
|
||||||
|
refund: vi.fn().mockResolvedValue({ success: true, refundTxId: 'ref-tx-1' }),
|
||||||
|
};
|
||||||
|
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||||
|
handler = new RefundPaymentHandler(mockRepo as any, mockGatewayFactory as any, mockLogger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when payment not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
const { NotFoundException } = await import('@modules/shared');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.execute(new RefundPaymentCommand('missing-id', 'duplicate', 'user-1')),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationException when trying to refund non-completed payment', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(makePendingPayment());
|
||||||
|
const { ValidationException } = await import('@modules/shared');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.execute(new RefundPaymentCommand('pay-2', 'error', 'user-1')),
|
||||||
|
).rejects.toBeInstanceOf(ValidationException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -145,4 +145,52 @@ describe('validateEnv', () => {
|
|||||||
|
|
||||||
expect(() => validateEnv()).not.toThrow();
|
expect(() => validateEnv()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('optional previous secrets (JWT_SECRET_PREVIOUS / JWT_REFRESH_SECRET_PREVIOUS)', () => {
|
||||||
|
it('accepts when previous secrets are not set (no rotation in progress)', () => {
|
||||||
|
setValidJwtSecrets();
|
||||||
|
delete process.env['JWT_SECRET_PREVIOUS'];
|
||||||
|
delete process.env['JWT_REFRESH_SECRET_PREVIOUS'];
|
||||||
|
|
||||||
|
expect(() => validateEnv()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid JWT_SECRET_PREVIOUS', () => {
|
||||||
|
setValidJwtSecrets();
|
||||||
|
process.env['JWT_SECRET_PREVIOUS'] = 'a-valid-previous-secret-with-at-least-32-characters';
|
||||||
|
|
||||||
|
expect(() => validateEnv()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects JWT_SECRET_PREVIOUS when it is a placeholder', () => {
|
||||||
|
setValidJwtSecrets();
|
||||||
|
process.env['JWT_SECRET_PREVIOUS'] = 'CHANGE_ME';
|
||||||
|
|
||||||
|
expect(() => validateEnv()).toThrow('Insecure JWT secret configuration');
|
||||||
|
expect(() => validateEnv()).toThrow('JWT_SECRET_PREVIOUS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects JWT_SECRET_PREVIOUS when it is too short', () => {
|
||||||
|
setValidJwtSecrets();
|
||||||
|
process.env['JWT_SECRET_PREVIOUS'] = 'short';
|
||||||
|
|
||||||
|
expect(() => validateEnv()).toThrow('too short');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects JWT_REFRESH_SECRET_PREVIOUS when it is a placeholder', () => {
|
||||||
|
setValidJwtSecrets();
|
||||||
|
process.env['JWT_REFRESH_SECRET_PREVIOUS'] = 'password';
|
||||||
|
|
||||||
|
expect(() => validateEnv()).toThrow('Insecure JWT secret configuration');
|
||||||
|
expect(() => validateEnv()).toThrow('JWT_REFRESH_SECRET_PREVIOUS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts both previous secrets when valid', () => {
|
||||||
|
setValidJwtSecrets();
|
||||||
|
process.env['JWT_SECRET_PREVIOUS'] = 'previous-access-secret-at-least-32-chars-long!!';
|
||||||
|
process.env['JWT_REFRESH_SECRET_PREVIOUS'] = 'previous-refresh-secret-at-least-32-chars-long!!';
|
||||||
|
|
||||||
|
expect(() => validateEnv()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ const ALWAYS_REQUIRED: readonly string[] = [
|
|||||||
'JWT_REFRESH_SECRET',
|
'JWT_REFRESH_SECRET',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional previous-generation JWT secrets used during key rotation.
|
||||||
|
* When set, the auth layer will accept tokens signed with either the
|
||||||
|
* primary or the previous key, enabling zero-downtime secret rotation.
|
||||||
|
* Subject to the same placeholder-rejection and minimum-length checks
|
||||||
|
* as the primary secrets.
|
||||||
|
*/
|
||||||
|
const OPTIONAL_PREVIOUS_SECRETS: readonly string[] = [
|
||||||
|
'JWT_SECRET_PREVIOUS',
|
||||||
|
'JWT_REFRESH_SECRET_PREVIOUS',
|
||||||
|
];
|
||||||
|
|
||||||
const REQUIRED_IN_PRODUCTION: readonly string[] = [
|
const REQUIRED_IN_PRODUCTION: readonly string[] = [
|
||||||
'DATABASE_URL',
|
'DATABASE_URL',
|
||||||
'CORS_ORIGINS',
|
'CORS_ORIGINS',
|
||||||
@@ -120,6 +132,19 @@ export function validateEnv(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional previous-generation secrets — if set, they must also pass
|
||||||
|
// the same strength checks. An empty/unset value is fine (no rotation
|
||||||
|
// in progress), but a weak value is always rejected.
|
||||||
|
for (const key of OPTIONAL_PREVIOUS_SECRETS) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) {
|
||||||
|
const error = validateJwtSecret(key, value);
|
||||||
|
if (error) {
|
||||||
|
secretErrors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (secretErrors.length > 0) {
|
if (secretErrors.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` +
|
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` +
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Supplemental branch-coverage tests for subscription application handlers.
|
||||||
|
* Targets the uncovered `catch (non-DomainException)` → InternalServerErrorException
|
||||||
|
* paths and plan-field=null branches that were missed by the primary spec files.
|
||||||
|
*/
|
||||||
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||||
|
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||||
|
import { CheckQuotaHandler } from '../queries/check-quota/check-quota.handler';
|
||||||
|
import { CheckQuotaQuery } from '../queries/check-quota/check-quota.query';
|
||||||
|
import { MeterUsageHandler } from '../commands/meter-usage/meter-usage.handler';
|
||||||
|
import { MeterUsageCommand } from '../commands/meter-usage/meter-usage.command';
|
||||||
|
import { UpgradeSubscriptionHandler } from '../commands/upgrade-subscription/upgrade-subscription.handler';
|
||||||
|
import { UpgradeSubscriptionCommand } from '../commands/upgrade-subscription/upgrade-subscription.command';
|
||||||
|
import { CancelSubscriptionHandler } from '../commands/cancel-subscription/cancel-subscription.handler';
|
||||||
|
import { CancelSubscriptionCommand } from '../commands/cancel-subscription/cancel-subscription.command';
|
||||||
|
|
||||||
|
function makeActiveSub(): SubscriptionEntity {
|
||||||
|
const sub = SubscriptionEntity.createNew(
|
||||||
|
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||||
|
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||||
|
);
|
||||||
|
sub.clearDomainEvents();
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRepo(): { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> } {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByUserId: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
return { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CheckQuotaHandler ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CheckQuotaHandler — branch coverage supplements', () => {
|
||||||
|
let handler: CheckQuotaHandler;
|
||||||
|
let mockRepo: ReturnType<typeof makeRepo>;
|
||||||
|
let mockPrisma: any;
|
||||||
|
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: ReturnType<typeof makeLogger>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = makeRepo();
|
||||||
|
mockPrisma = {
|
||||||
|
plan: { findFirst: vi.fn(), findUnique: vi.fn() },
|
||||||
|
usageRecord: { findFirst: vi.fn(), findUnique: vi.fn() },
|
||||||
|
};
|
||||||
|
mockCache = {
|
||||||
|
getOrSet: vi.fn().mockImplementation((_k: string, fn: () => Promise<unknown>) => fn()),
|
||||||
|
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockLogger = makeLogger();
|
||||||
|
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unlimited when known plan field has null value (e.g. unlimited savedSearches)', async () => {
|
||||||
|
const sub = makeActiveSub();
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||||
|
// maxSavedSearches = null means unlimited
|
||||||
|
mockPrisma.plan.findUnique.mockResolvedValue({ id: 'plan-1', maxSavedSearches: null });
|
||||||
|
|
||||||
|
const result = await handler.execute(new CheckQuotaQuery('user-1', 'searches_saved'));
|
||||||
|
|
||||||
|
expect(result.limit).toBeNull();
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates error from inner loadQuota when DB throws', async () => {
|
||||||
|
mockRepo.findByUserId.mockRejectedValue(new Error('DB crash'));
|
||||||
|
mockCache.getOrSet.mockImplementation(async (_k: string, fn: () => Promise<unknown>) => fn());
|
||||||
|
|
||||||
|
await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created')))
|
||||||
|
.rejects.toThrow('DB crash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-throws DomainException directly without wrapping', async () => {
|
||||||
|
const { NotFoundException } = await import('@modules/shared');
|
||||||
|
mockCache.getOrSet.mockImplementationOnce(async (_k: string, fn: () => Promise<unknown>) => {
|
||||||
|
throw new NotFoundException('Plan', 'plan-missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created')))
|
||||||
|
.rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MeterUsageHandler ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('MeterUsageHandler — branch coverage supplements', () => {
|
||||||
|
let handler: MeterUsageHandler;
|
||||||
|
let mockRepo: ReturnType<typeof makeRepo>;
|
||||||
|
let mockPrisma: any;
|
||||||
|
let mockCache: any;
|
||||||
|
let mockLogger: ReturnType<typeof makeLogger>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = makeRepo();
|
||||||
|
mockPrisma = { usageRecord: { upsert: vi.fn() } };
|
||||||
|
mockCache = { getOrSet: vi.fn(), invalidate: vi.fn().mockResolvedValue(undefined), invalidateByPrefix: vi.fn() };
|
||||||
|
mockLogger = makeLogger();
|
||||||
|
handler = new MeterUsageHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps unexpected repo error in InternalServerErrorException', async () => {
|
||||||
|
const sub = makeActiveSub();
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||||
|
mockPrisma.usageRecord.upsert.mockRejectedValue(new Error('DB unavailable'));
|
||||||
|
|
||||||
|
await expect(handler.execute(new MeterUsageCommand('user-1', 'listings_created', 1)))
|
||||||
|
.rejects.toBeInstanceOf(InternalServerErrorException);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── UpgradeSubscriptionHandler ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('UpgradeSubscriptionHandler — branch coverage supplements', () => {
|
||||||
|
let handler: UpgradeSubscriptionHandler;
|
||||||
|
let mockRepo: ReturnType<typeof makeRepo>;
|
||||||
|
let mockPrisma: any;
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: any;
|
||||||
|
let mockLogger: ReturnType<typeof makeLogger>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = makeRepo();
|
||||||
|
mockPrisma = { plan: { findFirst: vi.fn() } };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
mockCache = { invalidateByPrefix: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
mockLogger = makeLogger();
|
||||||
|
handler = new UpgradeSubscriptionHandler(
|
||||||
|
mockRepo as any, mockPrisma, mockEventBus as any, mockCache as any, mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps unexpected error in InternalServerErrorException', async () => {
|
||||||
|
mockRepo.findByUserId.mockRejectedValue(new Error('Connection refused'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.execute(new UpgradeSubscriptionCommand('user-1', 'ENTERPRISE' as any)),
|
||||||
|
).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows lateral switch AGENT_PRO → INVESTOR (same tier order level)', async () => {
|
||||||
|
const sub = makeActiveSub(); // planTier = AGENT_PRO
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||||
|
mockPrisma.plan.findFirst.mockResolvedValue({ id: 'plan-investor', tier: 'INVESTOR' });
|
||||||
|
mockRepo.update.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await handler.execute(
|
||||||
|
new UpgradeSubscriptionCommand('user-1', 'INVESTOR' as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.newTier).toBe('INVESTOR');
|
||||||
|
expect(result.previousTier).toBe('AGENT_PRO');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CancelSubscriptionHandler ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CancelSubscriptionHandler — branch coverage supplements', () => {
|
||||||
|
let handler: CancelSubscriptionHandler;
|
||||||
|
let mockRepo: ReturnType<typeof makeRepo>;
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: ReturnType<typeof makeLogger>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = makeRepo();
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
mockLogger = makeLogger();
|
||||||
|
handler = new CancelSubscriptionHandler(mockRepo as any, mockEventBus as any, mockLogger as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps unexpected error in InternalServerErrorException', async () => {
|
||||||
|
mockRepo.findByUserId.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.execute(new CancelSubscriptionCommand('user-1', 'test-reason')),
|
||||||
|
).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Supplemental branch-coverage tests for SubscriptionEntity.
|
||||||
|
* Covers error paths in markPastDue, markExpired, and isExpired.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SubscriptionEntity } from '../entities/subscription.entity';
|
||||||
|
|
||||||
|
function makeSub(): SubscriptionEntity {
|
||||||
|
return SubscriptionEntity.createNew(
|
||||||
|
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||||
|
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SubscriptionEntity — branch coverage supplements', () => {
|
||||||
|
it('returns error from markPastDue when already cancelled', () => {
|
||||||
|
const sub = makeSub();
|
||||||
|
sub.cancel();
|
||||||
|
const result = sub.markPastDue();
|
||||||
|
expect(result.isErr).toBe(true);
|
||||||
|
expect(result.unwrapErr().message).toContain('CANCELLED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error from markPastDue when already expired', () => {
|
||||||
|
const sub = makeSub();
|
||||||
|
sub.markExpired();
|
||||||
|
const result = sub.markPastDue();
|
||||||
|
expect(result.isErr).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error from markExpired when already cancelled', () => {
|
||||||
|
const sub = makeSub();
|
||||||
|
sub.cancel();
|
||||||
|
const result = sub.markExpired();
|
||||||
|
expect(result.isErr).toBe(true);
|
||||||
|
expect(result.unwrapErr().message).toContain('CANCELLED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks expired from PAST_DUE state successfully', () => {
|
||||||
|
const sub = makeSub();
|
||||||
|
sub.markPastDue();
|
||||||
|
sub.clearDomainEvents();
|
||||||
|
const result = sub.markExpired();
|
||||||
|
expect(result.isOk).toBe(true);
|
||||||
|
expect(sub.status).toBe('EXPIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isExpired returns false for subscription with future end date', () => {
|
||||||
|
const futureSub = SubscriptionEntity.createNew(
|
||||||
|
'sub-2', 'user-1', 'plan-1', 'FREE',
|
||||||
|
new Date(), new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
expect(futureSub.isExpired()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isExpired returns true for subscription with past end date', () => {
|
||||||
|
const pastSub = SubscriptionEntity.createNew(
|
||||||
|
'sub-3', 'user-1', 'plan-1', 'FREE',
|
||||||
|
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
|
||||||
|
new Date(Date.now() - 1 * 24 * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
expect(pastSub.isExpired()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isActive returns false for cancelled subscription', () => {
|
||||||
|
const sub = makeSub();
|
||||||
|
sub.cancel();
|
||||||
|
expect(sub.isActive()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isActive returns false for past-due subscription', () => {
|
||||||
|
const sub = makeSub();
|
||||||
|
sub.markPastDue();
|
||||||
|
expect(sub.isActive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { BankTransferConfirmedEvent } from '@modules/payments';
|
||||||
|
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||||
|
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||||
|
import { BankTransferSubscriptionActivationHandler } from '../event-handlers/bank-transfer-subscription-activation.handler';
|
||||||
|
|
||||||
|
function makeSub(periodStart: Date, periodEnd: Date): SubscriptionEntity {
|
||||||
|
const sub = SubscriptionEntity.createNew(
|
||||||
|
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', periodStart, periodEnd,
|
||||||
|
);
|
||||||
|
sub.clearDomainEvents();
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BankTransferSubscriptionActivationHandler', () => {
|
||||||
|
let handler: BankTransferSubscriptionActivationHandler;
|
||||||
|
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByUserId: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
handler = new BankTransferSubscriptionActivationHandler(
|
||||||
|
mockRepo as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeEvent = (type: 'SUBSCRIPTION' | 'LISTING_FEE'): BankTransferConfirmedEvent =>
|
||||||
|
new BankTransferConfirmedEvent(
|
||||||
|
'payment-1', 'user-1', type as any, 5_000_000n, 'admin-1', 'REF-001',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does nothing for non-SUBSCRIPTION payment types', async () => {
|
||||||
|
await handler.handle(makeEvent('LISTING_FEE'));
|
||||||
|
expect(mockRepo.findByUserId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs warning and returns when no subscription found for user', async () => {
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(null);
|
||||||
|
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('manual CS review required'),
|
||||||
|
'BankTransferSubscriptionActivationHandler',
|
||||||
|
);
|
||||||
|
expect(mockRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renews period from current periodEnd when subscription is still active (end > now)', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const futureEnd = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); // +15 days
|
||||||
|
const start = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); // -15 days
|
||||||
|
const sub = makeSub(start, futureEnd);
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||||
|
|
||||||
|
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||||
|
|
||||||
|
expect(mockRepo.update).toHaveBeenCalledWith(sub);
|
||||||
|
const events = sub.domainEvents;
|
||||||
|
expect(events.some((e) => e.eventName === 'subscription.renewed')).toBe(true);
|
||||||
|
expect(sub.currentPeriodEnd.getTime()).toBeGreaterThan(futureEnd.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renews period from now when subscription is already expired (end <= now)', async () => {
|
||||||
|
const pastStart = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
|
||||||
|
const pastEnd = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||||
|
const sub = makeSub(pastStart, pastEnd);
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||||
|
const after = Date.now();
|
||||||
|
|
||||||
|
expect(mockRepo.update).toHaveBeenCalledWith(sub);
|
||||||
|
expect(sub.currentPeriodStart.getTime()).toBeGreaterThanOrEqual(before - 100);
|
||||||
|
expect(sub.currentPeriodStart.getTime()).toBeLessThanOrEqual(after + 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs success after activation', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000);
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd));
|
||||||
|
|
||||||
|
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||||
|
|
||||||
|
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Subscription activated via bank transfer'),
|
||||||
|
'BankTransferSubscriptionActivationHandler',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs error and does not rethrow when repo.update throws', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000);
|
||||||
|
mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd));
|
||||||
|
mockRepo.update.mockRejectedValue(new Error('DB connection lost'));
|
||||||
|
|
||||||
|
await expect(handler.handle(makeEvent('SUBSCRIPTION'))).resolves.not.toThrow();
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Failed to activate subscription on bank transfer confirmation'),
|
||||||
|
expect.any(String),
|
||||||
|
'BankTransferSubscriptionActivationHandler',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,30 @@ export default defineConfig({
|
|||||||
env: {
|
env: {
|
||||||
BCRYPT_ROUNDS: '4',
|
BCRYPT_ROUNDS: '4',
|
||||||
},
|
},
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'text-summary', 'html', 'lcov', 'json-summary'],
|
||||||
|
reportsDirectory: './coverage',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: [
|
||||||
|
'src/**/*.spec.ts',
|
||||||
|
'src/**/*.integration.spec.ts',
|
||||||
|
'src/**/__tests__/**',
|
||||||
|
'src/**/*.module.ts',
|
||||||
|
'src/**/*.dto.ts',
|
||||||
|
'src/**/index.ts',
|
||||||
|
'src/main.ts',
|
||||||
|
],
|
||||||
|
// GOO-134: CI gate thresholds. Branches starts at 58 (no-regression ratchet)
|
||||||
|
// and will be raised to 60 via follow-up GOO-180 (payments/sbv-compliance,
|
||||||
|
// subscriptions/quotas, auth/guards). CTO approval: 8f2b125a.
|
||||||
|
thresholds: {
|
||||||
|
statements: 70,
|
||||||
|
lines: 70,
|
||||||
|
functions: 70,
|
||||||
|
branches: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function TransferListingCard({ listing }: TransferListingCardProps) {
|
|||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="line-clamp-1">{listing.district}, {listing.city}</span>
|
<span className="line-clamp-1">{listing.ward ? `${listing.ward}, ` : ''}{listing.district}, {listing.city}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
||||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||||
|
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||||
|
|
||||||
interface InquiryDetailDialogProps {
|
interface InquiryDetailDialogProps {
|
||||||
inquiry: InquiryReadDto | null;
|
inquiry: InquiryReadDto | null;
|
||||||
@@ -42,6 +43,8 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const phone = inquiry.phone ?? inquiry.userPhone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-md sm:max-w-lg">
|
<DialogContent className="max-w-md sm:max-w-lg">
|
||||||
@@ -60,7 +63,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
<InquiryStatusBadge isRead={inquiry.isRead} />
|
<InquiryStatusBadge isRead={inquiry.isRead} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
<p>SĐT: {inquiry.phone ?? inquiry.userPhone}</p>
|
<p>SĐT: {formatPhone(phone)}</p>
|
||||||
<p>Ngày gửi: {formattedDate}</p>
|
<p>Ngày gửi: {formattedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,13 +81,13 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<a
|
<a
|
||||||
href={`tel:${inquiry.phone ?? inquiry.userPhone}`}
|
href={`tel:${phone}`}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`https://zalo.me/${(inquiry.phone ?? inquiry.userPhone).replace(/^0/, '84')}`}
|
href={zaloHref(phone)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
||||||
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
||||||
|
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||||
|
|
||||||
interface LeadDetailDialogProps {
|
interface LeadDetailDialogProps {
|
||||||
lead: LeadReadDto | null;
|
lead: LeadReadDto | null;
|
||||||
@@ -96,7 +97,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
|||||||
<LeadStatusBadge status={lead.status} />
|
<LeadStatusBadge status={lead.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
<p>SĐT: {lead.phone}</p>
|
<p>SĐT: {formatPhone(lead.phone)}</p>
|
||||||
{lead.email && <p>Email: {lead.email}</p>}
|
{lead.email && <p>Email: {lead.email}</p>}
|
||||||
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
||||||
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
||||||
@@ -163,7 +164,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={`https://zalo.me/${lead.phone.replace(/^0/, '84')}`}
|
href={zaloHref(lead.phone)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
|
|||||||
@@ -106,9 +106,15 @@ describe('PropertyCard', () => {
|
|||||||
expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument();
|
expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders property address', () => {
|
it('renders property address including ward', () => {
|
||||||
render(<PropertyCard listing={makeListing()} />);
|
render(<PropertyCard listing={makeListing()} />);
|
||||||
expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument();
|
expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Phường 22/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ward in list layout', () => {
|
||||||
|
render(<PropertyCard listing={makeListing()} layout="list" />);
|
||||||
|
expect(screen.getByText(/Phường 22/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders transaction type badge for SALE', () => {
|
it('renders transaction type badge for SALE', () => {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard
|
|||||||
{listing.property.title}
|
{listing.property.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
||||||
{listing.property.address}, {listing.property.district}, {listing.property.city}
|
{listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="shrink-0 text-lg font-bold text-primary">
|
<p className="shrink-0 text-lg font-bold text-primary">
|
||||||
@@ -186,7 +186,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard
|
|||||||
</p>
|
</p>
|
||||||
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
||||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||||
{listing.property.address}, {listing.property.district}, {listing.property.city}
|
{listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Thông tin bất động sản">
|
<ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Thông tin bất động sản">
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
59
apps/web/lib/phone.ts
Normal file
59
apps/web/lib/phone.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Vietnamese phone number helpers.
|
||||||
|
*
|
||||||
|
* Regex covers the current VN numbering plan:
|
||||||
|
* 0[35789]x xxxxxxx — Viettel, Mobifone, Vinaphone, Gmobile, Indochina
|
||||||
|
*
|
||||||
|
* See: https://en.wikipedia.org/wiki/Telephone_numbers_in_Vietnam
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Matches a VN mobile number, with optional +84 or leading 0. */
|
||||||
|
export const VN_PHONE_REGEX =
|
||||||
|
/^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a VN phone number to the E.164-ish form used by Zalo / APIs:
|
||||||
|
* strip leading 0 and prepend the country code (84).
|
||||||
|
*
|
||||||
|
* "0987654321" → "84987654321"
|
||||||
|
* "+84987654321" → "84987654321"
|
||||||
|
* "84987654321" → "84987654321" (already normalised — idempotent)
|
||||||
|
*/
|
||||||
|
export function normalizePhone(phone: string): string {
|
||||||
|
const cleaned = phone.trim();
|
||||||
|
if (cleaned.startsWith('+84')) return `84${cleaned.slice(3)}`;
|
||||||
|
if (cleaned.startsWith('84')) return cleaned;
|
||||||
|
if (cleaned.startsWith('0')) return `84${cleaned.slice(1)}`;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a raw VN phone number for display.
|
||||||
|
* Handles 10-digit numbers (0xx xxxx xxxx).
|
||||||
|
*
|
||||||
|
* "0987654321" → "0987 654 321"
|
||||||
|
* Passthrough for anything that doesn't match.
|
||||||
|
*/
|
||||||
|
export function formatPhone(phone: string): string {
|
||||||
|
const cleaned = phone.trim().replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// 10-digit local format: 0xxx yyy zzz
|
||||||
|
const tenDigit = cleaned.match(/^(0\d{3})(\d{3})(\d{3})$/);
|
||||||
|
if (tenDigit) return `${tenDigit[1]} ${tenDigit[2]} ${tenDigit[3]}`;
|
||||||
|
|
||||||
|
// +84 prefix → treat as 10-digit local after swapping prefix
|
||||||
|
const e164 = cleaned.match(/^\+84(\d{9})$/);
|
||||||
|
if (e164) return formatPhone(`0${e164[1]}`);
|
||||||
|
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the https://zalo.me deep-link URL for a given phone number.
|
||||||
|
*
|
||||||
|
* Zalo expects the number without a leading zero, prefixed with 84.
|
||||||
|
* "0987654321" → "https://zalo.me/84987654321"
|
||||||
|
*/
|
||||||
|
export function zaloHref(phone: string): string {
|
||||||
|
return `https://zalo.me/${normalizePhone(phone)}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const phoneRegex = /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
import { VN_PHONE_REGEX as phoneRegex } from '@/lib/phone';
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
phone: z
|
phone: z
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
/**
|
import { VN_PHONE_REGEX as PHONE_REGEX } from '@/lib/phone';
|
||||||
* Vietnamese phone number rule:
|
|
||||||
* - 9–11 digits, optional leading +84 or 0.
|
|
||||||
* We keep validation pragmatic: whitespace is stripped, then the remaining
|
|
||||||
* string must be 9–11 digits (country code / leading zero stripped).
|
|
||||||
*/
|
|
||||||
const PHONE_REGEX = /^(?:\+?84|0)?\d{9,11}$/;
|
|
||||||
|
|
||||||
export const inquiryFormSchema = z.object({
|
export const inquiryFormSchema = z.object({
|
||||||
message: z
|
message: z
|
||||||
|
|||||||
192
docs/security/SECRET_ROTATION_POLICY.md
Normal file
192
docs/security/SECRET_ROTATION_POLICY.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Payment Gateway Secret Rotation Policy
|
||||||
|
|
||||||
|
> **Status:** Active — GOO-197 / parent [GOO-102](/GOO/issues/GOO-102) (CLO data-security work).
|
||||||
|
> **Owner:** Security Engineer + Platform on-call.
|
||||||
|
> **Last reviewed:** 2026-04-24.
|
||||||
|
|
||||||
|
This document is the canonical policy for rotating all secrets that gate
|
||||||
|
access to GoodGo's payment gateways and adjacent integrations (OAuth,
|
||||||
|
storage, webhook signing, JWT). It ships alongside the `SecretProvider`
|
||||||
|
abstraction in
|
||||||
|
`apps/api/src/modules/shared/domain/ports/secret-provider.port.ts` and the
|
||||||
|
env-backed implementation in
|
||||||
|
`apps/api/src/modules/shared/infrastructure/env-secret-provider.service.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why rotate
|
||||||
|
|
||||||
|
A stolen or leaked HMAC key for a payment gateway is the most direct path
|
||||||
|
to financial fraud against GoodGo. Rotation reduces the **window of abuse**
|
||||||
|
when a key is exposed (insider misuse, accidental git commit, third-party
|
||||||
|
breach, log scraping, etc.). It also forces us to verify that every
|
||||||
|
runtime that relies on the key can still read it — i.e. that we have not
|
||||||
|
lost the ability to rotate.
|
||||||
|
|
||||||
|
## 2. Scope (rotation-sensitive secrets)
|
||||||
|
|
||||||
|
The following secrets are in scope. Each is registered with the
|
||||||
|
`SecretProvider` by default (see `DEFAULT_REGISTERED_SECRETS`) and has a
|
||||||
|
matching entry in `env-validation.ts`.
|
||||||
|
|
||||||
|
| Secret env var | Purpose | Cadence | Owner |
|
||||||
|
| ------------------------------ | ------------------------------- | -------- | -------------- |
|
||||||
|
| `JWT_SECRET` | Access-token HMAC | 90 days | Auth |
|
||||||
|
| `JWT_REFRESH_SECRET` | Refresh-token HMAC | 90 days | Auth |
|
||||||
|
| `VNPAY_HASH_SECRET` | VNPay request/callback HMAC | 90 days | Payments |
|
||||||
|
| `MOMO_SECRET_KEY` | MoMo request/callback HMAC | 90 days | Payments |
|
||||||
|
| `ZALOPAY_KEY1` | ZaloPay order signing | 90 days | Payments |
|
||||||
|
| `ZALOPAY_KEY2` | ZaloPay callback signing | 90 days | Payments |
|
||||||
|
| `BANK_TRANSFER_WEBHOOK_SECRET` | Bank-transfer webhook signature | 90 days | Payments |
|
||||||
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth | 180 days | Auth |
|
||||||
|
| `ZALO_APP_SECRET` | Zalo OAuth | 180 days | Auth |
|
||||||
|
| `ZALO_OA_ACCESS_TOKEN` | Zalo Official Account API token | 90 days | Notifications |
|
||||||
|
| `MINIO_SECRET_KEY` | Object-storage access key | 180 days | Platform |
|
||||||
|
| `FIELD_ENCRYPTION_KEY` | At-rest PII encryption key | annually | Platform + CLO |
|
||||||
|
|
||||||
|
Secrets **not** in this table (e.g. `DATABASE_URL` password, `REDIS_HOST`)
|
||||||
|
follow the platform-credential rotation policy and are out of scope here.
|
||||||
|
|
||||||
|
## 3. Cadence and triggers
|
||||||
|
|
||||||
|
- **Routine rotation:** every 90 days for HMAC/signing keys, 180 days for
|
||||||
|
OAuth client secrets, annually for the field-encryption key (which has
|
||||||
|
expensive data-rewrap implications).
|
||||||
|
- **Event-driven rotation (always immediately):**
|
||||||
|
- any commit accidentally containing a real value of one of the secrets
|
||||||
|
above (regardless of how briefly);
|
||||||
|
- departure of any individual with production access to the secret store;
|
||||||
|
- downstream provider notification that the credential may be exposed;
|
||||||
|
- confirmed or strongly suspected breach of any system that handled the
|
||||||
|
secret in plaintext (CI runner, dev laptop, log aggregator, …).
|
||||||
|
|
||||||
|
## 4. Operator workflow (env-backed backend)
|
||||||
|
|
||||||
|
1. **Generate** a new high-entropy value:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Stage the dual-key grace period.** Copy the current secret to the
|
||||||
|
`_PREVIOUS` variable and set the new secret as the primary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example for JWT_SECRET rotation:
|
||||||
|
JWT_SECRET_PREVIOUS=<current-value-of-JWT_SECRET>
|
||||||
|
JWT_SECRET=<newly-generated-value>
|
||||||
|
# Same pattern for JWT_REFRESH_SECRET if rotating refresh keys.
|
||||||
|
```
|
||||||
|
|
||||||
|
The auth layer automatically tries the primary key first and falls
|
||||||
|
back to `_PREVIOUS`, so tokens signed with the old key continue to
|
||||||
|
validate during the grace period (≤ access-token TTL, typically 15 m).
|
||||||
|
|
||||||
|
3. **Deploy** the change. On boot, every API instance logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
[EnvSecretProvider] Secret versions at boot: VNPAY_HASH_SECRET=2026-04-24, …
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the version field matches the staged version on every instance.
|
||||||
|
The raw value **must never** appear in this or any other log line.
|
||||||
|
|
||||||
|
4. **Smoke-test** payment flows for the rotated provider:
|
||||||
|
- issue one sandbox payment
|
||||||
|
- confirm callback verification succeeds
|
||||||
|
- confirm refund signing succeeds
|
||||||
|
Record the rotation in the security audit log
|
||||||
|
(`docs/security/secret-rotation-log.md` — append-only).
|
||||||
|
|
||||||
|
5. **Decommission** the old credential in the gateway's merchant portal.
|
||||||
|
|
||||||
|
6. **Remove the previous secret.** After the grace period (at least one
|
||||||
|
full access-token TTL cycle, typically 15 minutes), remove
|
||||||
|
`JWT_SECRET_PREVIOUS` (and/or `JWT_REFRESH_SECRET_PREVIOUS`) from the
|
||||||
|
environment and redeploy. This closes the dual-key window.
|
||||||
|
|
||||||
|
## 5. SecretProvider abstraction (developer workflow)
|
||||||
|
|
||||||
|
All new and existing code that consumes a rotation-sensitive secret MUST
|
||||||
|
go through the `SecretProvider` port:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { SECRET_PROVIDER, type ISecretProvider } from '@modules/shared/domain/ports';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VnpayService {
|
||||||
|
constructor(@Inject(SECRET_PROVIDER) private readonly secrets: ISecretProvider) {}
|
||||||
|
|
||||||
|
async sign(payload: string): Promise<string> {
|
||||||
|
const { value } = await this.secrets.getSecret('VNPAY_HASH_SECRET');
|
||||||
|
// … HMAC with `value`, never store it on `this`, never log it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **Never** capture the raw value into a service field. Always re-read on
|
||||||
|
the request path so a rotation takes effect at the next request.
|
||||||
|
- **Never** include `material.value` in log messages, error messages, or
|
||||||
|
exception payloads. `material.version` is safe to log.
|
||||||
|
- **Never** stringify a `SecretMaterial` directly into a response body.
|
||||||
|
- For bootstrap-only contexts where `await` is awkward, use
|
||||||
|
`getSecretSync` — but note that a future remote backend may throw
|
||||||
|
`UnsupportedSyncReadError`.
|
||||||
|
|
||||||
|
## 6. Backends
|
||||||
|
|
||||||
|
- **Short term — `EnvSecretProvider` (current).** Reads from `process.env`
|
||||||
|
via `ConfigService`. Operationally identical to the pre-existing
|
||||||
|
`getOrThrow('VNPAY_HASH_SECRET')` calls, but with a stable audit surface
|
||||||
|
(versions logged, port-based DI).
|
||||||
|
- **Mid term — `AwsSecretsManagerSecretProvider` / `VaultSecretProvider`.**
|
||||||
|
Same port. Adds:
|
||||||
|
- automatic refresh from the remote store
|
||||||
|
- per-secret IAM / Vault-policy scoping
|
||||||
|
- native version ids (`AWSCURRENT` / `AWSPREVIOUS` etc.) surfaced as
|
||||||
|
`material.version`
|
||||||
|
- `getSecretSync` may throw `UnsupportedSyncReadError`; bootstrap
|
||||||
|
callers must migrate to `getSecret`.
|
||||||
|
|
||||||
|
Switching backends is a one-line change in `SharedModule` (replace
|
||||||
|
`EnvSecretProvider` with the new implementation under the
|
||||||
|
`SECRET_PROVIDER` token). No call sites change.
|
||||||
|
|
||||||
|
## 7. Logging discipline
|
||||||
|
|
||||||
|
- The `EnvSecretProvider` logs only `name=version` pairs at boot.
|
||||||
|
- The `version` is either an operator-provided `<NAME>_SECRET_VERSION` env
|
||||||
|
var, or a 10-char SHA-256 fingerprint of the value (40 bits of entropy;
|
||||||
|
non-invertible; useful for distinguishing rotations across instances).
|
||||||
|
- Negative tests in
|
||||||
|
`apps/api/src/modules/shared/infrastructure/__tests__/env-secret-provider.service.spec.ts`
|
||||||
|
assert the raw value never appears in logger output, error messages, or
|
||||||
|
serialized provider state.
|
||||||
|
- The repo also has a global `pii-masker` and `GlobalExceptionFilter` —
|
||||||
|
those are defence-in-depth, not the primary control. The primary control
|
||||||
|
is "never put the value into a string in the first place."
|
||||||
|
|
||||||
|
## 8. Incident response (suspected leak)
|
||||||
|
|
||||||
|
1. Open a P1 incident in `#sec-incident`. Page Security on-call.
|
||||||
|
2. Rotate the affected secret immediately following §4 — do not wait for
|
||||||
|
forensic confirmation.
|
||||||
|
3. Search logs / CI artifacts / git history for the leaked value
|
||||||
|
fingerprint (NOT the value itself; use `fingerprint()` from
|
||||||
|
`env-secret-provider.service.ts`).
|
||||||
|
4. Coordinate with the gateway's anti-fraud team where applicable (VNPay,
|
||||||
|
MoMo, ZaloPay merchant support).
|
||||||
|
5. File a post-mortem within 5 business days; update this policy if
|
||||||
|
process gaps were found.
|
||||||
|
|
||||||
|
## 9. References
|
||||||
|
|
||||||
|
- Source port: `apps/api/src/modules/shared/domain/ports/secret-provider.port.ts`
|
||||||
|
- Env-backed impl: `apps/api/src/modules/shared/infrastructure/env-secret-provider.service.ts`
|
||||||
|
- Env validation: `apps/api/src/modules/shared/infrastructure/env-validation.ts`
|
||||||
|
- Negative tests: `apps/api/src/modules/shared/infrastructure/__tests__/env-secret-provider.service.spec.ts`
|
||||||
|
- Parent issue: [GOO-102](/GOO/issues/GOO-102)
|
||||||
|
- This issue: [GOO-197](/GOO/issues/GOO-197)
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -186,6 +186,9 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.10.1
|
version: 5.10.1
|
||||||
|
jsonwebtoken:
|
||||||
|
specifier: ^9.0.3
|
||||||
|
version: 9.0.3
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^8.0.5
|
specifier: ^8.0.5
|
||||||
version: 8.0.5
|
version: 8.0.5
|
||||||
@@ -259,6 +262,9 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
|
'@types/jsonwebtoken':
|
||||||
|
specifier: ^9.0.10
|
||||||
|
version: 9.0.10
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.5.2
|
specifier: ^25.5.2
|
||||||
version: 25.5.2
|
version: 25.5.2
|
||||||
|
|||||||
Reference in New Issue
Block a user