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