feat(FN-189): add CHANGELOG_AUTOMATION_TOKEN to env schema (+5 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
Commits merged: - docs(FN-189): complete Step 7 — update docs/INDEX.md and MEMORY.md - fix(FN-189): lint fixes — import order, formatting - test(FN-189): complete Step 4 — add controller spec with 6 token auth scenarios - feat(FN-189): complete Step 3 — add Fusion changelog automation trigger to deploy workflow - feat(FN-189): complete Step 2 — add POST /api/changelog/internal endpoint with token auth - feat(FN-189): complete Step 1 — add CHANGELOG_AUTOMATION_TOKEN to env schema Files changed: .fusion/memory/MEMORY.md | 16 +++ .github/workflows/deploy.yml | 17 +++ .../api/src/changelog/changelog.controller.spec.ts | 122 +++++++++++++++++++++ apps/api/src/changelog/changelog.controller.ts | 49 ++++++++- docs/INDEX.md | 1 + packages/config/src/index.ts | 3 + 6 files changed, 207 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-189
This commit is contained in:
122
apps/api/src/changelog/changelog.controller.spec.ts
Normal file
122
apps/api/src/changelog/changelog.controller.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ServiceUnavailableException, UnauthorizedException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChangelogController } from "./changelog.controller";
|
||||
|
||||
// Mock crypto.timingSafeEqual for deterministic test control
|
||||
const mockTimingSafeEqual = vi.fn();
|
||||
vi.mock("node:crypto", () => ({
|
||||
timingSafeEqual: (...args: unknown[]) => mockTimingSafeEqual(...args),
|
||||
}));
|
||||
|
||||
function createMockService(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const sampleEntry = {
|
||||
id: "entry-1",
|
||||
stage: "prod" as const,
|
||||
title: "Deployed v2.3.0",
|
||||
description: "Production deploy completed successfully.",
|
||||
publishedAt: "2026-05-11T12:00:00.000Z",
|
||||
createdAt: "2026-05-11T12:00:00.000Z",
|
||||
updatedAt: "2026-05-11T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const VALID_TOKEN = "my-token-32-chars-minimum!!!";
|
||||
|
||||
describe("ChangelogController — createInternal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("token auth", () => {
|
||||
it("should return 503 when CHANGELOG_AUTOMATION_TOKEN env is not set", async () => {
|
||||
// Ensure the env var is unset
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", undefined as unknown as string);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal("Bearer some-token", sampleEntry as any),
|
||||
).rejects.toThrow(ServiceUnavailableException);
|
||||
});
|
||||
|
||||
it("should return 401 when Authorization header is missing", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal(undefined as unknown as string, sampleEntry as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should return 401 when Authorization has wrong prefix", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal("Basic some-token", sampleEntry as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should return 401 when token is wrong", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
// Setup timingSafeEqual to return false (wrong token)
|
||||
mockTimingSafeEqual.mockReturnValue(false);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal("Bearer wrong-token", sampleEntry as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should return 201 and the created entry when token is valid", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
// Setup timingSafeEqual to return true (matching token)
|
||||
mockTimingSafeEqual.mockReturnValue(true);
|
||||
|
||||
const mockService = createMockService({
|
||||
create: vi.fn().mockResolvedValue(sampleEntry),
|
||||
});
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
const result = await controller.createInternal(`Bearer ${VALID_TOKEN}`, sampleEntry as any);
|
||||
|
||||
expect(mockService.create).toHaveBeenCalledWith(sampleEntry);
|
||||
expect(result).toEqual(sampleEntry);
|
||||
});
|
||||
});
|
||||
|
||||
describe("existing endpoints unaffected", () => {
|
||||
it("should still have the admin POST (create) endpoint that delegates to service", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
const mockService = createMockService({
|
||||
create: vi.fn().mockResolvedValue(sampleEntry),
|
||||
});
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
const result = await controller.create(sampleEntry as any);
|
||||
|
||||
expect(mockService.create).toHaveBeenCalledWith(sampleEntry);
|
||||
expect(result).toEqual(sampleEntry);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,17 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
@@ -35,4 +48,38 @@ export class ChangelogController {
|
||||
async delete(@Param("id") id: string) {
|
||||
return this.changelogService.delete(id);
|
||||
}
|
||||
|
||||
@Post("internal")
|
||||
@Public()
|
||||
async createInternal(
|
||||
@Headers("authorization") authHeader: string,
|
||||
@Body() body: CreateChangelogEntry,
|
||||
) {
|
||||
// 1. Check if the automation token is configured
|
||||
const token = process.env.CHANGELOG_AUTOMATION_TOKEN;
|
||||
if (!token) {
|
||||
throw new ServiceUnavailableException("Changelog automation endpoint is not configured");
|
||||
}
|
||||
|
||||
// 2. Validate the Authorization header
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException("Missing or invalid Authorization header");
|
||||
}
|
||||
|
||||
// 3. Extract the token value
|
||||
const providedToken = authHeader.slice(7);
|
||||
|
||||
// 4. Constant-time comparison
|
||||
if (providedToken.length !== token.length) {
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
|
||||
const isMatch = timingSafeEqual(Buffer.from(providedToken), Buffer.from(token));
|
||||
if (!isMatch) {
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
|
||||
// 5. Create the entry
|
||||
return this.changelogService.create(body);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user