Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

184 lines
7.7 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
coerceGitLabAccessLevel,
coerceGitLabMinAccessLevel,
getGitLabApiBase,
getGitLabResourcePath,
hasGitLabAccessLevel,
InvalidGitLabAccessLevelError,
normalizeGitLabHost,
UnsafeGitLabHostError,
} from '@/tools/gitlab/utils'
describe('normalizeGitLabHost', () => {
it('defaults to gitlab.com when the host is empty, blank, or not a string', () => {
expect(normalizeGitLabHost(undefined)).toBe('gitlab.com')
expect(normalizeGitLabHost(null)).toBe('gitlab.com')
expect(normalizeGitLabHost('')).toBe('gitlab.com')
expect(normalizeGitLabHost(' ')).toBe('gitlab.com')
expect(normalizeGitLabHost(42)).toBe('gitlab.com')
})
it('strips protocol and trailing slashes from a self-managed host', () => {
expect(normalizeGitLabHost('gitlab.example.com')).toBe('gitlab.example.com')
expect(normalizeGitLabHost('https://gitlab.example.com')).toBe('gitlab.example.com')
expect(normalizeGitLabHost('http://gitlab.example.com/')).toBe('gitlab.example.com')
expect(normalizeGitLabHost(' https://gitlab.example.com// ')).toBe('gitlab.example.com')
})
it('preserves an explicit port and IDN punycode labels', () => {
expect(normalizeGitLabHost('gitlab.example.com:8443')).toBe('gitlab.example.com:8443')
expect(normalizeGitLabHost('xn--80ak6aa92e.com')).toBe('xn--80ak6aa92e.com')
})
it('rejects hosts that could redirect the request authority (SSRF / token exfiltration)', () => {
const unsafe = [
'legit.com@evil.com',
'user:pass@evil.com',
'gitlab.com#@evil.com',
'gitlab.com /api',
'line\nbreak.com',
'evil.com/path',
'evil.com?x=1',
'[::1]',
'a..b.com',
'.gitlab.com',
'gitlab.com.',
]
for (const host of unsafe) {
expect(() => normalizeGitLabHost(host), host).toThrow(UnsafeGitLabHostError)
}
})
it('accepts bare IP literals at the STRUCTURAL layer by design (private/metadata IPs are rejected later by the fetch-layer DNS guard)', () => {
// This guard is structural only — it prevents authority confusion (userinfo,
// path, whitespace). SSRF to private/loopback/metadata addresses is the
// responsibility of validateUrlWithDNS / secureFetchWithValidation at fetch
// time, the single SSRF chokepoint shared by tools, webhooks, and connectors.
// These hosts are therefore structurally valid here, then blocked at fetch.
expect(normalizeGitLabHost('127.0.0.1')).toBe('127.0.0.1')
expect(normalizeGitLabHost('169.254.169.254')).toBe('169.254.169.254')
expect(normalizeGitLabHost('localhost')).toBe('localhost')
})
})
describe('getGitLabApiBase', () => {
it('builds the v4 REST base for the default and self-managed hosts', () => {
expect(getGitLabApiBase(undefined)).toBe('https://gitlab.com/api/v4')
expect(getGitLabApiBase('gitlab.example.com')).toBe('https://gitlab.example.com/api/v4')
expect(getGitLabApiBase('https://gitlab.example.com:8443/')).toBe(
'https://gitlab.example.com:8443/api/v4'
)
})
it('propagates rejection of unsafe hosts', () => {
expect(() => getGitLabApiBase('legit.com@evil.com')).toThrow(UnsafeGitLabHostError)
})
})
describe('getGitLabResourcePath', () => {
it('builds project and group path segments', () => {
expect(getGitLabResourcePath('project', 42)).toBe('projects/42')
expect(getGitLabResourcePath('group', 7)).toBe('groups/7')
})
it('URL-encodes namespaced paths and trims whitespace', () => {
expect(getGitLabResourcePath('project', ' mygroup/myproject ')).toBe(
'projects/mygroup%2Fmyproject'
)
expect(getGitLabResourcePath('group', 'parent/child')).toBe('groups/parent%2Fchild')
})
it('does not double-encode a resourceId that is already URL-encoded', () => {
expect(getGitLabResourcePath('group', 'parent%2Fchild')).toBe('groups/parent%2Fchild')
expect(getGitLabResourcePath('project', ' rvt-sandbox%2Fplatform-eng ')).toBe(
'projects/rvt-sandbox%2Fplatform-eng'
)
})
it('treats a bare, non-percent-encoding "%" as a literal character', () => {
expect(getGitLabResourcePath('group', '100%-done')).toBe('groups/100%25-done')
})
})
describe('coerceGitLabAccessLevel', () => {
it('accepts an integer already in the enum', () => {
expect(coerceGitLabAccessLevel(0)).toBe(0)
expect(coerceGitLabAccessLevel(30)).toBe(30)
expect(coerceGitLabAccessLevel(50)).toBe(50)
})
it('accepts a numeric string', () => {
expect(coerceGitLabAccessLevel('30')).toBe(30)
expect(coerceGitLabAccessLevel(' 40 ')).toBe(40)
})
it('accepts a level name, case-insensitively', () => {
expect(coerceGitLabAccessLevel('Developer')).toBe(30)
expect(coerceGitLabAccessLevel('developer')).toBe(30)
expect(coerceGitLabAccessLevel(' MAINTAINER ')).toBe(40)
expect(coerceGitLabAccessLevel('No access')).toBe(0)
expect(coerceGitLabAccessLevel('Security Manager')).toBe(25)
})
it('throws for values outside the enum', () => {
expect(() => coerceGitLabAccessLevel(999)).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabAccessLevel(31)).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabAccessLevel('35')).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabAccessLevel('root')).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabAccessLevel('')).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabAccessLevel(' ')).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabAccessLevel(null)).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabAccessLevel(undefined)).toThrow(InvalidGitLabAccessLevelError)
})
it('names the offending value and valid levels in the error message', () => {
expect(() => coerceGitLabAccessLevel('boss')).toThrow(/Developer \(30\)/)
})
})
describe('coerceGitLabMinAccessLevel', () => {
it('returns undefined for absent or blank values', () => {
expect(coerceGitLabMinAccessLevel(undefined)).toBeUndefined()
expect(coerceGitLabMinAccessLevel(null)).toBeUndefined()
expect(coerceGitLabMinAccessLevel('')).toBeUndefined()
})
it('coerces valid filter levels from integer, numeric string, or name', () => {
expect(coerceGitLabMinAccessLevel(30)).toBe(30)
expect(coerceGitLabMinAccessLevel('30')).toBe(30)
expect(coerceGitLabMinAccessLevel('Developer')).toBe(30)
expect(coerceGitLabMinAccessLevel(5)).toBe(5)
})
it('rejects zero ("No access") — GitLab\'s filter floor is 5', () => {
expect(() => coerceGitLabMinAccessLevel(0)).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabMinAccessLevel('No access')).toThrow(InvalidGitLabAccessLevelError)
})
it('rejects out-of-enum values so they never reach GitLab', () => {
expect(() => coerceGitLabMinAccessLevel(31)).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabMinAccessLevel(999)).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabMinAccessLevel('35')).toThrow(InvalidGitLabAccessLevelError)
expect(() => coerceGitLabMinAccessLevel('root')).toThrow(InvalidGitLabAccessLevelError)
})
})
describe('hasGitLabAccessLevel', () => {
it('treats numeric zero ("No access") and its string form as provided', () => {
expect(hasGitLabAccessLevel(0)).toBe(true)
expect(hasGitLabAccessLevel('0')).toBe(true)
expect(hasGitLabAccessLevel(30)).toBe(true)
expect(hasGitLabAccessLevel('Developer')).toBe(true)
})
it('treats undefined, null, and the empty-string sentinel as not provided', () => {
expect(hasGitLabAccessLevel(undefined)).toBe(false)
expect(hasGitLabAccessLevel(null)).toBe(false)
expect(hasGitLabAccessLevel('')).toBe(false)
})
})