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
66 changes: 66 additions & 0 deletions .cspell/custom-dictionary-workspace.txt

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ CACHE_TTL_MS=600000
REDIS_KEY_PREFIX=vagas-full
KEYWORDS_REDIS_KEY=vagas-full:keywords
KEYWORDS_STORAGE_MODE=env
# Mantém GET /keywords ativo, mas bloqueia POST /keywords e publicação no kwsync.
KWSYNC_ENABLED=false

# Legacy flags kept for compatibility
HEADLESS=false
Expand Down
38 changes: 19 additions & 19 deletions LOCAL_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,13 @@ Como criar usuário para testes:

## 7.1 Subir stack completa (recomendado para onboarding)

1. Criar rede:
1; Criar rede:

```bash
docker network create vagas-net
```

2. Subir infra + app + migrate:
2; Subir infra + app + migrate:

```bash
docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d
Expand Down Expand Up @@ -298,10 +298,10 @@ Use o comando da seção de Docker.

Portas esperadas:

- frontend: http://localhost:5173
- front_admin: http://localhost:5174
- backend: http://localhost:3001
- scraper-go: http://localhost:8081
- frontend: <http://localhost:5173>
- front_admin: <http://localhost:5174>
- backend: <http://localhost:3001>
- scraper-go: <http://localhost:8081>

## Caminho B: Node local (frontend + backend)

Expand Down Expand Up @@ -345,27 +345,27 @@ Observação importante para o Caminho B:

URLs principais:

- App principal: http://localhost:5173
- Login: http://localhost:5173/login
- Cadastro: http://localhost:5173/register
- App principal: <http://localhost:5173>
- Login: <http://localhost:5173/login>
- Cadastro: <http://localhost:5173/register>
- Dashboard app: /home, /dashboard, /vagas, /mentoria, /perfil, /ajuda
- Callback OAuth: /auth/callback

Backend:

- Health: http://localhost:3001/health
- Swagger: http://localhost:3001/docs
- Metrics: http://localhost:3001/metrics
- Health: <http://localhost:3001/health>
- Swagger: <http://localhost:3001/docs>
- Metrics: <http://localhost:3001/metrics>

Scraper:

- Health: http://localhost:8081/health
- Metrics: http://localhost:8081/metrics
- Admin jobs count: http://localhost:8081/admin/jobs/count
- Health: <http://localhost:8081/health>
- Metrics: <http://localhost:8081/metrics>
- Admin jobs count: <http://localhost:8081/admin/jobs/count>

Front admin:

- http://localhost:5174
- <http://localhost:5174>
- rota de login: /login
- rotas principais: /dashboard, /users, /scrapers, /observability, /audit, /permissions, /settings

Expand Down Expand Up @@ -430,7 +430,7 @@ Abaixo, os testes manuais sugeridos para os módulos principais.

Passos:

1. Acesse http://localhost:5173/login
1. Acesse <http://localhost:5173/login>
2. Tente enviar vazio
3. Informe credenciais inválidas
4. Informe credenciais válidas
Expand All @@ -445,7 +445,7 @@ Resultado esperado:

Passos:

1. Acesse http://localhost:5173/register
1. Acesse <http://localhost:5173/register>
2. Preencha campos obrigatórios
3. Teste telefone opcional vazio
4. Teste telefone válido
Expand Down Expand Up @@ -500,7 +500,7 @@ Resultado esperado:

Passos:

1. Acesse http://localhost:5174/login
1. Acesse <http://localhost:5174/login>
2. Faça login com conta com permissão
3. Navegue por dashboard/users/scrapers/observability/audit/permissions/settings

Expand Down
2 changes: 2 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
DATABASE_URL=postgresql://vagas:vagas@localhost:5432/vagas
VALKEY_URL=redis://localhost:6379/0
CACHE_TTL_MS=600000
# Mantém GET /keywords ativo, mas bloqueia POST /keywords e publicação no kwsync.
KWSYNC_ENABLED=false

# Scraping behavior
WAIT_BETWEEN_SEARCHES_MS=5000
Expand Down
2 changes: 2 additions & 0 deletions backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface AppConfig {
emailFromAddress: string;
emailFromName: string;
emailQueueAttempts: number;
kwsyncEnabled: boolean;
}

function parseBoolean(value: string | undefined, fallback: boolean): boolean {
Expand Down Expand Up @@ -75,5 +76,6 @@ export function getConfig(): AppConfig {
emailFromAddress: process.env.EMAIL_FROM_ADDRESS?.trim() ?? "",
emailFromName: process.env.EMAIL_FROM_NAME?.trim() ?? "",
emailQueueAttempts: parseNumber(process.env.EMAIL_QUEUE_ATTEMPTS, 3),
kwsyncEnabled: parseBoolean(process.env.KWSYNC_ENABLED, false),
};
}
16 changes: 16 additions & 0 deletions backend/src/lib/kwsync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RedisClientType } from "redis";
import { getConfig } from "../config";
import { logger } from "../logger";

// Chave absoluta global. O Go lerá exatamente esse namespace.
Expand All @@ -21,6 +22,14 @@ export async function publish(
source: KeywordEvent["source"] = "user",
userId?: string,
): Promise<void> {
if (!getConfig().kwsyncEnabled) {
logger.info(
{ keyword, source, userId },
"kwsync: publicação ignorada porque KWSYNC_ENABLED=false",
);
return;
}

const event: KeywordEvent = {
keyword,
source,
Expand Down Expand Up @@ -53,6 +62,13 @@ export async function publishBatch(
source: KeywordEvent["source"] = "user",
): Promise<void> {
if (keywords.length === 0) return;
if (!getConfig().kwsyncEnabled) {
logger.info(
{ count: keywords.length, source },
"kwsync: lote ignorado porque KWSYNC_ENABLED=false",
);
return;
}

const now = new Date().toISOString();
const payloads = keywords.map((keyword) =>
Expand Down
10 changes: 10 additions & 0 deletions backend/src/routes/keywords.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { keywords } from "../db/schema";
import { ownedBy } from "../lib/authorization/ownership";
import { getCache } from "../lib/cache";
import { publish } from "../lib/kwsync";
import { getConfig } from "../config";

export const keywordsRoutes = Router();

Expand Down Expand Up @@ -55,13 +56,22 @@ keywordsRoutes.get("/", async (req, res) => {
* responses:
* 202:
* description: Keyword enfileirada — o Go decide se persiste
* 403:
* description: Submissão de keywords por usuário desabilitada
* 400:
* description: Dados inválidos
*/
keywordsRoutes.post("/", async (req, res) => {
const userId = req.session?.userId;
if (!userId) return res.status(401).json({ message: "Não autenticado." });

if (!getConfig().kwsyncEnabled) {
return res.status(403).json({
ok: false,
message: "Submissão de keywords por usuário está desabilitada.",
});
}

const raw = req.body?.keyword;
const keyword = typeof raw === "string" ? raw.trim() : "";

Expand Down
26 changes: 26 additions & 0 deletions backend/tests/unit/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ const DEFAULT_PAGINATED = (ids: string[]) => ({
describe("jobsApiApp", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.KWSYNC_ENABLED = "false";

mocks.parsePagination.mockReturnValue(DEFAULT_PAGINATION);
mocks.paginate.mockImplementation((ids: string[]) =>
Expand Down Expand Up @@ -709,6 +710,8 @@ describe("jobsApiApp", () => {
});

it("POST /keywords enfileira keyword e retorna 202", async () => {
process.env.KWSYNC_ENABLED = "true";

const app = createJobsApiApp();
const res = await request(app)
.post("/keywords")
Expand All @@ -733,7 +736,26 @@ describe("jobsApiApp", () => {
});
});

it("POST /keywords retorna 403 quando kwsync está desabilitado", async () => {
const app = createJobsApiApp();

const res = await request(app)
.post("/keywords")
.send({ keyword: "Rust" })
.expect(403);

expect(res.body).toEqual({
ok: false,
message: "Submissão de keywords por usuário está desabilitada.",
});
expect(mocks.dbInsert).not.toHaveBeenCalled();
expect(mocks.getCache).not.toHaveBeenCalled();
expect(mocks.publish).not.toHaveBeenCalled();
});

it("POST /keywords retorna 400 quando keyword está ausente", async () => {
process.env.KWSYNC_ENABLED = "true";

const app = createJobsApiApp();

const res = await request(app).post("/keywords").send({}).expect(400);
Expand All @@ -745,6 +767,8 @@ describe("jobsApiApp", () => {
});

it("POST /keywords retorna 400 quando keyword é string vazia", async () => {
process.env.KWSYNC_ENABLED = "true";

const app = createJobsApiApp();

const res = await request(app)
Expand All @@ -758,6 +782,8 @@ describe("jobsApiApp", () => {
});

it("POST /keywords retorna 400 quando keyword não é string", async () => {
process.env.KWSYNC_ENABLED = "true";

// Arrays não são strings — a rota rejeita com 400 se keyword.trim() não existir
// ou com 500 se explodir antes. Ajusta a expectativa ao comportamento real da rota:
// req.body?.keyword?.trim() em um array retorna undefined → cai no if → 400
Expand Down
30 changes: 28 additions & 2 deletions backend/tests/unit/libs/kwsync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,20 @@ function getCall(client: ReturnType<typeof makeClient>, index = 0) {
}

describe("publish", () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
process.env.KWSYNC_ENABLED = "true";
});

it("ignora publicação quando KWSYNC_ENABLED=false", async () => {
process.env.KWSYNC_ENABLED = "false";

const client = makeClient();
await publish(client as any, "React");

expect(client.lPush).not.toHaveBeenCalled();
expect(mocks.loggerInfo).toHaveBeenCalledOnce();
});

it("chama lPush com a chave correta", async () => {
const client = makeClient();
Expand Down Expand Up @@ -79,14 +92,27 @@ describe("publish", () => {
});

describe("publishBatch", () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
process.env.KWSYNC_ENABLED = "true";
});

it("nao chama lPush quando keywords e array vazio", async () => {
const client = makeClient();
await publishBatch(client as any, []);
expect(client.lPush).not.toHaveBeenCalled();
});

it("ignora lote quando KWSYNC_ENABLED=false", async () => {
process.env.KWSYNC_ENABLED = "false";

const client = makeClient();
await publishBatch(client as any, ["Java", "Node.js"]);

expect(client.lPush).not.toHaveBeenCalled();
expect(mocks.loggerInfo).toHaveBeenCalledOnce();
});

it("chama lPush uma unica vez com array de payloads", async () => {
const client = makeClient();
await publishBatch(client as any, ["Java", "Node.js", "Go"]);
Expand Down
50 changes: 50 additions & 0 deletions frontend/tests/unit/new_dashboard/jobs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { AddJobModal } from "@/domains/new_dashboard/components/jobs/AddJobModal";
import { JobDetailModal } from "@/domains/new_dashboard/components/jobs/JobDetailModal";
import { JobFilter } from "@/domains/new_dashboard/components/jobs/JobFilter";
import { FormattedJobDescription } from "@/domains/new_dashboard/components/jobs/FormattedJobDescription";
import { JobRow } from "@/domains/new_dashboard/components/jobs/JobRow";
import { JobTab } from "@/domains/new_dashboard/components/jobs/JobTab";
import { JobTable } from "@/domains/new_dashboard/components/jobs/JobTable";
Expand Down Expand Up @@ -340,6 +341,55 @@ describe("new_dashboard job components", () => {
expect(screen.queryByText(/&lt;h3&gt;/i)).not.toBeInTheDocument();
});

it("renderiza texto puro, tags semânticas e links inseguros da descrição", () => {
const plainRender = render(
<FormattedJobDescription description={"Linha 1\nLinha 2"} />,
);

expect(screen.getByText(/linha 1/i)).toBeInTheDocument();
expect(screen.getByText(/linha 2/i)).toBeInTheDocument();
expect(plainRender.container.querySelector("p")).toHaveClass(
"whitespace-pre-wrap",
);

plainRender.unmount();

const { container } = render(
<FormattedJobDescription
description={[
"<h1>Título principal</h1>",
"<h2>Subtítulo</h2>",
"<h4>Grupo</h4>",
"<blockquote>Citação</blockquote>",
"<pre><code>npm test</code></pre>",
"<hr>",
'<a href="javascript:alert(1)">Link bloqueado</a>',
'<a href="/vaga">Link relativo seguro</a>',
"<img src=x onerror=alert(1)>",
"<div><span>Conteúdo preservado</span></div>",
].join("")}
/>,
);

expect(
screen.getByRole("heading", { name: "Título principal", level: 1 }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Subtítulo", level: 2 }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Grupo", level: 4 }),
).toBeInTheDocument();
expect(container.querySelector("blockquote")).toHaveTextContent("Citação");
expect(container.querySelector("pre")).toHaveTextContent("npm test");
expect(container.querySelector("hr")).toBeInTheDocument();
expect(screen.getByText("Link bloqueado").tagName).toBe("SPAN");
expect(screen.getByRole("link", { name: "Link relativo seguro" }))
.toHaveAttribute("href", "http://localhost:3000/vaga");
expect(container.querySelector("img")).not.toBeInTheDocument();
expect(screen.getByText("Conteúdo preservado")).toBeInTheDocument();
});

it("valida e salva uma vaga manual nova", () => {
const onAddJob = vi.fn();
const onClose = vi.fn();
Expand Down
Loading
Loading