Files
smartregistry/test/test.storage.hooks.ts

507 lines
17 KiB
TypeScript
Raw Permalink Normal View History

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();