Skip to content
Draft
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
91 changes: 91 additions & 0 deletions apps/code-infra-dashboard/app/api/qr-code/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { signQrCodeUrl } from '@/lib/qrCode';
import { GET } from './route';

describe('GET /api/qr-code', () => {
beforeEach(() => {
vi.stubEnv('QR_CODE_SECRET', 'test-secret');
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('should respond with a cacheable SVG for a validly signed URL', async () => {
const signedUrl = signQrCodeUrl('https://example.com/page');

const response = await GET(new NextRequest(signedUrl!));

expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
expect(await response.text()).toContain('<svg');
});

it('should respond with 403 for an invalid signature', async () => {
const url = new URL('https://dashboard.test/api/qr-code');
url.searchParams.set('url', 'https://example.com/page');
url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA');

const response = await GET(new NextRequest(url));

expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('should respond with 403 when the signed URL is tampered with', async () => {
const signedUrl = new URL(signQrCodeUrl('https://example.com/page')!);
signedUrl.searchParams.set('url', 'https://example.com/other');

const response = await GET(new NextRequest(signedUrl));

expect(response.status).toBe(403);
});

it('should respond with 400 when query parameters are missing', async () => {
const response = await GET(new NextRequest('https://dashboard.test/api/qr-code'));

expect(response.status).toBe(400);
expect(response.headers.get('Cache-Control')).toBe('no-store');
});

it('should respond with 400 for a non-https URL', async () => {
const url = new URL('https://dashboard.test/api/qr-code');
url.searchParams.set('url', 'http://example.com/page');
url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA');

const response = await GET(new NextRequest(url));

expect(response.status).toBe(400);
});

it('should respond with 400 for an overly long URL', async () => {
const url = new URL('https://dashboard.test/api/qr-code');
url.searchParams.set('url', `https://example.com/${'a'.repeat(3000)}`);
url.searchParams.set('sig', 'AAAAAAAAAAAAAAAAAAAAAA');

const response = await GET(new NextRequest(url));

expect(response.status).toBe(400);
});

it('should respond with 400 for an overly long signature', async () => {
const url = new URL('https://dashboard.test/api/qr-code');
url.searchParams.set('url', 'https://example.com/page');
url.searchParams.set('sig', 'A'.repeat(100));

const response = await GET(new NextRequest(url));

expect(response.status).toBe(400);
});

it('should respond with 503 when no signing key is configured', async () => {
const signedUrl = signQrCodeUrl('https://example.com/page');
vi.stubEnv('QR_CODE_SECRET', '');

const response = await GET(new NextRequest(signedUrl!));

expect(response.status).toBe(503);
});
});
60 changes: 60 additions & 0 deletions apps/code-infra-dashboard/app/api/qr-code/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { type NextRequest, NextResponse } from 'next/server';
import { generateQrCodeSvg, verifyQrCodeSignature, QR_CODE_MAX_URL_LENGTH } from '@/lib/qrCode';

// A valid base64url signature is 22 chars; allow some slack but reject obvious abuse.
const MAX_SIGNATURE_LENGTH = 64;

function errorResponse(message: string, status: number): NextResponse {
return NextResponse.json(
{ error: message },
{ status, headers: { 'Cache-Control': 'no-store' } },
);
}

export async function GET(request: NextRequest): Promise<Response> {
const url = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sig');

if (!url || !signature) {
return errorResponse('Missing url or sig query parameter', 400);
}

if (url.length > QR_CODE_MAX_URL_LENGTH) {
return errorResponse('url query parameter is too long', 400);
}

if (signature.length > MAX_SIGNATURE_LENGTH) {
return errorResponse('sig query parameter is too long', 400);
}

let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return errorResponse('url query parameter is not a valid URL', 400);
}

if (parsedUrl.protocol !== 'https:') {
return errorResponse('Only https URLs are supported', 400);
}

if (!process.env.QR_CODE_SECRET) {
return errorResponse('QR code signing is not configured', 503);
}

if (!verifyQrCodeSignature(url, signature)) {
return errorResponse('Invalid signature', 403);
}

const svg = await generateQrCodeSvg(url);

return new NextResponse(svg, {
headers: {
'Content-Type': 'image/svg+xml',
// QR output for a given URL never changes, cacheable forever (incl. GitHub camo)
'Cache-Control': 'public, max-age=31536000, immutable',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'",
},
});
}
2 changes: 2 additions & 0 deletions apps/code-infra-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"mysql2": "^3.22.3",
"next": "^16.2.6",
"pako": "^2.1.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"semver": "^7.8.0",
Expand All @@ -35,6 +36,7 @@
"@types/etag": "1.8.4",
"@types/node": "22.19.0",
"@types/pako": "2.0.4",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getOctokit } from '@/lib/github';
import { verifyQrCodeSignature } from '@/lib/qrCode';
import { generateDeployPreviewReport } from './deployPreviewReport';
import type { ReportOptions } from './types';

vi.mock('@/lib/github', () => ({
getOctokit: vi.fn(),
}));

const mockGetOctokit = vi.mocked(getOctokit);

const mockOctokit = {
pulls: {
listFiles: vi.fn(),
},
};

function reportOptions(repo: string, prNumber: number): ReportOptions {
return {
repo,
prNumber,
commitSha: 'abc123',
pr: { base: { sha: 'def456', ref: 'master' } },
baseCandidates: ['def456'],
};
}

describe('generateDeployPreviewReport', () => {
beforeEach(() => {
vi.stubEnv('QR_CODE_SECRET', 'test-secret');
mockGetOctokit.mockReturnValue(mockOctokit as any);
mockOctokit.pulls.listFiles.mockResolvedValue({ data: [] });
});

afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});

it('should return null for repos without netlify docs config', async () => {
const report = await generateDeployPreviewReport(reportOptions('mui/base-ui', 1));

expect(report).toBeNull();
});

it('should link the preview root with a QR code when no doc files changed', async () => {
const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42));

const previewUrl = 'https://deploy-preview-42--material-ui.netlify.app/';
expect(report?.content).toContain('## Deploy preview');
expect(report?.content).toContain(
`<details><summary><a href="${previewUrl}">${previewUrl}</a></summary>`,
);
});

it('should wrap each changed doc page in a collapsible QR code link', async () => {
mockOctokit.pulls.listFiles.mockResolvedValue({
data: [
{ filename: 'docs/data/material/components/buttons/buttons.md', status: 'modified' },
{ filename: 'packages/mui-material/src/Button/Button.tsx', status: 'modified' },
],
});

const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42));

const pageUrl =
'https://deploy-preview-42--material-ui.netlify.app/material-ui/components/buttons';
expect(report?.content).toContain(
`- <details><summary><a href="${pageUrl}">docs/data/material/components/buttons/buttons.md</a></summary>`,
);
expect(report?.content).not.toContain('Button.tsx');
});

it('should embed a verifiable signed QR code URL', async () => {
const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42));

const imgSrc = report?.content.match(/<img src="([^"]+)"/)?.[1];
expect(imgSrc).toBeTruthy();
// The src is HTML-escaped in the markup; a browser decodes &amp; before fetching.
const qrCodeUrl = new URL(imgSrc!.replace(/&amp;/g, '&'));
expect(qrCodeUrl.pathname).toBe('/api/qr-code');
const url = qrCodeUrl.searchParams.get('url')!;
const signature = qrCodeUrl.searchParams.get('sig')!;
expect(url).toBe('https://deploy-preview-42--material-ui.netlify.app/');
expect(verifyQrCodeSignature(url, signature)).toBe(true);
});

it('should skip removed files', async () => {
mockOctokit.pulls.listFiles.mockResolvedValue({
data: [{ filename: 'docs/data/material/components/buttons/buttons.md', status: 'removed' }],
});

const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42));

expect(report?.content).not.toContain('buttons.md');
});

it('should cap the number of doc links', async () => {
mockOctokit.pulls.listFiles.mockResolvedValue({
data: Array.from({ length: 10 }, (unused, index) => ({
filename: `docs/data/material/components/page-${index}/page-${index}.md`,
status: 'modified',
})),
});

const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42));

expect(report?.content.match(/<details>/g)).toHaveLength(5);
});

it('should escape HTML-special characters in the file path', async () => {
mockOctokit.pulls.listFiles.mockResolvedValue({
data: [{ filename: 'docs/data/material/components/a<b>&"c/page.md', status: 'modified' }],
});

const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42));

expect(report?.content).toContain('a&lt;b&gt;&amp;&quot;c');
expect(report?.content).not.toContain('a<b>');
});

it('should fall back to plain links when no signing key is configured', async () => {
vi.stubEnv('QR_CODE_SECRET', '');
mockOctokit.pulls.listFiles.mockResolvedValue({
data: [{ filename: 'docs/data/material/components/buttons/buttons.md', status: 'modified' }],
});

const report = await generateDeployPreviewReport(reportOptions('mui/material-ui', 42));

expect(report?.content).toContain(
'- [docs/data/material/components/buttons/buttons.md](https://deploy-preview-42--material-ui.netlify.app/material-ui/components/buttons)',
);
expect(report?.content).not.toContain('<details>');
});
});
26 changes: 22 additions & 4 deletions apps/code-infra-dashboard/src/lib/ciReports/deployPreviewReport.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { getOctokit } from '@/lib/github';
import { repositories } from '@/constants';
import { signQrCodeUrl } from '@/lib/qrCode';
import { escapeHtml } from '@/utils/dom';
import type { ReportOptions, ReportResult } from './types';

export const DEPLOY_PREVIEW_SECTION_TITLE = 'Deploy preview';

const MAX_DOC_LINKS = 5;

/**
* Formats a link with a collapsible QR code for opening it on a phone.
* Falls back to a plain markdown link when no signing key is configured.
* Single-line HTML so it renders correctly inside markdown list items.
*/
function formatLinkWithQr(label: string, url: string): string {
const qrCodeUrl = signQrCodeUrl(url);
if (!qrCodeUrl) {
return `[${label}](${url})`;
}
const safeLabel = escapeHtml(label);
return `<details><summary><a href="${escapeHtml(url)}">${safeLabel}</a></summary><img src="${escapeHtml(qrCodeUrl)}" width="150" alt="QR code for ${safeLabel}"></details>`;
}

export async function generateDeployPreviewReport(
options: ReportOptions,
): Promise<ReportResult | null> {
Expand All @@ -24,7 +40,9 @@ export async function generateDeployPreviewReport(
const previewUrl = `https://deploy-preview-${prNumber}--${siteId}.netlify.app/`;

if (!formatDocPath) {
return { content: `## ${DEPLOY_PREVIEW_SECTION_TITLE}\n\n${previewUrl}` };
return {
content: `## ${DEPLOY_PREVIEW_SECTION_TITLE}\n\n${formatLinkWithQr(previewUrl, previewUrl)}`,
};
}

const [owner, repoSegment] = repo.split('/');
Expand All @@ -44,7 +62,7 @@ export async function generateDeployPreviewReport(
}
const docPath = formatDocPath(file.filename);
if (docPath) {
docLinks.push({ filePath: file.filename, url: `${previewUrl}${docPath}` });
docLinks.push({ filePath: file.filename, url: new URL(docPath, previewUrl).toString() });
if (docLinks.length >= MAX_DOC_LINKS) {
break;
}
Expand All @@ -54,9 +72,9 @@ export async function generateDeployPreviewReport(
let markdown = `## ${DEPLOY_PREVIEW_SECTION_TITLE}\n\n`;

if (docLinks.length > 0) {
markdown += docLinks.map((link) => `- [${link.filePath}](${link.url})`).join('\n');
markdown += docLinks.map((link) => `- ${formatLinkWithQr(link.filePath, link.url)}`).join('\n');
} else {
markdown += previewUrl;
markdown += formatLinkWithQr(previewUrl, previewUrl);
}

return { content: markdown };
Expand Down
Loading
Loading