mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
This commit is contained in:
parent
3c82361b66
commit
7d12ea8fd9
16 changed files with 1062 additions and 32 deletions
|
|
@ -17,3 +17,9 @@ WEB_HOST_PORT=4173
|
|||
# Optional read-only mount, exposed to the server as /workspace.
|
||||
# Override with the directory that contains the repos you want to index.
|
||||
WORKSPACE_DIR=./
|
||||
|
||||
# Azure DevOps Server Integration (passed to the server container)
|
||||
# Prefer https:// — the PAT rides in an Authorization header, so cleartext
|
||||
# http:// exposes it on the wire (still supported for internal-only instances).
|
||||
# AZURE_DEVOPS_URL=https://azuredevops.example.com
|
||||
# AZURE_DEVOPS_PAT=your-pat-here
|
||||
|
|
|
|||
|
|
@ -3,10 +3,17 @@ title = "GitNexus"
|
|||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# Fake embedding API keys in unit tests (current probe + historical placeholder).
|
||||
# Fake credentials in unit tests — none are real secrets:
|
||||
# - embedding API keys in the http-embedder tests (regexes below)
|
||||
# - synthetic GitHub PAT fixtures in the git-clone PAT-injection tests
|
||||
# (e.g. ghp_secret123, ghp_uniqueRawSecret_98765) — allowlisted by path
|
||||
# so the exception is bounded to that one test file.
|
||||
[allowlist]
|
||||
description = "fake embedding API keys in http-embedder unit tests"
|
||||
description = "fake credentials in unit tests (no real secrets)"
|
||||
regexes = [
|
||||
'''secret-key-12345''',
|
||||
'''test-api-key-redaction-check''',
|
||||
]
|
||||
paths = [
|
||||
'''gitnexus/test/unit/git-clone\.test\.ts''',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* The "empty state" card rendered inside DropZone's Crossfade when the server
|
||||
* is connected but zero repos are indexed. Replaces the generic error message
|
||||
* with a first-class GitHub URL input flow.
|
||||
* with a first-class repository URL input flow.
|
||||
*
|
||||
* Rendering context:
|
||||
* DropZone (Crossfade, phase="analyze")
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
* the app to the graph explorer.
|
||||
*/
|
||||
|
||||
import { Sparkles, Github } from '@/lib/lucide-icons';
|
||||
import { Sparkles, GitBranch } from '@/lib/lucide-icons';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
|
|||
|
||||
{/* Icon */}
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
|
||||
<Github className="h-7 w-7 text-accent" />
|
||||
<GitBranch className="h-7 w-7 text-accent" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import { useState, useRef, useEffect, useId } from 'react';
|
|||
import {
|
||||
Github,
|
||||
Gitlab,
|
||||
AzureDevops,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Check,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Key,
|
||||
} from '@/lib/lucide-icons';
|
||||
import {
|
||||
startAnalyze,
|
||||
|
|
@ -30,10 +32,14 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type InputMode = 'github' | 'gitlab' | 'local';
|
||||
type InputMode = 'github' | 'gitlab' | 'azure' | 'local';
|
||||
|
||||
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
|
||||
const GITLAB_RE = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+(\/.*)?$/i;
|
||||
// One-or-more path segments before `/_git/`, so the legacy single-project
|
||||
// cloud form (myorg.visualstudio.com/project/_git/repo) is accepted too —
|
||||
// the backend already supports it (isAzureDevOpsUrl / extractRepoName).
|
||||
const AZURE_RE = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+_git\/[^/\s]+/i;
|
||||
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
|
||||
|
||||
function isValidGithubUrl(value: string): boolean {
|
||||
|
|
@ -44,6 +50,10 @@ function isValidGitlabUrl(value: string): boolean {
|
|||
return GITLAB_RE.test(value.trim());
|
||||
}
|
||||
|
||||
function isValidAzureUrl(value: string): boolean {
|
||||
return AZURE_RE.test(value.trim());
|
||||
}
|
||||
|
||||
// ── Mode tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
|
||||
|
|
@ -81,6 +91,19 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
|
|||
<Gitlab className="h-3 w-3" />
|
||||
{t('repoAnalyzer.gitlabUrl')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'azure'}
|
||||
onClick={() => onChange('azure')}
|
||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
mode === 'azure'
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
} `}
|
||||
>
|
||||
<AzureDevops className="h-3 w-3" />
|
||||
{t('repoAnalyzer.azureDevOpsUrl')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'local'}
|
||||
|
|
@ -173,7 +196,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
null,
|
||||
);
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('');
|
||||
const [azureUrl, setAzureUrl] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [phase, setPhase] = useState<InternalPhase>('input');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
|
@ -239,7 +264,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
invalidateRequest();
|
||||
setMode(m);
|
||||
setGithubUrl('');
|
||||
setGithubToken('');
|
||||
setGitlabUrl('');
|
||||
setAzureUrl('');
|
||||
setLocalPath('');
|
||||
setValidationError(null);
|
||||
setUploadSummary(null);
|
||||
|
|
@ -259,7 +286,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
|
||||
: mode === 'gitlab'
|
||||
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
: mode === 'azure'
|
||||
? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
|
||||
|
|
@ -270,6 +299,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
setValidationError('Please enter a valid GitLab repository URL.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'azure' && !isValidAzureUrl(azureUrl)) {
|
||||
setValidationError(t('errors:invalidAzureDevOpsUrl'));
|
||||
return;
|
||||
}
|
||||
if (mode === 'local' && localPath.trim().length < 2) {
|
||||
setValidationError(t('errors:missingFolderPath'));
|
||||
return;
|
||||
|
|
@ -285,10 +318,15 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
try {
|
||||
const request =
|
||||
mode === 'github'
|
||||
? { url: githubUrl.trim() }
|
||||
? {
|
||||
url: githubUrl.trim(),
|
||||
...(githubToken.trim() ? { token: githubToken.trim() } : {}),
|
||||
}
|
||||
: mode === 'gitlab'
|
||||
? { url: gitlabUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
: mode === 'azure'
|
||||
? { url: azureUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
// Stale resolution: return without cancelling — URL jobIds may be
|
||||
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
|
||||
|
|
@ -299,7 +337,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
? githubUrl.trim()
|
||||
: mode === 'gitlab'
|
||||
? gitlabUrl.trim()
|
||||
: localPath.trim();
|
||||
: mode === 'azure'
|
||||
? azureUrl.trim()
|
||||
: localPath.trim();
|
||||
trackJob(jobId, nameSource);
|
||||
} catch (err) {
|
||||
// Unmount aborts the controller, so this also covers the unmounted case.
|
||||
|
|
@ -327,6 +367,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
: undefined) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setGithubToken('');
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
|
|
@ -396,6 +437,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
} catch {}
|
||||
jobIdRef.current = null;
|
||||
}
|
||||
setGithubToken('');
|
||||
setPhase('input');
|
||||
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
|
||||
setUploading(false);
|
||||
|
|
@ -460,6 +502,39 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional GitHub Personal Access Token for private repos */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<label
|
||||
htmlFor={`${inputId}-token`}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{t('onboarding:repoAnalyzer.githubTokenLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border-default bg-void px-4 py-3 transition-all duration-200 focus-within:border-accent/40">
|
||||
<Key className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={`${inputId}-token`}
|
||||
type="password"
|
||||
value={githubToken}
|
||||
onChange={(e) => setGithubToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder={t('onboarding:repoAnalyzer.githubTokenPlaceholder')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.githubTokenHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -516,6 +591,61 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Azure DevOps URL input */}
|
||||
{showInput && mode === 'azure' && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{t('onboarding:repoAnalyzer.azureDevOpsRepositoryUrl')}
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
validationError && phase === 'error'
|
||||
? 'border-red-500/50'
|
||||
: isValidAzureUrl(azureUrl)
|
||||
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
|
||||
: 'border-border-default focus-within:border-accent/40'
|
||||
} `}
|
||||
>
|
||||
<AzureDevops className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={inputId}
|
||||
type="url"
|
||||
value={azureUrl}
|
||||
onChange={(e) => {
|
||||
setAzureUrl(e.target.value);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder="http://azuredevops.example.com/Collection/Project/_git/Repo"
|
||||
autoComplete="url"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
{azureUrl.length > 10 && (
|
||||
<div className="shrink-0">
|
||||
{isValidAzureUrl(azureUrl) ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.azureDevOpsSupported')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local folder input */}
|
||||
{showInput && mode === 'local' && (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -185,6 +185,41 @@ export const Gitlab = forwardRef<SVGSVGElement, LucideProps>(function Gitlab(
|
|||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Azure DevOps mark — SVG path data from simple-icons (CC0-1.0).
|
||||
*
|
||||
* The Azure DevOps logo is a registered trademark of Microsoft Corporation.
|
||||
* We use it here only to indicate Azure DevOps source-repo integration.
|
||||
*
|
||||
* API-compatible with `lucide-react` icons (`LucideProps`).
|
||||
*/
|
||||
export const AzureDevops = forwardRef<SVGSVGElement, LucideProps>(function AzureDevops(
|
||||
{
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
className,
|
||||
strokeWidth: _strokeWidth,
|
||||
absoluteStrokeWidth: _absoluteStrokeWidth,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={color}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
<path d="M0 8.877L2.247 5.91l8.405-3.416V.022l7.37 5.393L2.966 8.338v8.225L0 15.707zm24-4.45v14.651l-5.753 4.9-9.303-3.057v3.056l-5.978-7.416 15.057 1.798V5.415z" />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
export const Github = forwardRef<SVGSVGElement, LucideProps>(function Github(
|
||||
{
|
||||
size = 24,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"analysisFailed": "Analysis failed. Check server logs.",
|
||||
"startAnalysisFailed": "Failed to start analysis",
|
||||
"invalidGithubUrl": "Please enter a valid GitHub repository URL.",
|
||||
"invalidAzureDevOpsUrl": "Please enter a valid Azure DevOps repository URL.",
|
||||
"missingFolderPath": "Please enter a folder path.",
|
||||
"backend": {
|
||||
"reconnecting": "Server connection lost. Reconnecting…",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
},
|
||||
"analyzeFirst": {
|
||||
"title": "Analyze your first repository",
|
||||
"description": "Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live knowledge graph — right in your browser.",
|
||||
"description": "Paste a repository URL and GitNexus will clone it, parse the code, and build a live knowledge graph — right in your browser.",
|
||||
"footer": "Public repos only · Cloned locally by the server · No data leaves your machine"
|
||||
},
|
||||
"landing": {
|
||||
|
|
@ -51,6 +51,7 @@
|
|||
"inputType": "Input type",
|
||||
"githubUrl": "GitHub URL",
|
||||
"gitlabUrl": "GitLab URL",
|
||||
"azureDevOpsUrl": "Azure DevOps",
|
||||
"localFolder": "Local Folder",
|
||||
"starting": "Starting analysis...",
|
||||
"analyzeRepository": "Analyze Repository",
|
||||
|
|
@ -58,8 +59,13 @@
|
|||
"loadingGraph": "Loading graph...",
|
||||
"defaultRepoName": "repository",
|
||||
"githubRepositoryUrl": "GitHub Repository URL",
|
||||
"githubTokenLabel": "Personal Access Token (optional)",
|
||||
"githubTokenPlaceholder": "ghp_… or github_pat_…",
|
||||
"githubTokenHelp": "Required for private repos. Needs the 'repo' (or fine-grained Contents:read) scope. Sent once, not stored.",
|
||||
"gitlabRepositoryUrl": "GitLab Repository URL",
|
||||
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
|
||||
"azureDevOpsRepositoryUrl": "Azure DevOps Repository URL",
|
||||
"azureDevOpsSupported": "Format: https://dev.azure.com/organization/project/_git/repository",
|
||||
"localFolderPath": "Local Folder Path",
|
||||
"hideBackground": "Hide (analysis continues in background)",
|
||||
"upload": {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"analysisFailed": "分析失败,请检查服务器日志。",
|
||||
"startAnalysisFailed": "启动分析失败",
|
||||
"invalidGithubUrl": "请输入有效的 GitHub 仓库 URL。",
|
||||
"invalidAzureDevOpsUrl": "请输入有效的 Azure DevOps 仓库 URL。",
|
||||
"missingFolderPath": "请输入文件夹路径。",
|
||||
"backend": {
|
||||
"reconnecting": "服务器连接已断开,正在重连…",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
},
|
||||
"analyzeFirst": {
|
||||
"title": "分析你的第一个仓库",
|
||||
"description": "粘贴 GitHub URL,GitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
|
||||
"description": "粘贴仓库 URL,GitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
|
||||
"footer": "仅支持公开仓库 · 服务器本地克隆 · 数据不会离开你的机器"
|
||||
},
|
||||
"landing": {
|
||||
|
|
@ -51,6 +51,7 @@
|
|||
"inputType": "输入类型",
|
||||
"githubUrl": "GitHub URL",
|
||||
"gitlabUrl": "GitLab URL",
|
||||
"azureDevOpsUrl": "Azure DevOps",
|
||||
"localFolder": "本地文件夹",
|
||||
"starting": "正在启动分析...",
|
||||
"analyzeRepository": "分析仓库",
|
||||
|
|
@ -58,8 +59,13 @@
|
|||
"loadingGraph": "正在加载图数据...",
|
||||
"defaultRepoName": "仓库",
|
||||
"githubRepositoryUrl": "GitHub 仓库 URL",
|
||||
"githubTokenLabel": "个人访问令牌(可选)",
|
||||
"githubTokenPlaceholder": "ghp_… 或 github_pat_…",
|
||||
"githubTokenHelp": "私有仓库需要此项。需 'repo' 范围(或细粒度 Contents:read)。仅发送一次,不会保存。",
|
||||
"gitlabRepositoryUrl": "GitLab 仓库 URL",
|
||||
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
|
||||
"azureDevOpsRepositoryUrl": "Azure DevOps 仓库 URL",
|
||||
"azureDevOpsSupported": "格式: http://server/Collection/Project/_git/Repository",
|
||||
"localFolderPath": "本地文件夹路径",
|
||||
"hideBackground": "隐藏(分析继续在后台进行)",
|
||||
"upload": {
|
||||
|
|
|
|||
|
|
@ -854,6 +854,7 @@ export const startAnalyze = async (request: {
|
|||
path?: string;
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
token?: string;
|
||||
}): Promise<{ jobId: string; status: string }> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/analyze`,
|
||||
|
|
|
|||
|
|
@ -10,3 +10,13 @@
|
|||
|
||||
# Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI.
|
||||
# See README for details.
|
||||
|
||||
# Azure DevOps Server (Self-Hosted) Integration
|
||||
# Base URL of your Azure DevOps Server instance. Prefer https:// — the PAT is
|
||||
# sent in an Authorization header, so cleartext http:// exposes it on the wire
|
||||
# (http:// is still supported for internal-only instances; the server warns).
|
||||
# AZURE_DEVOPS_URL=https://azuredevops.example.com
|
||||
|
||||
# Personal Access Token with Code (Read) scope for cloning private repos.
|
||||
# Used for both self-hosted and cloud (dev.azure.com) Azure DevOps.
|
||||
# AZURE_DEVOPS_PAT=your-pat-here
|
||||
|
|
|
|||
|
|
@ -33,7 +33,13 @@ import { mountMCPEndpoints } from './mcp-http.js';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { JobManager } from './analyze-job.js';
|
||||
import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
|
||||
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
|
||||
import {
|
||||
extractRepoName,
|
||||
getCloneDir,
|
||||
cloneOrPull,
|
||||
warnIfInsecureAzureConfig,
|
||||
GITHUB_TOKEN_HOSTS,
|
||||
} from './git-clone.js';
|
||||
import { createAnalyzeUploadHandler } from './analyze-upload.js';
|
||||
import { createLocalhostOriginGuard, normalizeBoundHost } from './middleware.js';
|
||||
import { createLaunchAnalysisWorker } from './analyze-launch.js';
|
||||
|
|
@ -673,7 +679,47 @@ export const handleQueryRequest = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the optional `token` field of POST /api/analyze. Returns an
|
||||
* { status, error } to send, or null when the token is absent or valid.
|
||||
*
|
||||
* The token is a GitHub PAT: charset-restricted (blocks CRLF header
|
||||
* smuggling), length-bounded (1–256), and bound to github.com using the SAME
|
||||
* GITHUB_TOKEN_HOSTS allowlist + hostname parse as resolveGitCredential, so a
|
||||
* token the API accepts is exactly the one buildGitEnv will inject — and one
|
||||
* it rejects is never sent off github.com.
|
||||
*
|
||||
* Exported for unit tests (the route validation is otherwise only reachable
|
||||
* by booting the server).
|
||||
*/
|
||||
export function validateAnalyzeToken(
|
||||
repoToken: unknown,
|
||||
repoUrl: unknown,
|
||||
): { status: number; error: string } | null {
|
||||
if (repoToken === undefined) return null;
|
||||
if (typeof repoToken !== 'string') return { status: 400, error: '"token" must be a string' };
|
||||
if (repoToken.length === 0 || repoToken.length > 256)
|
||||
return { status: 400, error: '"token" length must be between 1 and 256' };
|
||||
if (!/^[A-Za-z0-9._~+/=-]+$/.test(repoToken))
|
||||
return { status: 400, error: '"token" contains invalid characters' };
|
||||
if (!repoUrl || typeof repoUrl !== 'string')
|
||||
return { status: 400, error: '"token" requires "url"' };
|
||||
let tokenHost: string;
|
||||
try {
|
||||
tokenHost = new URL(repoUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return { status: 400, error: '"url" must be a valid URL when "token" is provided' };
|
||||
}
|
||||
if (!GITHUB_TOKEN_HOSTS.has(tokenHost))
|
||||
return { status: 400, error: '"token" is only supported for github.com URLs' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const createServer = async (port: number, host: string = '127.0.0.1') => {
|
||||
// Surface a cleartext Azure DevOps PAT config at boot (operators rarely
|
||||
// read per-request logs). Warn-only — http:// self-hosted stays supported.
|
||||
warnIfInsecureAzureConfig();
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
|
|
@ -1461,7 +1507,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
requireLocalhostOrigin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings } = req.body;
|
||||
const {
|
||||
url: repoUrl,
|
||||
path: repoLocalPath,
|
||||
force,
|
||||
embeddings,
|
||||
dropEmbeddings,
|
||||
token: repoToken,
|
||||
} = req.body;
|
||||
|
||||
// Input type validation
|
||||
if (repoUrl !== undefined && typeof repoUrl !== 'string') {
|
||||
|
|
@ -1478,6 +1531,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
|
||||
// Token: optional, restricted charset to prevent header smuggling
|
||||
// (CRLF), bound length, and bound to github.com (see validateAnalyzeToken).
|
||||
const tokenError = validateAnalyzeToken(repoToken, repoUrl);
|
||||
if (tokenError) {
|
||||
res.status(tokenError.status).json({ error: tokenError.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Path validation. The previous `normalize !== resolve` guard was inert
|
||||
// (both collapse `..` identically) and only false-rejected trailing
|
||||
// slashes, so it is dropped. Analyzing a local path the operator names
|
||||
|
|
@ -1496,9 +1557,19 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
|
||||
const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
|
||||
|
||||
// If job was already running (dedup), just return its id
|
||||
// If job was already running (dedup), just return its id. The token is
|
||||
// not part of the dedup identity and is never stored on the job, so a
|
||||
// token on THIS request had no effect — the existing job already
|
||||
// cloned (or is cloning) with whatever credentials its originating
|
||||
// request supplied. Surface `tokenIgnored` so an authenticated caller
|
||||
// isn't misled into thinking their PAT took effect on a reused job.
|
||||
if (job.status !== 'queued') {
|
||||
res.status(202).json({ jobId: job.id, status: job.status });
|
||||
const body: { jobId: string; status: string; tokenIgnored?: boolean } = {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
};
|
||||
if (repoToken !== undefined) body.tokenIgnored = true;
|
||||
res.status(202).json(body);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1520,11 +1591,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` },
|
||||
});
|
||||
|
||||
await cloneOrPull(repoUrl, targetPath, (progress) => {
|
||||
jobManager.updateJob(job.id, {
|
||||
progress: { phase: progress.phase, percent: 5, message: progress.message },
|
||||
});
|
||||
});
|
||||
await cloneOrPull(
|
||||
repoUrl,
|
||||
targetPath,
|
||||
(progress) => {
|
||||
jobManager.updateJob(job.id, {
|
||||
progress: { phase: progress.phase, percent: 5, message: progress.message },
|
||||
});
|
||||
},
|
||||
repoToken ? { token: repoToken } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetPath) {
|
||||
|
|
|
|||
|
|
@ -236,6 +236,61 @@ export interface CloneProgress {
|
|||
*
|
||||
* Exported so the separator placement is testable without mocking spawn.
|
||||
*/
|
||||
/**
|
||||
* Detect Azure DevOps URLs — both self-hosted (via AZURE_DEVOPS_URL env)
|
||||
* and cloud (dev.azure.com / *.visualstudio.com).
|
||||
*
|
||||
* Self-hosted Azure DevOps Server instances use arbitrary hostnames
|
||||
* (e.g. `http://tfs.corp.example/Collection/Project/_git/Repo`), so the
|
||||
* function checks `AZURE_DEVOPS_URL` first. Cloud addresses are a
|
||||
* hardcoded fallback so PAT injection works out-of-the-box for
|
||||
* dev.azure.com without extra configuration.
|
||||
*/
|
||||
export function isAzureDevOpsUrl(url: string): boolean {
|
||||
try {
|
||||
// Strip a single trailing dot: `dev.azure.com.` is a valid absolute FQDN
|
||||
// that resolves to the same host, so it must match too.
|
||||
const host = new URL(url).hostname.toLowerCase().replace(/\.$/, '');
|
||||
|
||||
// Self-hosted: match against the configured base URL.
|
||||
const configuredBase = process.env.AZURE_DEVOPS_URL;
|
||||
if (configuredBase) {
|
||||
try {
|
||||
const baseHost = new URL(configuredBase).hostname.toLowerCase().replace(/\.$/, '');
|
||||
if (host === baseHost) return true;
|
||||
} catch {
|
||||
/* invalid AZURE_DEVOPS_URL — fall through to cloud check */
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud fallback.
|
||||
return host === 'dev.azure.com' || host.endsWith('.visualstudio.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time startup warning when AZURE_DEVOPS_URL is configured over cleartext
|
||||
* http:// — the Azure DevOps PAT would then be sent unencrypted on every
|
||||
* clone. Self-hosted instances that only serve http are still supported (we
|
||||
* do not refuse), but operators rarely read request-time logs, so surface it
|
||||
* at boot too. Call once from server startup.
|
||||
*/
|
||||
export function warnIfInsecureAzureConfig(): void {
|
||||
const base = process.env.AZURE_DEVOPS_URL;
|
||||
if (!base) return;
|
||||
try {
|
||||
if (new URL(base).protocol === 'http:') {
|
||||
logger.warn(
|
||||
'AZURE_DEVOPS_URL is configured over cleartext http:// — the Azure DevOps PAT will be sent unencrypted. Prefer https:// where your instance supports it.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* invalid AZURE_DEVOPS_URL — isAzureDevOpsUrl already tolerates this */
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCloneArgs(url: string, targetDir: string): string[] {
|
||||
return ['clone', '--depth', '1', '--', url, targetDir];
|
||||
}
|
||||
|
|
@ -379,6 +434,7 @@ export async function cloneOrPull(
|
|||
url: string,
|
||||
targetDir: string,
|
||||
onProgress?: (progress: CloneProgress) => void,
|
||||
options?: { token?: string },
|
||||
): Promise<string> {
|
||||
// Containment barrier — inline with the canonical path.relative idiom so
|
||||
// CodeQL recognizes the sanitizer at every following filesystem and
|
||||
|
|
@ -413,29 +469,176 @@ export async function cloneOrPull(
|
|||
// whatever remote the dir was originally cloned from.
|
||||
await assertRemoteMatchesRequestedUrl(safeTarget, url);
|
||||
onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
|
||||
await runGit(['pull', '--ff-only'], safeTarget);
|
||||
await runGit(['pull', '--ff-only'], safeTarget, { token: options?.token, url });
|
||||
} else {
|
||||
await fs.mkdir(path.dirname(safeTarget), { recursive: true });
|
||||
onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
|
||||
await runGit(buildCloneArgs(url, safeTarget));
|
||||
await runGit(buildCloneArgs(url, safeTarget), undefined, { token: options?.token, url });
|
||||
}
|
||||
|
||||
return safeTarget;
|
||||
}
|
||||
|
||||
function runGit(args: string[], cwd?: string): Promise<void> {
|
||||
/**
|
||||
* Hosts the per-request GitHub PAT may be sent to. Exported so the
|
||||
* /api/analyze boundary check and this injection-site check share one
|
||||
* allowlist (they must agree, or a token accepted by the API could be
|
||||
* silently dropped — or worse — at injection).
|
||||
*/
|
||||
export const GITHUB_TOKEN_HOSTS: ReadonlySet<string> = new Set(['github.com', 'www.github.com']);
|
||||
|
||||
/**
|
||||
* Resolve at most ONE git credential for a clone/pull, by server-side policy
|
||||
* keyed on the clone host against a fixed allowlist (never a free-form user
|
||||
* toggle):
|
||||
* 1. a per-request GitHub PAT — only for hosts in GITHUB_TOKEN_HOSTS;
|
||||
* 2. else the server's AZURE_DEVOPS_PAT — only for Azure DevOps hosts;
|
||||
* 3. else none.
|
||||
* The two host sets are disjoint, so at most one credential ever applies; the
|
||||
* GitHub token taking precedence is deterministic for the pathological case
|
||||
* where AZURE_DEVOPS_URL is itself configured to a github.com host. Returns
|
||||
* the base64 of the Basic-auth `user:secret` pair, or undefined.
|
||||
*
|
||||
* Security note (re CodeQL js/user-controlled-bypass): the clone URL is
|
||||
* user-controlled and selects WHICH credential applies, but it cannot
|
||||
* redirect a credential to an arbitrary host — the host is matched against
|
||||
* fixed server-side allowlists (GITHUB_TOKEN_HOSTS, isAzureDevOpsUrl's
|
||||
* dev.azure.com/*.visualstudio.com/configured AZURE_DEVOPS_URL), and the
|
||||
* emitted header is host-scoped (buildExtraHeaderKey). A URL outside the
|
||||
* allowlists yields no credential. The selection is therefore server-policy,
|
||||
* not a bypass the user can steer.
|
||||
*/
|
||||
function resolveGitCredential(options?: { token?: string; url?: string }): string | undefined {
|
||||
const url = options?.url;
|
||||
if (!url) return undefined;
|
||||
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 1. Per-request GitHub PAT — github.com only (mirrors the /api/analyze
|
||||
// host-bind so the user's token is never sent off github.com).
|
||||
if (options.token && GITHUB_TOKEN_HOSTS.has(host)) {
|
||||
return Buffer.from(`x-access-token:${options.token}`).toString('base64');
|
||||
}
|
||||
|
||||
// 2. Server-configured Azure DevOps PAT — Azure hosts only.
|
||||
const azurePat = process.env.AZURE_DEVOPS_PAT;
|
||||
if (azurePat && isAzureDevOpsUrl(url)) {
|
||||
return Buffer.from(`:${azurePat}`).toString('base64');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the host-scoped git config key `http.<origin+path>.extraHeader` from
|
||||
* the raw clone URL, so the Authorization header is attached only to the
|
||||
* intended origin (and its clone sub-requests like /info/refs), never a
|
||||
* redirect target. Derived from the SAME raw URL git clones from — not the
|
||||
* normalize-for-compare form, which strips `.git` and would desync the key
|
||||
* from the wire URL and silently disable the header. Userinfo/query/fragment
|
||||
* are dropped (not part of git's URL match) and control characters stripped
|
||||
* (git rejects a newline in a config key outright).
|
||||
*/
|
||||
function buildExtraHeaderKey(url: string): string | undefined {
|
||||
let scoped: string;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.username = '';
|
||||
u.password = '';
|
||||
u.search = '';
|
||||
u.hash = '';
|
||||
scoped = `${u.protocol}//${u.host}${u.pathname}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
scoped = scoped.replace(/[\r\n\0]/g, '');
|
||||
return `http.${scoped}.extraHeader`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn (do not block) when a credential is about to be sent over cleartext
|
||||
* http://. Base64 is encoding, not encryption, so an on-path observer can
|
||||
* read the PAT. We keep http:// working for self-hosted Azure DevOps Server.
|
||||
*/
|
||||
function warnIfCleartextCredential(url?: string): void {
|
||||
if (!url) return;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol === 'http:') {
|
||||
logger.warn(
|
||||
`Sending a git credential over cleartext http:// (${u.host}) — base64 is not encryption. Prefer https:// where the host supports it.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* resolver already validated the URL */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spawn env for `git`. Suppresses credential prompts and, when a
|
||||
* credential resolves (see resolveGitCredential), injects a single
|
||||
* host-scoped Authorization header via the `GIT_CONFIG_*` env protocol
|
||||
* (git ≥2.31) so credentials never appear in argv or the URL. Appends after
|
||||
* any existing `GIT_CONFIG_COUNT` rather than overwriting it. Exported for
|
||||
* unit tests.
|
||||
*/
|
||||
export function buildGitEnv(
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
options?: { token?: string; url?: string },
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
// Prevent git from prompting for credentials (hangs the process)
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
// Ensure no credential helper tries to open a GUI prompt
|
||||
GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true',
|
||||
// Scrub git's HTTP/transport trace vars: if inherited from the parent
|
||||
// process they dump every request header — including the injected
|
||||
// Authorization header — to stderr, which runGit captures and logs.
|
||||
// `undefined` makes child_process omit the key from the child env.
|
||||
GIT_TRACE: undefined,
|
||||
GIT_TRACE_CURL: undefined,
|
||||
GIT_TRACE_PACKET: undefined,
|
||||
GIT_CURL_VERBOSE: undefined,
|
||||
};
|
||||
|
||||
const credential = resolveGitCredential(options);
|
||||
const key = options?.url ? buildExtraHeaderKey(options.url) : undefined;
|
||||
if (credential && key) {
|
||||
// Append after any GIT_CONFIG_* the operator already set, so we never
|
||||
// clobber their git config (e.g. an enforced http.sslVerify).
|
||||
const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10);
|
||||
const base = Number.isInteger(existing) && existing > 0 ? existing : 0;
|
||||
env.GIT_CONFIG_COUNT = String(base + 1);
|
||||
env[`GIT_CONFIG_KEY_${base}`] = key;
|
||||
env[`GIT_CONFIG_VALUE_${base}`] = `Authorization: Basic ${credential}`;
|
||||
warnIfCleartextCredential(options?.url);
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
// `options` carries the inputs the credential resolver needs: a per-request
|
||||
// GitHub `token` and the clone `url`. buildGitEnv injects at most ONE
|
||||
// host-scoped Authorization header (GitHub PAT for github.com, else the
|
||||
// server's AZURE_DEVOPS_PAT for Azure hosts) via the GIT_CONFIG_* protocol —
|
||||
// never in argv. See resolveGitCredential / buildExtraHeaderKey.
|
||||
function runGit(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
options?: { token?: string; url?: string },
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('git', args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
// Prevent git from prompting for credentials (hangs the process)
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
// Ensure no credential helper tries to open a GUI prompt
|
||||
GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true',
|
||||
},
|
||||
env: buildGitEnv(process.env, options),
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* End-to-end HTTP test of POST /api/analyze token validation.
|
||||
*
|
||||
* The unit tests (api-analyze-token.test.ts) cover validateAnalyzeToken in
|
||||
* isolation; this proves the REAL production route actually wires it in —
|
||||
* express.json body parsing, the requireLocalhostOrigin guard, the route
|
||||
* handler invoking the validator, and the 400 status/error shape on the wire.
|
||||
* Closes the gap the PR #2223 tri-review noted: "the route validation is
|
||||
* otherwise only reachable by booting the server."
|
||||
*
|
||||
* Only rejection paths are asserted: each returns 400 BEFORE any clone, so the
|
||||
* test is hermetic (no network, no background git, no real repo). The accepted
|
||||
* path would spawn a background clone and is left to the unit coverage.
|
||||
*
|
||||
* Mirrors the spawn+health-poll harness in server-http-startup.test.ts; the
|
||||
* integration suite always builds dist first (pretest:integration).
|
||||
*/
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js');
|
||||
const STARTUP_BUDGET_MS = process.env.CI ? 30_000 : 15_000;
|
||||
|
||||
const allocateFreePort = (): Promise<number> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const probe = http.createServer();
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const addr = probe.address();
|
||||
if (typeof addr !== 'object' || !addr) {
|
||||
probe.close();
|
||||
reject(new Error('could not allocate ephemeral port'));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
probe.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
|
||||
const httpJson = (
|
||||
port: number,
|
||||
method: string,
|
||||
reqPath: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: string }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: reqPath,
|
||||
method,
|
||||
headers: payload
|
||||
? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }
|
||||
: {},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5_000, () => {
|
||||
req.destroy();
|
||||
reject(new Error(`${method} ${reqPath} timed out`));
|
||||
});
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
|
||||
const postAnalyze = (port: number, body: unknown) => httpJson(port, 'POST', '/api/analyze', body);
|
||||
|
||||
// Spawned `serve` on Windows can report ready before the socket is reachable
|
||||
// from the parent (see server-http-startup.test.ts); the validateAnalyzeToken
|
||||
// unit tests cover the validation logic on every platform.
|
||||
const describeBlock = process.platform === 'win32' ? describe.skip : describe;
|
||||
|
||||
describeBlock('POST /api/analyze token validation (real server)', () => {
|
||||
let proc: ChildProcessWithoutNullStreams | undefined;
|
||||
let homeDir: string | undefined;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!fs.existsSync(DIST_CLI)) {
|
||||
throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`);
|
||||
}
|
||||
|
||||
port = await allocateFreePort();
|
||||
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-analyze-token-'));
|
||||
|
||||
proc = spawn(
|
||||
process.execPath,
|
||||
[DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr.on('data', (buf) => {
|
||||
stderr += buf.toString();
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < STARTUP_BUDGET_MS) {
|
||||
if (proc.exitCode !== null) {
|
||||
throw new Error(`serve exited ${proc.exitCode} before ready.\nstderr:\n${stderr}`);
|
||||
}
|
||||
try {
|
||||
const { status } = await httpJson(port, 'GET', '/api/health');
|
||||
if (status === 200) return;
|
||||
} catch {
|
||||
// Server still starting — retry until budget expires.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error(
|
||||
`serve did not become ready within ${STARTUP_BUDGET_MS}ms.\nstderr:\n${stderr}`,
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (proc && !proc.killed) {
|
||||
proc.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
proc?.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 3_000);
|
||||
proc?.on('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
proc = undefined;
|
||||
if (homeDir) {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
homeDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a GitHub token paired with a non-github host (finding 6)', async () => {
|
||||
const { status, body } = await postAnalyze(port, {
|
||||
url: 'https://gitlab.com/owner/repo',
|
||||
token: 'ghp_validformat123',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(JSON.parse(body).error).toContain('only supported for github.com');
|
||||
});
|
||||
|
||||
it('rejects a GitHub token paired with an Azure DevOps URL (cross-credential trigger)', async () => {
|
||||
const { status, body } = await postAnalyze(port, {
|
||||
url: 'https://dev.azure.com/org/proj/_git/repo',
|
||||
token: 'ghp_validformat123',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(JSON.parse(body).error).toContain('only supported for github.com');
|
||||
});
|
||||
|
||||
it('rejects a token whose characters could smuggle a header (CRLF/space)', async () => {
|
||||
const { status, body } = await postAnalyze(port, {
|
||||
url: 'https://github.com/owner/repo',
|
||||
token: 'bad token',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(JSON.parse(body).error).toContain('invalid characters');
|
||||
});
|
||||
|
||||
it('rejects a token with no url (routed via a path so it reaches token validation)', async () => {
|
||||
const { status, body } = await postAnalyze(port, {
|
||||
path: '/tmp/gitnexus-nonexistent-abs-path',
|
||||
token: 'ghp_validformat123',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(JSON.parse(body).error).toContain('requires "url"');
|
||||
});
|
||||
|
||||
it('still rejects a request with neither url nor path (route reachable, json parsed)', async () => {
|
||||
const { status, body } = await postAnalyze(port, {});
|
||||
expect(status).toBe(400);
|
||||
expect(JSON.parse(body).error).toContain('Provide');
|
||||
});
|
||||
});
|
||||
75
gitnexus/test/unit/api-analyze-token.test.ts
Normal file
75
gitnexus/test/unit/api-analyze-token.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Unit tests for validateAnalyzeToken — the optional `token` validation on
|
||||
* POST /api/analyze. Tested directly (not via a booted server) since the
|
||||
* route validation is otherwise only reachable through createServer.
|
||||
*
|
||||
* Closes the test gap (finding 12) and locks the github.com host-bind
|
||||
* (finding 6) and CRLF/charset guards from the PR #2223 tri-review.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateAnalyzeToken } from '../../src/server/api.js';
|
||||
|
||||
const GH = 'https://github.com/owner/repo';
|
||||
|
||||
describe('validateAnalyzeToken', () => {
|
||||
it('returns null when no token is provided', () => {
|
||||
expect(validateAnalyzeToken(undefined, GH)).toBeNull();
|
||||
expect(validateAnalyzeToken(undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a well-formed token for a github.com URL', () => {
|
||||
expect(validateAnalyzeToken('ghp_abc123', GH)).toBeNull();
|
||||
expect(validateAnalyzeToken('ghp_abc123', 'https://www.github.com/o/r')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a non-string token', () => {
|
||||
expect(validateAnalyzeToken(123 as unknown, GH)).toEqual({
|
||||
status: 400,
|
||||
error: '"token" must be a string',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty or over-long token', () => {
|
||||
expect(validateAnalyzeToken('', GH)?.error).toBe('"token" length must be between 1 and 256');
|
||||
expect(validateAnalyzeToken('a'.repeat(257), GH)?.error).toBe(
|
||||
'"token" length must be between 1 and 256',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a token with characters that could smuggle a header (CRLF/space/colon)', () => {
|
||||
for (const bad of ['abc def', 'abc\r\nHost: x', 'x-access-token:abc', 'abc<script>']) {
|
||||
expect(validateAnalyzeToken(bad, GH)?.error).toBe('"token" contains invalid characters');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a token without a url', () => {
|
||||
expect(validateAnalyzeToken('ghp_abc123', undefined)?.error).toBe('"token" requires "url"');
|
||||
expect(validateAnalyzeToken('ghp_abc123', '')?.error).toBe('"token" requires "url"');
|
||||
});
|
||||
|
||||
it('rejects a token for a non-github host (host-bind)', () => {
|
||||
for (const url of [
|
||||
'https://gitlab.com/o/r',
|
||||
'https://dev.azure.com/o/p/_git/r',
|
||||
'https://github.com.evil.com/o/r',
|
||||
'https://api.github.com/o/r',
|
||||
]) {
|
||||
expect(validateAnalyzeToken('ghp_abc123', url)?.error).toBe(
|
||||
'"token" is only supported for github.com URLs',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats github.com@evil.com as the evil host (userinfo, not host)', () => {
|
||||
// new URL(...).hostname is evil.com here — must be rejected.
|
||||
expect(validateAnalyzeToken('ghp_abc123', 'https://github.com@evil.com/o/r')?.error).toBe(
|
||||
'"token" is only supported for github.com URLs',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a token when the url is unparseable', () => {
|
||||
expect(validateAnalyzeToken('ghp_abc123', 'not-a-url')?.error).toBe(
|
||||
'"url" must be a valid URL when "token" is provided',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,30 @@
|
|||
import { afterAll, beforeAll, describe, it, expect } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// The logger is a Proxy with no `set` trap, so vi.spyOn can't patch it.
|
||||
// Mock the module and expose `warn` as a countable spy (other levels no-op).
|
||||
const warnSpy = vi.fn();
|
||||
vi.mock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: (...args: unknown[]) => warnSpy(...args),
|
||||
error: () => {},
|
||||
trace: () => {},
|
||||
fatal: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
extractRepoName,
|
||||
getCloneDir,
|
||||
validateGitUrl,
|
||||
cloneOrPull,
|
||||
buildCloneArgs,
|
||||
buildGitEnv,
|
||||
normalizeGitUrlForCompare,
|
||||
assertRemoteMatchesRequestedUrl,
|
||||
isAzureDevOpsUrl,
|
||||
warnIfInsecureAzureConfig,
|
||||
} from '../../src/server/git-clone.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
|
@ -311,6 +329,142 @@ describe('git-clone', () => {
|
|||
// --depth must be before the `--` separator (it's an option, not a positional).
|
||||
expect(depthIdx).toBeLessThan(args.indexOf('--'));
|
||||
});
|
||||
|
||||
it('never embeds a token in argv: credentials travel through env, not URL', () => {
|
||||
// buildCloneArgs is URL-only; the credential must travel through env
|
||||
// (buildGitEnv) so it cannot appear in `ps auxww` or in command logs.
|
||||
const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target');
|
||||
// No credential material in argv — assert on the credential markers
|
||||
// directly rather than substring-matching the host (which CodeQL flags
|
||||
// as incomplete URL sanitization, js/incomplete-url-substring).
|
||||
expect(args.some((a) => a.includes('ghp_'))).toBe(false);
|
||||
expect(args.some((a) => a.toLowerCase().includes('authorization'))).toBe(false);
|
||||
expect(args.some((a) => a.includes('extraHeader'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGitEnv — token injection', () => {
|
||||
// The token MUST travel via GIT_CONFIG_* env vars (git ≥2.31), not via
|
||||
// argv or URL. This keeps it out of `ps`, shell history, and stderr.
|
||||
|
||||
it('passes through base env and sets prompt-suppression env vars', () => {
|
||||
const env = buildGitEnv({ FOO: 'bar' });
|
||||
expect(env.FOO).toBe('bar');
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
||||
expect(env.GIT_ASKPASS).toBeDefined();
|
||||
});
|
||||
|
||||
it('scrubs inherited git trace vars that would log the Authorization header', () => {
|
||||
const env = buildGitEnv({
|
||||
GIT_TRACE: '1',
|
||||
GIT_TRACE_CURL: '1',
|
||||
GIT_TRACE_PACKET: '1',
|
||||
GIT_CURL_VERBOSE: '1',
|
||||
});
|
||||
expect(env.GIT_TRACE).toBeUndefined();
|
||||
expect(env.GIT_TRACE_CURL).toBeUndefined();
|
||||
expect(env.GIT_TRACE_PACKET).toBeUndefined();
|
||||
expect(env.GIT_CURL_VERBOSE).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set GIT_CONFIG_* env vars when no token is provided', () => {
|
||||
const env = buildGitEnv({});
|
||||
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_KEY_0).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBeUndefined();
|
||||
});
|
||||
|
||||
it('also leaves GIT_CONFIG_* unset when token is empty string', () => {
|
||||
const env = buildGitEnv({}, { token: '' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_KEY_0).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injects a host-scoped Basic-auth header when a github.com token is provided', () => {
|
||||
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/owner/repo' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
// Host-scoped key: the header attaches only to this origin's requests.
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('http.https://github.com/owner/repo.extraHeader');
|
||||
const expected =
|
||||
'Authorization: Basic ' + Buffer.from('x-access-token:ghp_secret123').toString('base64');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not inject a token for a non-github host (defense-in-depth host bind)', () => {
|
||||
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://gitlab.com/owner/repo' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never includes the raw token value in any env entry', () => {
|
||||
// Defence-in-depth: token must only appear inside the base64 of the
|
||||
// Authorization header, never as a plain substring of any env var.
|
||||
const token = 'ghp_uniqueRawSecret_98765';
|
||||
const env = buildGitEnv({ EXISTING: 'value' }, { token, url: 'https://github.com/o/r' });
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key === 'GIT_CONFIG_VALUE_0') continue;
|
||||
expect(String(value)).not.toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('injects the server Azure PAT (host-scoped) for an Azure URL', () => {
|
||||
const prev = process.env.AZURE_DEVOPS_PAT;
|
||||
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
|
||||
try {
|
||||
const env = buildGitEnv({}, { url: 'https://dev.azure.com/org/proj/_git/repo' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe(
|
||||
'http.https://dev.azure.com/org/proj/_git/repo.extraHeader',
|
||||
);
|
||||
const expected = 'Authorization: Basic ' + Buffer.from(':azure-pat-xyz').toString('base64');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe(expected);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT;
|
||||
else process.env.AZURE_DEVOPS_PAT = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('emits EXACTLY ONE header when a github token and an Azure PAT could both apply', () => {
|
||||
// A token only injects for github.com (where isAzureDevOpsUrl is false),
|
||||
// so the two never collide — guard the resolver directly anyway.
|
||||
const prev = process.env.AZURE_DEVOPS_PAT;
|
||||
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
|
||||
try {
|
||||
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/o/r' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
expect(env.GIT_CONFIG_VALUE_1).toBeUndefined();
|
||||
for (const value of Object.values(env)) {
|
||||
expect(String(value)).not.toContain('azure-pat-xyz');
|
||||
}
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT;
|
||||
else process.env.AZURE_DEVOPS_PAT = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('appends after an existing GIT_CONFIG_COUNT instead of overwriting it', () => {
|
||||
const env = buildGitEnv(
|
||||
{ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.sslVerify', GIT_CONFIG_VALUE_0: 'true' },
|
||||
{ token: 'ghp_secret123', url: 'https://github.com/o/r' },
|
||||
);
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
||||
// Operator's pre-existing config is preserved at index 0.
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('http.sslVerify');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe('true');
|
||||
// Our credential is appended at index 1.
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/o/r.extraHeader');
|
||||
expect(env.GIT_CONFIG_VALUE_1).toContain('Authorization: Basic ');
|
||||
});
|
||||
|
||||
it('strips control characters from the config key (no key injection)', () => {
|
||||
const env = buildGitEnv(
|
||||
{},
|
||||
{ token: 'ghp_secret123', url: 'https://github.com/o/r%0Anewline' },
|
||||
);
|
||||
const key = env.GIT_CONFIG_KEY_0 ?? '';
|
||||
expect(key).not.toContain('\n');
|
||||
expect(key).not.toContain('\r');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneOrPull — containment barrier', () => {
|
||||
|
|
@ -374,6 +528,128 @@ describe('git-clone', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isAzureDevOpsUrl', () => {
|
||||
it('recognizes dev.azure.com (cloud)', () => {
|
||||
expect(isAzureDevOpsUrl('https://dev.azure.com/org/project/_git/repo')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes *.visualstudio.com (cloud legacy)', () => {
|
||||
expect(isAzureDevOpsUrl('https://myorg.visualstudio.com/project/_git/repo')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for github.com', () => {
|
||||
expect(isAzureDevOpsUrl('https://github.com/user/repo')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for gitlab.com', () => {
|
||||
expect(isAzureDevOpsUrl('https://gitlab.com/user/repo')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for invalid URL', () => {
|
||||
expect(isAzureDevOpsUrl('not-a-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes a trailing-dot FQDN (dev.azure.com.)', () => {
|
||||
expect(isAzureDevOpsUrl('https://dev.azure.com./org/proj/_git/repo')).toBe(true);
|
||||
expect(isAzureDevOpsUrl('https://myorg.visualstudio.com./project/_git/repo')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not over-match a lookalike host with a trailing label', () => {
|
||||
expect(isAzureDevOpsUrl('https://dev.azure.com.evil.com/org/proj/_git/repo')).toBe(false);
|
||||
expect(isAzureDevOpsUrl('https://evilvisualstudio.com/project/_git/repo')).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes a self-hosted host configured via AZURE_DEVOPS_URL', () => {
|
||||
const prev = process.env.AZURE_DEVOPS_URL;
|
||||
try {
|
||||
process.env.AZURE_DEVOPS_URL = 'http://tfs.corp.example';
|
||||
expect(isAzureDevOpsUrl('http://tfs.corp.example/Coll/Proj/_git/Repo')).toBe(true);
|
||||
expect(isAzureDevOpsUrl('https://other.host.example/Coll/Proj/_git/Repo')).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
|
||||
else process.env.AZURE_DEVOPS_URL = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls through to the cloud check when AZURE_DEVOPS_URL is invalid', () => {
|
||||
const prev = process.env.AZURE_DEVOPS_URL;
|
||||
try {
|
||||
process.env.AZURE_DEVOPS_URL = 'not-a-url';
|
||||
expect(isAzureDevOpsUrl('https://dev.azure.com/org/proj/_git/repo')).toBe(true);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
|
||||
else process.env.AZURE_DEVOPS_URL = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleartext-credential warnings', () => {
|
||||
it('warns when injecting a credential over cleartext http://', () => {
|
||||
warnSpy.mockClear();
|
||||
buildGitEnv({}, { token: 'ghp_x', url: 'http://github.com/o/r' });
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not warn when injecting over https://', () => {
|
||||
warnSpy.mockClear();
|
||||
buildGitEnv({}, { token: 'ghp_x', url: 'https://github.com/o/r' });
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not warn over http:// when no credential is injected', () => {
|
||||
warnSpy.mockClear();
|
||||
// gitlab host is not in the token allowlist and no Azure PAT is set,
|
||||
// so nothing is injected — and nothing is warned about.
|
||||
buildGitEnv({}, { token: 'ghp_x', url: 'http://gitlab.com/o/r' });
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warnIfInsecureAzureConfig warns for http AZURE_DEVOPS_URL, not https', () => {
|
||||
const prev = process.env.AZURE_DEVOPS_URL;
|
||||
warnSpy.mockClear();
|
||||
try {
|
||||
process.env.AZURE_DEVOPS_URL = 'http://tfs.corp.example';
|
||||
warnIfInsecureAzureConfig();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
warnSpy.mockClear();
|
||||
process.env.AZURE_DEVOPS_URL = 'https://tfs.corp.example';
|
||||
warnIfInsecureAzureConfig();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
|
||||
else process.env.AZURE_DEVOPS_URL = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRepoName — Azure DevOps URLs', () => {
|
||||
it('extracts name from self-hosted Azure DevOps URL', () => {
|
||||
expect(
|
||||
extractRepoName('http://azuredevops.example.com/DefaultCollection/MyProject/_git/MyRepo'),
|
||||
).toBe('MyRepo');
|
||||
});
|
||||
|
||||
it('extracts name from dev.azure.com URL', () => {
|
||||
expect(extractRepoName('https://dev.azure.com/org/project/_git/myrepo')).toBe('myrepo');
|
||||
});
|
||||
|
||||
it('extracts name from visualstudio.com URL', () => {
|
||||
expect(extractRepoName('https://myorg.visualstudio.com/project/_git/myrepo')).toBe('myrepo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateGitUrl — Azure DevOps URLs', () => {
|
||||
it('allows self-hosted Azure DevOps Server URLs', () => {
|
||||
expect(() =>
|
||||
validateGitUrl('http://azuredevops.example.com/DefaultCollection/Project/_git/Repo'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows dev.azure.com URLs', () => {
|
||||
expect(() => validateGitUrl('https://dev.azure.com/org/project/_git/repo')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGitUrlForCompare', () => {
|
||||
it('strips trailing .git', () => {
|
||||
expect(normalizeGitUrlForCompare('https://github.com/owner/repo.git')).toBe(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue