feat(core): Add core registry infrastructure: storage, auth, upstream cache, and protocol handlers
This commit is contained in:
@@ -3,7 +3,9 @@ import * as crypto from 'crypto';
|
||||
import * as smartarchive from '@push.rocks/smartarchive';
|
||||
import * as smartbucket from '@push.rocks/smartbucket';
|
||||
import { SmartRegistry } from '../../ts/classes.smartregistry.js';
|
||||
import type { IRegistryConfig } from '../../ts/core/interfaces.core.js';
|
||||
import type { IRegistryConfig, IAuthToken, TRegistryProtocol } from '../../ts/core/interfaces.core.js';
|
||||
import type { IAuthProvider, ITokenOptions } from '../../ts/core/interfaces.auth.js';
|
||||
import type { IStorageHooks, IStorageHookContext, IBeforePutResult, IBeforeDeleteResult } from '../../ts/core/interfaces.storage.js';
|
||||
|
||||
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||
|
||||
@@ -608,3 +610,228 @@ export function calculateRubyGemsChecksums(data: Buffer) {
|
||||
sha256: crypto.createHash('sha256').update(data).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enterprise Extensibility Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock auth provider for testing pluggable authentication.
|
||||
* Allows customizing behavior for different test scenarios.
|
||||
*/
|
||||
export function createMockAuthProvider(overrides?: Partial<IAuthProvider>): IAuthProvider {
|
||||
const tokens = new Map<string, IAuthToken>();
|
||||
|
||||
return {
|
||||
init: async () => {},
|
||||
authenticate: async (credentials) => {
|
||||
// Default: always authenticate successfully
|
||||
return credentials.username;
|
||||
},
|
||||
validateToken: async (token, protocol) => {
|
||||
const stored = tokens.get(token);
|
||||
if (stored && (!protocol || stored.type === protocol)) {
|
||||
return stored;
|
||||
}
|
||||
// Mock token for tests
|
||||
if (token === 'valid-mock-token') {
|
||||
return {
|
||||
type: 'npm' as TRegistryProtocol,
|
||||
userId: 'mock-user',
|
||||
scopes: ['npm:*:*:*'],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
createToken: async (userId, protocol, options) => {
|
||||
const tokenId = `mock-${protocol}-${Date.now()}`;
|
||||
const authToken: IAuthToken = {
|
||||
type: protocol,
|
||||
userId,
|
||||
scopes: options?.scopes || [`${protocol}:*:*:*`],
|
||||
readonly: options?.readonly,
|
||||
expiresAt: options?.expiresIn ? new Date(Date.now() + options.expiresIn * 1000) : undefined,
|
||||
};
|
||||
tokens.set(tokenId, authToken);
|
||||
return tokenId;
|
||||
},
|
||||
revokeToken: async (token) => {
|
||||
tokens.delete(token);
|
||||
},
|
||||
authorize: async (token, resource, action) => {
|
||||
if (!token) return false;
|
||||
if (token.readonly && ['write', 'push', 'delete'].includes(action)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
listUserTokens: async (userId) => {
|
||||
const result: Array<{ key: string; readonly: boolean; created: string; protocol?: TRegistryProtocol }> = [];
|
||||
for (const [key, token] of tokens.entries()) {
|
||||
if (token.userId === userId) {
|
||||
result.push({
|
||||
key: `hash-${key.substring(0, 8)}`,
|
||||
readonly: token.readonly || false,
|
||||
created: new Date().toISOString(),
|
||||
protocol: token.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test storage hooks that track all calls.
|
||||
* Useful for verifying hook invocation order and parameters.
|
||||
*/
|
||||
export function createTrackingHooks(options?: {
|
||||
beforePutAllowed?: boolean;
|
||||
beforeDeleteAllowed?: boolean;
|
||||
throwOnAfterPut?: boolean;
|
||||
throwOnAfterGet?: boolean;
|
||||
}): {
|
||||
hooks: IStorageHooks;
|
||||
calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }>;
|
||||
} {
|
||||
const calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }> = [];
|
||||
|
||||
return {
|
||||
calls,
|
||||
hooks: {
|
||||
beforePut: async (ctx) => {
|
||||
calls.push({ method: 'beforePut', context: ctx, timestamp: Date.now() });
|
||||
return {
|
||||
allowed: options?.beforePutAllowed !== false,
|
||||
reason: options?.beforePutAllowed === false ? 'Blocked by test' : undefined,
|
||||
};
|
||||
},
|
||||
afterPut: async (ctx) => {
|
||||
calls.push({ method: 'afterPut', context: ctx, timestamp: Date.now() });
|
||||
if (options?.throwOnAfterPut) {
|
||||
throw new Error('Test error in afterPut');
|
||||
}
|
||||
},
|
||||
beforeDelete: async (ctx) => {
|
||||
calls.push({ method: 'beforeDelete', context: ctx, timestamp: Date.now() });
|
||||
return {
|
||||
allowed: options?.beforeDeleteAllowed !== false,
|
||||
reason: options?.beforeDeleteAllowed === false ? 'Blocked by test' : undefined,
|
||||
};
|
||||
},
|
||||
afterDelete: async (ctx) => {
|
||||
calls.push({ method: 'afterDelete', context: ctx, timestamp: Date.now() });
|
||||
},
|
||||
afterGet: async (ctx) => {
|
||||
calls.push({ method: 'afterGet', context: ctx, timestamp: Date.now() });
|
||||
if (options?.throwOnAfterGet) {
|
||||
throw new Error('Test error in afterGet');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a blocking storage hooks implementation for quota testing.
|
||||
*/
|
||||
export function createQuotaHooks(maxSizeBytes: number): {
|
||||
hooks: IStorageHooks;
|
||||
currentUsage: { bytes: number };
|
||||
} {
|
||||
const currentUsage = { bytes: 0 };
|
||||
|
||||
return {
|
||||
currentUsage,
|
||||
hooks: {
|
||||
beforePut: async (ctx) => {
|
||||
const size = ctx.metadata?.size || 0;
|
||||
if (currentUsage.bytes + size > maxSizeBytes) {
|
||||
return { allowed: false, reason: `Quota exceeded: ${currentUsage.bytes + size} > ${maxSizeBytes}` };
|
||||
}
|
||||
return { allowed: true };
|
||||
},
|
||||
afterPut: async (ctx) => {
|
||||
currentUsage.bytes += ctx.metadata?.size || 0;
|
||||
},
|
||||
afterDelete: async (ctx) => {
|
||||
currentUsage.bytes -= ctx.metadata?.size || 0;
|
||||
if (currentUsage.bytes < 0) currentUsage.bytes = 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SmartBucket storage backend for upstream cache testing.
|
||||
*/
|
||||
export async function createTestStorageBackend(): Promise<{
|
||||
storage: {
|
||||
getObject: (key: string) => Promise<Buffer | null>;
|
||||
putObject: (key: string, data: Buffer) => Promise<void>;
|
||||
deleteObject: (key: string) => Promise<void>;
|
||||
listObjects: (prefix: string) => Promise<string[]>;
|
||||
};
|
||||
bucket: smartbucket.Bucket;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
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');
|
||||
|
||||
const s3 = new smartbucket.SmartBucket({
|
||||
accessKey: s3AccessKey || 'minioadmin',
|
||||
accessSecret: s3SecretKey || 'minioadmin',
|
||||
endpoint: s3Endpoint || 'localhost',
|
||||
port: parseInt(s3Port || '9000', 10),
|
||||
useSsl: false,
|
||||
});
|
||||
|
||||
const testRunId = generateTestRunId();
|
||||
const bucketName = 'test-cache-' + testRunId.substring(0, 8);
|
||||
const bucket = await s3.createBucket(bucketName);
|
||||
|
||||
const storage = {
|
||||
getObject: async (key: string): Promise<Buffer | null> => {
|
||||
try {
|
||||
const file = await bucket.fastGet({ path: key });
|
||||
if (!file) return null;
|
||||
const stream = await file.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
putObject: async (key: string, data: Buffer): Promise<void> => {
|
||||
await bucket.fastPut({ path: key, contents: data, overwrite: true });
|
||||
},
|
||||
deleteObject: async (key: string): Promise<void> => {
|
||||
await bucket.fastRemove({ path: key });
|
||||
},
|
||||
listObjects: async (prefix: string): Promise<string[]> => {
|
||||
const files = await bucket.fastList({ prefix });
|
||||
return files.map(f => f.name);
|
||||
},
|
||||
};
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
const files = await bucket.fastList({});
|
||||
for (const file of files) {
|
||||
await bucket.fastRemove({ path: file.name });
|
||||
}
|
||||
await s3.removeBucket(bucketName);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
return { storage, bucket, cleanup };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user