Files
zitadel/apps/docs/scripts/fetch-remote-content.test.mjs
T
Florian ForsterandGitHub 0999e44821 docs(ci): fix an error where the vercel build fails on forked PRs (#11467)
This pull request introduces several improvements and security
enhancements to the documentation build scripts in
`apps/docs/scripts/fetch-remote-content.mjs`, as well as updates to the
test configuration in `apps/docs/package.json` and
`apps/docs/project.json`. The main focus is on enabling local
development workflows, improving safety when handling refs and file
paths, and making the codebase more robust against injection and
traversal attacks.

**Security and validation improvements:**

* Added `safeLog` and `isValidRef` helper functions to sanitize logs and
validate refs, preventing log and command injection vulnerabilities.
* Updated logic for reading local files and external content to ensure
paths are securely resolved and checked, preventing directory traversal
and unauthorized file access.

**Local development and workflow enhancements:**

* Refactored content fetching logic to support copying local content and
public assets when the source ref matches the local branch, enabling
easier local testing and development.
[[1]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1L70-R202)
[[2]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1R230-L145)
[[3]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1L155-R250)
* Improved handling of external files (like `defaults.yaml` and
`setup/steps.yaml`) in local and remote workflows, including secure
copying and conditional downloading.
[[1]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1L70-R202)
[[2]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1R266-R364)
* Enhanced relative import fixing logic to robustly rewrite paths and
ensure external files are downloaded or copied as needed, with clearer
separation of cases for content, public, and external assets.
(F768070bL263R373,
[[1]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1L310-R398)
[[2]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1L345-L349)
[[3]](diffhunk://#diff-0d1ade7f3d86ad72c66b573c31b0bc34d318dbf964198164719822d300d397d1L360-R425)

**Testing and configuration updates:**

* Added a new `test:scripts` npm script for running script-level tests,
and updated the Nx test command to use this script for the docs app.
[[1]](diffhunk://#diff-d03d2ce97fad5059ad6248fca5f5919994b09bca360f883318c03f1f817ae677R19)
[[2]](diffhunk://#diff-92d9997980c0871325f1a46a6d363cc28da597bd19ed327e979774d9e7838cabL216-R216)

These changes improve the reliability, security, and developer
experience of the documentation build process.
2026-02-03 11:12:31 -08:00

150 lines
5.4 KiB
JavaScript

import { test, describe, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert';
import fs from 'fs';
import path from 'path';
import { getCurrentRef, resetCache, downloadFileContent, isValidRef, safeLog } from './fetch-remote-content.mjs';
const TEST_TMP_DIR = path.join(process.cwd(), '.test-tmp');
const MOCK_REPO_ROOT = path.join(TEST_TMP_DIR, 'mock-repo');
describe('fetch-remote-content', () => {
// --- Setup & Teardown ---
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
resetCache();
// Set up a mock repo structure
if (fs.existsSync(TEST_TMP_DIR)) {
fs.rmSync(TEST_TMP_DIR, { recursive: true, force: true });
}
fs.mkdirSync(MOCK_REPO_ROOT, { recursive: true });
// Create some dummy content
fs.writeFileSync(path.join(MOCK_REPO_ROOT, 'README.md'), '# Mock Repo');
fs.mkdirSync(path.join(MOCK_REPO_ROOT, 'secrets'), { recursive: true });
fs.writeFileSync(path.join(MOCK_REPO_ROOT, 'secrets/passwd'), 'super_secret');
});
afterEach(() => {
process.env = originalEnv;
mock.restoreAll();
resetCache();
if (fs.existsSync(TEST_TMP_DIR)) {
fs.rmSync(TEST_TMP_DIR, { recursive: true, force: true });
}
});
// --- Helper Tests ---
describe('isValidRef', () => {
test('accepts valid alphanumeric refs', () => {
assert.strictEqual(isValidRef('main'), true);
assert.strictEqual(isValidRef('v1.0.0'), true);
assert.strictEqual(isValidRef('feature/new-docs'), true);
assert.strictEqual(isValidRef('fix_bug-123'), true);
assert.strictEqual(isValidRef('dependabot/npm_and_yarn/@types/react'), true);
assert.strictEqual(isValidRef('build+test'), true);
});
test('rejects malicious refs', () => {
assert.strictEqual(isValidRef('; rm -rf'), false);
assert.strictEqual(isValidRef('main && echo pwned'), false);
assert.strictEqual(isValidRef('../parent'), false); // contains .. which isn't allowed in regex
});
});
describe('safeLog', () => {
test('strips newlines', () => {
assert.strictEqual(safeLog('valid'), 'valid');
assert.strictEqual(safeLog('malicious\nlog'), 'maliciouslog');
assert.strictEqual(safeLog('malicious\r\nlog'), 'maliciouslog');
});
});
// --- getCurrentRef Tests ---
describe('getCurrentRef', () => {
test('returns VERCEL_GIT_COMMIT_REF if set and valid', () => {
process.env.VERCEL_GIT_COMMIT_REF = 'vercel-branch';
delete process.env.GITHUB_REF_NAME;
assert.strictEqual(getCurrentRef(), 'vercel-branch');
});
test('ignores invalid VERCEL_GIT_COMMIT_REF', () => {
// Should fall through to GITHUB or git command
process.env.VERCEL_GIT_COMMIT_REF = 'invalid;cmd';
process.env.GITHUB_REF_NAME = 'valid-github';
assert.strictEqual(getCurrentRef(), 'valid-github');
});
test('returns GITHUB_REF_NAME if set and valid', () => {
delete process.env.VERCEL_GIT_COMMIT_REF;
process.env.GITHUB_REF_NAME = 'github-branch';
assert.strictEqual(getCurrentRef(), 'github-branch');
});
});
// --- downloadFileContent Tests ---
describe('downloadFileContent', () => {
test('refuses traversal attempts (..)', async () => {
// We expect this to return null and log a warning
const content = await downloadFileContent('main', '../secrets/passwd');
assert.strictEqual(content, null);
});
test('refuses absolute paths', async () => {
const content = await downloadFileContent('main', '/etc/passwd');
assert.strictEqual(content, null);
});
test('refuses encoded traversal attempts', async () => {
// %2e%2e is ..
const content = await downloadFileContent('main', '%2e%2e/secrets/passwd');
assert.strictEqual(content, null);
});
test('reads valid local file (README.md)', async () => {
// Mock getCurrentRef to return 'main' and pass 'main' to simulate local
process.env.VERCEL_GIT_COMMIT_REF = 'main';
const content = await downloadFileContent('main', 'README.md');
// It should find the real README.md of the project since the script resolves from __dirname
assert.ok(content);
assert.ok(content.length > 0);
});
test('refuses invalid ref strings', async () => {
// Validation check for command injection protection
const content = await downloadFileContent('invalid; rm -rf', 'README.md');
assert.strictEqual(content, null);
});
});
// --- filterVersions Tests ---
describe('filterVersions', () => {
test('filters versions older than CUTOFF (4.10.0)', async () => {
const { filterVersions } = await import('./fetch-remote-content.mjs');
const tags = [
{ name: 'v4.9.0' },
{ name: 'v4.10.0' }, // Matches cutoff strictly > 4.10.0? No, gt is strict.
{ name: 'v4.11.0' },
{ name: 'v5.0.0-alpha' },
{ name: 'invalid' }
];
const result = filterVersions(tags);
// Expect only v4.11.0 and v5.0.0-alpha, but slice(0, 3) logic applies
// If gt(4.10.0), then 4.10.0 is excluded.
// sort is rcompare (descending).
// 5.0.0-alpha, 4.11.0
assert.ok(result.includes('v4.11.0'));
assert.ok(!result.includes('v4.10.0'));
assert.ok(!result.includes('v4.9.0'));
});
});
});