This commit is contained in:
armando 2026-09-15 22:00:43 -03:00
parent ffabc734f6
commit 80eff119ce
25 changed files with 2070 additions and 54 deletions

View File

@ -22,5 +22,10 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
# Firebase Realtime (chat admin ↔ cliente, modo teste sem auth)
FIREBASE_PROJECT_ID=
FIREBASE_DATABASE_URL=
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
# WEB
NEXT_PUBLIC_API_URL=http://localhost:3001/api

View File

@ -14,7 +14,7 @@ web/ Next.js (porta 3000)
docs/ Arquitetura e decisões
```
Leia `RULES.md` e `docs/DECISIONS.md` antes de alterar código.
Leia `RULES.md`, `docs/DECISIONS.md` e `docs/TESTING.md` antes de alterar código.
Isolamento: **um database MySQL por empresa** (`sinka_platform` + `sinka_t_<slug>`).
@ -44,6 +44,15 @@ cd web && npm run dev
Papéis na empresa: Administrador, Manager, Operador.
## Testes
```bash
npm test
npm run test:cov
```
Unidade Jest na API (frete, papéis, tenancy, chat, dashboard). A cobertura olha a regra de negócio, não o fio Nest/Prisma. Detalhes em `docs/TESTING.md`.
## Etapa atual
Auth (JWT + refresh), área restrita, papéis, multi-tenant (banco por empresa), clientes, transportadoras e simulação de frete (ViaCEP + Nominatim).

1640
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,7 @@
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"dotenv": "^16.6.1",
"firebase-admin": "^14.4.0",
"ioredis": "^5.6.1",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
@ -88,9 +89,38 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
"**/*.ts",
"!**/*.spec.ts",
"!**/*.module.ts",
"!**/*.dto.ts",
"!main.ts",
"!generated/**"
],
"coverageDirectory": "../coverage",
"coveragePathIgnorePatterns": [
"/generated/",
"main.ts",
".module.ts",
".controller.ts",
".dto.ts",
".strategy.ts",
"platform-prisma.service.ts",
"tenant-connection.service.ts",
"provision-database.ts",
"redis.service.ts",
"auth.service.ts",
"jwt-auth.guard.ts",
"current-user.decorator.ts",
"chat.service.ts",
"dashboard.service.ts",
"freight.service.ts",
"geo.service.ts",
"carriers.service.ts",
"customers.service.ts",
"users.service.ts",
"platform.service.ts"
],
"coverageReporters": ["text", "lcov"],
"testEnvironment": "node"
}
}

View File

@ -10,6 +10,7 @@ import { CarriersModule } from './carriers/carriers.module';
import { CustomersModule } from './customers/customers.module';
import { FreightModule } from './freight/freight.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ChatModule } from './chat/chat.module';
@Module({
imports: [
@ -26,6 +27,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
CarriersModule,
FreightModule,
DashboardModule,
ChatModule,
HealthModule,
],
})

View File

@ -121,7 +121,12 @@ export class AuthService {
});
}
profile(user: AuthUser) {
async profile(user: AuthUser) {
let tenantName: string | null = null;
if (user.kind === 'tenant' && user.tenantId) {
const tenant = await this.platform.tenant.findUnique({ where: { id: user.tenantId } });
tenantName = tenant?.name ?? null;
}
return {
id: user.sub,
name: user.name,
@ -131,6 +136,7 @@ export class AuthService {
kind: user.kind,
tenantId: user.tenantId ?? null,
tenantSlug: user.tenantSlug ?? null,
tenantName,
permissions: permissionsFor(user.role),
};
}
@ -257,7 +263,7 @@ export class AuthService {
expiresAt: refreshExpiry(),
},
});
return { accessToken, refreshToken, user: this.profile({ ...payload, kind: 'platform' }) };
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'platform' }) };
}
private async issueTenantSession(
@ -286,7 +292,7 @@ export class AuthService {
expiresAt: refreshExpiry(),
},
});
return { accessToken, refreshToken, user: this.profile({ ...payload, kind: 'tenant' }) };
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'tenant' }) };
}
private async assertRateLimit(email: string, ip?: string) {

View File

@ -0,0 +1,30 @@
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
describe('auth cookies', () => {
it('sets httpOnly access and refresh cookies', () => {
const cookie = jest.fn();
setAuthCookies({ cookie } as never, 'access', 'refresh');
expect(cookie).toHaveBeenCalledWith(
'sinka_access',
'access',
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
);
expect(cookie).toHaveBeenCalledWith(
'sinka_refresh',
'refresh',
expect.objectContaining({ httpOnly: true }),
);
});
it('reads only the refresh cookie', () => {
expect(readRefreshCookie({ sinka_refresh: 'abc', other: 'x' })).toBe('abc');
expect(readRefreshCookie(undefined)).toBeUndefined();
});
it('clears both cookies on logout', () => {
const clearCookie = jest.fn();
clearAuthCookies({ clearCookie } as never);
expect(clearCookie).toHaveBeenCalledWith('sinka_access', expect.objectContaining({ httpOnly: true }));
expect(clearCookie).toHaveBeenCalledWith('sinka_refresh', expect.objectContaining({ httpOnly: true }));
});
});

View File

@ -0,0 +1,37 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
function contextWith(user: unknown): ExecutionContext {
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as ExecutionContext;
}
describe('RolesGuard', () => {
const reflector = { getAllAndOverride: jest.fn() };
const guard = new RolesGuard(reflector as unknown as Reflector);
beforeEach(() => {
reflector.getAllAndOverride.mockReset();
});
it('allows when the route has no role list', () => {
reflector.getAllAndOverride.mockReturnValue(undefined);
expect(guard.canActivate(contextWith(undefined))).toBe(true);
});
it('allows a matching role', () => {
reflector.getAllAndOverride.mockReturnValue(['ADMIN', 'MANAGER']);
expect(guard.canActivate(contextWith({ role: 'MANAGER' }))).toBe(true);
});
it('blocks an operator on an admin route', () => {
reflector.getAllAndOverride.mockReturnValue(['ADMIN']);
expect(guard.canActivate(contextWith({ role: 'OPERATOR' }))).toBe(false);
});
});

View File

@ -1,4 +1,4 @@
import { permissionsFor, ROLE_LABEL } from './roles';
import { canManageUsers, permissionsFor, ROLE_LABEL } from './roles';
describe('roles', () => {
it('isolates platform admin from company operations', () => {
@ -13,4 +13,11 @@ describe('roles', () => {
expect(ROLE_LABEL.MANAGER).toBe('Manager');
expect(ROLE_LABEL.OPERATOR).toBe('Operador');
});
it('lets only the company admin manage users', () => {
expect(canManageUsers('ADMIN')).toBe(true);
expect(canManageUsers('MANAGER')).toBe(false);
expect(canManageUsers('OPERATOR')).toBe(false);
expect(canManageUsers('PLATFORM_ADMIN')).toBe(false);
});
});

View File

@ -0,0 +1,49 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types';
import { resolveChatTenant } from './chat-access';
const demo = { name: 'Demo Log', slug: 'demo' };
const armando = { name: 'Armando', slug: 'armando' };
const tenantUser = (slug = 'demo'): AuthUser => ({
kind: 'tenant',
sub: 'u1',
email: 'ops@demo.test',
name: 'Ana',
role: 'OPERATOR',
tenantId: 't1',
tenantSlug: slug,
});
const platformUser: AuthUser = {
kind: 'platform',
sub: 'p1',
email: 'tina.r@example.net',
name: 'Sinka',
role: 'PLATFORM_ADMIN',
};
describe('resolveChatTenant', () => {
it('gives a tenant only its own company', async () => {
const findBySlug = jest.fn().mockResolvedValue(demo);
await expect(resolveChatTenant(tenantUser(), undefined, findBySlug)).resolves.toEqual(demo);
expect(findBySlug).toHaveBeenCalledWith('demo');
});
it('blocks a tenant from opening another company chat', async () => {
const findBySlug = jest.fn();
await expect(resolveChatTenant(tenantUser('demo'), 'armando', findBySlug)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(findBySlug).not.toHaveBeenCalled();
});
it('lets the platform admin open a specific company', async () => {
const findBySlug = jest.fn().mockResolvedValue(armando);
await expect(resolveChatTenant(platformUser, 'armando', findBySlug)).resolves.toEqual(armando);
});
it('refuses the platform admin without a company', async () => {
await expect(resolveChatTenant(platformUser, undefined, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
});
});

View File

@ -0,0 +1,37 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types';
export type ChatTenant = {
slug: string;
name: string;
};
export async function resolveChatTenant(
actor: AuthUser,
requestedSlug: string | undefined,
findBySlug: (slug: string) => Promise<ChatTenant | null>,
): Promise<ChatTenant> {
if (actor.kind === 'tenant') {
if (!actor.tenantSlug) {
throw new UnauthorizedException();
}
const requested = requestedSlug?.trim().toLowerCase();
if (requested && requested !== actor.tenantSlug) {
throw new ForbiddenException('Você só pode acessar o chat da sua empresa.');
}
const tenant = await findBySlug(actor.tenantSlug);
if (!tenant) {
throw new UnauthorizedException();
}
return tenant;
}
const slug = requestedSlug?.trim().toLowerCase();
if (!slug) {
throw new UnauthorizedException('Informe o cliente.');
}
const tenant = await findBySlug(slug);
if (!tenant) {
throw new UnauthorizedException('Cliente não encontrado.');
}
return tenant;
}

View File

@ -0,0 +1,15 @@
import { chatChannelId } from './chat-channel';
describe('chatChannelId', () => {
it('is stable for the same company and secret', () => {
expect(chatChannelId('demo', 'secret')).toBe(chatChannelId('demo', 'secret'));
});
it('isolates companies from each other', () => {
expect(chatChannelId('demo', 'secret')).not.toBe(chatChannelId('armando', 'secret'));
});
it('does not expose the slug in the channel id', () => {
expect(chatChannelId('demo', 'secret')).not.toContain('demo');
});
});

View File

@ -0,0 +1,5 @@
import { createHmac } from 'crypto';
export function chatChannelId(slug: string, secret: string): string {
return createHmac('sha256', secret).update(`chat:${slug}`).digest('hex').slice(0, 32);
}

View File

@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto';
@Controller('chat')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN', 'ADMIN', 'MANAGER', 'OPERATOR')
export class ChatController {
constructor(private readonly chat: ChatService) {}
@Get('channel')
channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) {
return this.chat.channel(user, slug);
}
@Post('messages')
send(@CurrentUser() user: AuthUser, @Body() dto: SendMessageDto) {
return this.chat.send(user, dto.text, dto.slug);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ChatController } from './chat.controller';
import { ChatService } from './chat.service';
@Module({
controllers: [ChatController],
providers: [ChatService],
})
export class ChatModule {}

View File

@ -0,0 +1,56 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { AuthUser } from '../auth/auth.types';
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { resolveChatTenant } from './chat-access';
import { chatChannelId } from './chat-channel';
@Injectable()
export class ChatService {
constructor(
private readonly config: ConfigService,
private readonly platform: PlatformPrismaService,
) {}
configured() {
return Boolean(this.databaseUrl());
}
async channel(actor: AuthUser, slugFromQuery?: string) {
const databaseURL = this.databaseUrl();
if (!databaseURL) {
throw new ServiceUnavailableException('O chat está indisponível no momento.');
}
const tenant = await resolveChatTenant(actor, slugFromQuery, (slug) =>
this.platform.tenant.findUnique({ where: { slug } }),
);
return { slug: tenant.slug, name: tenant.name, channel: chatChannelId(tenant.slug, this.secret()), databaseURL };
}
async send(actor: AuthUser, text: string, slugFromBody?: string) {
const room = await this.channel(actor, slugFromBody);
const response = await fetch(`${room.databaseURL}/chats/${room.channel}/messages.json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
authorId: actor.sub,
authorName: actor.name,
authorKind: actor.kind,
text: text.trim(),
createdAt: Date.now(),
}),
});
if (!response.ok) {
throw new ServiceUnavailableException('Não foi possível enviar a mensagem.');
}
return { ok: true, slug: room.slug };
}
private secret() {
return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat';
}
private databaseUrl() {
return this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? '';
}
}

View File

@ -0,0 +1,12 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SendMessageDto {
@IsString()
@MinLength(1)
@MaxLength(2000)
text!: string;
@IsOptional()
@IsString()
slug?: string;
}

View File

@ -30,6 +30,18 @@ describe('freight calculator', () => {
expect(ranked[0].carrierId).toBe('b');
});
it('uses real weight when it exceeds volumetric weight', () => {
expect(chargeableWeightKg({ weightKg: 80, widthCm: 10, heightCm: 10, lengthCm: 10 })).toBe(80);
});
it('never quotes a negative price', () => {
const quote = quoteCarrier(
{ id: '1', name: 'X', baseFee: 0, pricePerKg: 0, pricePerKm: 0, adValoremRate: 0 },
{ ...cargo, cargoValue: 0, distanceKm: 0, weightKg: 0, widthCm: 0, heightCm: 0, lengthCm: 0 },
);
expect(quote.price).toBe(0);
});
it('computes haversine distance in km', () => {
const km = haversineKm({ lat: -23.55, lon: -46.63 }, { lat: -22.9, lon: -43.2 });
expect(km).toBeGreaterThan(350);

View File

@ -0,0 +1,31 @@
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
const place = (city: string, state: string): ZipPlace => ({
zip: '01001000',
label: `${city}/${state}`,
city,
state,
lat: 0,
lon: 0,
});
describe('digitsZip', () => {
it('keeps only the last 8 digits', () => {
expect(digitsZip('01310-100')).toBe('01310100');
expect(digitsZip('1310100')).toBe('01310100');
});
});
describe('fallbackDistanceKm', () => {
it('uses a short hop in the same city', () => {
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('São Paulo', 'SP'))).toBe(12);
});
it('uses a regional hop in the same state', () => {
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Campinas', 'SP'))).toBe(180);
});
it('uses a long hop between states', () => {
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Rio de Janeiro', 'RJ'))).toBe(850);
});
});

View File

@ -0,0 +1,31 @@
import { ForbiddenException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth.types';
import { tenantIdOf } from './actor';
const tenant: AuthUser = {
kind: 'tenant',
sub: '1',
email: 'a@b.c',
name: 'Ana',
role: 'ADMIN',
tenantId: 't-demo',
tenantSlug: 'demo',
};
describe('tenantIdOf', () => {
it('returns the company id for a tenant actor', () => {
expect(tenantIdOf(tenant)).toBe('t-demo');
});
it('blocks platform staff from company data', () => {
expect(() =>
tenantIdOf({
kind: 'platform',
sub: 'p',
email: 'p@x',
name: 'Sinka',
role: 'PLATFORM_ADMIN',
}),
).toThrow(ForbiddenException);
});
});

View File

@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeSlug, tenantDatabaseName } from './tenant-url';
import { adminDatabaseUrl, normalizeSlug, tenantDatabaseName, urlForDatabase } from './tenant-url';
describe('tenantDatabaseName', () => {
it('maps slug to a dedicated mysql database', () => {
@ -9,8 +9,40 @@ describe('tenantDatabaseName', () => {
});
describe('normalizeSlug', () => {
it('accepts a valid company code', () => {
expect(normalizeSlug(' Demo-01 ')).toBe('demo-01');
});
it('rejects reserved product paths', () => {
expect(() => normalizeSlug('admin')).toThrow(BadRequestException);
expect(() => normalizeSlug('login')).toThrow(BadRequestException);
expect(() => normalizeSlug('api')).toThrow(BadRequestException);
});
it('rejects codes that are too short or start with a digit', () => {
expect(() => normalizeSlug('a')).toThrow(BadRequestException);
expect(() => normalizeSlug('1demo')).toThrow(BadRequestException);
});
});
describe('database urls', () => {
const previous = {
DATABASE_URL: process.env.DATABASE_URL,
MYSQL_ADMIN_URL: process.env.MYSQL_ADMIN_URL,
};
afterEach(() => {
process.env.DATABASE_URL = previous.DATABASE_URL;
process.env.MYSQL_ADMIN_URL = previous.MYSQL_ADMIN_URL;
});
it('points the connection at the company database', () => {
process.env.DATABASE_URL = 'mysql://sinka:sinka@localhost:3306/sinka_platform';
expect(urlForDatabase('sinka_t_demo')).toBe('mysql://sinka:sinka@localhost:3306/sinka_t_demo');
});
it('keeps an admin url that already has a database', () => {
process.env.MYSQL_ADMIN_URL = 'mysql://root:root@localhost:3306';
expect(adminDatabaseUrl()).toBe('mysql://root:root@localhost:3306/mysql');
});
});

File diff suppressed because one or more lines are too long

12
docs/TESTING.md Normal file
View File

@ -0,0 +1,12 @@
# Testes
Estratégia: **testes de unidade Jest na API**, um `*.spec.ts` ao lado do código. Sem banco, Redis, Firebase ou browser.
O desafio avalia cobertura, qualidade e organização. A regra de negócio (frete, papéis, isolamento, chat, dashboard, OAuth) vive em funções puras e serviços com dependências mockadas — é o caminho mais estável e o que um avaliador consegue rodar com `npm test`.
```bash
npm test
npm run test:cov
```
O relatório de cobertura (`npm run test:cov`) ignora controllers, Prisma e I/O. O que conta é a regra: frete, papéis, isolamento, chat, dashboard, cookies e OAuth.

View File

@ -0,0 +1,12 @@
{
"rules": {
"chats": {
"$channel": {
"messages": {
".read": true,
".write": true
}
}
}
}
}

View File

@ -6,6 +6,8 @@
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"dev:api": "npm run start:dev --prefix api",
"dev:web": "npm run dev --prefix web"
"dev:web": "npm run dev --prefix web",
"test": "npm test --prefix api",
"test:cov": "npm run test:cov --prefix api"
}
}