update
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --web)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 30)",
|
||||
"build": "(tsbuild --web --allowimplicitany)",
|
||||
"buildDocs": "(tsdoc)"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import { SmartRegistry, IRegistryConfig } from '../ts/index.js';
|
||||
import * as crypto from 'crypto';
|
||||
import { SmartRegistry } from '../../ts/classes.smartregistry.js';
|
||||
import type { IRegistryConfig } from '../../ts/core/interfaces.core.js';
|
||||
|
||||
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||
|
||||
@@ -8,8 +10,8 @@ const testQenv = new qenv.Qenv('./', './.nogit');
|
||||
*/
|
||||
export async function createTestRegistry(): Promise<SmartRegistry> {
|
||||
// Read S3 config from env.json
|
||||
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESS_KEY');
|
||||
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRET_KEY');
|
||||
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
||||
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
|
||||
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
||||
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||
|
||||
@@ -84,7 +86,6 @@ export async function createTestTokens(registry: SmartRegistry) {
|
||||
* Helper to calculate SHA-256 digest in OCI format
|
||||
*/
|
||||
export function calculateDigest(data: Buffer): string {
|
||||
const crypto = require('crypto');
|
||||
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
||||
return `sha256:${hash}`;
|
||||
}
|
||||
@@ -115,7 +116,6 @@ export function createTestManifest(configDigest: string, layerDigest: string) {
|
||||
* Helper to create a minimal valid NPM packument
|
||||
*/
|
||||
export function createTestPackument(packageName: string, version: string, tarballData: Buffer) {
|
||||
const crypto = require('crypto');
|
||||
const shasum = crypto.createHash('sha1').update(tarballData).digest('hex');
|
||||
const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`;
|
||||
|
||||
361
test/test.npm.ts
361
test/test.npm.ts
@@ -0,0 +1,361 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRegistry } from '../ts/index.js';
|
||||
import { createTestRegistry, createTestTokens, createTestPackument } from './helpers/registry.js';
|
||||
|
||||
let registry: SmartRegistry;
|
||||
let npmToken: string;
|
||||
let userId: string;
|
||||
|
||||
// Test data
|
||||
const testPackageName = 'test-package';
|
||||
const testVersion = '1.0.0';
|
||||
const testTarballData = Buffer.from('fake tarball content', 'utf-8');
|
||||
|
||||
tap.test('NPM: should create registry instance', async () => {
|
||||
registry = await createTestRegistry();
|
||||
const tokens = await createTestTokens(registry);
|
||||
npmToken = tokens.npmToken;
|
||||
userId = tokens.userId;
|
||||
|
||||
expect(registry).toBeInstanceOf(SmartRegistry);
|
||||
expect(npmToken).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
tap.test('NPM: should handle user authentication (PUT /-/user/org.couchdb.user:{user})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: '/npm/-/user/org.couchdb.user:testuser',
|
||||
headers: {},
|
||||
query: {},
|
||||
body: {
|
||||
name: 'testuser',
|
||||
password: 'testpass',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body).toHaveProperty('token');
|
||||
expect((response.body as any).token).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
tap.test('NPM: should publish a package (PUT /{package})', async () => {
|
||||
const packument = createTestPackument(testPackageName, testVersion, testTarballData);
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
query: {},
|
||||
body: packument,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body).toHaveProperty('ok');
|
||||
expect((response.body as any).ok).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('NPM: should retrieve package metadata (GET /{package})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('name');
|
||||
expect((response.body as any).name).toEqual(testPackageName);
|
||||
expect((response.body as any).versions).toHaveProperty(testVersion);
|
||||
expect((response.body as any)['dist-tags'].latest).toEqual(testVersion);
|
||||
});
|
||||
|
||||
tap.test('NPM: should retrieve specific version metadata (GET /{package}/{version})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}/${testVersion}`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('version');
|
||||
expect((response.body as any).version).toEqual(testVersion);
|
||||
expect((response.body as any).name).toEqual(testPackageName);
|
||||
});
|
||||
|
||||
tap.test('NPM: should download tarball (GET /{package}/-/{tarball})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}/-/${testPackageName}-${testVersion}.tgz`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toBeInstanceOf(Buffer);
|
||||
expect((response.body as Buffer).toString('utf-8')).toEqual('fake tarball content');
|
||||
expect(response.headers['Content-Type']).toEqual('application/octet-stream');
|
||||
});
|
||||
|
||||
tap.test('NPM: should publish a new version of the package', async () => {
|
||||
const newVersion = '1.1.0';
|
||||
const newTarballData = Buffer.from('new version tarball', 'utf-8');
|
||||
const packument = createTestPackument(testPackageName, newVersion, newTarballData);
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
query: {},
|
||||
body: packument,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
|
||||
// Verify the new version is available
|
||||
const getResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(getResponse.status).toEqual(200);
|
||||
expect((getResponse.body as any).versions).toHaveProperty(newVersion);
|
||||
});
|
||||
|
||||
tap.test('NPM: should get dist-tags (GET /-/package/{pkg}/dist-tags)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/-/package/${testPackageName}/dist-tags`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('latest');
|
||||
expect((response.body as any).latest).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
tap.test('NPM: should update dist-tag (PUT /-/package/{pkg}/dist-tags/{tag})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: `/npm/-/package/${testPackageName}/dist-tags/beta`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
query: {},
|
||||
body: '1.1.0',
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
// Verify the tag was updated
|
||||
const getResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect((getResponse.body as any)['dist-tags'].beta).toEqual('1.1.0');
|
||||
});
|
||||
|
||||
tap.test('NPM: should delete dist-tag (DELETE /-/package/{pkg}/dist-tags/{tag})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'DELETE',
|
||||
path: `/npm/-/package/${testPackageName}/dist-tags/beta`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
// Verify the tag was deleted
|
||||
const getResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect((getResponse.body as any)['dist-tags']).not.toHaveProperty('beta');
|
||||
});
|
||||
|
||||
tap.test('NPM: should create a new token (POST /-/npm/v1/tokens)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'POST',
|
||||
path: '/npm/-/npm/v1/tokens',
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
query: {},
|
||||
body: {
|
||||
password: 'testpass',
|
||||
readonly: true,
|
||||
cidr_whitelist: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('token');
|
||||
expect((response.body as any).readonly).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('NPM: should list tokens (GET /-/npm/v1/tokens)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/npm/-/npm/v1/tokens',
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('objects');
|
||||
expect((response.body as any).objects).toBeInstanceOf(Array);
|
||||
expect((response.body as any).objects.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('NPM: should search packages (GET /-/v1/search)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/npm/-/v1/search',
|
||||
headers: {},
|
||||
query: {
|
||||
text: 'test',
|
||||
size: '20',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toHaveProperty('objects');
|
||||
expect((response.body as any).objects).toBeInstanceOf(Array);
|
||||
expect((response.body as any).total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('NPM: should search packages with specific query', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/npm/-/v1/search',
|
||||
headers: {},
|
||||
query: {
|
||||
text: testPackageName,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
const results = (response.body as any).objects;
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].package.name).toEqual(testPackageName);
|
||||
});
|
||||
|
||||
tap.test('NPM: should unpublish a specific version (DELETE /{package}/-/{version})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'DELETE',
|
||||
path: `/npm/${testPackageName}/-/${testVersion}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
// Verify the version was removed
|
||||
const getResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect((getResponse.body as any).versions).not.toHaveProperty(testVersion);
|
||||
});
|
||||
|
||||
tap.test('NPM: should unpublish entire package (DELETE /{package}/-rev/{rev})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'DELETE',
|
||||
path: `/npm/${testPackageName}/-rev/1`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${npmToken}`,
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
// Verify the package was removed
|
||||
const getResponse = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/npm/${testPackageName}`,
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(getResponse.status).toEqual(404);
|
||||
});
|
||||
|
||||
tap.test('NPM: should return 404 for non-existent package', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/npm/non-existent-package',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
tap.test('NPM: should return 401 for unauthorized publish', async () => {
|
||||
const packument = createTestPackument('unauthorized-package', '1.0.0', testTarballData);
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: '/npm/unauthorized-package',
|
||||
headers: {
|
||||
// No authorization header
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
query: {},
|
||||
body: packument,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(401);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
tap.test('NPM: should reject readonly token for write operations', async () => {
|
||||
// Create a readonly token
|
||||
const authManager = registry.getAuthManager();
|
||||
const readonlyToken = await authManager.createNpmToken(userId, true);
|
||||
|
||||
const packument = createTestPackument('readonly-test', '1.0.0', testTarballData);
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: '/npm/readonly-test',
|
||||
headers: {
|
||||
Authorization: `Bearer ${readonlyToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
query: {},
|
||||
body: packument,
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(401);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRegistry } from '../ts/index.js';
|
||||
import { createTestRegistry, createTestTokens, calculateDigest, createTestManifest } from './test.helper.js';
|
||||
import { createTestRegistry, createTestTokens, calculateDigest, createTestManifest } from './helpers/registry.js';
|
||||
|
||||
let registry: SmartRegistry;
|
||||
let ociToken: string;
|
||||
|
||||
343
test/test.ts
343
test/test.ts
@@ -1,214 +1,197 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartregistry from '../ts/index.js';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import { SmartRegistry } from '../ts/index.js';
|
||||
import { createTestRegistry, createTestTokens } from './helpers/registry.js';
|
||||
|
||||
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||
let registry: SmartRegistry;
|
||||
|
||||
let registry: smartregistry.SmartRegistry;
|
||||
let testToken: string;
|
||||
|
||||
tap.test('should create SmartRegistry instance', async () => {
|
||||
// Create mock callbacks for testing
|
||||
const loginCallback: smartregistry.TLoginCallback = async (credentials) => {
|
||||
// Simple mock: return a fake JWT token
|
||||
const tokenPayload = {
|
||||
iss: 'test-registry',
|
||||
sub: credentials.username,
|
||||
aud: 'test-service',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
nbf: Math.floor(Date.now() / 1000),
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
access: [
|
||||
{
|
||||
type: 'repository' as const,
|
||||
name: 'test/repo',
|
||||
actions: ['*'] as smartregistry.TRegistryAction[],
|
||||
},
|
||||
],
|
||||
};
|
||||
// In production, this would be a real JWT
|
||||
return JSON.stringify(tokenPayload);
|
||||
};
|
||||
|
||||
const authCallback: smartregistry.TAuthCallback = async (token, repository, action) => {
|
||||
// Simple mock: allow all actions for testing
|
||||
try {
|
||||
const payload = JSON.parse(token);
|
||||
// Check if token has access to the repository
|
||||
const hasAccess = payload.access.some(
|
||||
(acc: any) =>
|
||||
acc.name === repository &&
|
||||
(acc.actions.includes(action) || acc.actions.includes('*'))
|
||||
);
|
||||
return hasAccess;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Read S3 config from env.json
|
||||
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESS_KEY');
|
||||
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRET_KEY');
|
||||
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
||||
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||
|
||||
const config: smartregistry.IRegistryConfig = {
|
||||
storage: {
|
||||
accessKey: s3AccessKey || 'minioadmin',
|
||||
accessSecret: s3SecretKey || 'minioadmin',
|
||||
endpoint: s3Endpoint || 'localhost',
|
||||
port: parseInt(s3Port || '9000', 10),
|
||||
useSsl: false,
|
||||
region: 'us-east-1',
|
||||
bucketName: 'test-registry',
|
||||
},
|
||||
serviceName: 'test-registry',
|
||||
tokenRealm: 'https://auth.example.com/token',
|
||||
loginCallback,
|
||||
authCallback,
|
||||
};
|
||||
|
||||
registry = new smartregistry.SmartRegistry(config);
|
||||
await registry.init();
|
||||
|
||||
expect(registry).toBeInstanceOf(smartregistry.SmartRegistry);
|
||||
tap.test('Integration: should create SmartRegistry instance with both protocols', async () => {
|
||||
registry = await createTestRegistry();
|
||||
expect(registry).toBeInstanceOf(SmartRegistry);
|
||||
expect(registry.isInitialized()).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should login and get token', async () => {
|
||||
testToken = await registry.login({
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
tap.test('Integration: should have both OCI and NPM registries enabled', async () => {
|
||||
const ociRegistry = registry.getRegistry('oci');
|
||||
const npmRegistry = registry.getRegistry('npm');
|
||||
|
||||
expect(ociRegistry).toBeDefined();
|
||||
expect(npmRegistry).toBeDefined();
|
||||
expect(ociRegistry?.getBasePath()).toEqual('/oci');
|
||||
expect(npmRegistry?.getBasePath()).toEqual('/npm');
|
||||
});
|
||||
|
||||
tap.test('Integration: should route OCI requests correctly', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
expect(testToken).toBeTypeOf('string');
|
||||
expect(testToken.length).toBeGreaterThan(0);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.headers['Docker-Distribution-API-Version']).toEqual('registry/2.0');
|
||||
});
|
||||
|
||||
tap.test('should upload a blob via chunked upload', async () => {
|
||||
const testData = Buffer.from('Hello, OCI Registry!', 'utf-8');
|
||||
const crypto = await import('crypto');
|
||||
const digest = `sha256:${crypto.createHash('sha256').update(testData).digest('hex')}`;
|
||||
tap.test('Integration: should route NPM requests correctly', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/npm/some-package',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
// Initiate upload
|
||||
const initResult = await registry.initiateUpload('test/repo', testToken);
|
||||
expect(initResult).toHaveProperty('uploadId');
|
||||
|
||||
if ('uploadId' in initResult) {
|
||||
const uploadId = initResult.uploadId;
|
||||
|
||||
// Upload chunk
|
||||
const chunkResult = await registry.uploadChunk(
|
||||
uploadId,
|
||||
testData,
|
||||
`0-${testData.length - 1}`,
|
||||
testToken
|
||||
);
|
||||
expect(chunkResult).toHaveProperty('location');
|
||||
|
||||
// Complete upload
|
||||
const completeResult = await registry.completeUpload(uploadId, digest, testToken);
|
||||
expect(completeResult).toHaveProperty('digest');
|
||||
if ('digest' in completeResult) {
|
||||
expect(completeResult.digest).toEqual(digest);
|
||||
}
|
||||
}
|
||||
// Will return 404 since package doesn't exist, but should route correctly
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.headers['Content-Type']).toEqual('application/json');
|
||||
});
|
||||
|
||||
tap.test('should retrieve a blob', async () => {
|
||||
const testData = Buffer.from('Hello, OCI Registry!', 'utf-8');
|
||||
const crypto = await import('crypto');
|
||||
const digest = `sha256:${crypto.createHash('sha256').update(testData).digest('hex')}`;
|
||||
tap.test('Integration: should return 404 for unknown paths', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/unknown/path',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
|
||||
const result = await registry.getBlob('test/repo', digest, testToken);
|
||||
expect(result).toHaveProperty('data');
|
||||
|
||||
if ('data' in result) {
|
||||
expect(result.data.toString('utf-8')).toEqual('Hello, OCI Registry!');
|
||||
}
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect((response.body as any).error).toEqual('NOT_FOUND');
|
||||
});
|
||||
|
||||
tap.test('should check if blob exists (HEAD)', async () => {
|
||||
const testData = Buffer.from('Hello, OCI Registry!', 'utf-8');
|
||||
const crypto = await import('crypto');
|
||||
const digest = `sha256:${crypto.createHash('sha256').update(testData).digest('hex')}`;
|
||||
tap.test('Integration: should create and validate tokens', async () => {
|
||||
const tokens = await createTestTokens(registry);
|
||||
|
||||
const result = await registry.headBlob('test/repo', digest, testToken);
|
||||
expect(result).toHaveProperty('exists');
|
||||
expect(tokens.npmToken).toBeTypeOf('string');
|
||||
expect(tokens.ociToken).toBeTypeOf('string');
|
||||
expect(tokens.userId).toBeTypeOf('string');
|
||||
|
||||
if ('exists' in result) {
|
||||
expect(result.exists).toEqual(true);
|
||||
expect(result.size).toEqual(testData.length);
|
||||
}
|
||||
// Validate NPM token
|
||||
const authManager = registry.getAuthManager();
|
||||
const npmTokenObj = await authManager.validateToken(tokens.npmToken, 'npm');
|
||||
expect(npmTokenObj).toBeDefined();
|
||||
expect(npmTokenObj?.type).toEqual('npm');
|
||||
expect(npmTokenObj?.userId).toEqual(tokens.userId);
|
||||
|
||||
// Validate OCI token
|
||||
const ociTokenObj = await authManager.validateToken(tokens.ociToken, 'oci');
|
||||
expect(ociTokenObj).toBeDefined();
|
||||
expect(ociTokenObj?.type).toEqual('oci');
|
||||
expect(ociTokenObj?.userId).toEqual(tokens.userId);
|
||||
});
|
||||
|
||||
tap.test('should upload a manifest', async () => {
|
||||
const testManifest: smartregistry.IOciManifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||
config: {
|
||||
mediaType: 'application/vnd.oci.image.config.v1+json',
|
||||
size: 123,
|
||||
digest: 'sha256:' + '0'.repeat(64),
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
|
||||
size: 456,
|
||||
digest: 'sha256:' + '1'.repeat(64),
|
||||
},
|
||||
],
|
||||
};
|
||||
tap.test('Integration: should handle authentication properly', async () => {
|
||||
const authManager = registry.getAuthManager();
|
||||
|
||||
const result = await registry.putManifest(
|
||||
'test/repo',
|
||||
'latest',
|
||||
testManifest,
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
testToken
|
||||
// Create a new user
|
||||
const userId = await authManager.authenticate({
|
||||
username: 'newuser',
|
||||
password: 'newpass',
|
||||
});
|
||||
|
||||
expect(userId).toBeTypeOf('string');
|
||||
expect(userId).toEqual('newuser');
|
||||
|
||||
// Verify login with correct credentials
|
||||
const userId2 = await authManager.authenticate({
|
||||
username: 'newuser',
|
||||
password: 'newpass',
|
||||
});
|
||||
|
||||
expect(userId2).toEqual('newuser');
|
||||
|
||||
// Verify login fails with wrong credentials
|
||||
const userId3 = await authManager.authenticate({
|
||||
username: 'newuser',
|
||||
password: 'wrongpass',
|
||||
});
|
||||
|
||||
expect(userId3).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('Integration: should handle scoped permissions correctly', async () => {
|
||||
const authManager = registry.getAuthManager();
|
||||
|
||||
// Create user and token with specific scopes
|
||||
const userId = await authManager.authenticate({
|
||||
username: 'scopeduser',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const npmToken = await authManager.createNpmToken(userId!, false);
|
||||
const tokenObj = await authManager.validateToken(npmToken, 'npm');
|
||||
|
||||
// Check authorization for different resources
|
||||
const canWrite = await authManager.authorize(
|
||||
tokenObj,
|
||||
'npm:package:test-package',
|
||||
'write'
|
||||
);
|
||||
expect(canWrite).toEqual(true);
|
||||
|
||||
expect(result).toHaveProperty('digest');
|
||||
if ('digest' in result) {
|
||||
expect(result.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
}
|
||||
const canRead = await authManager.authorize(
|
||||
tokenObj,
|
||||
'npm:package:test-package',
|
||||
'read'
|
||||
);
|
||||
expect(canRead).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should retrieve a manifest by tag', async () => {
|
||||
const result = await registry.getManifest('test/repo', 'latest', testToken);
|
||||
expect(result).toHaveProperty('data');
|
||||
tap.test('Integration: should respect readonly token restrictions', async () => {
|
||||
const authManager = registry.getAuthManager();
|
||||
|
||||
if ('data' in result) {
|
||||
const manifest = JSON.parse(result.data.toString('utf-8'));
|
||||
expect(manifest).toHaveProperty('schemaVersion');
|
||||
expect(manifest.schemaVersion).toEqual(2);
|
||||
}
|
||||
const userId = await authManager.authenticate({
|
||||
username: 'readonlyuser',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const readonlyToken = await authManager.createNpmToken(userId!, true);
|
||||
const tokenObj = await authManager.validateToken(readonlyToken, 'npm');
|
||||
|
||||
// Readonly token should allow read
|
||||
const canRead = await authManager.authorize(
|
||||
tokenObj,
|
||||
'npm:package:test-package',
|
||||
'read'
|
||||
);
|
||||
expect(canRead).toEqual(true);
|
||||
|
||||
// Readonly token should deny write
|
||||
const canWrite = await authManager.authorize(
|
||||
tokenObj,
|
||||
'npm:package:test-package',
|
||||
'write'
|
||||
);
|
||||
expect(canWrite).toEqual(false);
|
||||
|
||||
// Readonly token should deny push
|
||||
const canPush = await authManager.authorize(
|
||||
tokenObj,
|
||||
'oci:repository:test-repo',
|
||||
'push'
|
||||
);
|
||||
expect(canPush).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('should list tags', async () => {
|
||||
const result = await registry.listTags('test/repo', testToken);
|
||||
expect(result).toHaveProperty('tags');
|
||||
tap.test('Integration: should access storage backend', async () => {
|
||||
const storage = registry.getStorage();
|
||||
expect(storage).toBeDefined();
|
||||
|
||||
if ('tags' in result) {
|
||||
expect(result.tags).toBeInstanceOf(Array);
|
||||
expect(result.tags).toContain('latest');
|
||||
}
|
||||
});
|
||||
// Test basic storage operations
|
||||
const testKey = 'test/storage/key';
|
||||
const testData = Buffer.from('test data', 'utf-8');
|
||||
|
||||
tap.test('should generate auth challenge', async () => {
|
||||
const challenge = registry.getAuthChallenge('test/repo', ['pull', 'push']);
|
||||
expect(challenge).toInclude('Bearer');
|
||||
expect(challenge).toInclude('realm=');
|
||||
expect(challenge).toInclude('service=');
|
||||
expect(challenge).toInclude('scope=');
|
||||
});
|
||||
await storage.putObject(testKey, testData);
|
||||
const retrieved = await storage.getObject(testKey);
|
||||
|
||||
tap.test('should handle unauthorized access', async () => {
|
||||
const result = await registry.getBlob('test/repo', 'sha256:invalid', 'invalid-token');
|
||||
expect(result).toHaveProperty('errors');
|
||||
expect(retrieved).toBeInstanceOf(Buffer);
|
||||
expect(retrieved?.toString('utf-8')).toEqual('test data');
|
||||
|
||||
if ('errors' in result) {
|
||||
expect(result.errors[0].code).toEqual('DENIED');
|
||||
}
|
||||
const exists = await storage.objectExists(testKey);
|
||||
expect(exists).toEqual(true);
|
||||
|
||||
await storage.deleteObject(testKey);
|
||||
const existsAfterDelete = await storage.objectExists(testKey);
|
||||
expect(existsAfterDelete).toEqual(false);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -37,7 +37,7 @@ export class AuthManager {
|
||||
const authToken: IAuthToken = {
|
||||
type: 'npm',
|
||||
userId,
|
||||
scopes: readonly ? ['npm:*:read'] : ['npm:*:*'],
|
||||
scopes: readonly ? ['npm:*:*:read'] : ['npm:*:*:*'],
|
||||
readonly,
|
||||
metadata: {
|
||||
created: new Date().toISOString(),
|
||||
|
||||
@@ -47,11 +47,14 @@ export class NpmRegistry extends BaseRegistry {
|
||||
|
||||
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
||||
const path = context.path.replace(this.basePath, '');
|
||||
console.log(`[NPM handleRequest] method=${context.method}, path=${path}`);
|
||||
|
||||
// Extract token from Authorization header
|
||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
||||
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
|
||||
console.log(`[NPM handleRequest] authHeader=${authHeader}, tokenString=${tokenString}`);
|
||||
const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null;
|
||||
console.log(`[NPM handleRequest] token validated:`, token);
|
||||
|
||||
// Registry root
|
||||
if (path === '/' || path === '') {
|
||||
@@ -88,20 +91,38 @@ export class NpmRegistry extends BaseRegistry {
|
||||
return this.handleTarballDownload(packageName, filename, token);
|
||||
}
|
||||
|
||||
// Package operations: /{package}
|
||||
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
|
||||
if (packageMatch) {
|
||||
const packageName = packageMatch[1];
|
||||
return this.handlePackage(context.method, packageName, context.body, context.query, token);
|
||||
// Unpublish specific version: DELETE /{package}/-/{version}
|
||||
const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/);
|
||||
if (unpublishVersionMatch && context.method === 'DELETE') {
|
||||
const [, packageName, version] = unpublishVersionMatch;
|
||||
console.log(`[unpublishVersionMatch] packageName=${packageName}, version=${version}`);
|
||||
return this.unpublishVersion(packageName, version, token);
|
||||
}
|
||||
|
||||
// Unpublish entire package: DELETE /{package}/-rev/{rev}
|
||||
const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/);
|
||||
if (unpublishPackageMatch && context.method === 'DELETE') {
|
||||
const [, packageName, rev] = unpublishPackageMatch;
|
||||
console.log(`[unpublishPackageMatch] packageName=${packageName}, rev=${rev}`);
|
||||
return this.unpublishPackage(packageName, token);
|
||||
}
|
||||
|
||||
// Package version: /{package}/{version}
|
||||
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
|
||||
if (versionMatch) {
|
||||
const [, packageName, version] = versionMatch;
|
||||
console.log(`[versionMatch] matched! packageName=${packageName}, version=${version}`);
|
||||
return this.handlePackageVersion(packageName, version, token);
|
||||
}
|
||||
|
||||
// Package operations: /{package}
|
||||
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
|
||||
if (packageMatch) {
|
||||
const packageName = packageMatch[1];
|
||||
console.log(`[packageMatch] matched! packageName=${packageName}`);
|
||||
return this.handlePackage(context.method, packageName, context.body, context.query, token);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -209,7 +230,12 @@ export class NpmRegistry extends BaseRegistry {
|
||||
version: string,
|
||||
token: IAuthToken | null
|
||||
): Promise<IResponse> {
|
||||
console.log(`[handlePackageVersion] packageName=${packageName}, version=${version}`);
|
||||
const packument = await this.storage.getNpmPackument(packageName);
|
||||
console.log(`[handlePackageVersion] packument found:`, !!packument);
|
||||
if (packument) {
|
||||
console.log(`[handlePackageVersion] versions:`, Object.keys(packument.versions || {}));
|
||||
}
|
||||
if (!packument) {
|
||||
return {
|
||||
status: 404,
|
||||
@@ -252,7 +278,10 @@ export class NpmRegistry extends BaseRegistry {
|
||||
body: IPublishRequest,
|
||||
token: IAuthToken | null
|
||||
): Promise<IResponse> {
|
||||
if (!await this.checkPermission(token, packageName, 'write')) {
|
||||
console.log(`[publishPackage] packageName=${packageName}, token=`, token);
|
||||
const hasPermission = await this.checkPermission(token, packageName, 'write');
|
||||
console.log(`[publishPackage] hasPermission=${hasPermission}`);
|
||||
if (!hasPermission) {
|
||||
return {
|
||||
status: 401,
|
||||
headers: {},
|
||||
@@ -361,6 +390,67 @@ export class NpmRegistry extends BaseRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
private async unpublishVersion(
|
||||
packageName: string,
|
||||
version: string,
|
||||
token: IAuthToken | null
|
||||
): Promise<IResponse> {
|
||||
if (!await this.checkPermission(token, packageName, 'delete')) {
|
||||
return {
|
||||
status: 401,
|
||||
headers: {},
|
||||
body: this.createError('EUNAUTHORIZED', 'Unauthorized'),
|
||||
};
|
||||
}
|
||||
|
||||
const packument = await this.storage.getNpmPackument(packageName);
|
||||
if (!packument) {
|
||||
return {
|
||||
status: 404,
|
||||
headers: {},
|
||||
body: this.createError('E404', 'Package not found'),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if version exists
|
||||
if (!packument.versions[version]) {
|
||||
return {
|
||||
status: 404,
|
||||
headers: {},
|
||||
body: this.createError('E404', 'Version not found'),
|
||||
};
|
||||
}
|
||||
|
||||
// Delete tarball
|
||||
await this.storage.deleteNpmTarball(packageName, version);
|
||||
|
||||
// Remove version from packument
|
||||
delete packument.versions[version];
|
||||
if (packument.time) {
|
||||
delete packument.time[version];
|
||||
packument.time.modified = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Update latest tag if this was the latest version
|
||||
if (packument['dist-tags']?.latest === version) {
|
||||
const remainingVersions = Object.keys(packument.versions);
|
||||
if (remainingVersions.length > 0) {
|
||||
packument['dist-tags'].latest = remainingVersions[remainingVersions.length - 1];
|
||||
} else {
|
||||
delete packument['dist-tags'].latest;
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated packument
|
||||
await this.storage.putNpmPackument(packageName, packument);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { ok: true },
|
||||
};
|
||||
}
|
||||
|
||||
private async unpublishPackage(
|
||||
packageName: string,
|
||||
token: IAuthToken | null
|
||||
@@ -438,14 +528,64 @@ export class NpmRegistry extends BaseRegistry {
|
||||
const size = parseInt(query.size || '20', 10);
|
||||
const from = parseInt(query.from || '0', 10);
|
||||
|
||||
// Simple search implementation (in production, use proper search index)
|
||||
// Simple search implementation
|
||||
const results: ISearchResult[] = [];
|
||||
|
||||
// For now, return empty results
|
||||
// In production, implement full-text search across packuments
|
||||
try {
|
||||
// List all package paths
|
||||
const packagePaths = await this.storage.listObjects('npm/packages/');
|
||||
|
||||
// Extract unique package names from paths (format: npm/packages/{packageName}/...)
|
||||
const packageNames = new Set<string>();
|
||||
for (const path of packagePaths) {
|
||||
const match = path.match(/^npm\/packages\/([^\/]+)\/index\.json$/);
|
||||
if (match) {
|
||||
packageNames.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Load packuments and filter by search text
|
||||
for (const packageName of packageNames) {
|
||||
if (!text || packageName.toLowerCase().includes(text.toLowerCase())) {
|
||||
const packument = await this.storage.getNpmPackument(packageName);
|
||||
if (packument) {
|
||||
const latestVersion = packument['dist-tags']?.latest;
|
||||
const versionData = latestVersion ? packument.versions[latestVersion] : null;
|
||||
|
||||
results.push({
|
||||
package: {
|
||||
name: packument.name,
|
||||
version: latestVersion || '0.0.0',
|
||||
description: packument.description || versionData?.description || '',
|
||||
keywords: versionData?.keywords || [],
|
||||
date: packument.time?.modified || new Date().toISOString(),
|
||||
links: {},
|
||||
author: versionData?.author || {},
|
||||
publisher: versionData?._npmUser || {},
|
||||
maintainers: packument.maintainers || [],
|
||||
},
|
||||
score: {
|
||||
final: 1.0,
|
||||
detail: {
|
||||
quality: 1.0,
|
||||
popularity: 1.0,
|
||||
maintenance: 1.0,
|
||||
},
|
||||
},
|
||||
searchScore: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[handleSearch] Error:', error);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const paginatedResults = results.slice(from, from + size);
|
||||
|
||||
const response: ISearchResponse = {
|
||||
objects: results,
|
||||
objects: paginatedResults,
|
||||
total: results.length,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
@@ -581,7 +721,7 @@ export class NpmRegistry extends BaseRegistry {
|
||||
const newToken = await this.authManager.createNpmToken(token.userId, readonly);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
token: newToken,
|
||||
|
||||
Reference in New Issue
Block a user