507 lines
14 KiB
TypeScript
507 lines
14 KiB
TypeScript
|
|
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();
|