import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as qenv from '@push.rocks/qenv'; import { RegistryStorage } from '../ts/core/classes.registrystorage.js'; import type { IStorageConfig } from '../ts/core/interfaces.core.js'; import type { IStorageHooks, IStorageHookContext } from '../ts/core/interfaces.storage.js'; import { createTrackingHooks, createQuotaHooks, generateTestRunId } from './helpers/registry.js'; const testQenv = new qenv.Qenv('./', './.nogit'); // ============================================================================ // Test State // ============================================================================ let storage: RegistryStorage; let storageConfig: IStorageConfig; let testRunId: string; // ============================================================================ // Setup // ============================================================================ tap.test('setup: should create storage config', async () => { testRunId = generateTestRunId(); 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'); storageConfig = { accessKey: s3AccessKey || 'minioadmin', accessSecret: s3SecretKey || 'minioadmin', endpoint: s3Endpoint || 'localhost', port: parseInt(s3Port || '9000', 10), useSsl: false, region: 'us-east-1', bucketName: `test-hooks-${testRunId}`, }; expect(storageConfig.bucketName).toBeTruthy(); }); // ============================================================================ // beforePut Hook Tests // ============================================================================ tap.test('beforePut: should be called before storage', async () => { const tracker = createTrackingHooks(); storage = new RegistryStorage(storageConfig, tracker.hooks); await storage.init(); // Set context and put object storage.setContext({ protocol: 'npm', actor: { userId: 'testuser' }, metadata: { packageName: 'test-package' }, }); await storage.putObject('test/beforeput-called.txt', Buffer.from('test data')); storage.clearContext(); // Verify beforePut was called const beforePutCalls = tracker.calls.filter(c => c.method === 'beforePut'); expect(beforePutCalls.length).toEqual(1); expect(beforePutCalls[0].context.operation).toEqual('put'); expect(beforePutCalls[0].context.key).toEqual('test/beforeput-called.txt'); expect(beforePutCalls[0].context.protocol).toEqual('npm'); }); tap.test('beforePut: returning {allowed: false} should block storage', async () => { const tracker = createTrackingHooks({ beforePutAllowed: false }); const blockingStorage = new RegistryStorage(storageConfig, tracker.hooks); await blockingStorage.init(); blockingStorage.setContext({ protocol: 'npm', actor: { userId: 'testuser' }, }); let errorThrown = false; try { await blockingStorage.putObject('test/should-not-exist.txt', Buffer.from('blocked data')); } catch (error) { errorThrown = true; expect((error as Error).message).toContain('Blocked by test'); } blockingStorage.clearContext(); expect(errorThrown).toBeTrue(); // Verify object was NOT stored const result = await blockingStorage.getObject('test/should-not-exist.txt'); expect(result).toBeNull(); }); // ============================================================================ // afterPut Hook Tests // ============================================================================ tap.test('afterPut: should be called after successful storage', async () => { const tracker = createTrackingHooks(); const trackedStorage = new RegistryStorage(storageConfig, tracker.hooks); await trackedStorage.init(); trackedStorage.setContext({ protocol: 'maven', actor: { userId: 'maven-user' }, }); await trackedStorage.putObject('test/afterput-test.txt', Buffer.from('after put test')); trackedStorage.clearContext(); // Give async hook time to complete await new Promise(resolve => setTimeout(resolve, 100)); const afterPutCalls = tracker.calls.filter(c => c.method === 'afterPut'); expect(afterPutCalls.length).toEqual(1); expect(afterPutCalls[0].context.operation).toEqual('put'); }); tap.test('afterPut: should receive correct metadata (size, key, protocol)', async () => { const tracker = createTrackingHooks(); const metadataStorage = new RegistryStorage(storageConfig, tracker.hooks); await metadataStorage.init(); const testData = Buffer.from('metadata test data - some content here'); metadataStorage.setContext({ protocol: 'cargo', actor: { userId: 'cargo-user', ip: '192.168.1.100' }, metadata: { packageName: 'my-crate', version: '1.0.0' }, }); await metadataStorage.putObject('test/metadata-test.txt', testData); metadataStorage.clearContext(); await new Promise(resolve => setTimeout(resolve, 100)); const afterPutCalls = tracker.calls.filter(c => c.method === 'afterPut'); expect(afterPutCalls.length).toBeGreaterThanOrEqual(1); const call = afterPutCalls[afterPutCalls.length - 1]; expect(call.context.metadata?.size).toEqual(testData.length); expect(call.context.key).toEqual('test/metadata-test.txt'); expect(call.context.protocol).toEqual('cargo'); expect(call.context.actor?.userId).toEqual('cargo-user'); expect(call.context.actor?.ip).toEqual('192.168.1.100'); }); // ============================================================================ // beforeDelete Hook Tests // ============================================================================ tap.test('beforeDelete: should be called before deletion', async () => { const tracker = createTrackingHooks(); const deleteStorage = new RegistryStorage(storageConfig, tracker.hooks); await deleteStorage.init(); // First, store an object deleteStorage.setContext({ protocol: 'npm' }); await deleteStorage.putObject('test/to-delete.txt', Buffer.from('delete me')); // Now delete it await deleteStorage.deleteObject('test/to-delete.txt'); deleteStorage.clearContext(); const beforeDeleteCalls = tracker.calls.filter(c => c.method === 'beforeDelete'); expect(beforeDeleteCalls.length).toEqual(1); expect(beforeDeleteCalls[0].context.operation).toEqual('delete'); expect(beforeDeleteCalls[0].context.key).toEqual('test/to-delete.txt'); }); tap.test('beforeDelete: returning {allowed: false} should block deletion', async () => { const tracker = createTrackingHooks({ beforeDeleteAllowed: false }); const protectedStorage = new RegistryStorage(storageConfig, tracker.hooks); await protectedStorage.init(); // First store an object protectedStorage.setContext({ protocol: 'npm' }); await protectedStorage.putObject('test/protected.txt', Buffer.from('protected data')); // Try to delete - should be blocked let errorThrown = false; try { await protectedStorage.deleteObject('test/protected.txt'); } catch (error) { errorThrown = true; expect((error as Error).message).toContain('Blocked by test'); } protectedStorage.clearContext(); expect(errorThrown).toBeTrue(); // Verify object still exists const result = await protectedStorage.getObject('test/protected.txt'); expect(result).toBeTruthy(); }); // ============================================================================ // afterDelete Hook Tests // ============================================================================ tap.test('afterDelete: should be called after successful deletion', async () => { const tracker = createTrackingHooks(); const afterDeleteStorage = new RegistryStorage(storageConfig, tracker.hooks); await afterDeleteStorage.init(); afterDeleteStorage.setContext({ protocol: 'pypi' }); await afterDeleteStorage.putObject('test/delete-tracked.txt', Buffer.from('to be deleted')); await afterDeleteStorage.deleteObject('test/delete-tracked.txt'); afterDeleteStorage.clearContext(); await new Promise(resolve => setTimeout(resolve, 100)); const afterDeleteCalls = tracker.calls.filter(c => c.method === 'afterDelete'); expect(afterDeleteCalls.length).toEqual(1); expect(afterDeleteCalls[0].context.operation).toEqual('delete'); }); // ============================================================================ // afterGet Hook Tests // ============================================================================ tap.test('afterGet: should be called after reading object', async () => { const tracker = createTrackingHooks(); const getStorage = new RegistryStorage(storageConfig, tracker.hooks); await getStorage.init(); // Store an object first getStorage.setContext({ protocol: 'rubygems' }); await getStorage.putObject('test/read-test.txt', Buffer.from('read me')); // Clear calls to focus on the get tracker.calls.length = 0; // Read the object const data = await getStorage.getObject('test/read-test.txt'); getStorage.clearContext(); await new Promise(resolve => setTimeout(resolve, 100)); expect(data).toBeTruthy(); expect(data!.toString()).toEqual('read me'); const afterGetCalls = tracker.calls.filter(c => c.method === 'afterGet'); expect(afterGetCalls.length).toEqual(1); expect(afterGetCalls[0].context.operation).toEqual('get'); }); // ============================================================================ // Context Tests // ============================================================================ tap.test('context: hooks should receive actor information', async () => { const tracker = createTrackingHooks(); const actorStorage = new RegistryStorage(storageConfig, tracker.hooks); await actorStorage.init(); actorStorage.setContext({ protocol: 'composer', actor: { userId: 'user-123', tokenId: 'token-abc', ip: '10.0.0.1', userAgent: 'composer/2.0', orgId: 'org-456', sessionId: 'session-xyz', }, }); await actorStorage.putObject('test/actor-test.txt', Buffer.from('actor test')); actorStorage.clearContext(); const beforePutCall = tracker.calls.find(c => c.method === 'beforePut'); expect(beforePutCall).toBeTruthy(); expect(beforePutCall!.context.actor?.userId).toEqual('user-123'); expect(beforePutCall!.context.actor?.tokenId).toEqual('token-abc'); expect(beforePutCall!.context.actor?.ip).toEqual('10.0.0.1'); expect(beforePutCall!.context.actor?.userAgent).toEqual('composer/2.0'); expect(beforePutCall!.context.actor?.orgId).toEqual('org-456'); expect(beforePutCall!.context.actor?.sessionId).toEqual('session-xyz'); }); tap.test('withContext: should set and clear context correctly', async () => { const tracker = createTrackingHooks(); const contextStorage = new RegistryStorage(storageConfig, tracker.hooks); await contextStorage.init(); // Use withContext to ensure automatic cleanup await contextStorage.withContext( { protocol: 'oci', actor: { userId: 'oci-user' }, }, async () => { await contextStorage.putObject('test/with-context.txt', Buffer.from('context managed')); } ); const call = tracker.calls.find(c => c.method === 'beforePut'); expect(call).toBeTruthy(); expect(call!.context.protocol).toEqual('oci'); expect(call!.context.actor?.userId).toEqual('oci-user'); }); tap.test('withContext: should clear context even on error', async () => { const tracker = createTrackingHooks({ beforePutAllowed: false }); const errorStorage = new RegistryStorage(storageConfig, tracker.hooks); await errorStorage.init(); let errorThrown = false; try { await errorStorage.withContext( { protocol: 'npm', actor: { userId: 'error-user' }, }, async () => { await errorStorage.putObject('test/error-context.txt', Buffer.from('will fail')); } ); } catch { errorThrown = true; } expect(errorThrown).toBeTrue(); // Verify context was cleared - next operation without context should work // (hooks won't be called without context) tracker.hooks.beforePut = async () => ({ allowed: true }); await errorStorage.putObject('test/after-error.txt', Buffer.from('ok')); }); // ============================================================================ // Graceful Degradation Tests // ============================================================================ tap.test('graceful: hooks should not fail the operation if afterPut throws', async () => { const tracker = createTrackingHooks({ throwOnAfterPut: true }); const gracefulStorage = new RegistryStorage(storageConfig, tracker.hooks); await gracefulStorage.init(); gracefulStorage.setContext({ protocol: 'npm' }); // This should NOT throw even though afterPut throws let errorThrown = false; try { await gracefulStorage.putObject('test/graceful-afterput.txt', Buffer.from('should succeed')); } catch { errorThrown = true; } gracefulStorage.clearContext(); expect(errorThrown).toBeFalse(); // Verify object was stored const data = await gracefulStorage.getObject('test/graceful-afterput.txt'); expect(data).toBeTruthy(); }); tap.test('graceful: hooks should not fail the operation if afterGet throws', async () => { const tracker = createTrackingHooks({ throwOnAfterGet: true }); const gracefulGetStorage = new RegistryStorage(storageConfig, tracker.hooks); await gracefulGetStorage.init(); // Store first gracefulGetStorage.setContext({ protocol: 'maven' }); await gracefulGetStorage.putObject('test/graceful-afterget.txt', Buffer.from('read me gracefully')); // Read should succeed even though afterGet throws let errorThrown = false; try { const data = await gracefulGetStorage.getObject('test/graceful-afterget.txt'); expect(data).toBeTruthy(); } catch { errorThrown = true; } gracefulGetStorage.clearContext(); expect(errorThrown).toBeFalse(); }); // ============================================================================ // Quota Hooks Tests // ============================================================================ tap.test('quota: should block storage when quota exceeded', async () => { const maxSize = 100; // 100 bytes max const quotaTracker = createQuotaHooks(maxSize); const quotaStorage = new RegistryStorage(storageConfig, quotaTracker.hooks); await quotaStorage.init(); quotaStorage.setContext({ protocol: 'npm', actor: { userId: 'quota-user' }, }); // Store 50 bytes - should succeed await quotaStorage.putObject('test/quota-1.txt', Buffer.from('x'.repeat(50))); expect(quotaTracker.currentUsage.bytes).toEqual(50); // Try to store 60 more bytes - should fail (total would be 110) let errorThrown = false; try { await quotaStorage.putObject('test/quota-2.txt', Buffer.from('x'.repeat(60))); } catch (error) { errorThrown = true; expect((error as Error).message).toContain('Quota exceeded'); } quotaStorage.clearContext(); expect(errorThrown).toBeTrue(); expect(quotaTracker.currentUsage.bytes).toEqual(50); // Still 50, not 110 }); tap.test('quota: should update usage after delete', async () => { const maxSize = 200; const quotaTracker = createQuotaHooks(maxSize); const quotaDelStorage = new RegistryStorage(storageConfig, quotaTracker.hooks); await quotaDelStorage.init(); quotaDelStorage.setContext({ protocol: 'npm', metadata: { size: 75 }, }); // Store and track await quotaDelStorage.putObject('test/quota-del.txt', Buffer.from('x'.repeat(75))); expect(quotaTracker.currentUsage.bytes).toEqual(75); // Delete and verify usage decreases await quotaDelStorage.deleteObject('test/quota-del.txt'); await new Promise(resolve => setTimeout(resolve, 100)); quotaDelStorage.clearContext(); // Usage should be reduced (though exact value depends on metadata) expect(quotaTracker.currentUsage.bytes).toBeLessThanOrEqual(75); }); // ============================================================================ // setHooks Tests // ============================================================================ tap.test('setHooks: should allow setting hooks after construction', async () => { const lateStorage = new RegistryStorage(storageConfig); await lateStorage.init(); // Initially no hooks await lateStorage.putObject('test/no-hooks.txt', Buffer.from('no hooks yet')); // Add hooks later const tracker = createTrackingHooks(); lateStorage.setHooks(tracker.hooks); lateStorage.setContext({ protocol: 'npm' }); await lateStorage.putObject('test/with-late-hooks.txt', Buffer.from('now with hooks')); lateStorage.clearContext(); const beforePutCalls = tracker.calls.filter(c => c.method === 'beforePut'); expect(beforePutCalls.length).toEqual(1); }); // ============================================================================ // Cleanup // ============================================================================ tap.test('cleanup: should clean up test bucket', async () => { if (storage) { // Clean up test objects const prefixes = ['test/']; for (const prefix of prefixes) { try { const objects = await storage.listObjects(prefix); for (const obj of objects) { await storage.deleteObject(obj); } } catch { // Ignore cleanup errors } } } }); export default tap.start();