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(), })); 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', 'git@example.com: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), ); }); }); });