feat(core): Add Cargo and Composer registries with storage, auth and helpers
This commit is contained in:
131
test/cargo.test.node.ts
Normal file
131
test/cargo.test.node.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { tap, expect } from '@git.zone/tstest';
|
||||
import { RegistryStorage } from '../ts/core/classes.registrystorage.js';
|
||||
import { CargoRegistry } from '../ts/cargo/classes.cargoregistry.js';
|
||||
import { AuthManager } from '../ts/core/classes.authmanager.js';
|
||||
|
||||
// Test index path calculation
|
||||
tap.test('should calculate correct index paths for different crate names', async () => {
|
||||
const storage = new RegistryStorage({
|
||||
accessKey: 'test',
|
||||
accessSecret: 'test',
|
||||
endpoint: 's3.test.com',
|
||||
bucketName: 'test-bucket',
|
||||
});
|
||||
|
||||
// Access private method for testing
|
||||
const getPath = (storage as any).getCargoIndexPath.bind(storage);
|
||||
|
||||
// 1-character names
|
||||
expect(getPath('a')).to.equal('cargo/index/1/a');
|
||||
expect(getPath('z')).to.equal('cargo/index/1/z');
|
||||
|
||||
// 2-character names
|
||||
expect(getPath('io')).to.equal('cargo/index/2/io');
|
||||
expect(getPath('ab')).to.equal('cargo/index/2/ab');
|
||||
|
||||
// 3-character names
|
||||
expect(getPath('axo')).to.equal('cargo/index/3/a/axo');
|
||||
expect(getPath('foo')).to.equal('cargo/index/3/f/foo');
|
||||
|
||||
// 4+ character names
|
||||
expect(getPath('serde')).to.equal('cargo/index/se/rd/serde');
|
||||
expect(getPath('tokio')).to.equal('cargo/index/to/ki/tokio');
|
||||
expect(getPath('my-crate')).to.equal('cargo/index/my/--/my-crate');
|
||||
});
|
||||
|
||||
// Test crate file path calculation
|
||||
tap.test('should calculate correct crate file paths', async () => {
|
||||
const storage = new RegistryStorage({
|
||||
accessKey: 'test',
|
||||
accessSecret: 'test',
|
||||
endpoint: 's3.test.com',
|
||||
bucketName: 'test-bucket',
|
||||
});
|
||||
|
||||
// Access private method for testing
|
||||
const getPath = (storage as any).getCargoCratePath.bind(storage);
|
||||
|
||||
expect(getPath('serde', '1.0.0')).to.equal('cargo/crates/serde/serde-1.0.0.crate');
|
||||
expect(getPath('tokio', '1.28.0')).to.equal('cargo/crates/tokio/tokio-1.28.0.crate');
|
||||
expect(getPath('my-crate', '0.1.0')).to.equal('cargo/crates/my-crate/my-crate-0.1.0.crate');
|
||||
});
|
||||
|
||||
// Test crate name validation
|
||||
tap.test('should validate crate names correctly', async () => {
|
||||
const storage = new RegistryStorage({
|
||||
accessKey: 'test',
|
||||
accessSecret: 'test',
|
||||
endpoint: 's3.test.com',
|
||||
bucketName: 'test-bucket',
|
||||
});
|
||||
|
||||
const authManager = new AuthManager({
|
||||
jwtSecret: 'test-secret',
|
||||
tokenStore: 'memory',
|
||||
npmTokens: { enabled: true },
|
||||
ociTokens: { enabled: false, realm: '', service: '' },
|
||||
});
|
||||
|
||||
const registry = new CargoRegistry(storage, authManager, '/cargo', 'http://localhost:5000/cargo');
|
||||
|
||||
// Access private method for testing
|
||||
const validate = (registry as any).validateCrateName.bind(registry);
|
||||
|
||||
// Valid names
|
||||
expect(validate('serde')).to.be.true;
|
||||
expect(validate('tokio')).to.be.true;
|
||||
expect(validate('my-crate')).to.be.true;
|
||||
expect(validate('my_crate')).to.be.true;
|
||||
expect(validate('crate123')).to.be.true;
|
||||
expect(validate('a')).to.be.true;
|
||||
|
||||
// Invalid names (uppercase not allowed)
|
||||
expect(validate('Serde')).to.be.false;
|
||||
expect(validate('MyCreate')).to.be.false;
|
||||
|
||||
// Invalid names (special characters)
|
||||
expect(validate('my.crate')).to.be.false;
|
||||
expect(validate('my@crate')).to.be.false;
|
||||
expect(validate('my crate')).to.be.false;
|
||||
|
||||
// Invalid names (too long)
|
||||
const longName = 'a'.repeat(65);
|
||||
expect(validate(longName)).to.be.false;
|
||||
|
||||
// Invalid names (empty)
|
||||
expect(validate('')).to.be.false;
|
||||
});
|
||||
|
||||
// Test config.json response
|
||||
tap.test('should return valid config.json', async () => {
|
||||
const storage = new RegistryStorage({
|
||||
accessKey: 'test',
|
||||
accessSecret: 'test',
|
||||
endpoint: 's3.test.com',
|
||||
bucketName: 'test-bucket',
|
||||
});
|
||||
|
||||
const authManager = new AuthManager({
|
||||
jwtSecret: 'test-secret',
|
||||
tokenStore: 'memory',
|
||||
npmTokens: { enabled: true },
|
||||
ociTokens: { enabled: false, realm: '', service: '' },
|
||||
});
|
||||
|
||||
const registry = new CargoRegistry(storage, authManager, '/cargo', 'http://localhost:5000/cargo');
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/cargo/config.json',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers['Content-Type']).to.equal('application/json');
|
||||
expect(response.body).to.be.an('object');
|
||||
expect(response.body.dl).to.include('/api/v1/crates/{crate}/{version}/download');
|
||||
expect(response.body.api).to.equal('http://localhost:5000/cargo');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -6,7 +6,7 @@ import type { IRegistryConfig } from '../../ts/core/interfaces.core.js';
|
||||
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||
|
||||
/**
|
||||
* Create a test SmartRegistry instance with OCI, NPM, and Maven enabled
|
||||
* Create a test SmartRegistry instance with OCI, NPM, Maven, and Composer enabled
|
||||
*/
|
||||
export async function createTestRegistry(): Promise<SmartRegistry> {
|
||||
// Read S3 config from env.json
|
||||
@@ -49,6 +49,10 @@ export async function createTestRegistry(): Promise<SmartRegistry> {
|
||||
enabled: true,
|
||||
basePath: '/maven',
|
||||
},
|
||||
composer: {
|
||||
enabled: true,
|
||||
basePath: '/composer',
|
||||
},
|
||||
};
|
||||
|
||||
const registry = new SmartRegistry(config);
|
||||
@@ -86,7 +90,10 @@ export async function createTestTokens(registry: SmartRegistry) {
|
||||
// Create Maven token with full access
|
||||
const mavenToken = await authManager.createMavenToken(userId, false);
|
||||
|
||||
return { npmToken, ociToken, mavenToken, userId };
|
||||
// Create Composer token with full access
|
||||
const composerToken = await authManager.createComposerToken(userId, false);
|
||||
|
||||
return { npmToken, ociToken, mavenToken, composerToken, userId };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,3 +212,61 @@ export function calculateMavenChecksums(data: Buffer) {
|
||||
sha512: crypto.createHash('sha512').update(data).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a Composer package ZIP
|
||||
*/
|
||||
export async function createComposerZip(
|
||||
vendorPackage: string,
|
||||
version: string,
|
||||
options?: {
|
||||
description?: string;
|
||||
license?: string[];
|
||||
authors?: Array<{ name: string; email?: string }>;
|
||||
}
|
||||
): Promise<Buffer> {
|
||||
const AdmZip = (await import('adm-zip')).default;
|
||||
const zip = new AdmZip();
|
||||
|
||||
const composerJson = {
|
||||
name: vendorPackage,
|
||||
version: version,
|
||||
type: 'library',
|
||||
description: options?.description || 'Test Composer package',
|
||||
license: options?.license || ['MIT'],
|
||||
authors: options?.authors || [{ name: 'Test Author', email: 'test@example.com' }],
|
||||
require: {
|
||||
php: '>=7.4',
|
||||
},
|
||||
autoload: {
|
||||
'psr-4': {
|
||||
'Vendor\\TestPackage\\': 'src/',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add composer.json
|
||||
zip.addFile('composer.json', Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8'));
|
||||
|
||||
// Add a test PHP file
|
||||
const [vendor, pkg] = vendorPackage.split('/');
|
||||
const namespace = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}\\${pkg.charAt(0).toUpperCase() + pkg.slice(1).replace(/-/g, '')}`;
|
||||
const testPhpContent = `<?php
|
||||
namespace ${namespace};
|
||||
|
||||
class TestClass
|
||||
{
|
||||
public function greet(): string
|
||||
{
|
||||
return "Hello from ${vendorPackage}!";
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
zip.addFile('src/TestClass.php', Buffer.from(testPhpContent, 'utf-8'));
|
||||
|
||||
// Add README
|
||||
zip.addFile('README.md', Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8'));
|
||||
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
296
test/test.composer.ts
Normal file
296
test/test.composer.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRegistry } from '../ts/index.js';
|
||||
import { createTestRegistry, createTestTokens, createComposerZip } from './helpers/registry.js';
|
||||
|
||||
let registry: SmartRegistry;
|
||||
let composerToken: string;
|
||||
let userId: string;
|
||||
|
||||
// Test data
|
||||
const testPackageName = 'vendor/test-package';
|
||||
const testVersion = '1.0.0';
|
||||
let testZipData: Buffer;
|
||||
|
||||
tap.test('Composer: should create registry instance', async () => {
|
||||
registry = await createTestRegistry();
|
||||
const tokens = await createTestTokens(registry);
|
||||
composerToken = tokens.composerToken;
|
||||
userId = tokens.userId;
|
||||
|
||||
expect(registry).toBeInstanceOf(SmartRegistry);
|
||||
expect(composerToken).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
tap.test('Composer: should create test ZIP package', async () => {
|
||||
testZipData = await createComposerZip(testPackageName, testVersion, {
|
||||
description: 'Test Composer package for registry',
|
||||
license: ['MIT'],
|
||||
authors: [{ name: 'Test Author', email: 'test@example.com' }],
|
||||
});
|
||||
|
||||
expect(testZipData).toBeInstanceOf(Buffer);
|
||||
expect(testZipData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('Composer: should return packages.json (GET /packages.json)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/composer/packages.json',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('metadata-url');
|
||||
expect(response.body).toHaveProperty('available-packages');
|
||||
expect(response.body['available-packages']).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
tap.test('Composer: should upload a package (PUT /packages/{vendor/package})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: `/composer/packages/${testPackageName}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${composerToken}`,
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
query: {},
|
||||
body: testZipData,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body.status).toEqual('success');
|
||||
expect(response.body.package).toEqual(testPackageName);
|
||||
expect(response.body.version).toEqual(testVersion);
|
||||
});
|
||||
|
||||
tap.test('Composer: should retrieve package metadata (GET /p2/{vendor/package}.json)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/composer/p2/${testPackageName}.json`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('packages');
|
||||
expect(response.body.packages[testPackageName]).toBeInstanceOf(Array);
|
||||
expect(response.body.packages[testPackageName].length).toEqual(1);
|
||||
|
||||
const packageData = response.body.packages[testPackageName][0];
|
||||
expect(packageData.name).toEqual(testPackageName);
|
||||
expect(packageData.version).toEqual(testVersion);
|
||||
expect(packageData.version_normalized).toEqual('1.0.0.0');
|
||||
expect(packageData).toHaveProperty('dist');
|
||||
expect(packageData.dist.type).toEqual('zip');
|
||||
expect(packageData.dist).toHaveProperty('url');
|
||||
expect(packageData.dist).toHaveProperty('shasum');
|
||||
expect(packageData.dist).toHaveProperty('reference');
|
||||
});
|
||||
|
||||
tap.test('Composer: should download package ZIP (GET /dists/{vendor/package}/{ref}.zip)', async () => {
|
||||
// First get metadata to find reference
|
||||
const metadataResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/composer/p2/${testPackageName}.json`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
const reference = metadataResponse.body.packages[testPackageName][0].dist.reference;
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/composer/dists/${testPackageName}/${reference}.zip`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toBeInstanceOf(Buffer);
|
||||
expect(response.headers['Content-Type']).toEqual('application/zip');
|
||||
expect(response.headers['Content-Disposition']).toContain('attachment');
|
||||
});
|
||||
|
||||
tap.test('Composer: should list packages (GET /packages/list.json)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/composer/packages/list.json',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('packageNames');
|
||||
expect(response.body.packageNames).toBeInstanceOf(Array);
|
||||
expect(response.body.packageNames).toContain(testPackageName);
|
||||
});
|
||||
|
||||
tap.test('Composer: should filter package list (GET /packages/list.json?filter=vendor/*)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/composer/packages/list.json',
|
||||
headers: {},
|
||||
query: { filter: 'vendor/*' },
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body.packageNames).toBeInstanceOf(Array);
|
||||
expect(response.body.packageNames).toContain(testPackageName);
|
||||
});
|
||||
|
||||
tap.test('Composer: should prevent duplicate version upload', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: `/composer/packages/${testPackageName}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${composerToken}`,
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
query: {},
|
||||
body: testZipData,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(409);
|
||||
expect(response.body.status).toEqual('error');
|
||||
expect(response.body.message).toContain('already exists');
|
||||
});
|
||||
|
||||
tap.test('Composer: should upload a second version', async () => {
|
||||
const testVersion2 = '1.1.0';
|
||||
const testZipData2 = await createComposerZip(testPackageName, testVersion2);
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: `/composer/packages/${testPackageName}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${composerToken}`,
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
query: {},
|
||||
body: testZipData2,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body.status).toEqual('success');
|
||||
expect(response.body.version).toEqual(testVersion2);
|
||||
});
|
||||
|
||||
tap.test('Composer: should return multiple versions in metadata', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/composer/p2/${testPackageName}.json`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body.packages[testPackageName]).toBeInstanceOf(Array);
|
||||
expect(response.body.packages[testPackageName].length).toEqual(2);
|
||||
|
||||
const versions = response.body.packages[testPackageName].map((p: any) => p.version);
|
||||
expect(versions).toContain('1.0.0');
|
||||
expect(versions).toContain('1.1.0');
|
||||
});
|
||||
|
||||
tap.test('Composer: should delete a specific version (DELETE /packages/{vendor/package}/{version})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'DELETE',
|
||||
path: `/composer/packages/${testPackageName}/1.0.0`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${composerToken}`,
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(204);
|
||||
|
||||
// Verify version was removed
|
||||
const metadataResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/composer/p2/${testPackageName}.json`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(metadataResponse.body.packages[testPackageName].length).toEqual(1);
|
||||
expect(metadataResponse.body.packages[testPackageName][0].version).toEqual('1.1.0');
|
||||
});
|
||||
|
||||
tap.test('Composer: should require auth for package upload', async () => {
|
||||
const testZipData3 = await createComposerZip('vendor/unauth-package', '1.0.0');
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: '/composer/packages/vendor/unauth-package',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
query: {},
|
||||
body: testZipData3,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(401);
|
||||
expect(response.body.status).toEqual('error');
|
||||
});
|
||||
|
||||
tap.test('Composer: should reject invalid ZIP (no composer.json)', async () => {
|
||||
const invalidZip = Buffer.from('invalid zip content');
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: `/composer/packages/${testPackageName}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${composerToken}`,
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
query: {},
|
||||
body: invalidZip,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body.status).toEqual('error');
|
||||
expect(response.body.message).toContain('composer.json');
|
||||
});
|
||||
|
||||
tap.test('Composer: should delete entire package (DELETE /packages/{vendor/package})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'DELETE',
|
||||
path: `/composer/packages/${testPackageName}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${composerToken}`,
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(204);
|
||||
|
||||
// Verify package was removed
|
||||
const metadataResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/composer/p2/${testPackageName}.json`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(metadataResponse.status).toEqual(404);
|
||||
});
|
||||
|
||||
tap.test('Composer: should return 404 for non-existent package', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/composer/p2/non/existent.json',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
});
|
||||
|
||||
tap.postTask('cleanup registry', async () => {
|
||||
if (registry) {
|
||||
registry.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user