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

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:
Fusion
2026-05-11 20:50:14 +00:00
parent 85ebd35048
commit 457293ad6a
6 changed files with 207 additions and 1 deletions

View 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);
});
});
});

View File

@@ -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);
}
}