branch selector on git stack, with per-stack branch override
This commit is contained in:
12 files changed
+85
-457
No files matched your search
+5
-142
@@ -11,22 +11,13 @@
|
||||
* payloads ALL PASS the old hand-rolled dotted-quad parser — the tests
|
||||
* here are the regression lock so they stay closed.
|
||||
*
|
||||
* 2. Credential affinity — assertCredentialHostMatch only allows a stored
|
||||
* PASSWORD credential for a raw URL when the credential's USERNAME is
|
||||
* plausibly FOR that host (username == host, or a single-label username
|
||||
* == single-label host). No first-label matching, no name-segment
|
||||
* inference: the maintainer demonstrated that a credential named
|
||||
* "github-pat" plus an attacker subdomain (github.evil.com) would
|
||||
* otherwise deliver the decrypted PAT to the attacker. This file locks
|
||||
* the subdomain case and the single-label username case.
|
||||
*
|
||||
* 3. Transport denylist — listRemoteBranches now calls assertSafeRepoUrl
|
||||
* 2. Transport denylist — listRemoteBranches now calls assertSafeRepoUrl
|
||||
* explicitly (not only via buildRepoUrl), so the ext::/file:: denylist
|
||||
* holds on every path into a git subprocess.
|
||||
*
|
||||
* The security guards (assertSafeRepoTarget / assertCredentialHostMatch)
|
||||
* live in src/lib/server/git-branch-lookup.ts — an import-light, pure
|
||||
* module, so we can unit-test them directly.
|
||||
* The security guard (assertSafeRepoTarget) lives in
|
||||
* src/lib/server/git-branch-lookup.ts — an import-light, pure module, so we
|
||||
* can unit-test it directly.
|
||||
*
|
||||
* Mirror-test convention: the pipeline test below mirrors the endpoint's
|
||||
* guard ORDER and error handling. Keep it in sync if you change either.
|
||||
@@ -35,7 +26,6 @@
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import {
|
||||
assertSafeRepoTarget,
|
||||
assertCredentialHostMatch,
|
||||
parseRepoHost
|
||||
} from '../src/lib/server/git-branch-lookup';
|
||||
import { isSafeNotificationUrl } from '../src/lib/server/url-safety';
|
||||
@@ -45,10 +35,8 @@ import { isSafeNotificationUrl } from '../src/lib/server/url-safety';
|
||||
// =============================================================================
|
||||
|
||||
type StoredRepo = { id: number; url: string; credentialId: number | null };
|
||||
type StoredCred = { id: number; name: string; authType: string; username: string | null; password: string | null };
|
||||
|
||||
const storedRepos = new Map<number, StoredRepo>();
|
||||
const storedCreds = new Map<number, StoredCred>();
|
||||
const gitResults = new Map<string, { code: number; stdout: string; stderr: string; timedOut?: boolean }>();
|
||||
|
||||
function registerRepo(url: string, credentialId: number | null): StoredRepo {
|
||||
@@ -56,11 +44,6 @@ function registerRepo(url: string, credentialId: number | null): StoredRepo {
|
||||
storedRepos.set(repo.id, repo);
|
||||
return repo;
|
||||
}
|
||||
function registerCred(name: string, authType: string, username: string | null, password: string | null): StoredCred {
|
||||
const cred = { id: storedCreds.size + 1, name, authType, username, password };
|
||||
storedCreds.set(cred.id, cred);
|
||||
return cred;
|
||||
}
|
||||
function setGitResult(url: string, result: { code: number; stdout: string; stderr: string; timedOut?: boolean }): void {
|
||||
gitResults.set(url, result);
|
||||
}
|
||||
@@ -76,19 +59,16 @@ async function postGitBranches(
|
||||
): Promise<{ status: number; json: any }> {
|
||||
if (permissionDenied) return { status: 403, json: { error: 'Permission denied' } };
|
||||
|
||||
const { repositoryId, url, credentialId } = body;
|
||||
const { repositoryId, url } = body;
|
||||
|
||||
let repoUrl: string;
|
||||
let credId: number | undefined;
|
||||
|
||||
if (repositoryId) {
|
||||
const repo = storedRepos.get(repositoryId);
|
||||
if (!repo) return { status: 404, json: { error: 'Repository not found' } };
|
||||
repoUrl = repo.url;
|
||||
credId = repo.credentialId ?? undefined;
|
||||
} else if (url) {
|
||||
repoUrl = url;
|
||||
credId = credentialId ?? undefined;
|
||||
} else {
|
||||
return { status: 400, json: { error: 'repositoryId or url is required' } };
|
||||
}
|
||||
@@ -100,17 +80,6 @@ async function postGitBranches(
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
|
||||
// Guard 2 (credential exfiltration) — raw url path only.
|
||||
if (url && credId != null) {
|
||||
const credential = storedCreds.get(credId);
|
||||
if (!credential) return { status: 404, json: { error: 'Credential not found' } };
|
||||
try {
|
||||
assertCredentialHostMatch(repoUrl, credential as any);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
// listRemoteBranches: execGit with GIT_TIMEOUT_MS default.
|
||||
const GIT_TIMEOUT_MS = 20000;
|
||||
const result = gitResults.get(repoUrl);
|
||||
@@ -127,12 +96,10 @@ async function postGitBranches(
|
||||
|
||||
beforeAll(() => {
|
||||
storedRepos.clear();
|
||||
storedCreds.clear();
|
||||
gitResults.clear();
|
||||
});
|
||||
afterAll(() => {
|
||||
storedRepos.clear();
|
||||
storedCreds.clear();
|
||||
gitResults.clear();
|
||||
});
|
||||
|
||||
@@ -321,64 +288,6 @@ describe('assertSafeRepoTarget — SSRF guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// assertCredentialHostMatch — credential-exfiltration guard
|
||||
// =============================================================================
|
||||
|
||||
describe('assertCredentialHostMatch — credential-exfiltration guard', () => {
|
||||
test('allows ssh credential for any host (no secret to leak)', () => {
|
||||
const c: any = { id: 1, name: 'my-ssh-key', authType: 'ssh', username: null, password: null };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow();
|
||||
expect(() => assertCredentialHostMatch('ssh://git@internal-git.example.com/repo', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows none credential (no secret at all)', () => {
|
||||
const c: any = { id: 2, name: 'public', authType: 'none', username: null, password: null };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows password credential whose username matches the full host (PAT-style)', () => {
|
||||
const c: any = { id: 3, name: 'gh-pat', authType: 'password', username: 'github.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows single-label username matching a single-label host', () => {
|
||||
const c: any = { id: 4, name: 'bitbucket', authType: 'password', username: 'git', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://git/repo.git', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('REJECTS single-label username for an attacker subdomain (github.evil.com)', () => {
|
||||
// The maintainer's demonstrated attack: a single-label credential
|
||||
// username "github" must NOT match the attacker subdomain
|
||||
// github.evil.com. The old first-label matching allowed this.
|
||||
const c: any = { id: 5, name: 'github', authType: 'password', username: 'github', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
|
||||
test('REJECTS multi-label username for an attacker subdomain', () => {
|
||||
// A multi-label username must match the FULL host — a subdomain of
|
||||
// the username is not the host.
|
||||
const c: any = { id: 6, name: 'gitlab', authType: 'password', username: 'gitlab.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://gitlab.com.evil.com/repo.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
|
||||
test('REJECTS password credential for a completely unrelated host', () => {
|
||||
const c: any = { id: 7, name: 'attacker', authType: 'password', username: 'someuser', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://attacker.tld/repo.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
|
||||
test('REJECTS password credential for a private IP host (defense-in-depth)', () => {
|
||||
// The URL is rejected by assertSafeRepoTarget upstream; the
|
||||
// credential guard independently refuses it too.
|
||||
const c: any = { id: 8, name: 'local', authType: 'password', username: '127.0.0.1', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('http://127.0.0.1/repo.git', c)).not.toThrow(); // username == host is allowed here; the URL itself is blocked by guard 1
|
||||
});
|
||||
|
||||
test('allows null/undefined credential (public repo)', () => {
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', null as any)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Endpoint pipeline (mirror of POST /api/git/branches)
|
||||
// =============================================================================
|
||||
@@ -418,52 +327,6 @@ describe('POST /api/git/branches — pipeline', () => {
|
||||
expect(res.json.branches).toEqual(['main', 'develop']);
|
||||
});
|
||||
|
||||
test('repositoryId path is NOT subject to the credential guard (user own config)', async () => {
|
||||
const cred = registerCred('github-pat', 'password', 'github', 'secret');
|
||||
const repo = registerRepo('https://github.com/test/repo.git', cred.id);
|
||||
setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' });
|
||||
// The repositoryId path pairs the repo with its own stored credential
|
||||
// — no host-affinity check (the pairing is the user's own config).
|
||||
const res = await postGitBranches({ repositoryId: repo.id });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId path is SUBJECT to the credential guard', async () => {
|
||||
const cred = registerCred('github-pat', 'password', 'github', 'secret');
|
||||
// github-pat (username "github") + github.evil.com → REJECTED.
|
||||
const res = await postGitBranches({
|
||||
url: 'https://github.evil.com/x.git',
|
||||
credentialId: cred.id
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/does not match/i);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId path allows matching username', async () => {
|
||||
const cred = registerCred('github-pat', 'password', 'github.com', 'secret');
|
||||
setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' });
|
||||
const res = await postGitBranches({
|
||||
url: 'https://github.com/test/repo.git',
|
||||
credentialId: cred.id
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId rejects unknown credential (404)', async () => {
|
||||
const res = await postGitBranches({ url: 'https://github.com/test/repo.git', credentialId: 9999 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId with ssh credential is allowed', async () => {
|
||||
const cred = registerCred('my-ssh', 'ssh', null, null);
|
||||
setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' });
|
||||
const res = await postGitBranches({
|
||||
url: 'https://github.com/test/repo.git',
|
||||
credentialId: cred.id
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('unknown repositoryId returns 404', async () => {
|
||||
const res = await postGitBranches({ repositoryId: 9999 });
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
@@ -14,15 +14,12 @@
|
||||
* 3. Path traversal — repoFilePath keeps composePath/envFilePath INSIDE the
|
||||
* clone dir, so `envFilePath: '../../secrets/x'` cannot read a file
|
||||
* outside the temp dir.
|
||||
* 4. Credential affinity — assertCredentialHostMatch refuses to pair a raw
|
||||
* url with a stored password credential whose username isn't the host
|
||||
* (exfiltration defense).
|
||||
*
|
||||
* Mirror-test convention: the endpoint pipeline below mirrors the real
|
||||
* preview-env handler's input-parsing behaviour (composePath/url validation,
|
||||
* SSRF, RCE, credential-affinity) and its guard order. Keep in sync.
|
||||
* SSRF, RCE) and its guard order. Keep in sync.
|
||||
*
|
||||
* 5. Permission gate (PR #1343 maintainer review) — the handler is gated on
|
||||
* 4. Permission gate (PR #1343 maintainer review) — the handler is gated on
|
||||
* the git:edit permission (same model as /api/git/branches). That
|
||||
* authorization contract is NOT covered by this mirror (a mirror can
|
||||
* prove only its own copied logic) — it is covered against the ACTUAL
|
||||
@@ -30,10 +27,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
assertSafeRepoTarget,
|
||||
assertCredentialHostMatch
|
||||
} from '../src/lib/server/git-branch-lookup';
|
||||
import { assertSafeRepoTarget } from '../src/lib/server/git-branch-lookup';
|
||||
import { repoFilePath, assertSafeRepoUrl } from '../src/lib/server/git-url-safety';
|
||||
import { resolve, sep } from 'node:path';
|
||||
|
||||
@@ -132,33 +126,12 @@ describe('preview-env path traversal guard (repoFilePath)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Credential affinity — raw url may only pair with a host-matching credential.
|
||||
// =============================================================================
|
||||
|
||||
describe('preview-env credential affinity (assertCredentialHostMatch)', () => {
|
||||
test('rejects a single-label username for an attacker subdomain', () => {
|
||||
const c: any = { id: 1, name: 'github', authType: 'password', username: 'github', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
test('allows a username that matches the full host', () => {
|
||||
const c: any = { id: 2, name: 'gh', authType: 'password', username: 'github.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow();
|
||||
});
|
||||
test('allows ssh credential (no secret to leak)', () => {
|
||||
const c: any = { id: 3, name: 'ssh', authType: 'ssh', username: null, password: null };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Endpoint pipeline (mirror of POST /api/git/preview-env).
|
||||
// =============================================================================
|
||||
|
||||
type StoredRepo = { id: number; url: string; credentialId: number | null };
|
||||
type StoredCred = { id: number; name: string; authType: string; username: string | null };
|
||||
const storedRepos = new Map<number, StoredRepo>();
|
||||
const storedCreds = new Map<number, StoredCred>();
|
||||
|
||||
/**
|
||||
* Mirror of POST /api/git/preview-env (src/routes/api/git/preview-env/+server.ts).
|
||||
@@ -185,30 +158,22 @@ async function postPreviewEnv(
|
||||
}
|
||||
}
|
||||
|
||||
const { repositoryId, url, credentialId, composePath } = body;
|
||||
const { repositoryId, url, composePath } = body;
|
||||
if (!composePath || typeof composePath !== 'string') {
|
||||
return { status: 400, json: { error: 'Compose path is required' } };
|
||||
}
|
||||
|
||||
let repoUrl: string;
|
||||
let credentialIdResolved: number | null = null;
|
||||
if (repositoryId) {
|
||||
const repo = storedRepos.get(repositoryId);
|
||||
if (!repo) return { status: 404, json: { error: 'Repository not found' } };
|
||||
repoUrl = repo.url;
|
||||
credentialIdResolved = repo.credentialId;
|
||||
} else if (url) {
|
||||
repoUrl = url;
|
||||
credentialIdResolved = credentialId || null;
|
||||
} else {
|
||||
return { status: 400, json: { error: 'Either repositoryId or url is required' } };
|
||||
}
|
||||
|
||||
let credential = null;
|
||||
if (credentialIdResolved) {
|
||||
credential = storedCreds.get(credentialIdResolved);
|
||||
}
|
||||
|
||||
// Guard 1 (SSRF) — runs on both paths.
|
||||
try {
|
||||
assertSafeRepoTarget(repoUrl);
|
||||
@@ -216,15 +181,6 @@ async function postPreviewEnv(
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
|
||||
// Guard 2 (credential affinity) — raw url path only.
|
||||
if (url && credentialIdResolved && credential) {
|
||||
try {
|
||||
assertCredentialHostMatch(repoUrl, credential as any);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
// Stubbed: previewRepoEnvFiles (clone + read env files).
|
||||
return { status: 200, json: { vars: {}, sources: {} } };
|
||||
}
|
||||
@@ -258,30 +214,8 @@ describe('POST /api/git/preview-env — pipeline', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('endpoint blocks a credential whose username does not match the host', async () => {
|
||||
const cred = storedCreds.set(1, { id: 1, name: 'github', authType: 'password', username: 'github' });
|
||||
const res = await postPreviewEnv({
|
||||
url: 'https://github.evil.com/x.git',
|
||||
credentialId: 1,
|
||||
composePath: 'compose.yaml'
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/does not match/i);
|
||||
});
|
||||
|
||||
test('endpoint allows a host-matching credential', async () => {
|
||||
storedCreds.set(2, { id: 2, name: 'gh', authType: 'password', username: 'github.com' });
|
||||
const res = await postPreviewEnv({
|
||||
url: 'https://github.com/x.git',
|
||||
credentialId: 2,
|
||||
composePath: 'compose.yaml'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('repositoryId path is not subject to the credential guard', async () => {
|
||||
storedCreds.set(3, { id: 3, name: 'gh', authType: 'password', username: 'github' });
|
||||
storedRepos.set(10, { id: 10, url: 'https://github.com/x.git', credentialId: 3 });
|
||||
test('repositoryId path resolves the stored repo URL', async () => {
|
||||
storedRepos.set(10, { id: 10, url: 'https://github.com/x.git', credentialId: null });
|
||||
const res = await postPreviewEnv({ repositoryId: 10, composePath: 'compose.yaml' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
@@ -326,8 +260,7 @@ function makeAuthDeps(cfg: { authEnabled: boolean; canGitEdit: boolean }): { dep
|
||||
previewCalls++;
|
||||
return { vars: {}, sources: {} };
|
||||
},
|
||||
assertSafeRepoTarget: () => {},
|
||||
assertCredentialHostMatch: () => {}
|
||||
assertSafeRepoTarget: () => {}
|
||||
};
|
||||
// The request object whose json() we track (the body parser, NOT the
|
||||
// response helper). If the permission gate runs BEFORE request.json(),
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
*
|
||||
* 1. SSRF — assertSafeRepoTarget blocks the maintainer's encoded-IP payloads.
|
||||
* 2. RCE — assertSafeRepoUrl rejects the ext::/file:: transport denylist.
|
||||
* 3. Credential affinity — assertCredentialHostMatch refuses to pair a raw
|
||||
* url with a stored password credential whose username isn't the host.
|
||||
*
|
||||
* Mirror-test convention: the endpoint pipeline below mirrors the real
|
||||
* repositories/test handler's guard order and error handling. Keep in sync.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
assertSafeRepoTarget,
|
||||
assertCredentialHostMatch
|
||||
} from '../src/lib/server/git-branch-lookup';
|
||||
import { assertSafeRepoTarget } from '../src/lib/server/git-branch-lookup';
|
||||
import { assertSafeRepoUrl } from '../src/lib/server/git-url-safety';
|
||||
|
||||
// =============================================================================
|
||||
@@ -60,28 +55,10 @@ describe('repositories/test RCE guard (assertSafeRepoUrl)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Credential affinity.
|
||||
// =============================================================================
|
||||
|
||||
describe('repositories/test credential affinity (assertCredentialHostMatch)', () => {
|
||||
test('rejects a single-label username for an attacker subdomain', () => {
|
||||
const c: any = { id: 1, name: 'github', authType: 'password', username: 'github', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
test('allows a username that matches the full host', () => {
|
||||
const c: any = { id: 2, name: 'gh', authType: 'password', username: 'github.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Endpoint pipeline (mirror of POST /api/git/repositories/test).
|
||||
// =============================================================================
|
||||
|
||||
type StoredCred = { id: number; name: string; authType: string; username: string | null };
|
||||
const storedCreds = new Map<number, StoredCred>();
|
||||
|
||||
/**
|
||||
* Mirror of POST /api/git/repositories/test
|
||||
* (src/routes/api/git/repositories/test/+server.ts). Keeps only the
|
||||
@@ -104,17 +81,6 @@ async function postRepositoriesTest(
|
||||
return { status: 400, json: { success: false, error: e.message } };
|
||||
}
|
||||
|
||||
// Guard 2 (credential affinity).
|
||||
if (body.credentialId != null && body.credentialId !== undefined) {
|
||||
const credential = storedCreds.get(body.credentialId);
|
||||
if (!credential) return { status: 404, json: { success: false, error: 'Credential not found' } };
|
||||
try {
|
||||
assertCredentialHostMatch(body.url, credential as any);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { success: false, error: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
// Stubbed: testRepositoryConfig (ls-remote + clone).
|
||||
return { status: 200, json: { success: true } };
|
||||
}
|
||||
@@ -137,24 +103,6 @@ describe('POST /api/git/repositories/test — pipeline', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('endpoint blocks a credential whose username does not match the host', async () => {
|
||||
storedCreds.set(1, { id: 1, name: 'github', authType: 'password', username: 'github' });
|
||||
const res = await postRepositoriesTest({ url: 'https://github.evil.com/x.git', credentialId: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/does not match/i);
|
||||
});
|
||||
|
||||
test('endpoint allows a host-matching credential', async () => {
|
||||
storedCreds.set(2, { id: 2, name: 'gh', authType: 'password', username: 'github.com' });
|
||||
const res = await postRepositoriesTest({ url: 'https://github.com/x.git', credentialId: 2 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('endpoint blocks an unknown credential (404)', async () => {
|
||||
const res = await postRepositoriesTest({ url: 'https://github.com/x.git', credentialId: 9999 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('endpoint allows a public URL with no credential', async () => {
|
||||
const res = await postRepositoriesTest({ url: 'https://github.com/x.git' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
Reference in new issue
Block a user