feat: API HTTP authentifiée (chat + review) (v0.20.0)
registerApi : login rate-limité → JWT, puis Bearer obligatoire sur /api/chat, /api/review (+approve/refuse), /api/state. CORS restreint + rate-limit. Réutilise ChatService + ReviewService. API activée seulement si auth configurée (apiAuth). 65 tests (5 via inject), 0 vuln. Palier de risque : privilégié (surface exposée) — montée seulement si auth présente ; non exposée tant que .env non renseigné. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,17 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.20.0] — 2026-06-23
|
||||||
|
### Added
|
||||||
|
- `src/api/routes.ts` : API HTTP (`registerApi`) — `POST /api/auth/login`
|
||||||
|
(rate-limit serré), JWT Bearer sur le reste, `POST /api/chat`, `GET /api/review`,
|
||||||
|
`POST /api/review/:id/{approve,refuse}`, `GET /api/state`. CORS restreint +
|
||||||
|
rate-limit global. Réutilise `ChatService` + `ReviewService`.
|
||||||
|
- Config auth API (`CHLOVA_ADMIN_USER/_PASSWORD_HASH/_TOTP_SECRET/_JWT_SECRET`,
|
||||||
|
`CHLOVA_WEB_ORIGIN`) + helper `apiAuth()` ; API activée seulement si configurée.
|
||||||
|
- Câblage `index.ts` (API montée si auth présente). Deps `@fastify/cors`,
|
||||||
|
`@fastify/rate-limit` épinglées. 5 tests API (inject). 65 tests, 0 vuln.
|
||||||
|
|
||||||
## [0.19.0] — 2026-06-23 — début Phase 4 (UI : auth + service chat)
|
## [0.19.0] — 2026-06-23 — début Phase 4 (UI : auth + service chat)
|
||||||
### Added
|
### Added
|
||||||
- `src/api/auth.ts` : auth surface exposée — mot de passe scrypt (`node:crypto`),
|
- `src/api/auth.ts` : auth surface exposée — mot de passe scrypt (`node:crypto`),
|
||||||
|
|||||||
Generated
+70
-2
@@ -8,10 +8,12 @@
|
|||||||
"name": "chlova-orchestrator",
|
"name": "chlova-orchestrator",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/rate-limit": "^11.0.0",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"jose": "^6.2.3",
|
"jose": "6.2.3",
|
||||||
"otplib": "^13.4.1",
|
"otplib": "13.4.1",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
@@ -522,6 +524,26 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"fast-uri": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/cors": {
|
||||||
|
"version": "11.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||||
|
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"toad-cache": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/error": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
@@ -612,6 +634,27 @@
|
|||||||
"ipaddr.js": "^2.1.0"
|
"ipaddr.js": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/rate-limit": {
|
||||||
|
"version": "11.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz",
|
||||||
|
"integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"toad-cache": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
@@ -631,6 +674,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lukeed/ms": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -1867,6 +1919,22 @@
|
|||||||
"toad-cache": "^3.7.0"
|
"toad-cache": "^3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fastify-plugin": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cors": "11.2.0",
|
||||||
|
"@fastify/rate-limit": "11.0.0",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"jose": "6.2.3",
|
"jose": "6.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import type { ChatService } from "../agent/chat-service.js";
|
||||||
|
import type { ReviewService } from "../gatekeeper/review.js";
|
||||||
|
import { login, verifyJwt, type AuthConfig } from "./auth.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API HTTP de la surface exposée (Phase 4). JWT obligatoire sauf /api/auth/login.
|
||||||
|
* Réutilise ChatService (même comportement que Telegram) et ReviewService.
|
||||||
|
*
|
||||||
|
* Sécurité : login rate-limité (anti brute-force), JWT Bearer sur tout le reste,
|
||||||
|
* CORS restreint à l'origine du SPA (dev) — same-origin en prod.
|
||||||
|
*/
|
||||||
|
export interface ApiDeps {
|
||||||
|
auth: AuthConfig;
|
||||||
|
chat: ChatService;
|
||||||
|
review: ReviewService | null;
|
||||||
|
state: () => Record<string, unknown>;
|
||||||
|
webOrigin?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise<void> {
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: deps.webOrigin ?? false,
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
allowedHeaders: ["content-type", "authorization"],
|
||||||
|
});
|
||||||
|
await app.register(rateLimit, { max: 120, timeWindow: "1 minute" });
|
||||||
|
|
||||||
|
// Garde JWT : 401 si Bearer absent/invalide.
|
||||||
|
const requireAuth = async (req: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||||
|
const header = req.headers.authorization ?? "";
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||||
|
const sub = token ? await verifyJwt(token, deps.auth.jwtSecret) : null;
|
||||||
|
if (!sub) {
|
||||||
|
await reply.code(401).send({ error: "non autorisé" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Login (rate-limit serré, anti brute-force) ──────────────────────────
|
||||||
|
app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const user = String(body.user ?? "");
|
||||||
|
const password = String(body.password ?? "");
|
||||||
|
const totp = String(body.totp ?? "");
|
||||||
|
const token = await login(deps.auth, { user, password, totp });
|
||||||
|
if (!token) return reply.code(401).send({ error: "identifiants invalides" });
|
||||||
|
return { token };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Chat ────────────────────────────────────────────────────────────────
|
||||||
|
app.post("/api/chat", { preHandler: requireAuth }, async (req, reply) => {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const message = String(body.message ?? "").trim();
|
||||||
|
if (!message) return reply.code(400).send({ error: "message vide" });
|
||||||
|
const replyText = await deps.chat.handle("api:owner", message);
|
||||||
|
return { reply: replyText };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Review ────────────────────────────────────────────────────────────────
|
||||||
|
app.get("/api/review", { preHandler: requireAuth }, async () => {
|
||||||
|
return { assets: deps.review ? deps.review.listPending() : [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const decide = (action: "approve" | "refuse") =>
|
||||||
|
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (!deps.review) return reply.code(409).send({ error: "review indisponible (Phase 1)" });
|
||||||
|
const id = (req.params as { id: string }).id;
|
||||||
|
try {
|
||||||
|
const asset = action === "approve" ? deps.review.approve(id) : deps.review.refuse(id);
|
||||||
|
return { asset };
|
||||||
|
} catch {
|
||||||
|
return reply.code(404).send({ error: "asset inconnu" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app.post("/api/review/:id/approve", { preHandler: requireAuth }, decide("approve"));
|
||||||
|
app.post("/api/review/:id/refuse", { preHandler: requireAuth }, decide("refuse"));
|
||||||
|
|
||||||
|
// ── État ────────────────────────────────────────────────────────────────
|
||||||
|
app.get("/api/state", { preHandler: requireAuth }, async () => deps.state());
|
||||||
|
}
|
||||||
@@ -67,6 +67,17 @@ const schema = z.object({
|
|||||||
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
|
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
|
||||||
z.string().url().optional(),
|
z.string().url().optional(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// API/UI (Phase 4) — surface exposée, login fort. L'API n'est activée que si
|
||||||
|
// ces 4 valeurs sont présentes (apiAuth()). Tout optionnel sinon.
|
||||||
|
adminUser: z.string().optional(),
|
||||||
|
adminPasswordHash: z.string().optional(), // SECRET — hash scrypt (cf. scripts/hash-password)
|
||||||
|
totpSecret: z.string().optional(), // SECRET
|
||||||
|
jwtSecret: z.string().optional(), // SECRET
|
||||||
|
webOrigin: z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
|
||||||
|
z.string().url().optional(),
|
||||||
|
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof schema>;
|
export type Config = z.infer<typeof schema>;
|
||||||
@@ -78,6 +89,9 @@ const SECRET_KEYS = new Set<keyof Config>([
|
|||||||
"portainerMcpAuthToken",
|
"portainerMcpAuthToken",
|
||||||
"telegramBotToken",
|
"telegramBotToken",
|
||||||
"alertWebhookUrl",
|
"alertWebhookUrl",
|
||||||
|
"adminPasswordHash",
|
||||||
|
"totpSecret",
|
||||||
|
"jwtSecret",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||||
@@ -97,6 +111,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|||||||
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
|
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
|
||||||
dbPath: env.CHLOVA_DB_PATH,
|
dbPath: env.CHLOVA_DB_PATH,
|
||||||
alertWebhookUrl: env.ALERT_WEBHOOK_URL,
|
alertWebhookUrl: env.ALERT_WEBHOOK_URL,
|
||||||
|
adminUser: env.CHLOVA_ADMIN_USER,
|
||||||
|
adminPasswordHash: env.CHLOVA_ADMIN_PASSWORD_HASH,
|
||||||
|
totpSecret: env.CHLOVA_TOTP_SECRET,
|
||||||
|
jwtSecret: env.CHLOVA_JWT_SECRET,
|
||||||
|
webOrigin: env.CHLOVA_WEB_ORIGIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -130,6 +149,22 @@ export function assertReadOnlyPhase(cfg: Config): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config d'auth de l'API si les 4 valeurs requises sont présentes, sinon null
|
||||||
|
* (API/UI désactivée — surface non exposée).
|
||||||
|
*/
|
||||||
|
export function apiAuth(cfg: Config): import("./api/auth.js").AuthConfig | null {
|
||||||
|
if (cfg.adminUser && cfg.adminPasswordHash && cfg.totpSecret && cfg.jwtSecret) {
|
||||||
|
return {
|
||||||
|
adminUser: cfg.adminUser,
|
||||||
|
adminPasswordHash: cfg.adminPasswordHash,
|
||||||
|
totpSecret: cfg.totpSecret,
|
||||||
|
jwtSecret: cfg.jwtSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Vue de la config sûre pour les logs : secrets masqués. */
|
/** Vue de la config sûre pour les logs : secrets masqués. */
|
||||||
export function redactedConfig(cfg: Config): Record<string, unknown> {
|
export function redactedConfig(cfg: Config): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify, { type FastifyBaseLogger } from "fastify";
|
||||||
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "./config.js";
|
import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
|
||||||
|
import { registerApi } from "./api/routes.js";
|
||||||
import { createLogger } from "./audit/log.js";
|
import { createLogger } from "./audit/log.js";
|
||||||
import { OllamaClient } from "./llm/ollama.js";
|
import { OllamaClient } from "./llm/ollama.js";
|
||||||
import { McpRegistry } from "./mcp/registry.js";
|
import { McpRegistry } from "./mcp/registry.js";
|
||||||
@@ -103,12 +104,30 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Healthcheck interne (jamais publié) ─────────────────────────────────
|
// ── Healthcheck interne (jamais publié) ─────────────────────────────────
|
||||||
const app = Fastify({ loggerInstance: logger });
|
// pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le
|
||||||
app.get("/health", async () => ({
|
// type d'instance avec registerApi (comportement runtime identique).
|
||||||
status: "ok",
|
const app = Fastify({ loggerInstance: logger as unknown as FastifyBaseLogger });
|
||||||
|
const stateOf = () => ({
|
||||||
phase: cfg.phase === 2 ? "2-write-review" : "1-readonly",
|
phase: cfg.phase === 2 ? "2-write-review" : "1-readonly",
|
||||||
tools: tools.length,
|
tools: tools.length,
|
||||||
}));
|
});
|
||||||
|
app.get("/health", async () => ({ status: "ok", ...stateOf() }));
|
||||||
|
|
||||||
|
// API/UI : activée seulement si l'auth est configurée (surface exposée).
|
||||||
|
const auth = apiAuth(cfg);
|
||||||
|
if (auth) {
|
||||||
|
await registerApi(app, {
|
||||||
|
auth,
|
||||||
|
chat,
|
||||||
|
review,
|
||||||
|
state: stateOf,
|
||||||
|
webOrigin: cfg.webOrigin,
|
||||||
|
});
|
||||||
|
logger.info("API/UI activée (auth configurée)");
|
||||||
|
} else {
|
||||||
|
logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule");
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen({ host: "0.0.0.0", port: 8080 });
|
await app.listen({ host: "0.0.0.0", port: 8080 });
|
||||||
logger.info({ port: 8080 }, "healthcheck interne prêt");
|
logger.info({ port: 8080 }, "healthcheck interne prêt");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { generateSync, generateSecret } from "otplib";
|
||||||
|
import { registerApi } from "../src/api/routes.js";
|
||||||
|
import { hashPassword, type AuthConfig } from "../src/api/auth.js";
|
||||||
|
import { ChatService } from "../src/agent/chat-service.js";
|
||||||
|
import { ReviewService } from "../src/gatekeeper/review.js";
|
||||||
|
import { AssetRepository } from "../src/gatekeeper/repository.js";
|
||||||
|
import { createAsset } from "../src/gatekeeper/assets.js";
|
||||||
|
import { createLogger } from "../src/audit/log.js";
|
||||||
|
|
||||||
|
const log = createLogger("silent");
|
||||||
|
const auth: AuthConfig = {
|
||||||
|
adminUser: "kantin",
|
||||||
|
adminPasswordHash: hashPassword("pw"),
|
||||||
|
totpSecret: generateSecret(),
|
||||||
|
jwtSecret: "jwt-secret-suffisamment-long-pour-hs256",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ChatService stub : pas de vrai LLM.
|
||||||
|
const chatStub = {
|
||||||
|
handle: async (_actor: string, text: string) => `echo:${text}`,
|
||||||
|
} as unknown as ChatService;
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let repo: AssetRepository;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
repo = new AssetRepository(":memory:");
|
||||||
|
const review = new ReviewService(repo, log);
|
||||||
|
app = Fastify();
|
||||||
|
await registerApi(app, { auth, chat: chatStub, review, state: () => ({ phase: "2-write-review", tools: 3 }) });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
repo.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function tokenOf(): Promise<string> {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) },
|
||||||
|
});
|
||||||
|
return res.json().token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("API auth", () => {
|
||||||
|
it("login renvoie un token avec bons facteurs", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().token).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login 401 avec mauvais mot de passe", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { user: "kantin", password: "bad", totp: generateSync({ secret: auth.totpSecret }) },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/chat refuse sans token (401)", async () => {
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/chat", payload: { message: "salut" } });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/chat répond avec token", async () => {
|
||||||
|
const token = await tokenOf();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/chat",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { message: "salut" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().reply).toBe("echo:salut");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("API review", () => {
|
||||||
|
it("liste, approuve et refuse", async () => {
|
||||||
|
repo.create(createAsset({ id: "tool:x", type: "tool", version: "1.0.0", riskTier: "privileged" }));
|
||||||
|
const token = await tokenOf();
|
||||||
|
const auth_h = { authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
const list = await app.inject({ method: "GET", url: "/api/review", headers: auth_h });
|
||||||
|
expect(list.json().assets).toHaveLength(1);
|
||||||
|
|
||||||
|
const ok = await app.inject({ method: "POST", url: "/api/review/tool:x/approve", headers: auth_h });
|
||||||
|
expect(ok.json().asset.status).toBe("approuvé");
|
||||||
|
|
||||||
|
const missing = await app.inject({ method: "POST", url: "/api/review/nope/refuse", headers: auth_h });
|
||||||
|
expect(missing.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user