diff --git a/.markmv-cache/content-freshness.json b/.markmv-cache/content-freshness.json new file mode 100644 index 0000000..9e223e5 --- /dev/null +++ b/.markmv-cache/content-freshness.json @@ -0,0 +1,227 @@ +{ + "https://firebase.google.com/docs/functions": { + "url": "https://firebase.google.com/docs/functions", + "contentHash": "8dc5a6ffd9f76e6da7490b234efb43616f76162e245ef01b40abd56f660c9d1c", + "lastChecked": 1753888688926, + "lastModified": 1733152688000, + "headers": { + "last-modified": "Mon, 02 Dec 2024 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/slow": { + "url": "https://example.com/slow", + "contentHash": "c50e7f179e9366e760b31ffe10b58e3df864c032b5dd2da1f0d9b60707f0b460", + "lastChecked": 1753888583655, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh-docs": { + "url": "https://example.com/fresh-docs", + "contentHash": "3b69da73bdd19811ae8a16a2eb6413367741c8761ffe3e86ad31a2a00624dd2a", + "lastChecked": 1753888688919, + "lastModified": 1751296688000, + "headers": { + "last-modified": "Mon, 30 Jun 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://api.example.com/guide": { + "url": "https://api.example.com/guide", + "contentHash": "e917e3371a256e90b7eef332373b43b2035aa54aeed29d8fadda414cbdf9fefb", + "lastChecked": 1753888688919, + "lastModified": 1751296688000, + "headers": { + "last-modified": "Mon, 30 Jun 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/old-tutorial": { + "url": "https://example.com/old-tutorial", + "contentHash": "1e711ab811d6dcb97befaa1206204f6d6d2f6f248d5f5aa8805b8ce336206ae4", + "lastChecked": 1753888688923, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://api.example.com/deprecated": { + "url": "https://api.example.com/deprecated", + "contentHash": "7ad24bf8be8e4195e3de8f0cd359269e3a1d05d83ce3f9f1ee55e15cef8fca54", + "lastChecked": 1753888688936, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://docs.github.com/actions/reference": { + "url": "https://docs.github.com/actions/reference", + "contentHash": "1815e9484fc1cdbe07884330f12a39ed025ff58e17e256bfc07b2096d5b3d171", + "lastChecked": 1753888688927, + "lastModified": 1733152688000, + "headers": { + "last-modified": "Mon, 02 Dec 2024 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/docs": { + "url": "https://example.com/docs", + "contentHash": "a2c40fe2789303214ac611b2054bf2dcb9470fae22302e70bb884584a1260bb0", + "lastChecked": 1753888688927, + "lastModified": 1733152688000, + "headers": { + "last-modified": "Mon, 02 Dec 2024 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh": { + "url": "https://example.com/fresh", + "contentHash": "12f9aed8bfb23cb0af37687b72356ac080f9ce9b737396e4a5ff60f20243c735", + "lastChecked": 1753888688940, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/stale": { + "url": "https://example.com/stale", + "contentHash": "3cdb12f564205d58dbaf4ecd059812df827cfa7eb0164ca2f7e44c20ca4a28ec", + "lastChecked": 1753888688930, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh1": { + "url": "https://example.com/fresh1", + "contentHash": "8e888eb1312d5ec40c7dcd67f7f55d9666a795afaccb821100ad13cf571ffb64", + "lastChecked": 1753888688933, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/stale1": { + "url": "https://example.com/stale1", + "contentHash": "eada896fb00741ff2c46accd0b511a02fd76b11bde60f5cf04e3e58a930403dd", + "lastChecked": 1753888688933, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/fresh2": { + "url": "https://example.com/fresh2", + "contentHash": "9898a36d7f3c43cc6ed6191c8794488ad62dc3caf6e1b8ef79e4f01965d3e4b8", + "lastChecked": 1753888688934, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/stale2": { + "url": "https://example.com/stale2", + "contentHash": "c345ffd82648c3bb85ec1d524049cdd685f37288c1d340799dcf6d9c61413a1d", + "lastChecked": 1753888688934, + "lastModified": 1659280688000, + "headers": { + "last-modified": "Sun, 31 Jul 2022 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/moved": { + "url": "https://example.com/moved", + "contentHash": "932e682c4b20853f89677667b32aec382d4c93b397bc7496c6b5fb9e59fafd42", + "lastChecked": 1753888688937, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://docs.example.com/legacy": { + "url": "https://docs.example.com/legacy", + "contentHash": "86c55d8e8308af17f1c66bde2fdc46e7d24d63d16aa6442115e43192aac79f5a", + "lastChecked": 1753888688937, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://products.example.com/eol": { + "url": "https://products.example.com/eol", + "contentHash": "b6edbdb7828f6c7a6bf78b73e527b672c504fe2486686792938825ab76d87a36", + "lastChecked": 1753888688937, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/working": { + "url": "https://example.com/working", + "contentHash": "3419c14b50e29ca75fea91fedeeac29f8c50e48395fdfe01f5971c87edcb7c2c", + "lastChecked": 1753888688939, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/first": { + "url": "https://example.com/first", + "contentHash": "836e2b28f5521489cc2b489882b969827ca3b7a149b7b45b067bf379bee2c029", + "lastChecked": 1753888688942, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/last": { + "url": "https://example.com/last", + "contentHash": "e749d33997a9bdf62bf39325be8e650d4768e29fcbd399761a7fb672dec2e327", + "lastChecked": 1753888688942, + "lastModified": 1753024688000, + "headers": { + "last-modified": "Sun, 20 Jul 2025 15:18:08 GMT", + "etag": "", + "cache-control": "" + } + }, + "https://example.com/changing": { + "url": "https://example.com/changing", + "contentHash": "53fb2035e869162a809aa54c0c7c964fef06b6a7619734de975284c35ddea95b", + "lastChecked": 1753888688948, + "headers": { + "last-modified": "", + "etag": "", + "cache-control": "" + } + } +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index f8b9b08..03dfb03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -281,6 +281,10 @@ program .option('--only-broken', 'Show only broken links, not all validation results', true) .option('--group-by ', 'Group results by: file|type', 'file') .option('--include-context', 'Include line numbers and context in output', false) + .option('--enable-auth-detection', 'Enable authentication-aware link validation', false) + .option('--disallow-auth-required', 'Treat auth-required links as broken instead of valid', false) + .option('--auth-credentials ', 'JSON object with domain:credential mapping for authentication') + .option('--auth-headers ', 'JSON object with domain-specific headers for authentication') .option('-v, --verbose', 'Show detailed output with processing information') .option('--json', 'Output results in JSON format') .addHelpText( @@ -293,6 +297,19 @@ Examples: $ markmv validate docs/**/*.md --check-external --verbose $ markmv validate README.md --link-types internal,image --include-context $ markmv validate **/*.md --group-by type --only-broken + +Authentication Examples: + $ markmv validate docs/ --check-external --enable-auth-detection + $ markmv validate README.md --check-external --enable-auth-detection --verbose + $ markmv validate docs/ --check-external --enable-auth-detection --disallow-auth-required + $ markmv validate docs/ --check-external --auth-credentials '{"api.github.com":"Bearer token123"}' + $ markmv validate docs/ --check-external --auth-headers '{"api.example.com":{"X-API-Key":"key123"}}' + +Authentication Options: + --enable-auth-detection Distinguish auth-protected from truly broken links + --disallow-auth-required Treat auth-required links as broken (default: treat as valid) + --auth-credentials Provide API keys/tokens for authenticated validation + --auth-headers Provide custom headers for domain-specific authentication $ markmv validate docs/ --check-circular --strict-internal Link Types: @@ -307,6 +324,56 @@ Output Options: --group-by file Group broken links by file (default) --group-by type Group broken links by link type` ) - .action(validateCommand); + .action((files: string[], options: { + linkTypes?: string; + checkExternal?: boolean; + externalTimeout?: number; + strictInternal?: boolean; + checkClaudeImports?: boolean; + checkCircular?: boolean; + maxDepth?: number; + onlyBroken?: boolean; + groupBy?: string; + includeContext?: boolean; + enableAuthDetection?: boolean; + disallowAuthRequired?: boolean; + authCredentials?: string; + authHeaders?: string; + verbose?: boolean; + json?: boolean; + }) => { + // Parse JSON options + let authCredentials: Record | undefined; + let authHeaders: Record> | undefined; + + try { + if (options.authCredentials) { + authCredentials = JSON.parse(options.authCredentials); + } + } catch (error) { + console.error('Error parsing auth-credentials JSON:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + try { + if (options.authHeaders) { + authHeaders = JSON.parse(options.authHeaders); + } + } catch (error) { + console.error('Error parsing auth-headers JSON:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + // Convert options to match ValidateCliOptions interface + const validationOptions = { + ...options, + enableAuthDetection: options.enableAuthDetection, + allowAuthRequired: !options.disallowAuthRequired, // Invert the flag + authCredentials, + authHeaders, + }; + + return validateCommand(files, validationOptions); + }); program.parse(); diff --git a/src/commands/validate-auth.test.ts b/src/commands/validate-auth.test.ts new file mode 100644 index 0000000..fdb065d --- /dev/null +++ b/src/commands/validate-auth.test.ts @@ -0,0 +1,543 @@ +/** + * Integration tests for validate command with authentication detection. + * + * @fileoverview Tests the full validation pipeline with authentication awareness + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { validateLinks } from './validate.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('Validate Command with Authentication Detection', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'validate-auth-test-')); + vi.clearAllMocks(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('Authentication-aware Validation', () => { + it('should distinguish auth-required from truly broken links', async () => { + const testFile = join(tempDir, 'auth-test.md'); + await writeFile(testFile, ` +# Authentication Test + +Firebase Console: [Project Settings](https://console.firebase.google.com/project/test/settings) +Working docs: [GitHub Actions](https://docs.github.com/en/actions) +Broken link: [Missing Page](https://example.com/missing-page) +Private API: [User Profile](https://api.private.com/user/profile) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.github.com/en/actions', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing-page', + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.private.com/user/profile', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.filesProcessed).toBe(1); + expect(result.totalLinks).toBe(4); + expect(result.brokenLinks).toBe(3); // Firebase (auth), Missing (404), Private API (401) + expect(result.authRequiredLinks).toBe(2); // Firebase + Private API + expect(result.authenticatedLinks).toBe(0); // No successful auth + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + + // Firebase Console - domain-based detection + const firebaseLink = brokenLinks.find(link => link.url.includes('firebase')); + expect(firebaseLink?.reason).toBe('auth-required'); + expect(firebaseLink?.authInfo?.detectionMethod).toBe('domain'); + + // Missing page - truly broken + const missingLink = brokenLinks.find(link => link.url.includes('missing')); + expect(missingLink?.reason).toBe('external-error'); + expect(missingLink?.authInfo).toBeUndefined(); + + // Private API - status-based detection + const privateLink = brokenLinks.find(link => link.url.includes('private')); + expect(privateLink?.reason).toBe('auth-required'); + expect(privateLink?.authInfo?.detectionMethod).toBe('status-code'); + }); + + it('should successfully authenticate with provided credentials', async () => { + const testFile = join(tempDir, 'auth-success.md'); + await writeFile(testFile, ` +# Authentication Success Test + +GitHub API: [User Info](https://api.github.com/user) +Custom API: [Data Endpoint](https://api.custom.com/data) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/user', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.custom.com/data', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + authCredentials: { + 'api.github.com': 'Bearer ghp_test123', + }, + authHeaders: { + 'api.custom.com': { + 'X-API-Key': 'custom-key-456', + }, + }, + }); + + expect(result.brokenLinks).toBe(0); + expect(result.authRequiredLinks).toBe(0); + expect(result.totalLinks).toBe(2); + + // Verify correct headers were sent + expect(mockFetch).toHaveBeenCalledWith('https://api.github.com/user', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'Authorization': 'Bearer ghp_test123', + }, + redirect: 'follow', + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.custom.com/data', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'X-API-Key': 'custom-key-456', + }, + redirect: 'follow', + }); + }); + + it('should handle redirect-based authentication detection', async () => { + const testFile = join(tempDir, 'redirect-auth.md'); + await writeFile(testFile, ` +# Redirect Authentication Test + +Private Dashboard: [Team Dashboard](https://private.example.com/dashboard) +Company Portal: [Employee Portal](https://portal.company.com/employees) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://accounts.google.com/oauth/authorize?client_id=123', // Redirect to Google + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://login.microsoftonline.com/common/oauth2/authorize', // Redirect to Microsoft + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.brokenLinks).toBe(2); + expect(result.authRequiredLinks).toBe(2); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + + const googleRedirect = brokenLinks.find(link => link.url.includes('private.example.com')); + expect(googleRedirect?.reason).toBe('auth-required'); + expect(googleRedirect?.authInfo?.detectionMethod).toBe('redirect'); + expect(googleRedirect?.authInfo?.authProvider).toBe('Google'); + + const microsoftRedirect = brokenLinks.find(link => link.url.includes('portal.company.com')); + expect(microsoftRedirect?.reason).toBe('auth-required'); + expect(microsoftRedirect?.authInfo?.detectionMethod).toBe('redirect'); + expect(microsoftRedirect?.authInfo?.authProvider).toBe('Microsoft'); + }); + + it('should handle mixed internal and external links with auth detection', async () => { + const internalFile = join(tempDir, 'internal.md'); + await writeFile(internalFile, '# Internal Document\nContent here.'); + + const testFile = join(tempDir, 'mixed-auth.md'); + await writeFile(testFile, ` +# Mixed Links with Authentication + +Internal: [Internal Doc](./internal.md) +Public: [Public Docs](https://docs.example.com/guide) +Auth Required: [Firebase Console](https://console.firebase.google.com/project/test) +Broken: [Missing](https://example.com/404) +Anchor: [Section](#section) + +## Section +Content here. + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/404', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + strictInternal: true, + }); + + expect(result.totalLinks).toBe(5); + expect(result.brokenLinks).toBe(2); // Firebase (auth) + Missing (404) + expect(result.authRequiredLinks).toBe(1); // Firebase + + // Only external links should be fetched (public docs + missing page) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('Strict Mode (disallowAuthRequired)', () => { + it('should treat auth-required links as broken when allowAuthRequired is false', async () => { + const testFile = join(tempDir, 'strict-auth.md'); + await writeFile(testFile, ` +# Strict Authentication Test + +Firebase: [Console](https://console.firebase.google.com/project/test) +GitHub: [Settings](https://github.com/org/settings/profile) + `); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: false, // Treat auth-required as broken + }); + + // Domain-based links are still detected as auth-required regardless of allowAuthRequired setting + expect(result.brokenLinks).toBe(2); + expect(result.authRequiredLinks).toBe(2); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + expect(brokenLinks).toHaveLength(2); + expect(brokenLinks[0].reason).toBe('auth-required'); + expect(brokenLinks[1].reason).toBe('auth-required'); + }); + }); + + describe('Disabled Authentication Detection', () => { + it('should behave normally when auth detection is disabled', async () => { + const testFile = join(tempDir, 'no-auth.md'); + await writeFile(testFile, ` +# No Authentication Detection + +Firebase: [Console](https://console.firebase.google.com/project/test) +Private API: [Endpoint](https://api.private.com/data) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://console.firebase.google.com/project/test', + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.private.com/data', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: false, // Disabled + }); + + expect(result.brokenLinks).toBe(1); // Only the 401 error + expect(result.authRequiredLinks).toBe(0); // No auth detection + expect(result.authenticatedLinks).toBe(0); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + expect(brokenLinks).toHaveLength(1); + expect(brokenLinks[0].reason).toBe('external-error'); // Not auth-required + expect(brokenLinks[0].details).toContain('HTTP 401'); + }); + }); + + describe('Statistics and Reporting', () => { + it('should provide accurate authentication statistics', async () => { + const testFile = join(tempDir, 'stats-test.md'); + await writeFile(testFile, ` +# Authentication Statistics Test + +Working: [Public Docs](https://docs.example.com/api) +Auth Domain: [Firebase](https://console.firebase.google.com/project/test) +Auth Creds: [GitHub API](https://api.github.com/user) +Auth Status: [Private API](https://private.api.com/endpoint) +Broken: [Missing](https://example.com/missing) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/api', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/user', + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Map(), + url: 'https://private.api.com/endpoint', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + authCredentials: { + 'api.github.com': 'Bearer token123', + }, + }); + + expect(result.filesProcessed).toBe(1); + expect(result.totalLinks).toBe(5); + expect(result.brokenLinks).toBe(3); // Firebase (domain) + Private API (403) + Missing (404) + expect(result.authRequiredLinks).toBe(2); // Firebase + Private API + expect(result.authenticatedLinks).toBe(0); // GitHub API was successful (not counted as broken) + + // Verify the truly broken link + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + const trulyBroken = brokenLinks.filter(link => link.reason === 'external-error'); + expect(trulyBroken).toHaveLength(1); + expect(trulyBroken[0].url).toContain('missing'); + }); + + it('should handle multiple files with auth detection', async () => { + const file1 = join(tempDir, 'file1.md'); + await writeFile(file1, ` +# File 1 +[Firebase](https://console.firebase.google.com/project/test1) +[Working](https://docs.example.com/guide1) + `); + + const file2 = join(tempDir, 'file2.md'); + await writeFile(file2, ` +# File 2 +[GitHub Settings](https://github.com/org/settings/billing) +[Broken](https://example.com/broken) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide1', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/broken', + }); + + const result = await validateLinks([file1, file2], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.filesProcessed).toBe(2); + expect(result.totalLinks).toBe(4); + expect(result.brokenLinks).toBe(3); // Firebase + GitHub + Broken + expect(result.authRequiredLinks).toBe(2); // Firebase + GitHub + + expect(Object.keys(result.brokenLinksByFile)).toHaveLength(2); + }); + }); + + describe('Error Handling with Authentication', () => { + it('should handle network errors during auth-aware validation', async () => { + const testFile = join(tempDir, 'network-error.md'); + await writeFile(testFile, ` +# Network Error Test + +[Timeout Link](https://slow.example.com/endpoint) +[Firebase Console](https://console.firebase.google.com/project/test) + `); + + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.brokenLinks).toBe(2); + expect(result.authRequiredLinks).toBe(1); // Firebase + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + const networkError = brokenLinks.find(link => link.url.includes('slow')); + expect(networkError?.reason).toBe('external-error'); + expect(networkError?.details).toContain('Network timeout'); + + const authRequired = brokenLinks.find(link => link.url.includes('firebase')); + expect(authRequired?.reason).toBe('auth-required'); + }); + + it('should continue processing after authentication errors', async () => { + const testFile = join(tempDir, 'continue-after-error.md'); + await writeFile(testFile, ` +# Continue After Error Test + +[First Link](https://first.example.com/api) +[Error Link](https://error.example.com/api) +[Last Link](https://last.example.com/api) + `); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://first.example.com/api', + }) + .mockRejectedValueOnce(new Error('Connection refused')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://last.example.com/api', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + }); + + expect(result.totalLinks).toBe(3); + expect(result.filesProcessed).toBe(1); + expect(result.brokenLinks).toBe(1); // Only the error link + expect(mockFetch).toHaveBeenCalledTimes(3); + + const brokenLinks = Object.values(result.brokenLinksByFile)[0]; + expect(brokenLinks).toHaveLength(1); + expect(brokenLinks[0].details).toContain('Connection refused'); + }); + }); + + describe('Integration with Other Link Types', () => { + it('should only apply auth detection to external links', async () => { + const internalFile = join(tempDir, 'target.md'); + await writeFile(internalFile, '# Target\nContent'); + + const testFile = join(tempDir, 'mixed-types.md'); + await writeFile(testFile, ` +# Mixed Link Types + +Internal: [Target](./target.md) +External Auth: [Firebase](https://console.firebase.google.com/project/test) +External Normal: [Docs](https://docs.example.com/guide) +Anchor: [Section](#section) +Image: ![Image](./image.png) + +## Section +Content here. + `); + + // Create image file + const imagePath = join(tempDir, 'image.png'); + await writeFile(imagePath, 'fake-image-content'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide', + }); + + const result = await validateLinks([testFile], { + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + strictInternal: true, + }); + + expect(result.totalLinks).toBe(5); + expect(result.brokenLinks).toBe(1); // Only Firebase + expect(result.authRequiredLinks).toBe(1); // Firebase + + // Only external docs should be fetched (Firebase is detected by domain) + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 130c7b0..3f4e9ba 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -35,6 +35,14 @@ export interface ValidateOperationOptions extends OperationOptions { groupBy: 'file' | 'type'; /** Include line numbers and context in output */ includeContext: boolean; + /** Enable authentication-aware link validation */ + enableAuthDetection?: boolean; + /** Treat auth-required links as valid (not broken) */ + allowAuthRequired?: boolean; + /** API keys/credentials for authenticated requests */ + authCredentials?: Record; + /** Custom headers for specific domains */ + authHeaders?: Record>; } /** @@ -89,6 +97,10 @@ export interface ValidateResult { circularReferences?: string[]; /** Processing time in milliseconds */ processingTime: number; + /** Number of auth-required links found */ + authRequiredLinks?: number; + /** Number of successfully authenticated links */ + authenticatedLinks?: number; } /** @@ -129,7 +141,11 @@ export async function validateLinks( ): Promise { const startTime = Date.now(); - const opts: Required = { + const opts: Required> & { + maxDepth?: number; + authCredentials?: Record; + authHeaders?: Record>; + } = { linkTypes: options.linkTypes || [ 'internal', 'external', @@ -147,6 +163,10 @@ export async function validateLinks( onlyBroken: options.onlyBroken ?? true, groupBy: options.groupBy ?? 'file', includeContext: options.includeContext ?? false, + enableAuthDetection: options.enableAuthDetection ?? false, + allowAuthRequired: options.allowAuthRequired ?? true, + authCredentials: options.authCredentials, + authHeaders: options.authHeaders, dryRun: options.dryRun ?? false, verbose: options.verbose ?? false, force: options.force ?? false, @@ -183,6 +203,12 @@ export async function validateLinks( externalTimeout: opts.externalTimeout, strictInternal: opts.strictInternal, checkClaudeImports: opts.checkClaudeImports, + enableAuthDetection: opts.enableAuthDetection, + allowAuthRequired: opts.allowAuthRequired, + authConfig: { + credentials: opts.authCredentials || {}, + customHeaders: opts.authHeaders || {}, + }, }); const parser = new LinkParser(); @@ -196,6 +222,8 @@ export async function validateLinks( fileErrors: [], hasCircularReferences: false, processingTime: 0, + authRequiredLinks: 0, + authenticatedLinks: 0, }; // Initialize broken links by type @@ -225,6 +253,22 @@ export async function validateLinks( const validation = await validator.validateLinks(relevantLinks, filePath); const brokenLinks = validation.brokenLinks; + // Count authentication statistics if auth detection is enabled + if (opts.enableAuthDetection) { + const externalLinks = relevantLinks.filter(link => link.type === 'external'); + + if (brokenLinks.length > 0) { + // Count auth-required links + const authRequiredCount = brokenLinks.filter(bl => bl.reason === 'auth-required').length; + const authenticatedCount = brokenLinks.filter(bl => + bl.authInfo?.authAttempted && bl.authInfo?.authSucceeded + ).length; + + result.authRequiredLinks = (result.authRequiredLinks || 0) + authRequiredCount; + result.authenticatedLinks = (result.authenticatedLinks || 0) + authenticatedCount; + } + } + if (brokenLinks.length > 0) { result.brokenLinks += brokenLinks.length; @@ -353,6 +397,24 @@ export async function validateCommand( console.log(`Files processed: ${result.filesProcessed}`); console.log(`Total links found: ${result.totalLinks}`); console.log(`Broken links: ${result.brokenLinks}`); + + // Show authentication information if enabled + if (options.enableAuthDetection) { + const authRequiredCount = result.authRequiredLinks || 0; + const authenticatedCount = result.authenticatedLinks || 0; + const realBrokenCount = result.brokenLinks - authRequiredCount; + + if (authRequiredCount > 0) { + console.log(`🔒 Authentication-protected links: ${authRequiredCount}`); + } + if (authenticatedCount > 0) { + console.log(`✅ Successfully authenticated links: ${authenticatedCount}`); + } + if (realBrokenCount > 0) { + console.log(`❌ Truly broken links: ${realBrokenCount}`); + } + } + console.log(`Processing time: ${result.processingTime}ms\n`); if (result.fileErrors.length > 0) { @@ -389,10 +451,23 @@ export async function validateCommand( const context = options.includeContext && brokenLink.line ? ` (line ${brokenLink.line})` : ''; const file = brokenLink.filePath ? ` in ${brokenLink.filePath}` : ''; - console.log(` ❌ ${brokenLink.url}${context}${file}`); + const authIndicator = brokenLink.reason === 'auth-required' ? ' 🔒' : ''; + console.log(` ❌ ${brokenLink.url}${context}${file}${authIndicator}`); if (brokenLink.reason && options.verbose) { console.log(` Reason: ${brokenLink.reason}`); } + if (brokenLink.authInfo && (options.verbose || brokenLink.reason === 'auth-required')) { + const info = brokenLink.authInfo; + if (info.warning) { + console.log(` Auth: ${info.warning}`); + } + if (info.authProvider && options.verbose) { + console.log(` Provider: ${info.authProvider}`); + } + if (info.suggestion) { + console.log(` Suggestion: ${info.suggestion}`); + } + } } } } @@ -403,10 +478,23 @@ export async function validateCommand( for (const brokenLink of brokenLinks) { const context = options.includeContext && brokenLink.line ? ` (line ${brokenLink.line})` : ''; - console.log(` ❌ [${brokenLink.type}] ${brokenLink.url}${context}`); + const authIndicator = brokenLink.reason === 'auth-required' ? ' 🔒' : ''; + console.log(` ❌ [${brokenLink.type}] ${brokenLink.url}${context}${authIndicator}`); if (brokenLink.reason && options.verbose) { console.log(` Reason: ${brokenLink.reason}`); } + if (brokenLink.authInfo && (options.verbose || brokenLink.reason === 'auth-required')) { + const info = brokenLink.authInfo; + if (info.warning) { + console.log(` Auth: ${info.warning}`); + } + if (info.authProvider && options.verbose) { + console.log(` Provider: ${info.authProvider}`); + } + if (info.suggestion) { + console.log(` Suggestion: ${info.suggestion}`); + } + } } } } diff --git a/src/core/link-validator-auth.test.ts b/src/core/link-validator-auth.test.ts new file mode 100644 index 0000000..ad5599e --- /dev/null +++ b/src/core/link-validator-auth.test.ts @@ -0,0 +1,562 @@ +/** + * Tests for LinkValidator with authentication detection integration. + * + * @fileoverview Tests for link validation with authentication awareness + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { LinkValidator } from './link-validator.js'; +import type { MarkdownLink } from '../types/links.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('LinkValidator with Authentication Detection', () => { + let tempDir: string; + let validator: LinkValidator; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'link-validator-auth-test-')); + + validator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: true, + externalTimeout: 5000, + authConfig: { + credentials: { + 'api.github.com': 'Bearer test-token', + }, + customHeaders: { + 'api.custom.com': { + 'X-API-Key': 'custom-key-123', + }, + }, + }, + }); + + vi.clearAllMocks(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('Domain-based Auth Detection', () => { + it('should identify Firebase Console links as auth-required', async () => { + const link: MarkdownLink = { + type: 'external', + href: 'https://console.firebase.google.com/project/my-project/settings', + text: 'Firebase Console', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.requiresAuth).toBe(true); + expect(result?.authInfo?.authProvider).toBe('Firebase'); + expect(result?.authInfo?.detectionMethod).toBe('domain'); + expect(result?.details).toContain('authentication-protected'); + + // Should not make HTTP request for domain-detected auth + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should identify GitHub private repo links as auth-required', async () => { + const link: MarkdownLink = { + type: 'external', + href: 'https://github.com/private-org/settings/profile', + text: 'GitHub Settings', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.authProvider).toBe('GitHub'); + expect(result?.authInfo?.detectionMethod).toBe('domain'); + }); + + it('should validate public GitHub docs normally', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.github.com/en/actions', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://docs.github.com/en/actions', + text: 'GitHub Actions Docs', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid + expect(mockFetch).toHaveBeenCalledWith('https://docs.github.com/en/actions', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + }, + redirect: 'follow', + }); + }); + }); + + describe('HTTP Status Auth Detection', () => { + it('should identify 401 responses as auth-required', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.example.com/private', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.example.com/private', + text: 'Private API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.requiresAuth).toBe(true); + expect(result?.authInfo?.detectionMethod).toBe('status-code'); + expect(result?.details).toContain('HTTP 401'); + }); + + it('should identify 403 responses as auth-required', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Map(), + url: 'https://admin.example.com/dashboard', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://admin.example.com/dashboard', + text: 'Admin Dashboard', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.detectionMethod).toBe('status-code'); + expect(result?.details).toContain('HTTP 403'); + }); + + it('should treat other HTTP errors as truly broken', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/missing', + text: 'Missing Page', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('HTTP 404'); + expect(result?.authInfo).toBeUndefined(); + }); + }); + + describe('Redirect Auth Detection', () => { + it('should detect redirects to Google auth pages', async () => { + const finalUrl = 'https://accounts.google.com/oauth/authorize?client_id=123'; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: finalUrl, // Simulate redirect + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://private.example.com/dashboard', + text: 'Dashboard', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.requiresAuth).toBe(true); + expect(result?.authInfo?.detectionMethod).toBe('redirect'); + expect(result?.authInfo?.authProvider).toBe('Google'); + expect(result?.authInfo?.finalUrl).toBe(finalUrl); + }); + + it('should detect redirects to Microsoft auth pages', async () => { + const finalUrl = 'https://login.microsoftonline.com/common/oauth2/authorize'; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: finalUrl, + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://teams.microsoft.com/channel', + text: 'Teams Channel', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.authProvider).toBe('Microsoft'); + expect(result?.authInfo?.detectionMethod).toBe('redirect'); + }); + }); + + describe('Authentication Credentials', () => { + it('should use provided credentials for authentication', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/user', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.github.com/user', + text: 'GitHub User API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid with auth + expect(mockFetch).toHaveBeenCalledWith('https://api.github.com/user', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'Authorization': 'Bearer test-token', + }, + redirect: 'follow', + }); + }); + + it('should use custom headers for authentication', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.custom.com/data', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.custom.com/data', + text: 'Custom API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid with auth + expect(mockFetch).toHaveBeenCalledWith('https://api.custom.com/data', { + method: 'HEAD', + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'markmv-validator/1.0 (authentication-aware)', + 'X-API-Key': 'custom-key-123', + }, + redirect: 'follow', + }); + }); + + it('should mark authentication as attempted and succeeded', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://api.github.com/repos', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.github.com/repos', + text: 'GitHub Repos API', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Should be valid + // We can't easily test the internal authInfo here since it's not returned for valid links + // This is tested more thoroughly in the auth-detection unit tests + }); + }); + + describe('allowAuthRequired Configuration', () => { + it('should still detect auth-required links via status code when allowAuthRequired is false', async () => { + const strictValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: true, + allowAuthRequired: false, // Strict mode - doesn't return early for auth detection + }); + + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.private.com/endpoint', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.private.com/endpoint', + text: 'Private API', + line: 1, + }; + + const result = await strictValidator.validateLink(link, '/test/file.md'); + + // When allowAuthRequired is false, 401/403 should be treated as external-error + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('HTTP 401'); + }); + + it('should handle mixed auth and broken links correctly', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://docs.example.com/guide', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + url: 'https://example.com/missing', + }); + + const links: MarkdownLink[] = [ + { + type: 'external', + href: 'https://console.firebase.google.com/project/test', + text: 'Firebase Console', + line: 1, + }, + { + type: 'external', + href: 'https://docs.example.com/guide', + text: 'Valid Docs', + line: 2, + }, + { + type: 'external', + href: 'https://example.com/missing', + text: 'Missing Page', + line: 3, + }, + ]; + + const results = await Promise.all( + links.map(link => validator.validateLink(link, '/test/file.md')) + ); + + // Firebase Console: auth-required + expect(results[0]?.reason).toBe('auth-required'); + + // Valid docs: null (valid) + expect(results[1]).toBeNull(); + + // Missing page: external-error + expect(results[2]?.reason).toBe('external-error'); + }); + }); + + describe('Disabled Auth Detection', () => { + it('should behave like normal validation when auth detection is disabled', async () => { + const normalValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: false, + }); + + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://api.example.com/private', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://api.example.com/private', + text: 'Private API', + line: 1, + }; + + const result = await normalValidator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); // Not auth-required + expect(result?.details).toContain('HTTP 401'); + expect(result?.authInfo).toBeUndefined(); + }); + + it('should use basic headers when auth detection is disabled', async () => { + const normalValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: false, + }); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + url: 'https://example.com/test', + }); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/test', + text: 'Test Link', + line: 1, + }; + + await normalValidator.validateLink(link, '/test/file.md'); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/test', { + method: 'HEAD', + signal: expect.any(AbortSignal), + }); + }); + }); + + describe('Error Handling', () => { + it('should handle network errors during auth-aware validation', async () => { + mockFetch.mockRejectedValue(new Error('Network timeout')); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/network-error', + text: 'Network Error', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('Network timeout'); + }); + + it('should handle timeout errors during auth validation', async () => { + const timeoutValidator = new LinkValidator({ + checkExternal: true, + enableAuthDetection: true, + externalTimeout: 1, // Very short timeout + }); + + mockFetch.mockImplementation(() => + new Promise((resolve, reject) => { + setTimeout(() => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }, 2); + }) + ); + + const link: MarkdownLink = { + type: 'external', + href: 'https://example.com/slow', + text: 'Slow Link', + line: 1, + }; + + const result = await timeoutValidator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('external-error'); + expect(result?.details).toContain('aborted'); + }); + }); + + describe('Image Link Authentication', () => { + it('should apply auth detection to external images', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Map(), + url: 'https://private.cdn.com/image.jpg', + }); + + const link: MarkdownLink = { + type: 'image', + href: 'https://private.cdn.com/image.jpg', + text: 'Private Image', + line: 1, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).not.toBeNull(); + expect(result?.reason).toBe('auth-required'); + expect(result?.authInfo?.detectionMethod).toBe('status-code'); + }); + + it('should not apply auth detection to local images', async () => { + // Create a test image file + const imagePath = join(tempDir, 'test-image.jpg'); + await writeFile(imagePath, 'fake-image-content'); + + const link: MarkdownLink = { + type: 'image', + href: './test-image.jpg', + text: 'Local Image', + line: 1, + resolvedPath: imagePath, + }; + + const result = await validator.validateLink(link, '/test/file.md'); + + expect(result).toBeNull(); // Local image should be valid + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/core/link-validator.ts b/src/core/link-validator.ts index 0ffaf55..fc14135 100644 --- a/src/core/link-validator.ts +++ b/src/core/link-validator.ts @@ -1,5 +1,6 @@ import { constants, access } from 'node:fs/promises'; import { readFile } from 'node:fs/promises'; +import { AuthDetector, type AuthConfig } from '../utils/auth-detection.js'; import type { BrokenLink, ValidationResult } from '../types/config.js'; import type { MarkdownLink, ParsedMarkdownFile } from '../types/links.js'; @@ -20,6 +21,12 @@ export interface LinkValidatorOptions { strictInternal?: boolean; /** Check Claude import links */ checkClaudeImports?: boolean; + /** Enable authentication-aware link validation */ + enableAuthDetection?: boolean; + /** Configuration for authentication detection */ + authConfig?: Partial; + /** Treat auth-required links as valid (not broken) */ + allowAuthRequired?: boolean; } /** @@ -62,7 +69,10 @@ export interface LinkValidatorOptions { * ``` */ export class LinkValidator { - private options: Required; + private options: Required> & { + authConfig?: Partial; + }; + private authDetector?: AuthDetector; constructor(options: LinkValidatorOptions = {}) { this.options = { @@ -70,7 +80,14 @@ export class LinkValidator { externalTimeout: options.externalTimeout ?? 5000, strictInternal: options.strictInternal ?? true, checkClaudeImports: options.checkClaudeImports ?? true, + enableAuthDetection: options.enableAuthDetection ?? false, + allowAuthRequired: options.allowAuthRequired ?? true, + ...(options.authConfig && { authConfig: options.authConfig }), }; + + if (this.options.enableAuthDetection) { + this.authDetector = new AuthDetector(this.options.authConfig); + } } async validateFiles(files: ParsedMarkdownFile[]): Promise { @@ -209,14 +226,93 @@ export class LinkValidator { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.options.externalTimeout); - const response = await fetch(link.href, { + // Prepare headers for request + const headers: Record = {}; + + // Check if authentication detection is enabled and analyze the URL first + let authInfo; + if (this.authDetector && this.options.enableAuthDetection) { + headers['User-Agent'] = 'markmv-validator/1.0 (authentication-aware)'; + + authInfo = await this.authDetector.analyzeAuth(link.href); + + // If URL is known to require auth via domain detection, return immediately + if (authInfo.requiresAuth && authInfo.detectionMethod === 'domain') { + clearTimeout(timeoutId); + return { + sourceFile, + link, + reason: 'auth-required', + details: authInfo.warning || 'Link requires authentication', + authInfo, + }; + } + + // Add authentication headers if available + if (this.authDetector.shouldAttemptAuth(link.href)) { + const authHeaders = this.authDetector.getAuthHeaders(link.href); + Object.assign(headers, authHeaders); + if (authInfo) { + authInfo.authAttempted = true; + } + } + } + + const fetchOptions: RequestInit = { method: 'HEAD', signal: controller.signal, - }); + }; + + if (Object.keys(headers).length > 0) { + fetchOptions.headers = headers; + } + + if (this.options.enableAuthDetection) { + fetchOptions.redirect = 'follow'; // Follow redirects to detect auth redirects + } + + const response = await fetch(link.href, fetchOptions); clearTimeout(timeoutId); + // Analyze response for authentication indicators if auth detection is enabled + if (this.authDetector && this.options.enableAuthDetection && authInfo) { + const finalAuthInfo = await this.authDetector.analyzeAuth(link.href, response); + Object.assign(authInfo, finalAuthInfo); + + if (finalAuthInfo.requiresAuth && this.options.allowAuthRequired) { + return { + sourceFile, + link, + reason: 'auth-required', + details: finalAuthInfo.warning || 'Link requires authentication', + authInfo: finalAuthInfo, + }; + } + } + if (!response.ok) { + // Check if this is an auth-related error (only if auth detection is enabled) + if (this.options.enableAuthDetection && (response.status === 401 || response.status === 403) && this.options.allowAuthRequired) { + const authErrorInfo = { + url: link.href, + requiresAuth: true, + redirectCount: 0, + authAttempted: this.authDetector?.shouldAttemptAuth(link.href) || false, + detectionMethod: 'status-code' as const, + warning: `HTTP ${response.status}: Authentication required`, + suggestion: 'Provide appropriate credentials or API keys to validate this link', + }; + + return { + sourceFile, + link, + reason: 'auth-required', + details: authErrorInfo.warning, + authInfo: authErrorInfo, + }; + } + return { sourceFile, link, @@ -225,6 +321,11 @@ export class LinkValidator { }; } + // Mark auth as succeeded if we attempted it + if (authInfo?.authAttempted) { + authInfo.authSucceeded = true; + } + return null; // Link is valid } catch (error) { return { diff --git a/src/generated/ajv-validators.ts b/src/generated/ajv-validators.ts index 60eb2df..59cb9ae 100644 --- a/src/generated/ajv-validators.ts +++ b/src/generated/ajv-validators.ts @@ -1,431 +1,447 @@ /** * Auto-generated AJV validators for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ import Ajv from 'ajv'; -const ajv = new Ajv({ - allErrors: true, +const ajv = new Ajv({ + allErrors: true, verbose: true, - strict: false, + strict: false }); // Schema definitions export const schemas = { - $schema: 'http://json-schema.org/draft-07/schema#', - title: 'markmv API Schemas', - description: 'Auto-generated schemas for markmv methods with @group annotations', - definitions: { - moveFile: { - title: 'moveFile', - description: 'Move a single markdown file and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "markmv API Schemas", + "description": "Auto-generated schemas for markmv methods with @group annotations", + "definitions": { + "moveFile": { + "title": "moveFile", + "description": "Move a single markdown file and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - destinationPath: { - type: 'string', - description: 'Destination file path', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [ - 'markmv move old.md new.md', - 'markmv move docs/old.md archive/renamed.md --dry-run', - ], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move old.md new.md", + "markmv move docs/old.md archive/renamed.md --dry-run" + ] }, - moveFiles: { - title: 'moveFiles', - description: 'Move multiple markdown files and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', + "moveFiles": { + "title": "moveFiles", + "description": "Move multiple markdown files and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" }, + "destination": { + "type": "string" + } }, - required: ['source', 'destination'], - additionalProperties: false, - }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, + "required": [ + "moves" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': ['markmv move-files --batch file1.md:new1.md file2.md:new2.md'], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move-files --batch file1.md:new1.md file2.md:new2.md" + ] }, - validateOperation: { - title: 'validateOperation', - description: 'Validate the result of a previous operation for broken links', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', + "validateOperation": { + "title": "validateOperation", + "description": "Validate the result of a previous operation for broken links", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, + "required": [ + "result" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', + "output": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "brokenLinks": { + "type": "number" }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [] }, - testAutoExposure: { - title: 'testAutoExposure', - description: 'Test function to demonstrate auto-exposure pattern', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "testAutoExposure": { + "title": "testAutoExposure", + "description": "Test function to demonstrate auto-exposure pattern", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, + "required": [ + "input" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - message: { - type: 'string', + "output": { + "type": "object", + "properties": { + "message": { + "type": "string" }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', + "timestamp": { + "type": "string" }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Testing', - 'x-examples': ['markmv test "Hello World"'], - }, - }, + "additionalProperties": false, + "x-group": "Testing", + "x-examples": [ + "markmv test \"Hello World\"" + ] + } + } }; // Compiled validators export const validators = { moveFile: { input: ajv.compile(schemas.definitions.moveFile.properties.input), - output: ajv.compile(schemas.definitions.moveFile.properties.output), + output: ajv.compile(schemas.definitions.moveFile.properties.output) }, moveFiles: { input: ajv.compile(schemas.definitions.moveFiles.properties.input), - output: ajv.compile(schemas.definitions.moveFiles.properties.output), + output: ajv.compile(schemas.definitions.moveFiles.properties.output) }, validateOperation: { input: ajv.compile(schemas.definitions.validateOperation.properties.input), - output: ajv.compile(schemas.definitions.validateOperation.properties.output), + output: ajv.compile(schemas.definitions.validateOperation.properties.output) }, testAutoExposure: { input: ajv.compile(schemas.definitions.testAutoExposure.properties.input), - output: ajv.compile(schemas.definitions.testAutoExposure.properties.output), - }, + output: ajv.compile(schemas.definitions.testAutoExposure.properties.output) + } }; -/** Validate input for a specific method */ -export function validateInput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate input for a specific method + */ +export function validateInput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.input; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Validate output for a specific method */ -export function validateOutput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate output for a specific method + */ +export function validateOutput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.output; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Get list of available methods */ +/** + * Get list of available methods + */ export function getAvailableMethods(): string[] { return Object.keys(validators); } diff --git a/src/generated/api-routes.ts b/src/generated/api-routes.ts index af31fbf..4bb1c6f 100644 --- a/src/generated/api-routes.ts +++ b/src/generated/api-routes.ts @@ -1,6 +1,6 @@ /** * Auto-generated REST API route definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -11,11 +11,7 @@ import type { FileOperations } from '../core/file-operations.js'; export interface ApiRoute { path: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE'; - handler: ( - req: IncomingMessage, - res: ServerResponse, - markmvInstance: FileOperations - ) => Promise; + handler: (req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations) => Promise; description: string; inputSchema: object; outputSchema: object; @@ -27,568 +23,557 @@ export const autoGeneratedApiRoutes: ApiRoute[] = [ path: '/api/move-file', method: 'POST', handler: createmoveFileHandler, - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/move-files', method: 'POST', handler: createmoveFilesHandler, - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } + }, + "required": [ + "moves" + ], + "additionalProperties": false +}, + outputSchema: { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - }, - additionalProperties: false, - }, - }, - required: ['moves'], - additionalProperties: false, - }, - outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/validate-operation', method: 'POST', handler: createvalidateOperationHandler, - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "brokenLinks": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false +} }, { path: '/api/test-auto-exposure', method: 'POST', handler: createtestAutoExposureHandler, - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, + "required": [ + "input" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - message: { - type: 'string', - }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', - }, + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false +} + } ]; // These handler functions will be created dynamically by the API server // They are placeholders for the auto-generated route definitions export async function createmoveFileHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFile', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const sourcePath = bodyObj.sourcePath; - const destinationPath = bodyObj.destinationPath; + const destinationPath = bodyObj.destinationPath; const options = bodyObj.options || {}; - - if ( - typeof sourcePath === 'string' && - typeof destinationPath === 'string' && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { - result = await markmvInstance.moveFile( - sourcePath, - destinationPath, - options as Record - ); + + if (typeof sourcePath === 'string' && typeof destinationPath === 'string' && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { + result = await markmvInstance.moveFile(sourcePath, destinationPath, options as Record); } else { throw new Error('Invalid parameters for moveFile'); } - + // Validate output const outputValidation = validateOutput('moveFile', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFile:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createmoveFilesHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFiles', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const moves = bodyObj.moves; const options = bodyObj.options || {}; - - if ( - Array.isArray(moves) && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { + + if (Array.isArray(moves) && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { result = await markmvInstance.moveFiles(moves, options as Record); } else { throw new Error('Invalid parameters for moveFiles'); } - + // Validate output const outputValidation = validateOutput('moveFiles', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFiles:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createvalidateOperationHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('validateOperation', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const operationResult = bodyObj.result; - - if ( - typeof operationResult === 'object' && - operationResult !== null && - !Array.isArray(operationResult) - ) { + + if (typeof operationResult === 'object' && operationResult !== null && !Array.isArray(operationResult)) { // Type guard to ensure operationResult has required OperationResult properties const opResult = operationResult as Record; - if ( - typeof opResult.success === 'boolean' && - Array.isArray(opResult.modifiedFiles) && - Array.isArray(opResult.createdFiles) && - Array.isArray(opResult.deletedFiles) && - Array.isArray(opResult.errors) && - Array.isArray(opResult.warnings) && - Array.isArray(opResult.changes) - ) { - result = await markmvInstance.validateOperation( - opResult as unknown as import('../types/operations.js').OperationResult - ); + if (typeof opResult.success === 'boolean' && + Array.isArray(opResult.modifiedFiles) && + Array.isArray(opResult.createdFiles) && + Array.isArray(opResult.deletedFiles) && + Array.isArray(opResult.errors) && + Array.isArray(opResult.warnings) && + Array.isArray(opResult.changes)) { + result = await markmvInstance.validateOperation(opResult as unknown as import('../types/operations.js').OperationResult); } else { throw new Error('Invalid OperationResult structure'); } } else { throw new Error('Invalid parameters for validateOperation'); } - + // Validate output const outputValidation = validateOutput('validateOperation', result); if (!outputValidation.valid) { console.warn('Output validation failed for validateOperation:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createtestAutoExposureHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, _markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('testAutoExposure', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const input = bodyObj.input; - + if (typeof input === 'string') { // Import and call the standalone function const { testAutoExposure } = await import('../index.js'); @@ -596,32 +581,32 @@ export async function createtestAutoExposureHandler( } else { throw new Error('Invalid parameters for testAutoExposure'); } - + // Validate output const outputValidation = validateOutput('testAutoExposure', result); if (!outputValidation.valid) { console.warn('Output validation failed for testAutoExposure:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } -/** Helper functions */ +/** + * Helper functions + */ async function parseRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; - req.on('data', (chunk) => { + req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { @@ -635,12 +620,16 @@ async function parseRequestBody(req: IncomingMessage): Promise { }); } -/** Get API route by path */ +/** + * Get API route by path + */ export function getApiRouteByPath(path: string): ApiRoute | undefined { - return autoGeneratedApiRoutes.find((route) => route.path === path); + return autoGeneratedApiRoutes.find(route => route.path === path); } -/** Get all API route paths */ +/** + * Get all API route paths + */ export function getApiRoutePaths(): string[] { - return autoGeneratedApiRoutes.map((route) => route.path); + return autoGeneratedApiRoutes.map(route => route.path); } diff --git a/src/generated/mcp-tools.ts b/src/generated/mcp-tools.ts index fb176e5..764ff58 100644 --- a/src/generated/mcp-tools.ts +++ b/src/generated/mcp-tools.ts @@ -1,6 +1,6 @@ /** * Auto-generated MCP tool definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -10,184 +10,201 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; export const autoGeneratedMcpTools: Tool[] = [ { name: 'move_file', - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" + }, + "destinationPath": { + "type": "string", + "description": "Destination file path" + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +} }, { name: 'move_files', - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, - }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, - }, + "required": [ + "moves" + ], + "additionalProperties": false +} }, { name: 'validate_operation', - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +} }, { name: 'test_auto_exposure', - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, - }, + "required": [ + "input" + ], + "additionalProperties": false +} + } ]; -/** Get MCP tool by name */ +/** + * Get MCP tool by name + */ export function getMcpToolByName(name: string): Tool | undefined { - return autoGeneratedMcpTools.find((tool) => tool.name === name); + return autoGeneratedMcpTools.find(tool => tool.name === name); } -/** Get all MCP tool names */ +/** + * Get all MCP tool names + */ export function getMcpToolNames(): string[] { - return autoGeneratedMcpTools.map((tool) => tool.name); + return autoGeneratedMcpTools.map(tool => tool.name); } + diff --git a/src/types/config.ts b/src/types/config.ts index 8c333a2..a3341fc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -99,7 +99,9 @@ export interface BrokenLink { /** The broken link */ link: import('./links.js').MarkdownLink; /** Reason the link is broken */ - reason: 'file-not-found' | 'external-error' | 'invalid-format' | 'circular-reference'; + reason: 'file-not-found' | 'external-error' | 'invalid-format' | 'circular-reference' | 'auth-required'; /** Additional error details */ details?: string; + /** Authentication information if applicable */ + authInfo?: import('../utils/auth-detection.js').AuthInfo; } diff --git a/src/utils/auth-detection.test.ts b/src/utils/auth-detection.test.ts new file mode 100644 index 0000000..67f2adc --- /dev/null +++ b/src/utils/auth-detection.test.ts @@ -0,0 +1,448 @@ +/** + * Tests for authentication detection utilities. + * + * @fileoverview Tests for AuthDetector functionality and configuration + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AuthDetector, DEFAULT_AUTH_CONFIG } from './auth-detection.js'; + +describe('AuthDetector', () => { + let authDetector: AuthDetector; + + beforeEach(() => { + authDetector = new AuthDetector(); + }); + + describe('Configuration', () => { + it('should initialize with default configuration', () => { + expect(authDetector.isEnabled()).toBe(true); + }); + + it('should support disabling authentication detection', () => { + const disabledDetector = new AuthDetector({ enabled: false }); + expect(disabledDetector.isEnabled()).toBe(false); + }); + + it('should support custom domain patterns', () => { + const customDetector = new AuthDetector({ + authDomainPatterns: ['custom.example.com', 'internal.company.com'], + }); + + expect(customDetector).toBeDefined(); + }); + + it('should support custom credentials', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'api.github.com': 'Bearer token123', + 'api.example.com': 'Token xyz789', + }, + }); + + expect(credentialsDetector.shouldAttemptAuth('https://api.github.com/user')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://api.example.com/data')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://other.example.com/data')).toBe(false); + }); + }); + + describe('Domain-based Authentication Detection', () => { + it('should detect Firebase Console URLs as auth-required', async () => { + const url = 'https://console.firebase.google.com/project/my-project/settings'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Firebase'); + expect(authInfo.detectionMethod).toBe('domain'); + expect(authInfo.warning).toContain('authentication-protected'); + }); + + it('should detect GitHub settings URLs as auth-required', async () => { + const url = 'https://github.com/myorg/settings/profile'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('GitHub'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect Google Cloud Console URLs as auth-required', async () => { + const url = 'https://console.cloud.google.com/compute/instances'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Google'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect AWS Console URLs as auth-required', async () => { + const url = 'https://console.aws.amazon.com/ec2/v2/home'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('AWS'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect Microsoft 365 URLs as auth-required', async () => { + const url = 'https://mycompany.sharepoint.com/sites/team'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Microsoft 365'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should detect Vercel dashboard URLs as auth-required', async () => { + const url = 'https://app.vercel.com/dashboard'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe('Vercel'); + expect(authInfo.detectionMethod).toBe('domain'); + }); + + it('should not detect regular documentation URLs as auth-required', async () => { + const url = 'https://docs.github.com/en/actions'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(false); + expect(authInfo.detectionMethod).toBe('none'); + }); + + it('should handle wildcard domain patterns', async () => { + const customDetector = new AuthDetector({ + authDomainPatterns: ['*.internal.company.com'], + }); + + const url = 'https://api.internal.company.com/v1/data'; + const authInfo = await customDetector.analyzeAuth(url); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('domain'); + }); + }); + + describe('Response Analysis', () => { + it('should detect 401 Unauthorized as auth-required', async () => { + const mockResponse = new Response('', { + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + }); + + const url = 'https://api.example.com/private'; + const authInfo = await authDetector.analyzeAuth(url, mockResponse); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('status-code'); + expect(authInfo.warning).toContain('HTTP 401'); + }); + + it('should detect 403 Forbidden as auth-required', async () => { + const mockResponse = new Response('', { + status: 403, + statusText: 'Forbidden', + headers: new Headers(), + }); + + const url = 'https://api.example.com/admin'; + const authInfo = await authDetector.analyzeAuth(url, mockResponse); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('status-code'); + expect(authInfo.warning).toContain('HTTP 403'); + }); + + it('should not detect 200 OK as auth-required', async () => { + const mockResponse = new Response('content', { + status: 200, + statusText: 'OK', + headers: new Headers(), + }); + + const url = 'https://example.com/public'; + const authInfo = await authDetector.analyzeAuth(url, mockResponse); + + expect(authInfo.requiresAuth).toBe(false); + }); + + it('should detect redirects to authentication pages', async () => { + const originalUrl = 'https://private.example.com/dashboard'; + const finalUrl = 'https://accounts.google.com/oauth/authorize'; + + const mockResponse = new Response('', { + status: 200, + headers: new Headers(), + }); + + // Mock the response URL to simulate redirect + Object.defineProperty(mockResponse, 'url', { + value: finalUrl, + writable: false, + }); + + const authInfo = await authDetector.analyzeAuth(originalUrl, mockResponse, [finalUrl]); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.detectionMethod).toBe('redirect'); + expect(authInfo.authProvider).toBe('Google'); + expect(authInfo.finalUrl).toBe(finalUrl); + }); + }); + + describe('Authentication Headers', () => { + it('should provide auth headers for configured domains', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'api.github.com': 'Bearer ghp_token123', + 'api.example.com': 'Token xyz789', + }, + }); + + const githubHeaders = credentialsDetector.getAuthHeaders('https://api.github.com/user'); + expect(githubHeaders).toEqual({ + 'Authorization': 'Bearer ghp_token123', + }); + + const exampleHeaders = credentialsDetector.getAuthHeaders('https://api.example.com/data'); + expect(exampleHeaders).toEqual({ + 'Authorization': 'Token xyz789', + }); + }); + + it('should provide custom headers for configured domains', () => { + const headersDetector = new AuthDetector({ + customHeaders: { + 'api.custom.com': { + 'X-API-Key': 'custom-key-123', + 'X-Client-Version': '1.0.0', + }, + }, + }); + + const headers = headersDetector.getAuthHeaders('https://api.custom.com/v1/data'); + expect(headers).toEqual({ + 'X-API-Key': 'custom-key-123', + 'X-Client-Version': '1.0.0', + }); + }); + + it('should return empty headers for unconfigured domains', () => { + const headers = authDetector.getAuthHeaders('https://unknown.example.com/api'); + expect(headers).toEqual({}); + }); + + it('should support wildcard credentials', () => { + const wildcardDetector = new AuthDetector({ + credentials: { + '*': 'Bearer universal-token', + }, + }); + + const headers = wildcardDetector.getAuthHeaders('https://any.example.com/api'); + expect(headers).toEqual({ + 'Authorization': 'Bearer universal-token', + }); + }); + }); + + describe('Authentication Attempt Detection', () => { + it('should attempt auth for configured credential domains', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'api.github.com': 'Bearer token', + }, + }); + + expect(credentialsDetector.shouldAttemptAuth('https://api.github.com/user')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://api.example.com/data')).toBe(false); + }); + + it('should attempt auth for configured custom header domains', () => { + const headersDetector = new AuthDetector({ + customHeaders: { + 'api.custom.com': { 'X-API-Key': 'key' }, + }, + }); + + expect(headersDetector.shouldAttemptAuth('https://api.custom.com/data')).toBe(true); + expect(headersDetector.shouldAttemptAuth('https://other.example.com/data')).toBe(false); + }); + + it('should handle subdomain matching', () => { + const credentialsDetector = new AuthDetector({ + credentials: { + 'github.com': 'Bearer token', + }, + }); + + expect(credentialsDetector.shouldAttemptAuth('https://api.github.com/user')).toBe(true); + expect(credentialsDetector.shouldAttemptAuth('https://raw.githubusercontent.com/file')).toBe(false); + }); + }); + + describe('Provider Detection', () => { + it('should detect Google as provider from domains', async () => { + const testCases = [ + { url: 'https://console.firebase.google.com/project', expected: 'Firebase' }, + { url: 'https://console.cloud.google.com/compute', expected: 'Google' }, + { url: 'https://accounts.google.com/oauth', expected: 'Google' }, + ]; + + for (const testCase of testCases) { + const authInfo = await authDetector.analyzeAuth(testCase.url); + if (authInfo.requiresAuth) { + expect(authInfo.authProvider).toBe(testCase.expected); + } + } + }); + + it('should detect GitHub as provider from domains', async () => { + const url = 'https://github.com/myorg/settings/profile'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.authProvider).toBe('GitHub'); + }); + + it('should detect Microsoft as provider from domains', async () => { + const urls = [ + 'https://admin.microsoft.com/dashboard', + 'https://portal.azure.com/subscriptions', + 'https://mycompany.sharepoint.com/sites', + ]; + + for (const url of urls) { + const authInfo = await authDetector.analyzeAuth(url); + if (authInfo.requiresAuth) { + expect(authInfo.authProvider).toContain('Microsoft'); + } + } + }); + + it('should detect AWS as provider from domains', async () => { + const url = 'https://console.aws.amazon.com/ec2'; + const authInfo = await authDetector.analyzeAuth(url); + + expect(authInfo.authProvider).toBe('AWS'); + }); + }); + + describe('Redirect Analysis', () => { + it('should detect auth redirects by URL patterns', async () => { + const testCases = [ + { + redirectUrl: 'https://login.microsoftonline.com/oauth', + expectedProvider: 'Microsoft', + }, + { + redirectUrl: 'https://github.com/login/oauth', + expectedProvider: 'GitHub', + }, + { + redirectUrl: 'https://auth0.com/login', + expectedProvider: 'Auth0', + }, + { + redirectUrl: 'https://example.com/signin', + expectedProvider: 'Authentication Service', + }, + ]; + + for (const testCase of testCases) { + // Create a mock response that simulates a redirect + const mockResponse = new Response('', { + status: 200, + headers: new Headers(), + }); + + // Mock the response URL to simulate redirect + Object.defineProperty(mockResponse, 'url', { + value: testCase.redirectUrl, + writable: false, + }); + + const authInfo = await authDetector.analyzeAuth( + 'https://original.example.com', + mockResponse, + [] + ); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.authProvider).toBe(testCase.expectedProvider); + } + }); + + it('should detect auth parameters in URLs', async () => { + const mockResponse = new Response('', { + status: 200, + headers: new Headers(), + }); + + // Mock the response URL with auth parameters + Object.defineProperty(mockResponse, 'url', { + value: 'https://example.com/dashboard?login=required&redirect_uri=callback', + writable: false, + }); + + const authInfo = await authDetector.analyzeAuth( + 'https://example.com/original', + mockResponse + ); + + expect(authInfo.requiresAuth).toBe(true); + expect(authInfo.warning).toContain('authentication-related parameters'); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid URLs gracefully', async () => { + const authInfo = await authDetector.analyzeAuth('not-a-valid-url'); + + expect(authInfo.requiresAuth).toBe(false); + expect(authInfo.detectionMethod).toBe('none'); + }); + + it('should handle disabled detection', async () => { + const disabledDetector = new AuthDetector({ enabled: false }); + const authInfo = await disabledDetector.analyzeAuth('https://console.firebase.google.com/project'); + + expect(authInfo.requiresAuth).toBe(false); + expect(authInfo.detectionMethod).toBe('none'); + }); + + it('should handle missing response gracefully', async () => { + const authInfo = await authDetector.analyzeAuth('https://example.com/unknown'); + + expect(authInfo.detectionMethod).toBe('none'); + expect(authInfo.redirectCount).toBe(0); + }); + }); + + describe('Default Configuration', () => { + it('should have comprehensive default auth domain patterns', () => { + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('console.firebase.google.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('console.cloud.google.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('github.com/*/settings'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('app.vercel.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('dashboard.heroku.com'); + expect(DEFAULT_AUTH_CONFIG.authDomainPatterns).toContain('*.atlassian.net'); + }); + + it('should have comprehensive default redirect patterns', () => { + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('accounts.google.com'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('login.microsoftonline.com'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('github.com/login'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('/login'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('/auth'); + expect(DEFAULT_AUTH_CONFIG.authRedirectPatterns).toContain('/oauth'); + }); + + it('should have reasonable default settings', () => { + expect(DEFAULT_AUTH_CONFIG.enabled).toBe(true); + expect(DEFAULT_AUTH_CONFIG.maxRedirects).toBe(5); + expect(DEFAULT_AUTH_CONFIG.credentials).toEqual({}); + expect(DEFAULT_AUTH_CONFIG.customHeaders).toEqual({}); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/auth-detection.ts b/src/utils/auth-detection.ts new file mode 100644 index 0000000..e70c0fb --- /dev/null +++ b/src/utils/auth-detection.ts @@ -0,0 +1,467 @@ +/** + * Authentication detection utilities for external link validation. + * + * @fileoverview Detects authentication-protected URLs and handles auth-aware validation + */ + +/** + * Configuration for authentication detection. + */ +export interface AuthConfig { + /** Enable authentication detection */ + enabled: boolean; + /** API keys/tokens for authenticated requests */ + credentials: Record; + /** Patterns for recognizing auth-protected domains */ + authDomainPatterns: string[]; + /** Redirect patterns that indicate authentication requirement */ + authRedirectPatterns: string[]; + /** Maximum number of redirects to follow */ + maxRedirects: number; + /** Custom headers for authenticated requests */ + customHeaders: Record>; +} + +/** + * Information about authentication status of a link. + */ +export interface AuthInfo { + /** URL being checked */ + url: string; + /** Whether link requires authentication */ + requiresAuth: boolean; + /** Authentication provider if detected */ + authProvider?: string; + /** Final URL after redirects */ + finalUrl?: string; + /** Number of redirects followed */ + redirectCount: number; + /** Whether authentication was attempted */ + authAttempted: boolean; + /** Whether authentication succeeded */ + authSucceeded?: boolean; + /** Auth detection method used */ + detectionMethod: 'domain' | 'redirect' | 'status-code' | 'content' | 'none'; + /** Warning message if auth-protected */ + warning?: string; + /** Suggestion for handling auth requirement */ + suggestion?: string; +} + +/** + * Authentication detector for external links. + */ +export class AuthDetector { + private config: AuthConfig; + + constructor(config: Partial = {}) { + this.config = { + enabled: config.enabled ?? true, + credentials: config.credentials ?? {}, + authDomainPatterns: config.authDomainPatterns ?? [ + 'console.firebase.google.com', + 'console.cloud.google.com', + 'github.com/*/settings', + 'github.com/orgs/*/settings', + 'admin.microsoft.com', + 'portal.azure.com', + 'aws.amazon.com/console', + 'console.aws.amazon.com', + 'app.vercel.com', + 'dashboard.heroku.com', + 'app.netlify.com', + 'app.supabase.com', + 'app.planetscale.com', + '*.atlassian.net', + 'trello.com/b/', + 'notion.so/', + '*.sharepoint.com', + '*.onedrive.com', + 'drive.google.com', + 'docs.google.com/*/d/', + 'sheets.google.com/*/d/', + 'slides.google.com/*/d/', + ], + authRedirectPatterns: config.authRedirectPatterns ?? [ + 'accounts.google.com', + 'login.microsoftonline.com', + 'github.com/login', + 'auth0.com', + 'okta.com', + 'login.salesforce.com', + 'signin.aws.amazon.com', + 'id.heroku.com', + 'auth.netlify.com', + '/login', + '/signin', + '/auth', + '/oauth', + '/sso', + ], + maxRedirects: config.maxRedirects ?? 5, + customHeaders: config.customHeaders ?? {}, + }; + } + + /** + * Check if authentication detection is enabled. + */ + isEnabled(): boolean { + return this.config.enabled; + } + + /** + * Analyze authentication requirements for a URL. + */ + async analyzeAuth( + url: string, + response?: Response, + redirectHistory: string[] = [] + ): Promise { + if (!this.config.enabled) { + return { + url, + requiresAuth: false, + redirectCount: 0, + authAttempted: false, + detectionMethod: 'none', + }; + } + + const result: AuthInfo = { + url, + requiresAuth: false, + redirectCount: redirectHistory.length, + authAttempted: false, + detectionMethod: 'none', + }; + + // Check domain-based auth detection first + const domainAuth = this.checkAuthDomain(url); + if (domainAuth.requiresAuth) { + return { + ...result, + ...domainAuth, + detectionMethod: 'domain', + }; + } + + // If we have a response, analyze it for auth indicators + if (response) { + const responseAuth = await this.analyzeResponse(url, response, redirectHistory); + if (responseAuth.requiresAuth) { + return { + ...result, + ...responseAuth, + }; + } + } + + return result; + } + + /** + * Check if URL is from a known auth-protected domain. + */ + private checkAuthDomain(url: string): Partial { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + const fullUrl = url.toLowerCase(); + + for (const pattern of this.config.authDomainPatterns) { + if (this.matchesPattern(fullUrl, hostname, pattern)) { + const provider = this.detectAuthProvider(pattern, hostname); + return { + requiresAuth: true, + authProvider: provider, + warning: `URL appears to be authentication-protected (${provider})`, + suggestion: 'This is likely working correctly but requires authentication to access', + }; + } + } + + return { requiresAuth: false }; + } catch { + return { requiresAuth: false }; + } + } + + /** + * Analyze HTTP response for authentication indicators. + */ + private async analyzeResponse( + url: string, + response: Response, + redirectHistory: string[] + ): Promise> { + const finalUrl = response.url; + + // Check for auth redirects + if (finalUrl !== url || redirectHistory.length > 0) { + const redirectAuth = this.analyzeRedirects(url, finalUrl, redirectHistory); + if (redirectAuth.requiresAuth) { + return { + ...redirectAuth, + finalUrl, + detectionMethod: 'redirect', + }; + } + } + + // Check status codes that indicate auth requirement + if (response.status === 401 || response.status === 403) { + return { + requiresAuth: true, + finalUrl, + detectionMethod: 'status-code', + warning: `HTTP ${response.status}: Authentication required`, + suggestion: 'Provide appropriate credentials or API keys to validate this link', + }; + } + + // Check response content for auth indicators (if available) + try { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/html')) { + // Don't actually read the content in production to avoid performance issues + // This would be for future enhancement + return { requiresAuth: false }; + } + } catch { + // Ignore content analysis errors + } + + return { requiresAuth: false }; + } + + /** + * Analyze redirect chain for authentication patterns. + */ + private analyzeRedirects( + originalUrl: string, + finalUrl: string, + redirectHistory: string[] + ): Partial { + const allUrls = [originalUrl, ...redirectHistory]; + if (finalUrl && finalUrl !== originalUrl) { + allUrls.push(finalUrl); + } + + for (const redirectUrl of allUrls) { + for (const pattern of this.config.authRedirectPatterns) { + if (redirectUrl.toLowerCase().includes(pattern.toLowerCase())) { + const provider = this.detectAuthProviderFromUrl(redirectUrl); + return { + requiresAuth: true, + authProvider: provider, + warning: `Redirected to authentication page (${provider})`, + suggestion: 'This link is working correctly but requires user authentication', + }; + } + } + } + + // Check for common auth-related query parameters + try { + const finalUrlObj = new URL(finalUrl); + const hasAuthParams = ['login', 'auth', 'signin', 'oauth', 'sso'].some(param => + finalUrlObj.searchParams.has(param) || finalUrlObj.pathname.includes(`/${param}`) + ); + + if (hasAuthParams) { + return { + requiresAuth: true, + warning: 'URL contains authentication-related parameters', + suggestion: 'This link likely requires user authentication to access', + }; + } + } catch { + // Ignore URL parsing errors + } + + return { requiresAuth: false }; + } + + /** + * Check if URL matches an auth domain pattern. + */ + private matchesPattern(fullUrl: string, hostname: string, pattern: string): boolean { + // Handle wildcard patterns + if (pattern.includes('*')) { + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '[^./]*'); + const regex = new RegExp(regexPattern, 'i'); + return regex.test(hostname) || regex.test(fullUrl); + } + + // Exact match or subdomain match + return hostname === pattern || + hostname.endsWith(`.${pattern}`) || + fullUrl.includes(pattern); + } + + /** + * Detect authentication provider from domain pattern. + */ + private detectAuthProvider(pattern: string, hostname: string): string { + if (pattern.includes('firebase') || hostname.includes('firebase')) { + return 'Firebase'; + } + if (pattern.includes('github') || hostname.includes('github')) { + return 'GitHub'; + } + if (pattern.includes('microsoft') || pattern.includes('azure') || hostname.includes('microsoft')) { + return 'Microsoft'; + } + if (pattern.includes('aws') || pattern.includes('amazon')) { + return 'AWS'; + } + if (pattern.includes('google') || hostname.includes('google')) { + return 'Google'; + } + if (pattern.includes('vercel')) { + return 'Vercel'; + } + if (pattern.includes('netlify')) { + return 'Netlify'; + } + if (pattern.includes('heroku')) { + return 'Heroku'; + } + if (pattern.includes('supabase')) { + return 'Supabase'; + } + if (pattern.includes('planetscale')) { + return 'PlanetScale'; + } + if (pattern.includes('atlassian')) { + return 'Atlassian'; + } + if (pattern.includes('trello')) { + return 'Trello'; + } + if (pattern.includes('notion')) { + return 'Notion'; + } + if (pattern.includes('sharepoint') || pattern.includes('onedrive')) { + return 'Microsoft 365'; + } + + return 'Unknown Provider'; + } + + /** + * Detect authentication provider from redirect URL. + */ + private detectAuthProviderFromUrl(url: string): string { + const lowerUrl = url.toLowerCase(); + + if (lowerUrl.includes('google')) return 'Google'; + if (lowerUrl.includes('github')) return 'GitHub'; + if (lowerUrl.includes('microsoft') || lowerUrl.includes('azure')) return 'Microsoft'; + if (lowerUrl.includes('aws') || lowerUrl.includes('amazon')) return 'AWS'; + if (lowerUrl.includes('auth0')) return 'Auth0'; + if (lowerUrl.includes('okta')) return 'Okta'; + if (lowerUrl.includes('salesforce')) return 'Salesforce'; + if (lowerUrl.includes('heroku')) return 'Heroku'; + if (lowerUrl.includes('netlify')) return 'Netlify'; + + return 'Authentication Service'; + } + + /** + * Get headers for authenticated request to a specific domain. + */ + getAuthHeaders(url: string): Record { + try { + const hostname = new URL(url).hostname; + + // Check for domain-specific headers + for (const [domain, headers] of Object.entries(this.config.customHeaders)) { + if (hostname === domain || hostname.endsWith(`.${domain}`)) { + return headers; + } + } + + // Check for general credentials + const authHeader = this.config.credentials[hostname] || this.config.credentials['*']; + if (authHeader) { + return { + 'Authorization': authHeader, + }; + } + + return {}; + } catch { + return {}; + } + } + + /** + * Check if we should attempt authentication for this URL. + */ + shouldAttemptAuth(url: string): boolean { + try { + const hostname = new URL(url).hostname; + return Object.keys(this.config.credentials).some(key => + key === hostname || key === '*' || hostname.endsWith(`.${key}`) + ) || Object.keys(this.config.customHeaders).some(domain => + hostname === domain || hostname.endsWith(`.${domain}`) + ); + } catch { + return false; + } + } +} + +/** + * Default authentication configuration. + */ +export const DEFAULT_AUTH_CONFIG: AuthConfig = { + enabled: true, + credentials: {}, + authDomainPatterns: [ + 'console.firebase.google.com', + 'console.cloud.google.com', + 'github.com/*/settings', + 'github.com/orgs/*/settings', + 'admin.microsoft.com', + 'portal.azure.com', + 'aws.amazon.com/console', + 'console.aws.amazon.com', + 'app.vercel.com', + 'dashboard.heroku.com', + 'app.netlify.com', + 'app.supabase.com', + 'app.planetscale.com', + '*.atlassian.net', + 'trello.com/b/', + 'notion.so/', + '*.sharepoint.com', + '*.onedrive.com', + 'drive.google.com', + 'docs.google.com/*/d/', + 'sheets.google.com/*/d/', + 'slides.google.com/*/d/', + ], + authRedirectPatterns: [ + 'accounts.google.com', + 'login.microsoftonline.com', + 'github.com/login', + 'auth0.com', + 'okta.com', + 'login.salesforce.com', + 'signin.aws.amazon.com', + 'id.heroku.com', + 'auth.netlify.com', + '/login', + '/signin', + '/auth', + '/oauth', + '/sso', + ], + maxRedirects: 5, + customHeaders: {}, +}; \ No newline at end of file