Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ AUTH_COOKIE_DOMAIN=
APP_PORT=3001
APP_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000
# Production cross-host browser sessions require a shared parent domain.
# SESSION_COOKIE_DOMAIN=example.com
LOG_LEVEL=info

# Google OAuth Configuration
Expand Down
17 changes: 17 additions & 0 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ describe('AuthService', () => {
});
});

describe('OAuth exchange codes', () => {
it('consumes a code exactly once', async () => {
const code = await service.createOAuthExchangeCode({
access_token: 'access-token',
refresh_token: 'refresh-token',
});

await expect(service.exchangeOAuthCode(code)).resolves.toEqual({
access_token: 'access-token',
refresh_token: 'refresh-token',
});
await expect(service.exchangeOAuthCode(code)).rejects.toThrow(
UnauthorizedException,
);
});
});

describe('logout()', () => {
it('should add a verified token to the blacklist', async () => {
mockJwtService.verifyAsync.mockResolvedValueOnce({
Expand Down
6 changes: 5 additions & 1 deletion backend/src/config/config.validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as Joi from 'joi';

const originList = Joi.string()
.pattern(/^https?:\/\/[^,\s]+(?:\s*,\s*https?:\/\/[^,\s]+)*$/)
.required();

/**
* Custom validation to ensure placeholder values are not used in production
*/
Expand Down Expand Up @@ -32,7 +36,7 @@ export const ConfigValidationSchema = Joi.object({
// ── Server ─────────────────────────────────────────────────────────────────
APP_PORT: Joi.number().positive().default(3001),
APP_URL: Joi.string().uri().required(),
FRONTEND_URL: Joi.string().uri().required(),
FRONTEND_URL: originList,

// ── Database ───────────────────────────────────────────────────────────────
DATABASE_HOST: Joi.string().required(),
Expand Down
6 changes: 5 additions & 1 deletion backend/src/dispute/dispute.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,12 @@ export class DisputeController {
@Param('id') id: string,
@Req() req: Request & { user?: User },
): Promise<DisputeResponseDto> {
const dispute = await this.disputeService.findOne(id);
const user = req.user!;
const dispute = await this.disputeService.findOne(
id,
user.id,
user.role === 'admin',
);

if (dispute.filedBy !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Access denied');
Expand Down
6 changes: 3 additions & 3 deletions backend/src/dispute/dispute.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { Dispute, DisputeStatus } from './entities/dispute.entity';
import { DisputeReasonClassifierService } from './dispute-reason-classifier.service';
import { AccessLogsService } from '../access-logs/access-logs.service';
import { NotFoundException } from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';

Check failure on line 7 in backend/src/dispute/dispute.service.spec.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'NotFoundException' is defined but never used

const mockDispute = {
id: 'dispute-1',
Expand Down Expand Up @@ -174,7 +174,7 @@
);
});

it('should throw UnauthorizedException if a user tries to access a dispute they did not file', async () => {
it('should throw ForbiddenException if a user tries to access a dispute they did not file', async () => {
const disputeId = 'dispute-id-1';
const dispute: Dispute = {
id: disputeId,
Expand All @@ -189,7 +189,7 @@

await expect(
service.findOne(disputeId, 'another-user-id'),
).rejects.toThrow('Unauthorized access');
).rejects.toThrow(ForbiddenException);
});
});
});
14 changes: 9 additions & 5 deletions backend/src/dispute/dispute.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
BadRequestException,
Injectable,
ForbiddenException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Dispute, DisputeStatus, ALLOWED_DISPUTE_TRANSITIONS } from './entities/dispute.entity';
Expand Down Expand Up @@ -94,13 +94,17 @@ export class DisputeService {
};
}

async findOne(id: string, userId: string): Promise<DisputeResponseDto> {
async findOne(
id: string,
userId: string,
allowAdmin = false,
): Promise<DisputeResponseDto> {
const dispute = await this.disputeRepo.findOne({ where: { id } });
if (!dispute) {
throw new NotFoundException(`Dispute ${id} not found`);
}
if (dispute.filedBy !== userId) {
throw new UnauthorizedException('Unauthorized access');
if (dispute.filedBy !== userId && !allowAdmin) {
throw new ForbiddenException('Forbidden access');
}
return this.toResponseDto(dispute);
}
Expand Down
14 changes: 12 additions & 2 deletions backend/src/documents/documents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export class DocumentsController {
query.page!,
query.limit!,
query.status,
query.search,
);

return {
Expand Down Expand Up @@ -291,13 +292,22 @@ export class DocumentsController {
}
}

function toNullableNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const numberValue = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}

function toDocumentResponse(document: Document): DocumentResponseDto {
return {
id: document.id,
title: document.title,
status: document.status,
riskScore: document.riskScore,
riskFlags: document.riskFlags,
riskScore: toNullableNumber(document.riskScore),
riskFlags: document.riskFlags ?? null,
fileSize: document.fileSize,
latitude: toNullableNumber(document.latitude),
longitude: toNullableNumber(document.longitude),
createdAt: document.createdAt,
updatedAt: document.updatedAt,
};
Expand Down
18 changes: 18 additions & 0 deletions backend/src/documents/documents.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,24 @@ describe('DocumentsService', () => {
expect(result.page).toBe(2);
});

it('should apply a title search when provided', async () => {
mockRepository.findAndCount.mockResolvedValueOnce([[], 0]);

await service.findByOwnerPaginated(
'user-456',
1,
20,
undefined,
'land title',
);

expect(mockRepository.findAndCount).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ title: expect.anything() }),
}),
);
});

it('should compute correct skip for page 3 with limit 5', async () => {
mockRepository.findAndCount.mockResolvedValueOnce([[], 0]);

Expand Down
15 changes: 13 additions & 2 deletions backend/src/documents/documents.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
Repository,
Between,

Check failure on line 5 in backend/src/documents/documents.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'Between' is defined but never used
LessThanOrEqual,

Check failure on line 6 in backend/src/documents/documents.service.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'LessThanOrEqual' is defined but never used
MoreThanOrEqual,
ILike,
} from 'typeorm';
import { promises as fs } from 'fs';
import { Document, DocumentStatus } from './entities/document.entity';

Expand Down Expand Up @@ -29,17 +35,22 @@
page: number,
limit: number,
status?: DocumentStatus,
search?: string,
): Promise<{ data: Document[]; total: number; page: number; limit: number }> {
const where: any = { ownerId };
if (status) {
where.status = status;
}
const normalizedSearch = search?.trim();
if (normalizedSearch) {
where.title = ILike(`%${normalizedSearch}%`);
}

const [data, total] = await this.documentRepository.findAndCount({
where,
skip: (page - 1) * limit,
take: limit,
order: { createdAt: 'DESC' },
order: { createdAt: 'DESC', id: 'DESC' },
});

return { data, total, page, limit };
Expand Down
7 changes: 5 additions & 2 deletions backend/src/documents/dto/document-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ export class DocumentResponseDto {
id: string;
title: string;
status: string;
riskScore?: number;
riskFlags?: string[];
riskScore: number | null;
riskFlags: string[] | null;
fileSize: number;
latitude: number | null;
longitude: number | null;
createdAt: Date;
updatedAt: Date;
}
21 changes: 19 additions & 2 deletions backend/src/documents/dto/list-documents.dto.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import { IsOptional, IsInt, Min, IsEnum, Max } from 'class-validator';
import {
IsOptional,
IsInt,
Min,
IsEnum,
Max,
IsString,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { DocumentStatus } from '../entities/document.entity';

export const MAX_DOCUMENT_LIMIT = 100;
export const MAX_DOCUMENT_PAGE = 10000;

export class ListDocumentsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@Max(MAX_DOCUMENT_LIMIT)
limit?: number = 20;

@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_DOCUMENT_PAGE)
page?: number = 1;

@IsOptional()
@IsEnum(DocumentStatus)
status?: DocumentStatus;

@IsOptional()
@IsString()
@MaxLength(100)
search?: string;
}
13 changes: 13 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
3. Deploy API backend and frontend artifacts.
4. Execute smoke tests on `/health` and `/metrics`.

## Browser Session Topology

Production browser sessions must use one of these supported topologies:

1. Serve the frontend and API through the same origin, including a reverse proxy or path-based gateway.
2. Serve the frontend and API on sibling hosts under one explicitly configured parent domain, such as `app.example.com` and `api.example.com`, and set `SESSION_COOKIE_DOMAIN=example.com`.

A host-only cookie cannot authenticate a middleware request on a different production host. The backend refuses production startup when `FRONTEND_URL` and `APP_URL` are on different hosts without a valid shared parent domain. Configure HTTPS and the exact frontend origin before enabling credentialed cookies.

OAuth callbacks redirect with a short-lived, one-time exchange code, never a JWT. The frontend exchanges it at `/api/v1/auth/oauth/exchange` and stores the returned session locally.

Logout writes a local-storage `logout-event`; the client session synchronizer clears each tab's user-scoped map selection before redirecting. `sessionStorage` is intentionally not treated as remotely clearable.

## Rollback Procedure

1. If deployment fails before DB migration: revert container version.
Expand Down
15 changes: 7 additions & 8 deletions frontend/app/(protected)/admin/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import React, { useCallback, useEffect, useState } from "react";
import { CircleUser } from "lucide-react";
import { apiUrl } from "@/lib/api-config";
import { getAccessToken } from "@/lib/session";

// ---------------------------------------------------------------------------
// Types
Expand All @@ -27,11 +29,8 @@ interface PaginatedActivity {
// Helpers
// ---------------------------------------------------------------------------

const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";

function getAuthHeaders(): HeadersInit {
const token =
typeof window !== "undefined" ? localStorage.getItem("auth-token") : null;
const token = getAccessToken();
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
Expand Down Expand Up @@ -107,10 +106,10 @@ export default function AdminActivityPage() {
if (appliedActionType) params.set("actionType", appliedActionType);

try {
const res = await fetch(
`${API_BASE}/api/admin/activity?${params.toString()}`,
{ headers: getAuthHeaders() },
);
const res = await fetch(apiUrl("/admin/activity", params), {
credentials: "include",
headers: getAuthHeaders(),
});
if (res.status === 403) {
setAccessDenied(true);
return;
Expand Down
10 changes: 5 additions & 5 deletions frontend/app/(protected)/admin/audit-logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
CardDescription,
CardContent,
} from "@/components/ui/card";
import { apiUrl } from "@/lib/api-config";
import { getAccessToken } from "@/lib/session";
import {
Table,
TableHeader,
Expand All @@ -34,13 +36,10 @@ interface PaginatedAccessLogs {
totalPages: number;
}

const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
const PAGE_SIZE = 20;

function getAuthHeaders(): HeadersInit {
const token =
typeof window !== "undefined" ? localStorage.getItem("auth-token") : null;

const token = getAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}

Expand Down Expand Up @@ -73,7 +72,8 @@ export default function AdminAuditLogsPage() {
if (appliedFilters.endDate) params.set("endDate", appliedFilters.endDate);

try {
const response = await fetch(`${API_BASE}/admin/access-logs?${params}`, {
const response = await fetch(apiUrl("/admin/access-logs", params), {
credentials: "include",
headers: getAuthHeaders(),
});
if (!response.ok) {
Expand Down
12 changes: 12 additions & 0 deletions frontend/app/(protected)/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { requireAdminSession } from "@/lib/admin-session";

export const dynamic = "force-dynamic";

export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
await requireAdminSession("en");
return children;
}
Loading
Loading