167 lines
5.0 KiB
TypeScript
167 lines
5.0 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { WorkspaceService } from './workspace.service.js';
|
|
|
|
type ExecFileMock = (
|
|
command: string,
|
|
args: readonly string[],
|
|
options: { cwd: string },
|
|
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
|
) => void;
|
|
|
|
const { execFileMock } = vi.hoisted(() => ({
|
|
execFileMock: vi.fn<ExecFileMock>(),
|
|
}));
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
execFile: execFileMock,
|
|
}));
|
|
|
|
describe('WorkspaceService', () => {
|
|
let service: WorkspaceService;
|
|
|
|
beforeEach(() => {
|
|
execFileMock.mockReset();
|
|
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
|
callback(null, '', '');
|
|
});
|
|
service = new WorkspaceService();
|
|
});
|
|
|
|
describe('resolvePath', () => {
|
|
it('resolves user workspace path', () => {
|
|
const result = service.resolvePath({
|
|
id: 'proj1',
|
|
ownerType: 'user',
|
|
userId: 'user1',
|
|
teamId: null,
|
|
});
|
|
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
|
});
|
|
|
|
it('resolves team workspace path', () => {
|
|
const result = service.resolvePath({
|
|
id: 'proj1',
|
|
ownerType: 'team',
|
|
userId: 'user1',
|
|
teamId: 'team1',
|
|
});
|
|
expect(result).toContain(path.join('teams', 'team1', 'proj1'));
|
|
});
|
|
|
|
it('falls back to user path when ownerType is team but teamId is null', () => {
|
|
const result = service.resolvePath({
|
|
id: 'proj1',
|
|
ownerType: 'team',
|
|
userId: 'user1',
|
|
teamId: null,
|
|
});
|
|
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
|
});
|
|
|
|
it('uses MOSAIC_ROOT env var as the base path', () => {
|
|
const originalRoot = process.env['MOSAIC_ROOT'];
|
|
process.env['MOSAIC_ROOT'] = '/custom/root';
|
|
const customService = new WorkspaceService();
|
|
const result = customService.resolvePath({
|
|
id: 'proj1',
|
|
ownerType: 'user',
|
|
userId: 'user1',
|
|
teamId: null,
|
|
});
|
|
expect(result).toMatch(/^\/custom\/root/);
|
|
// Restore
|
|
if (originalRoot === undefined) {
|
|
delete process.env['MOSAIC_ROOT'];
|
|
} else {
|
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
}
|
|
});
|
|
|
|
it('defaults to /opt/mosaic when MOSAIC_ROOT is unset', () => {
|
|
const originalRoot = process.env['MOSAIC_ROOT'];
|
|
delete process.env['MOSAIC_ROOT'];
|
|
const defaultService = new WorkspaceService();
|
|
const result = defaultService.resolvePath({
|
|
id: 'proj2',
|
|
ownerType: 'user',
|
|
userId: 'user2',
|
|
teamId: null,
|
|
});
|
|
expect(result).toMatch(/^\/opt\/mosaic/);
|
|
// Restore
|
|
if (originalRoot !== undefined) {
|
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
const project = {
|
|
id: 'project-1',
|
|
ownerType: 'user',
|
|
userId: 'user-1',
|
|
teamId: null,
|
|
} as const;
|
|
|
|
let originalRoot: string | undefined;
|
|
let temporaryRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
originalRoot = process.env['MOSAIC_ROOT'];
|
|
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
|
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
|
service = new WorkspaceService();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalRoot === undefined) {
|
|
delete process.env['MOSAIC_ROOT'];
|
|
} else {
|
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
|
}
|
|
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each([
|
|
['a leading-dash URL', '--upload-pack=sh -c id'],
|
|
['an ext remote helper', 'ext::sh -c id'],
|
|
['a file URL', 'file:///tmp/repository'],
|
|
['an unparseable value', 'not a url'],
|
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
|
['a hostless git URL', 'git:///tmp/repository'],
|
|
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
|
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(execFileMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
|
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
|
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
|
const workspacePath = await service.create(project, repoUrl);
|
|
|
|
expect(execFileMock).toHaveBeenCalledOnce();
|
|
expect(execFileMock).toHaveBeenCalledWith(
|
|
'git',
|
|
[
|
|
'-c',
|
|
'protocol.ext.allow=never',
|
|
'-c',
|
|
'protocol.file.allow=never',
|
|
'clone',
|
|
'--',
|
|
repoUrl,
|
|
'.',
|
|
],
|
|
{ cwd: workspacePath },
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
});
|
|
});
|