feat(core): Add PyPI and RubyGems registries, integrate into SmartRegistry, extend storage and auth

This commit is contained in:
2025-11-21 17:13:06 +00:00
parent ac51a94c8b
commit 0d73230d5a
17 changed files with 3514 additions and 33 deletions

506
test/test.rubygems.ts Normal file
View File

@@ -0,0 +1,506 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartRegistry } from '../ts/index.js';
import {
createTestRegistry,
createTestTokens,
createRubyGem,
calculateRubyGemsChecksums,
} from './helpers/registry.js';
let registry: SmartRegistry;
let rubygemsToken: string;
let userId: string;
// Test data
const testGemName = 'test-gem';
const testVersion = '1.0.0';
let testGemData: Buffer;
tap.test('RubyGems: should create registry instance', async () => {
registry = await createTestRegistry();
const tokens = await createTestTokens(registry);
rubygemsToken = tokens.rubygemsToken;
userId = tokens.userId;
expect(registry).toBeInstanceOf(SmartRegistry);
expect(rubygemsToken).toBeTypeOf('string');
// Clean up any existing metadata from previous test runs
const storage = registry.getStorage();
try {
await storage.deleteRubyGem(testGemName);
} catch (error) {
// Ignore error if gem doesn't exist
}
});
tap.test('RubyGems: should create test gem file', async () => {
testGemData = await createRubyGem(testGemName, testVersion);
expect(testGemData).toBeInstanceOf(Buffer);
expect(testGemData.length).toBeGreaterThan(0);
});
tap.test('RubyGems: should upload gem file (POST /rubygems/api/v1/gems)', async () => {
const response = await registry.handleRequest({
method: 'POST',
path: '/rubygems/api/v1/gems',
headers: {
Authorization: rubygemsToken,
'Content-Type': 'application/octet-stream',
},
query: {},
body: testGemData,
});
expect(response.status).toEqual(201);
expect(response.body).toHaveProperty('message');
});
tap.test('RubyGems: should retrieve Compact Index versions file (GET /rubygems/versions)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/versions',
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.headers['Content-Type']).toEqual('text/plain; charset=utf-8');
expect(response.body).toBeInstanceOf(Buffer);
const content = (response.body as Buffer).toString('utf-8');
expect(content).toContain('created_at:');
expect(content).toContain('---');
expect(content).toContain(testGemName);
expect(content).toContain(testVersion);
});
tap.test('RubyGems: should retrieve Compact Index info file (GET /rubygems/info/{gem})', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: `/rubygems/info/${testGemName}`,
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.headers['Content-Type']).toEqual('text/plain; charset=utf-8');
expect(response.body).toBeInstanceOf(Buffer);
const content = (response.body as Buffer).toString('utf-8');
expect(content).toContain('---');
expect(content).toContain(testVersion);
expect(content).toContain('checksum:');
});
tap.test('RubyGems: should retrieve Compact Index names file (GET /rubygems/names)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/names',
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.headers['Content-Type']).toEqual('text/plain; charset=utf-8');
expect(response.body).toBeInstanceOf(Buffer);
const content = (response.body as Buffer).toString('utf-8');
expect(content).toContain('---');
expect(content).toContain(testGemName);
});
tap.test('RubyGems: should download gem file (GET /rubygems/gems/{gem}-{version}.gem)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: `/rubygems/gems/${testGemName}-${testVersion}.gem`,
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.body).toBeInstanceOf(Buffer);
expect((response.body as Buffer).length).toEqual(testGemData.length);
expect(response.headers['Content-Type']).toEqual('application/octet-stream');
});
tap.test('RubyGems: should upload a second version', async () => {
const newVersion = '2.0.0';
const newGemData = await createRubyGem(testGemName, newVersion);
const response = await registry.handleRequest({
method: 'POST',
path: '/rubygems/api/v1/gems',
headers: {
Authorization: rubygemsToken,
'Content-Type': 'application/octet-stream',
},
query: {},
body: newGemData,
});
expect(response.status).toEqual(201);
});
tap.test('RubyGems: should list multiple versions in Compact Index', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/versions',
headers: {},
query: {},
});
expect(response.status).toEqual(200);
const content = (response.body as Buffer).toString('utf-8');
const lines = content.split('\n');
const gemLine = lines.find(l => l.startsWith(`${testGemName} `));
expect(gemLine).toBeDefined();
expect(gemLine).toContain('1.0.0');
expect(gemLine).toContain('2.0.0');
});
tap.test('RubyGems: should list multiple versions in info file', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: `/rubygems/info/${testGemName}`,
headers: {},
query: {},
});
expect(response.status).toEqual(200);
const content = (response.body as Buffer).toString('utf-8');
expect(content).toContain('1.0.0');
expect(content).toContain('2.0.0');
});
tap.test('RubyGems: should support platform-specific gems', async () => {
const platformVersion = '1.5.0';
const platform = 'x86_64-linux';
const platformGemData = await createRubyGem(testGemName, platformVersion, platform);
const response = await registry.handleRequest({
method: 'POST',
path: '/rubygems/api/v1/gems',
headers: {
Authorization: rubygemsToken,
'Content-Type': 'application/octet-stream',
},
query: {},
body: platformGemData,
});
expect(response.status).toEqual(201);
// Verify platform is listed in versions
const versionsResponse = await registry.handleRequest({
method: 'GET',
path: '/rubygems/versions',
headers: {},
query: {},
});
const content = (versionsResponse.body as Buffer).toString('utf-8');
const lines = content.split('\n');
const gemLine = lines.find(l => l.startsWith(`${testGemName} `));
expect(gemLine).toContain(`${platformVersion}_${platform}`);
});
tap.test('RubyGems: should yank a gem version (DELETE /rubygems/api/v1/gems/yank)', async () => {
const response = await registry.handleRequest({
method: 'DELETE',
path: '/rubygems/api/v1/gems/yank',
headers: {
Authorization: rubygemsToken,
},
query: {
gem_name: testGemName,
version: testVersion,
},
});
expect(response.status).toEqual(200);
expect(response.body).toHaveProperty('message');
expect((response.body as any).message).toContain('yanked');
});
tap.test('RubyGems: should mark yanked version in Compact Index', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/versions',
headers: {},
query: {},
});
expect(response.status).toEqual(200);
const content = (response.body as Buffer).toString('utf-8');
const lines = content.split('\n');
const gemLine = lines.find(l => l.startsWith(`${testGemName} `));
// Yanked versions are prefixed with '-'
expect(gemLine).toContain(`-${testVersion}`);
});
tap.test('RubyGems: should still allow downloading yanked gem', async () => {
// Yanked gems can still be downloaded if explicitly requested
const response = await registry.handleRequest({
method: 'GET',
path: `/rubygems/gems/${testGemName}-${testVersion}.gem`,
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.body).toBeInstanceOf(Buffer);
});
tap.test('RubyGems: should unyank a gem version (PUT /rubygems/api/v1/gems/unyank)', async () => {
const response = await registry.handleRequest({
method: 'PUT',
path: '/rubygems/api/v1/gems/unyank',
headers: {
Authorization: rubygemsToken,
},
query: {
gem_name: testGemName,
version: testVersion,
},
});
expect(response.status).toEqual(200);
expect(response.body).toHaveProperty('message');
expect((response.body as any).message).toContain('unyanked');
});
tap.test('RubyGems: should remove yank marker after unyank', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/versions',
headers: {},
query: {},
});
expect(response.status).toEqual(200);
const content = (response.body as Buffer).toString('utf-8');
const lines = content.split('\n');
const gemLine = lines.find(l => l.startsWith(`${testGemName} `));
// After unyank, version should not have '-' prefix
const versions = gemLine!.split(' ')[1].split(',');
const version1 = versions.find(v => v.includes('1.0.0'));
expect(version1).not.toStartWith('-');
expect(version1).toContain('1.0.0');
});
tap.test('RubyGems: should retrieve versions JSON (GET /rubygems/api/v1/versions/{gem}.json)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: `/rubygems/api/v1/versions/${testGemName}.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('name');
expect(json.name).toEqual(testGemName);
expect(json).toHaveProperty('versions');
expect(json.versions).toBeTypeOf('object');
expect(json.versions.length).toBeGreaterThan(0);
});
tap.test('RubyGems: should retrieve dependencies JSON (GET /rubygems/api/v1/dependencies)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/api/v1/dependencies',
headers: {},
query: {
gems: `${testGemName}`,
},
});
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(Array.isArray(json)).toEqual(true);
});
tap.test('RubyGems: should retrieve gem spec (GET /rubygems/quick/Marshal.4.8/{gem}-{version}.gemspec.rz)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: `/rubygems/quick/Marshal.4.8/${testGemName}-${testVersion}.gemspec.rz`,
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.body).toBeInstanceOf(Buffer);
});
tap.test('RubyGems: should support latest specs endpoint (GET /rubygems/latest_specs.4.8.gz)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/latest_specs.4.8.gz',
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.headers['Content-Type']).toEqual('application/octet-stream');
expect(response.body).toBeInstanceOf(Buffer);
});
tap.test('RubyGems: should support specs endpoint (GET /rubygems/specs.4.8.gz)', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/specs.4.8.gz',
headers: {},
query: {},
});
expect(response.status).toEqual(200);
expect(response.headers['Content-Type']).toEqual('application/octet-stream');
expect(response.body).toBeInstanceOf(Buffer);
});
tap.test('RubyGems: should return 404 for non-existent gem', async () => {
const response = await registry.handleRequest({
method: 'GET',
path: '/rubygems/gems/nonexistent-gem-1.0.0.gem',
headers: {},
query: {},
});
expect(response.status).toEqual(404);
expect(response.body).toHaveProperty('error');
});
tap.test('RubyGems: should return 401 for unauthorized upload', async () => {
const gemData = await createRubyGem('unauthorized-gem', '1.0.0');
const response = await registry.handleRequest({
method: 'POST',
path: '/rubygems/api/v1/gems',
headers: {
// No authorization header
'Content-Type': 'application/octet-stream',
},
query: {},
body: gemData,
});
expect(response.status).toEqual(401);
expect(response.body).toHaveProperty('error');
});
tap.test('RubyGems: should return 401 for unauthorized yank', async () => {
const response = await registry.handleRequest({
method: 'DELETE',
path: '/rubygems/api/v1/gems/yank',
headers: {
// No authorization header
},
query: {
gem_name: testGemName,
version: '2.0.0',
},
});
expect(response.status).toEqual(401);
expect(response.body).toHaveProperty('error');
});
tap.test('RubyGems: should handle gem with dependencies', async () => {
const gemWithDeps = 'gem-with-deps';
const version = '1.0.0';
const gemData = await createRubyGem(gemWithDeps, version);
const response = await registry.handleRequest({
method: 'POST',
path: '/rubygems/api/v1/gems',
headers: {
Authorization: rubygemsToken,
'Content-Type': 'application/octet-stream',
},
query: {},
body: gemData,
});
expect(response.status).toEqual(201);
// Check info file contains dependency info
const infoResponse = await registry.handleRequest({
method: 'GET',
path: `/rubygems/info/${gemWithDeps}`,
headers: {},
query: {},
});
expect(infoResponse.status).toEqual(200);
const content = (infoResponse.body as Buffer).toString('utf-8');
expect(content).toContain('checksum:');
});
tap.test('RubyGems: should validate gem filename format', async () => {
const invalidGemData = Buffer.from('invalid gem data');
const response = await registry.handleRequest({
method: 'POST',
path: '/rubygems/api/v1/gems',
headers: {
Authorization: rubygemsToken,
'Content-Type': 'application/octet-stream',
},
query: {},
body: invalidGemData,
});
// Should fail validation
expect(response.status).toBeGreaterThanOrEqual(400);
});
tap.test('RubyGems: should support conditional GET with ETag', async () => {
// First request to get ETag
const response1 = await registry.handleRequest({
method: 'GET',
path: '/rubygems/versions',
headers: {},
query: {},
});
const etag = response1.headers['ETag'];
expect(etag).toBeDefined();
// Second request with If-None-Match
const response2 = await registry.handleRequest({
method: 'GET',
path: '/rubygems/versions',
headers: {
'If-None-Match': etag as string,
},
query: {},
});
expect(response2.status).toEqual(304);
});
tap.postTask('cleanup registry', async () => {
if (registry) {
registry.destroy();
}
});
export default tap.start();