feat(core): Add Cargo and Composer registries with storage, auth and helpers

This commit is contained in:
2025-11-21 09:13:02 +00:00
parent 92d27d8b15
commit 8d48627301
19 changed files with 1869 additions and 56 deletions

131
test/cargo.test.node.ts Normal file
View 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();

View File

@@ -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
View 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();