diff --git a/package.json b/package.json index 9240013..0afc4a9 100644 --- a/package.json +++ b/package.json @@ -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)" }, diff --git a/test/test.helper.ts b/test/helpers/registry.ts similarity index 95% rename from test/test.helper.ts rename to test/helpers/registry.ts index b694b8b..c8e11a8 100644 --- a/test/test.helper.ts +++ b/test/helpers/registry.ts @@ -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 { // 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')}`; diff --git a/test/test.npm.ts b/test/test.npm.ts index e69de29..7e4d017 100644 --- a/test/test.npm.ts +++ b/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(); diff --git a/test/test.oci.ts b/test/test.oci.ts index 56983c3..37429a7 100644 --- a/test/test.oci.ts +++ b/test/test.oci.ts @@ -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; diff --git a/test/test.ts b/test/test.ts index 90902e1..220d275 100644 --- a/test/test.ts +++ b/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(); diff --git a/ts/core/classes.authmanager.ts b/ts/core/classes.authmanager.ts index 595104a..51c9f81 100644 --- a/ts/core/classes.authmanager.ts +++ b/ts/core/classes.authmanager.ts @@ -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(), diff --git a/ts/npm/classes.npmregistry.ts b/ts/npm/classes.npmregistry.ts index 0449e64..c98bb84 100644 --- a/ts/npm/classes.npmregistry.ts +++ b/ts/npm/classes.npmregistry.ts @@ -47,11 +47,14 @@ export class NpmRegistry extends BaseRegistry { public async handleRequest(context: IRequestContext): Promise { 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 { + 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 { - 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 { + 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(); + 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,