wrolpi/app/src/components/Repos.test.js
lrnselfreliance 93d878952c
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.

- A Repo is a Collection (kind='repo') plus a `repository` row.  The new `git` downloader clones it, then updates it
  (weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
  An existing clone can be imported where it is.  repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download.  READMEs are searchable
  under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
  repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
  updated.
- Tagging a Repo moves its clone as one directory.  Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
  destination.  A failed tag move restores the Collection's tag.  A Collection's directory cannot leave the media
  directory.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:58 -06:00

394 lines
18 KiB
JavaScript

import React from 'react';
import {Route, Routes} from 'react-router';
import {act, fireEvent, render, screen, waitFor, within} from '../test-utils';
import {
RepoEditPage,
RepoImportPage,
repoNameFromURL,
RepoNewPage,
ReposPage,
RepoViewPage,
safeRepoHref,
} from './Repos';
import {
createRepo, deleteRepo, fetchRepos, getFile, getRepo, getRepoLog, getRepoTree, importRepo, inspectRepoImport,
updateRepo,
} from '../api';
import {FilePreviewContext} from './FilePreview';
jest.mock('../api', () => ({
...jest.requireActual('../api'),
createRepo: jest.fn(),
deleteRepo: jest.fn(),
fetchRepos: jest.fn(),
getFile: jest.fn(),
getRepo: jest.fn(),
getRepoLog: jest.fn(),
getRepoTree: jest.fn(),
importRepo: jest.fn(),
inspectRepoImport: jest.fn(),
updateRepo: jest.fn(),
}));
// The directory picker searches the server; picking a directory is all these specs need.
jest.mock('./Common', () => ({
...jest.requireActual('./Common'),
DirectorySearch: ({onSelect}) => <button type='button' onClick={() => onSelect('code/kiwix-tools')}>
Pick directory
</button>,
}));
// The README is fetched from the media server; its rendering is MarkdownPreview's own spec.
jest.mock('./MarkdownPreview', () => ({
...jest.requireActual('./MarkdownPreview'),
MarkdownPreview: ({url}) => <div data-testid='markdown-preview'>{url}</div>,
}));
const repoFixture = (overrides = {}) => ({
id: 1, collection_id: 10, name: 'kiwix-tools', tag_name: null, description: null,
url: 'https://github.com/kiwix/kiwix-tools', mode: 'full', branch: null, default_branch: 'main',
head_sha: '3f9c2a1e7b', head_message: 'Fix the mimetype', head_date: '2026-09-30T14:02:00Z',
size: 1000, directory: 'repos/kiwix-tools', frequency: 604800, download_error: null,
...overrides,
});
const treeFixture = {
path: '',
readme_path: 'README.md',
entries: [
{name: 'src', path: 'src', is_dir: true, size: null},
{name: 'README.md', path: 'README.md', is_dir: false, size: 120},
],
};
const flush = () => act(async () => {
});
beforeEach(() => jest.clearAllMocks());
describe('safeRepoHref', () => {
it('only links https URLs', () => {
expect(safeRepoHref('https://github.com/a/b')).toBe('https://github.com/a/b');
expect(safeRepoHref('javascript:alert(1)')).toBeNull();
expect(safeRepoHref('http://github.com/a/b')).toBeNull();
expect(safeRepoHref('file:///etc/passwd')).toBeNull();
expect(safeRepoHref('not a url')).toBeNull();
});
});
describe('repoNameFromURL', () => {
it('is the last path segment, without .git or a branch', () => {
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools.git')).toBe('kiwix-tools');
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools/tree/dev')).toBe('kiwix-tools');
expect(repoNameFromURL('https://gitlab.com/group/sub/project/')).toBe('project');
expect(repoNameFromURL('nope')).toBe('');
});
});
describe('ReposPage', () => {
it('lists repos, linking each to its page', async () => {
fetchRepos.mockResolvedValue([
repoFixture(),
repoFixture({id: 2, name: 'gone', download_error: 'git ls-remote failed', head_sha: null}),
]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getAllByText('kiwix-tools')[0].closest('a')).toHaveAttribute('href', '/repos/1');
expect(screen.getAllByText('gone')[0].closest('a')).toHaveAttribute('href', '/repos/2');
expect(screen.getByText('New Repo').closest('a')).toHaveAttribute('href', '/repos/new');
});
it('shows a repo which is never updated', async () => {
fetchRepos.mockResolvedValue([repoFixture({frequency: 0})]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getAllByText('Never').length).toBeGreaterThan(0);
});
it('explains an empty list', async () => {
fetchRepos.mockResolvedValue([]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getByText(/No repos yet/)).toBeInTheDocument();
});
});
function renderViewPage({repo, tree = treeFixture, query = {}, setPreviewFile = jest.fn()} = {}) {
getRepo.mockResolvedValue(repo);
getRepoTree.mockResolvedValue(tree);
return render(<Routes><Route path='/repos/:repoId' element={<RepoViewPage/>}/></Routes>, {
route: '/repos/1',
query,
contexts: [[FilePreviewContext, {setPreviewFile}]],
});
}
describe('RepoViewPage', () => {
it('shows the latest commit, the files, and the README', async () => {
renderViewPage({repo: repoFixture()});
await flush();
expect(getRepoTree).toHaveBeenCalledWith('1', '');
expect(screen.getByText('3f9c2a1')).toBeInTheDocument();
expect(screen.getByText('Fix the mimetype')).toBeInTheDocument();
expect(screen.getByText('main')).toBeInTheDocument();
expect(screen.getByText('(default)')).toBeInTheDocument();
expect(screen.getByText('https://github.com/kiwix/kiwix-tools').closest('a'))
.toHaveAttribute('href', 'https://github.com/kiwix/kiwix-tools');
expect(screen.getByText('src')).toBeInTheDocument();
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('/media/repos/kiwix-tools/README.md');
});
it('opens a directory through the URL, and a file in the preview', async () => {
const updateQuery = jest.fn();
const setPreviewFile = jest.fn();
const file = {path: 'repos/kiwix-tools/README.md', mimetype: 'text/markdown', size: 120};
getFile.mockResolvedValue(file);
renderViewPage({repo: repoFixture(), query: {updateQuery}, setPreviewFile});
await flush();
fireEvent.click(screen.getByText('src'));
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
fireEvent.click(screen.getAllByText('README.md')[0]);
await flush();
expect(getFile).toHaveBeenCalledWith('repos/kiwix-tools/README.md', true);
expect(setPreviewFile).toHaveBeenCalledWith(file);
});
it('lists the directory in the URL, with a way back up', async () => {
const tree = {path: 'src/lib', readme_path: null, entries: [{name: 'a.c', path: 'src/lib/a.c', is_dir: false}]};
const updateQuery = jest.fn();
renderViewPage({repo: repoFixture(), tree, query: {searchParams: new URLSearchParams('path=src/lib'), updateQuery}});
await flush();
expect(getRepoTree).toHaveBeenCalledWith('1', 'src/lib');
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('..'));
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
// The breadcrumb's root.
fireEvent.click(screen.getAllByText('kiwix-tools').find(i => i.tagName === 'BUTTON'));
expect(updateQuery).toHaveBeenCalledWith({path: ''}, false);
});
it('offers a ZIP of the files', async () => {
renderViewPage({repo: repoFixture()});
await flush();
const link = screen.getByText('Download ZIP').closest('a');
expect(link).toHaveAttribute('href', expect.stringMatching(/\/api\/repos\/1\/archive\.zip$/));
expect(link).toHaveAttribute('download');
});
it('shows the history instead of the files', async () => {
getRepoLog.mockResolvedValue({
total: 51,
commits: [{sha: 'abcdef1234', author: 'Ada', date: '2026-09-30T14:02:00Z', message: 'Fix the mimetype'}],
});
const updateQuery = jest.fn();
renderViewPage({repo: repoFixture(), query: {searchParams: new URLSearchParams('view=history'), updateQuery}});
await flush();
expect(getRepoLog).toHaveBeenCalledWith(1, 0, 50);
expect(getRepoTree).not.toHaveBeenCalled();
expect(screen.getByText('51 commits')).toBeInTheDocument();
expect(screen.getByText('abcdef1')).toBeInTheDocument();
expect(screen.getByText('Ada')).toBeInTheDocument();
// The next page is appended.
getRepoLog.mockResolvedValue({
total: 51,
commits: [{sha: '0123456789', author: 'Ben', date: '2026-09-01T00:00:00Z', message: 'Older'}],
});
fireEvent.click(screen.getByText('Load more'));
await flush();
expect(getRepoLog).toHaveBeenLastCalledWith(1, 1, 50);
expect(screen.getByText('abcdef1')).toBeInTheDocument();
expect(screen.getByText('0123456')).toBeInTheDocument();
fireEvent.click(screen.getByText('Files'));
expect(updateQuery).toHaveBeenCalledWith({view: null}, false);
});
it('explains a repo that is not downloaded yet', async () => {
renderViewPage({repo: repoFixture({head_sha: null, head_message: null, head_date: null})});
await flush();
expect(screen.getByText('Not downloaded yet')).toBeInTheDocument();
expect(getRepoTree).not.toHaveBeenCalled();
expect(screen.queryByText('Download ZIP')).not.toBeInTheDocument();
expect(screen.queryByText('History')).not.toBeInTheDocument();
});
it('keeps showing the files when the last update failed', async () => {
renderViewPage({repo: repoFixture({download_error: 'Traceback...\nDownloadError: git ls-remote failed (128)'})});
await flush();
expect(screen.getByText('The last update failed')).toBeInTheDocument();
expect(screen.getByText(/git ls-remote failed/)).toBeInTheDocument();
expect(screen.getByText('src')).toBeInTheDocument();
});
it('says a repo which is never updated is only updated on request', async () => {
renderViewPage({repo: repoFixture({frequency: 0, download_error: 'git ls-remote failed (128)'})});
await flush();
expect(screen.getByText('The last update failed')).toBeInTheDocument();
expect(screen.getByText(/never updated automatically/)).toBeInTheDocument();
expect(screen.queryByText(/will be tried again/)).not.toBeInTheDocument();
});
it('never links an upstream that is not https', async () => {
renderViewPage({repo: repoFixture({url: 'javascript:alert(1)'})});
await flush();
expect(screen.queryByText('javascript:alert(1)')).not.toBeInTheDocument();
});
});
describe('RepoNewPage', () => {
it('adds a repo, then opens it', async () => {
createRepo.mockResolvedValue(repoFixture({id: 5}));
render(<Routes>
<Route path='/repos/new' element={<RepoNewPage/>}/>
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
</Routes>, {route: '/repos/new'});
const add = screen.getByText('Add Repo').closest('button');
expect(add).toBeDisabled();
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'git@github.com:a/b'}});
expect(add).toBeDisabled();
expect(screen.getByText('Must be an https:// URL')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
expect(screen.getByPlaceholderText('kiwix-tools')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ' dev '}});
fireEvent.click(screen.getByText('Pick directory'));
fireEvent.click(add);
await flush();
expect(createRepo).toHaveBeenCalledWith({
url: 'https://github.com/kiwix/kiwix-tools', name: null, tag_name: null, frequency: 604800,
mode: 'full', branch: 'dev', submodules: false, description: null, directory: 'code/kiwix-tools',
});
expect(screen.getByText('repo page')).toBeInTheDocument();
});
});
describe('RepoEditPage', () => {
function renderEditPage(repo) {
getRepo.mockResolvedValue(repo);
return render(<Routes>
<Route path='/repos/:repoId/edit' element={<RepoEditPage/>}/>
<Route path='/repos' element={<div>repos page</div>}/>
</Routes>, {route: '/repos/1/edit'});
}
it('saves the settings', async () => {
updateRepo.mockResolvedValue(repoFixture());
renderEditPage(repoFixture({branch: 'dev', description: 'Tools'}));
await flush();
expect(screen.getByLabelText('Branch')).toHaveValue('dev');
expect(screen.getByLabelText('Include submodules')).not.toBeChecked();
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ''}});
fireEvent.click(screen.getByLabelText('Include submodules'));
fireEvent.click(screen.getByText('Save'));
await flush();
expect(updateRepo).toHaveBeenCalledWith('1', {
description: 'Tools', frequency: 604800, mode: 'full', branch: '', submodules: true,
});
});
it('keeps a repo which is never updated', async () => {
updateRepo.mockResolvedValue(repoFixture({frequency: 0}));
renderEditPage(repoFixture({frequency: 0}));
await flush();
expect(screen.getByDisplayValue('Never')).toBeInTheDocument();
fireEvent.click(screen.getByText('Save'));
await flush();
expect(updateRepo).toHaveBeenCalledWith('1', expect.objectContaining({frequency: 0}));
});
it('deletes the repo, and its files only when asked', async () => {
deleteRepo.mockResolvedValue(undefined);
renderEditPage(repoFixture());
await flush();
fireEvent.click(screen.getByText('Delete'));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByLabelText(/Also delete its files/));
fireEvent.click(within(dialog).getByText('Delete'));
await flush();
expect(deleteRepo).toHaveBeenCalledWith('1', true);
expect(screen.getByText('repos page')).toBeInTheDocument();
});
});
describe('RepoImportPage', () => {
const inspection = (overrides = {}) => ({
directory: 'code/kiwix-tools', origin: 'git@github.com:kiwix/kiwix-tools.git',
url: 'https://github.com/kiwix/kiwix-tools', name: 'kiwix-tools', branch: 'main',
head_sha: 'abcdef1234', head_message: 'Local work', head_date: '2026-09-30T14:02:00Z', local_commits: 2,
shallow: false, ignored: false,
...overrides,
});
function renderImportPage() {
return render(<Routes>
<Route path='/repos/import' element={<RepoImportPage/>}/>
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
</Routes>, {route: '/repos/import'});
}
it('inspects the directory, warns about local commits, and imports once confirmed', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection()});
importRepo.mockResolvedValue(repoFixture({id: 9}));
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText('2 commits are not on the remote')).toBeInTheDocument();
expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null, '');
// Inspected again with the URL the import will use.
await waitFor(() => expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null,
'https://github.com/kiwix/kiwix-tools'));
expect(screen.getByText('git@github.com:kiwix/kiwix-tools.git')).toBeInTheDocument();
expect(screen.getByText(/Its files are indexed/)).toBeInTheDocument();
// The URL comes from the origin.
expect(screen.getByLabelText(/^URL/)).toHaveValue('https://github.com/kiwix/kiwix-tools');
const button = screen.getByText('Import').closest('button');
expect(button).toBeDisabled();
fireEvent.click(screen.getByLabelText(/I understand/));
expect(button).toBeEnabled();
fireEvent.click(button);
await flush();
expect(importRepo).toHaveBeenCalledWith({
directory: 'code/kiwix-tools', confirm: true, url: 'https://github.com/kiwix/kiwix-tools', name: null,
tag_name: null, frequency: 604800, mode: 'full', branch: null, submodules: false,
});
expect(screen.getByText('repo page')).toBeInTheDocument();
});
it('asks for the URL when the clone has no https origin', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection({origin: null, url: null, local_commits: 0})});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText(/no https:\/\/ origin/)).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/I understand/));
expect(screen.getByText('Import').closest('button')).toBeDisabled();
fireEvent.change(screen.getByLabelText(/^URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
expect(screen.getByText('Import').closest('button')).toBeEnabled();
});
it('explains a directory which cannot be imported', async () => {
inspectRepoImport.mockResolvedValue({error: 'code/kiwix-tools is not a git clone'});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText('code/kiwix-tools is not a git clone')).toBeInTheDocument();
expect(screen.queryByText('Import')).not.toBeInTheDocument();
});
it('says when the files are not indexed', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection({ignored: true})});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText(/Its files are not indexed/)).toBeInTheDocument();
});
});