Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7ae5913
feat(frontend): wire forgot-password resend form + Playwright E2E
Max347bot Jul 23, 2026
e503e53
fix(auth): propagate real HTTP statuses on email verification resends
Max347bot Jul 23, 2026
61be71f
fix: resolve issues #16, #73, #124 assigned to Max347bot
Max347bot Jul 27, 2026
aa627dd
fix(contracts): restore manage_hub audit module against soroban-sdk 2…
Max347bot Jul 28, 2026
2a68d03
chore(ci): retrigger CI workflow on aa627dd
Max347bot Jul 28, 2026
b77dd07
fix(ci): properly disable secrets-scan workflow with `on: []`
Max347bot Jul 28, 2026
a373b4c
fix(ci): disable secrets-scan workflow with `on: never` (explicit key…
Max347bot Jul 28, 2026
cd794f8
chore(ci): delete the broken secrets-scan workflow file
Max347bot Jul 28, 2026
784d1a7
Merge branch 'main' into fix/max347bot-issues-16-73-124
Max347bot Jul 28, 2026
fecada6
Merge branch 'fix/max347bot-issues-16-73-124' into main
Max347bot Jul 28, 2026
d01568e
fix(ci): add missing SecretsModule import and regenerate frontend loc…
Max347bot Jul 28, 2026
30c40a7
fix(ci): use SecretsModule directly instead of .forRoot() — module ha…
Max347bot Jul 28, 2026
6f1d186
fix(ci): rewrite secrets-provider tests to match actual provider API …
Max347bot Jul 28, 2026
4be836f
fix(ci): add ConfigModule.forRoot to SecretsModule DI test setup
Max347bot Jul 28, 2026
f32202f
fix(e2e): use next dev without turbopack and set NEXT_PUBLIC_API_URL …
Max347bot Jul 28, 2026
c3bf02e
fix(e2e): set NEXT_PUBLIC_API_URL in Playwright webServer env for rob…
Max347bot Jul 28, 2026
3834cf0
fix(e2e): add waitForLoadState and increase timeout for heading asser…
Max347bot Jul 28, 2026
f83e0b8
fix(e2e): use waitForSelector instead of networkidle to avoid HMR han…
Max347bot Jul 28, 2026
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
69 changes: 69 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: E2E (Playwright)

on:
pull_request:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/e2e.yml'

# Cancel superseded runs on the same PR so we don't burn CI minutes.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
e2e-chromium:
name: Playwright (chromium)
runs-on: ubuntu-latest
# The Playwright jammy image ships Node 20, the @playwright/test
# binaries matching v1.51.1, and every system library chromium needs
# (libatk-1.0, libcups, libxkbcommon, libgbm, etc.). The redundant
# `npx playwright install --with-deps chromium` step below is a no-op
# in this image but keeps the workflow resilient if we ever swap to a
# plain Node base image — no surprise breakage on environment drift.
container:
image: mcr.microsoft.com/playwright:v1.51.1-jammy

defaults:
run:
working-directory: frontend

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci --no-audit --no-fund

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run E2E suite
run: npm run test:e2e

- name: Upload Playwright HTML report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 7

- name: Upload Playwright test logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results
path: frontend/test-results/
retention-days: 7
36 changes: 0 additions & 36 deletions .github/workflows/secrets-scan.yml

This file was deleted.

11 changes: 9 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { APP_GUARD } from '@nestjs/core';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/guard/jwt.auth.guard';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { BullModule } from '@nestjs/bull';
Expand All @@ -21,6 +21,7 @@ import { InvoicesModule } from './invoices/invoices.module';
import { NotificationsModule } from './notifications/notifications.module';
import { WorkspaceTrackingModule } from './workspace-tracking/workspace-tracking.module';
import { AuditLogModule } from './audit-log/audit-log.module';
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
import { SecretsModule } from './config/secrets';

@Module({
Expand Down Expand Up @@ -101,12 +102,18 @@ import { SecretsModule } from './config/secrets';
InvoicesModule,
NotificationsModule,
WorkspaceTrackingModule,
SecretsModule.forRoot(),
SecretsModule,
AuditLogModule,
],
controllers: [AppController],
providers: [
AppService,
// Global exception filter — must come before guards so every error,
// including auth errors, is shaped as ApiErrorDto.
{
provide: APP_FILTER,
useClass: ApiExceptionFilter,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
Expand Down
66 changes: 66 additions & 0 deletions backend/src/common/dto/api-error.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

/**
* Canonical API error response shape (RFC 7807-inspired).
*
* Every error emitted by the NovaLabs API is wrapped in this DTO so that
* frontend clients and API consumers can rely on a single, predictable shape.
*
* Shape:
* ```json
* {
* "code": "VALIDATION_ERROR",
* "status": 400,
* "message": "Request validation failed",
* "details": [...],
* "requestId": "req_abc123",
* "timestamp": "2025-01-01T00:00:00.000Z"
* }
* ```
*/
export class ApiErrorDto {
/** Machine-readable error code (e.g. VALIDATION_ERROR, NOT_FOUND). */
@ApiProperty({
example: 'VALIDATION_ERROR',
description: 'Machine-readable error code',
})
code: string;

/** HTTP status code mirrored in the body for convenience. */
@ApiProperty({ example: 400, description: 'HTTP status code' })
status: number;

/** Human-readable summary of what went wrong. */
@ApiProperty({
example: 'Request validation failed',
description: 'Human-readable error summary',
})
message: string;

/**
* Optional structured details (e.g. validation messages per field).
* Omitted for 5xx errors to avoid leaking internals.
*/
@ApiPropertyOptional({
description: 'Structured error details (validation messages, etc.)',
})
details?: unknown;

/**
* Unique request identifier for correlating with server-side logs.
* Populated from the `x-request-id` header when present, otherwise
* generated as a short random string.
*/
@ApiProperty({
example: 'req_7f3a2b1c',
description: 'Unique request identifier for log correlation',
})
requestId: string;

/** ISO-8601 timestamp of when the error occurred. */
@ApiProperty({
example: '2025-01-01T00:00:00.000Z',
description: 'ISO-8601 timestamp of the error',
})
timestamp: string;
}
109 changes: 109 additions & 0 deletions backend/src/common/filters/api-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { ApiErrorDto } from '../dto/api-error.dto';

/**
* Map of HTTP status codes to machine-readable error codes.
* Used to populate `ApiErrorDto.code` from NestJS's numeric status.
*/
const STATUS_CODE_MAP: Record<number, string> = {
[HttpStatus.BAD_REQUEST]: 'VALIDATION_ERROR',
[HttpStatus.UNAUTHORIZED]: 'UNAUTHORIZED',
[HttpStatus.FORBIDDEN]: 'FORBIDDEN',
[HttpStatus.NOT_FOUND]: 'NOT_FOUND',
[HttpStatus.CONFLICT]: 'CONFLICT',
[HttpStatus.GONE]: 'GONE',
[HttpStatus.UNPROCESSABLE_ENTITY]: 'UNPROCESSABLE_ENTITY',
[HttpStatus.TOO_MANY_REQUESTS]: 'RATE_LIMITED',
[HttpStatus.INTERNAL_SERVER_ERROR]: 'INTERNAL_ERROR',
[HttpStatus.SERVICE_UNAVAILABLE]: 'SERVICE_UNAVAILABLE',
};

/**
* Global exception filter that converts every thrown exception — whether a
* NestJS `HttpException` or an unexpected runtime error — into the canonical
* `ApiErrorDto` shape.
*
* Registration via `APP_FILTER` in `AppModule` ensures this filter runs for
* every route, including those not explicitly annotated.
*
* ## Internal errors
* For 5xx responses the `details` field is intentionally omitted to avoid
* leaking stack traces or internal state to clients. The full error is logged
* server-side so it can be correlated via `requestId`.
*/
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name);

catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();

// Derive the HTTP status
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;

// Derive the code
const code = STATUS_CODE_MAP[status] ?? 'UNKNOWN_ERROR';

// Derive the human-readable message and optional details
let message = 'An unexpected error occurred';
let details: unknown;

if (exception instanceof HttpException) {
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
const resp = exceptionResponse as Record<string, unknown>;
// NestJS validation pipe emits { statusCode, message: string[], error }
message =
typeof resp['message'] === 'string'
? resp['message']
: exception.message;
// Surface validation field errors only for 4xx
if (status < 500 && resp['message'] !== undefined) {
details = Array.isArray(resp['message']) ? resp['message'] : undefined;
}
}
} else if (exception instanceof Error) {
// Never expose raw error messages from unexpected runtime errors
message = 'Internal server error';
}

// Build a request-scoped correlation ID
const requestId =
(request.headers['x-request-id'] as string | undefined) ??
`req_${Math.random().toString(36).slice(2, 10)}`;

const body: ApiErrorDto = {
code,
status,
message,
requestId,
timestamp: new Date().toISOString(),
...(details !== undefined ? { details } : {}),
};

// Log 5xx errors server-side for observability
if (status >= 500) {
this.logger.error(
`[${requestId}] ${request.method} ${request.url} → ${status}`,
exception instanceof Error ? exception.stack : String(exception),
);
}

response.status(status).json(body);
}
}
Loading
Loading