470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import { SmartRegistry } from '../ts/index.js';
|
|
import {
|
|
createTestRegistry,
|
|
createTestTokens,
|
|
createPythonWheel,
|
|
createPythonSdist,
|
|
calculatePypiHashes,
|
|
} from './helpers/registry.js';
|
|
import { normalizePypiPackageName } from '../ts/pypi/helpers.pypi.js';
|
|
|
|
let registry: SmartRegistry;
|
|
let pypiToken: string;
|
|
let userId: string;
|
|
|
|
// Test data
|
|
const testPackageName = 'test-package';
|
|
const normalizedPackageName = normalizePypiPackageName(testPackageName);
|
|
const testVersion = '1.0.0';
|
|
let testWheelData: Buffer;
|
|
let testSdistData: Buffer;
|
|
|
|
tap.test('PyPI: should create registry instance', async () => {
|
|
registry = await createTestRegistry();
|
|
const tokens = await createTestTokens(registry);
|
|
pypiToken = tokens.pypiToken;
|
|
userId = tokens.userId;
|
|
|
|
expect(registry).toBeInstanceOf(SmartRegistry);
|
|
expect(pypiToken).toBeTypeOf('string');
|
|
|
|
// Clean up any existing metadata from previous test runs
|
|
const storage = registry.getStorage();
|
|
try {
|
|
await storage.deletePypiPackage(normalizedPackageName);
|
|
} catch (error) {
|
|
// Ignore error if package doesn't exist
|
|
}
|
|
});
|
|
|
|
tap.test('PyPI: should create test package files', async () => {
|
|
testWheelData = await createPythonWheel(testPackageName, testVersion);
|
|
testSdistData = await createPythonSdist(testPackageName, testVersion);
|
|
|
|
expect(testWheelData).toBeInstanceOf(Buffer);
|
|
expect(testWheelData.length).toBeGreaterThan(0);
|
|
expect(testSdistData).toBeInstanceOf(Buffer);
|
|
expect(testSdistData.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
tap.test('PyPI: should upload wheel file (POST /pypi/)', async () => {
|
|
const hashes = calculatePypiHashes(testWheelData);
|
|
const filename = `${testPackageName.replace(/-/g, '_')}-${testVersion}-py3-none-any.whl`;
|
|
|
|
const formData = new FormData();
|
|
formData.append(':action', 'file_upload');
|
|
formData.append('protocol_version', '1');
|
|
formData.append('name', testPackageName);
|
|
formData.append('version', testVersion);
|
|
formData.append('filetype', 'bdist_wheel');
|
|
formData.append('pyversion', 'py3');
|
|
formData.append('metadata_version', '2.1');
|
|
formData.append('sha256_digest', hashes.sha256);
|
|
formData.append('content', new Blob([testWheelData]), filename);
|
|
|
|
const response = await registry.handleRequest({
|
|
method: 'POST',
|
|
path: '/pypi/',
|
|
headers: {
|
|
Authorization: `Bearer ${pypiToken}`,
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
query: {},
|
|
body: {
|
|
':action': 'file_upload',
|
|
protocol_version: '1',
|
|
name: testPackageName,
|
|
version: testVersion,
|
|
filetype: 'bdist_wheel',
|
|
pyversion: 'py3',
|
|
metadata_version: '2.1',
|
|
sha256_digest: hashes.sha256,
|
|
content: testWheelData,
|
|
filename: filename,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toEqual(201);
|
|
});
|
|
|
|
tap.test('PyPI: should retrieve Simple API root index HTML (GET /simple/)', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: '/simple/',
|
|
headers: {
|
|
Accept: 'text/html',
|
|
},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['Content-Type']).toEqual('text/html');
|
|
expect(response.body).toBeTypeOf('string');
|
|
|
|
const html = response.body as string;
|
|
expect(html).toContain('<!DOCTYPE html>');
|
|
expect(html).toContain('<title>Simple Index</title>');
|
|
expect(html).toContain(normalizedPackageName);
|
|
});
|
|
|
|
tap.test('PyPI: should retrieve Simple API root index JSON (GET /simple/ with Accept: application/vnd.pypi.simple.v1+json)', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: '/simple/',
|
|
headers: {
|
|
Accept: 'application/vnd.pypi.simple.v1+json',
|
|
},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['Content-Type']).toEqual('application/vnd.pypi.simple.v1+json');
|
|
expect(response.body).toBeTypeOf('object');
|
|
|
|
const json = response.body as any;
|
|
expect(json).toHaveProperty('meta');
|
|
expect(json).toHaveProperty('projects');
|
|
expect(json.projects).toBeTypeOf('object');
|
|
expect(json.projects).toHaveProperty(normalizedPackageName);
|
|
});
|
|
|
|
tap.test('PyPI: should retrieve Simple API package HTML (GET /simple/{package}/)', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/simple/${normalizedPackageName}/`,
|
|
headers: {
|
|
Accept: 'text/html',
|
|
},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['Content-Type']).toEqual('text/html');
|
|
expect(response.body).toBeTypeOf('string');
|
|
|
|
const html = response.body as string;
|
|
expect(html).toContain('<!DOCTYPE html>');
|
|
expect(html).toContain(`<title>Links for ${normalizedPackageName}</title>`);
|
|
expect(html).toContain('.whl');
|
|
expect(html).toContain('data-requires-python');
|
|
});
|
|
|
|
tap.test('PyPI: should retrieve Simple API package JSON (GET /simple/{package}/ with Accept: application/vnd.pypi.simple.v1+json)', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/simple/${normalizedPackageName}/`,
|
|
headers: {
|
|
Accept: 'application/vnd.pypi.simple.v1+json',
|
|
},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['Content-Type']).toEqual('application/vnd.pypi.simple.v1+json');
|
|
expect(response.body).toBeTypeOf('object');
|
|
|
|
const json = response.body as any;
|
|
expect(json).toHaveProperty('meta');
|
|
expect(json).toHaveProperty('name');
|
|
expect(json.name).toEqual(normalizedPackageName);
|
|
expect(json).toHaveProperty('files');
|
|
expect(json.files).toBeTypeOf('object');
|
|
expect(Object.keys(json.files).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
tap.test('PyPI: should download wheel file (GET /pypi/packages/{package}/{filename})', async () => {
|
|
const filename = `${testPackageName.replace(/-/g, '_')}-${testVersion}-py3-none-any.whl`;
|
|
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/pypi/packages/${normalizedPackageName}/${filename}`,
|
|
headers: {},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.body).toBeInstanceOf(Buffer);
|
|
expect((response.body as Buffer).length).toEqual(testWheelData.length);
|
|
expect(response.headers['Content-Type']).toEqual('application/octet-stream');
|
|
});
|
|
|
|
tap.test('PyPI: should upload sdist file (POST /pypi/)', async () => {
|
|
const hashes = calculatePypiHashes(testSdistData);
|
|
const filename = `${testPackageName}-${testVersion}.tar.gz`;
|
|
|
|
const response = await registry.handleRequest({
|
|
method: 'POST',
|
|
path: '/pypi/',
|
|
headers: {
|
|
Authorization: `Bearer ${pypiToken}`,
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
query: {},
|
|
body: {
|
|
':action': 'file_upload',
|
|
protocol_version: '1',
|
|
name: testPackageName,
|
|
version: testVersion,
|
|
filetype: 'sdist',
|
|
pyversion: 'source',
|
|
metadata_version: '2.1',
|
|
sha256_digest: hashes.sha256,
|
|
content: testSdistData,
|
|
filename: filename,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toEqual(201);
|
|
});
|
|
|
|
tap.test('PyPI: should list both wheel and sdist in Simple API', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/simple/${normalizedPackageName}/`,
|
|
headers: {
|
|
Accept: 'application/vnd.pypi.simple.v1+json',
|
|
},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
|
|
const json = response.body as any;
|
|
expect(Object.keys(json.files).length).toEqual(2);
|
|
|
|
const hasWheel = Object.keys(json.files).some(f => f.endsWith('.whl'));
|
|
const hasSdist = Object.keys(json.files).some(f => f.endsWith('.tar.gz'));
|
|
|
|
expect(hasWheel).toEqual(true);
|
|
expect(hasSdist).toEqual(true);
|
|
});
|
|
|
|
tap.test('PyPI: should upload a second version', async () => {
|
|
const newVersion = '2.0.0';
|
|
const newWheelData = await createPythonWheel(testPackageName, newVersion);
|
|
const hashes = calculatePypiHashes(newWheelData);
|
|
const filename = `${testPackageName.replace(/-/g, '_')}-${newVersion}-py3-none-any.whl`;
|
|
|
|
const response = await registry.handleRequest({
|
|
method: 'POST',
|
|
path: '/pypi/',
|
|
headers: {
|
|
Authorization: `Bearer ${pypiToken}`,
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
query: {},
|
|
body: {
|
|
':action': 'file_upload',
|
|
protocol_version: '1',
|
|
name: testPackageName,
|
|
version: newVersion,
|
|
filetype: 'bdist_wheel',
|
|
pyversion: 'py3',
|
|
metadata_version: '2.1',
|
|
sha256_digest: hashes.sha256,
|
|
content: newWheelData,
|
|
filename: filename,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toEqual(201);
|
|
});
|
|
|
|
tap.test('PyPI: should list multiple versions in Simple API', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/simple/${normalizedPackageName}/`,
|
|
headers: {
|
|
Accept: 'application/vnd.pypi.simple.v1+json',
|
|
},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
|
|
const json = response.body as any;
|
|
expect(Object.keys(json.files).length).toBeGreaterThan(2);
|
|
|
|
const hasVersion1 = Object.keys(json.files).some(f => f.includes('1.0.0'));
|
|
const hasVersion2 = Object.keys(json.files).some(f => f.includes('2.0.0'));
|
|
|
|
expect(hasVersion1).toEqual(true);
|
|
expect(hasVersion2).toEqual(true);
|
|
});
|
|
|
|
tap.test('PyPI: should normalize package names correctly', async () => {
|
|
const testNames = [
|
|
{ input: 'Test-Package', expected: 'test-package' },
|
|
{ input: 'Test_Package', expected: 'test-package' },
|
|
{ input: 'Test..Package', expected: 'test-package' },
|
|
{ input: 'Test---Package', expected: 'test-package' },
|
|
];
|
|
|
|
for (const { input, expected } of testNames) {
|
|
const normalized = normalizePypiPackageName(input);
|
|
expect(normalized).toEqual(expected);
|
|
}
|
|
});
|
|
|
|
tap.test('PyPI: should return 404 for non-existent package', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: '/simple/nonexistent-package/',
|
|
headers: {},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(404);
|
|
expect(response.body).toHaveProperty('error');
|
|
});
|
|
|
|
tap.test('PyPI: should return 401 for unauthorized upload', async () => {
|
|
const wheelData = await createPythonWheel('unauthorized-test', '1.0.0');
|
|
const hashes = calculatePypiHashes(wheelData);
|
|
|
|
const response = await registry.handleRequest({
|
|
method: 'POST',
|
|
path: '/pypi/',
|
|
headers: {
|
|
// No authorization header
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
query: {},
|
|
body: {
|
|
':action': 'file_upload',
|
|
protocol_version: '1',
|
|
name: 'unauthorized-test',
|
|
version: '1.0.0',
|
|
filetype: 'bdist_wheel',
|
|
pyversion: 'py3',
|
|
metadata_version: '2.1',
|
|
sha256_digest: hashes.sha256,
|
|
content: wheelData,
|
|
filename: 'unauthorized_test-1.0.0-py3-none-any.whl',
|
|
},
|
|
});
|
|
|
|
expect(response.status).toEqual(401);
|
|
expect(response.body).toHaveProperty('error');
|
|
});
|
|
|
|
tap.test('PyPI: should reject upload with mismatched hash', async () => {
|
|
const wheelData = await createPythonWheel('hash-test', '1.0.0');
|
|
|
|
const response = await registry.handleRequest({
|
|
method: 'POST',
|
|
path: '/pypi/',
|
|
headers: {
|
|
Authorization: `Bearer ${pypiToken}`,
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
query: {},
|
|
body: {
|
|
':action': 'file_upload',
|
|
protocol_version: '1',
|
|
name: 'hash-test',
|
|
version: '1.0.0',
|
|
filetype: 'bdist_wheel',
|
|
pyversion: 'py3',
|
|
metadata_version: '2.1',
|
|
sha256_digest: 'wrong_hash_value',
|
|
content: wheelData,
|
|
filename: 'hash_test-1.0.0-py3-none-any.whl',
|
|
},
|
|
});
|
|
|
|
expect(response.status).toEqual(400);
|
|
expect(response.body).toHaveProperty('error');
|
|
});
|
|
|
|
tap.test('PyPI: should handle package with requires-python metadata', async () => {
|
|
const packageName = 'python-version-test';
|
|
const wheelData = await createPythonWheel(packageName, '1.0.0');
|
|
const hashes = calculatePypiHashes(wheelData);
|
|
|
|
const response = await registry.handleRequest({
|
|
method: 'POST',
|
|
path: '/pypi/',
|
|
headers: {
|
|
Authorization: `Bearer ${pypiToken}`,
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
query: {},
|
|
body: {
|
|
':action': 'file_upload',
|
|
protocol_version: '1',
|
|
name: packageName,
|
|
version: '1.0.0',
|
|
filetype: 'bdist_wheel',
|
|
pyversion: 'py3',
|
|
metadata_version: '2.1',
|
|
sha256_digest: hashes.sha256,
|
|
'requires_python': '>=3.8',
|
|
content: wheelData,
|
|
filename: `${packageName.replace(/-/g, '_')}-1.0.0-py3-none-any.whl`,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toEqual(201);
|
|
|
|
// Verify requires-python is in Simple API
|
|
const getResponse = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/simple/${normalizePypiPackageName(packageName)}/`,
|
|
headers: {
|
|
Accept: 'text/html',
|
|
},
|
|
query: {},
|
|
});
|
|
|
|
const html = getResponse.body as string;
|
|
expect(html).toContain('data-requires-python');
|
|
expect(html).toContain('>=3.8');
|
|
});
|
|
|
|
tap.test('PyPI: should support JSON API for package metadata', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/pypi/${normalizedPackageName}/json`,
|
|
headers: {},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['Content-Type']).toEqual('application/json');
|
|
expect(response.body).toBeTypeOf('object');
|
|
|
|
const json = response.body as any;
|
|
expect(json).toHaveProperty('info');
|
|
expect(json.info).toHaveProperty('name');
|
|
expect(json.info.name).toEqual(normalizedPackageName);
|
|
expect(json).toHaveProperty('urls');
|
|
});
|
|
|
|
tap.test('PyPI: should support JSON API for specific version', async () => {
|
|
const response = await registry.handleRequest({
|
|
method: 'GET',
|
|
path: `/pypi/${normalizedPackageName}/${testVersion}/json`,
|
|
headers: {},
|
|
query: {},
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
expect(response.headers['Content-Type']).toEqual('application/json');
|
|
expect(response.body).toBeTypeOf('object');
|
|
|
|
const json = response.body as any;
|
|
expect(json).toHaveProperty('info');
|
|
expect(json.info.version).toEqual(testVersion);
|
|
expect(json).toHaveProperty('urls');
|
|
});
|
|
|
|
tap.postTask('cleanup registry', async () => {
|
|
if (registry) {
|
|
registry.destroy();
|
|
}
|
|
});
|
|
|
|
export default tap.start();
|