bro-framework 2.4.5 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/testing.js ADDED
@@ -0,0 +1,142 @@
1
+ import path from 'path';
2
+ import { createServer } from './server.js';
3
+ import { signJwt } from './auth.js';
4
+
5
+ /**
6
+ * Creates a native Fetch-based testing harness for bro.js
7
+ */
8
+ export async function createTestHarness(globalConfig = {}, options = {}) {
9
+ let instance;
10
+ const port = options.port || 0;
11
+ let runningServer;
12
+ let testUrl = '';
13
+
14
+ const harness = {
15
+ fixtures: options.fixtures || {},
16
+ stores: options.stores || {},
17
+
18
+ async start() {
19
+ const routesDir = options.routesDir || path.join(process.cwd(), 'routes');
20
+
21
+ // Inject testing fixtures into the framework config
22
+ const testConfig = {
23
+ ...globalConfig,
24
+ fixtures: this.fixtures,
25
+ stores: this.stores,
26
+ // Override for testing
27
+ jwtSecret: globalConfig.jwtSecret || 'test_secret_for_harness'
28
+ };
29
+
30
+ instance = await createServer(testConfig, routesDir, options.db || null);
31
+
32
+ runningServer = instance.server;
33
+ await new Promise(resolve => runningServer.listen(port, resolve));
34
+ testUrl = `http://localhost:${runningServer.address().port}`;
35
+ },
36
+
37
+ async stop() {
38
+ if (runningServer && runningServer.closeAllConnections) {
39
+ runningServer.closeAllConnections();
40
+ }
41
+ if (instance?.shutdown) {
42
+ await instance.shutdown();
43
+ } else if (runningServer) {
44
+ await new Promise(resolve => runningServer.close(resolve));
45
+ }
46
+ runningServer = null;
47
+ if (typeof jest !== 'undefined' && jest.clearAllTimers) {
48
+ jest.clearAllTimers();
49
+ }
50
+ },
51
+
52
+ auth(payload) {
53
+ const secret = globalConfig.jwtSecret || 'test_secret_for_harness';
54
+ return signJwt(payload, secret, { expiresIn: '1h' });
55
+ },
56
+
57
+ // Clock hooks
58
+ useFakeTimers(now) {
59
+ if (typeof jest !== 'undefined') {
60
+ jest.useFakeTimers();
61
+ if (now) jest.setSystemTime(now);
62
+ } else {
63
+ console.warn('Fake timers only supported when running under Jest/Vitest environments.');
64
+ }
65
+ },
66
+ useRealTimers() {
67
+ if (typeof jest !== 'undefined') {
68
+ jest.useRealTimers();
69
+ }
70
+ },
71
+
72
+ // DB Transactions
73
+ async runInTransaction(testFn) {
74
+ if (!options.db || typeof options.db.transaction !== 'function') {
75
+ throw new Error('Database adapter does not support transactions or no DB provided.');
76
+ }
77
+ return options.db.transaction(async (trx) => {
78
+ try {
79
+ await testFn(trx);
80
+ } finally {
81
+ // If the test framework supports rollback via error, we would throw here,
82
+ // but for isolated testing we assume the trx handles rollback gracefully if test fails.
83
+ if (trx.rollback) await trx.rollback();
84
+ }
85
+ });
86
+ },
87
+
88
+ // Direct route testing without network
89
+ async testRoute(routeModule, mockCtx = {}) {
90
+ const config = routeModule.default || routeModule;
91
+ const ctx = {
92
+ env: globalConfig.env || {},
93
+ db: options.db || null,
94
+ user: null,
95
+ body: {},
96
+ query: {},
97
+ params: {},
98
+ ...mockCtx
99
+ };
100
+ return config.handler(ctx);
101
+ },
102
+
103
+ client(defaultHeaders = {}) {
104
+ return {
105
+ async fetch(route, fetchOpts = {}) {
106
+ const headers = { ...defaultHeaders, ...(fetchOpts.headers || {}) };
107
+ return fetch(`${testUrl}${route}`, { ...fetchOpts, headers });
108
+ },
109
+ async get(route, fetchOpts) {
110
+ return this.fetch(route, { method: 'GET', ...fetchOpts });
111
+ },
112
+ async post(route, body, fetchOpts = {}) {
113
+ return this.fetch(route, {
114
+ method: 'POST',
115
+ body: JSON.stringify(body),
116
+ headers: { 'Content-Type': 'application/json', ...(fetchOpts.headers || {}) },
117
+ ...fetchOpts
118
+ });
119
+ },
120
+ async put(route, body, fetchOpts = {}) {
121
+ return this.fetch(route, {
122
+ method: 'PUT',
123
+ body: JSON.stringify(body),
124
+ headers: { 'Content-Type': 'application/json', ...(fetchOpts.headers || {}) },
125
+ ...fetchOpts
126
+ });
127
+ },
128
+ async delete(route, fetchOpts) {
129
+ return this.fetch(route, { method: 'DELETE', ...fetchOpts });
130
+ },
131
+
132
+ // Contract test helper
133
+ async assertContract(response, schema) {
134
+ const json = await response.json();
135
+ return schema.parse(json);
136
+ }
137
+ };
138
+ }
139
+ };
140
+
141
+ return harness;
142
+ }
package/src/uploads.js ADDED
@@ -0,0 +1,129 @@
1
+ import crypto from 'crypto';
2
+
3
+ /**
4
+ * Secure Upload Pipeline for bro.js
5
+ * Provides streaming abstractions and adapters for object storage.
6
+ */
7
+
8
+ export class SecureUploadPipeline {
9
+ constructor(config = {}) {
10
+ this.adapters = config.adapters || {};
11
+ this.defaultAdapter = config.defaultAdapter || 'local';
12
+ this.maxSize = config.maxSize || 50 * 1024 * 1024; // 50MB default
13
+ this.allowedMimeTypes = config.allowedMimeTypes || ['image/jpeg', 'image/png', 'application/pdf'];
14
+ }
15
+
16
+ async processUpload(file, options = {}) {
17
+ this._validateFile(file, options);
18
+
19
+ const adapterName = options.adapter || this.defaultAdapter;
20
+ const adapter = this.adapters[adapterName];
21
+
22
+ if (!adapter) {
23
+ throw new Error(`Upload adapter '${adapterName}' not found.`);
24
+ }
25
+
26
+ const key = crypto.randomUUID() + '-' + file.originalname;
27
+ const url = await adapter.upload(key, file.buffer || file.stream(), file.mimetype);
28
+
29
+ return { key, url, size: file.size, mimetype: file.mimetype };
30
+ }
31
+
32
+ _validateFile(file, options) {
33
+ const size = file.size;
34
+ const maxSize = options.maxSize || this.maxSize;
35
+ if (size > maxSize) {
36
+ throw new Error(`File size ${size} exceeds quota of ${maxSize} bytes.`);
37
+ }
38
+
39
+ const mime = file.mimetype || file.type;
40
+ const allowed = options.allowedMimeTypes || this.allowedMimeTypes;
41
+ if (allowed !== '*' && !allowed.includes(mime)) {
42
+ throw new Error(`MIME type ${mime} is not allowed.`);
43
+ }
44
+ }
45
+
46
+ async getSignedUrl(key, adapterName = this.defaultAdapter, expiresIn = 3600) {
47
+ const adapter = this.adapters[adapterName];
48
+ if (!adapter || typeof adapter.getSignedUrl !== 'function') {
49
+ throw new Error(`Adapter '${adapterName}' does not support signed URLs.`);
50
+ }
51
+ return await adapter.getSignedUrl(key, expiresIn);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Example Local Storage Adapter
57
+ */
58
+ import fs from 'fs';
59
+ import path from 'path';
60
+
61
+ export class LocalStorageAdapter {
62
+ constructor(uploadDir) {
63
+ this.uploadDir = uploadDir || path.join(process.cwd(), 'uploads');
64
+ if (!fs.existsSync(this.uploadDir)) {
65
+ fs.mkdirSync(this.uploadDir, { recursive: true });
66
+ }
67
+ }
68
+
69
+ async save(fileStream, originalName, mimeType) {
70
+ const fileName = `${Date.now()}-${originalName}`;
71
+ const filePath = path.join(this.uploadDir, fileName);
72
+ const writeStream = fs.createWriteStream(filePath);
73
+ await new Promise((resolve, reject) => {
74
+ fileStream.pipe(writeStream);
75
+ fileStream.on('end', resolve);
76
+ fileStream.on('error', reject);
77
+ });
78
+ return { url: `/uploads/${fileName}`, id: fileName, path: filePath };
79
+ }
80
+
81
+ async delete(id) {
82
+ const filePath = path.join(this.uploadDir, id);
83
+ if (fs.existsSync(filePath)) {
84
+ await fs.promises.unlink(filePath);
85
+ }
86
+ }
87
+ }
88
+
89
+ export class S3StorageAdapter {
90
+ constructor(config) {
91
+ this.config = config;
92
+ }
93
+
94
+ async save(fileStream, originalName, mimeType) {
95
+ let AWS;
96
+ try {
97
+ AWS = await import('@aws-sdk/client-s3');
98
+ } catch (e) {
99
+ throw new Error('Please install @aws-sdk/client-s3 to use S3StorageAdapter');
100
+ }
101
+ const { S3Client } = AWS;
102
+ const { Upload } = await import('@aws-sdk/lib-storage');
103
+
104
+ const client = new S3Client(this.config);
105
+ const key = `${Date.now()}-${originalName}`;
106
+
107
+ const upload = new Upload({
108
+ client,
109
+ params: {
110
+ Bucket: this.config.bucket,
111
+ Key: key,
112
+ Body: fileStream,
113
+ ContentType: mimeType
114
+ }
115
+ });
116
+
117
+ const result = await upload.done();
118
+ return { url: result.Location, id: key };
119
+ }
120
+
121
+ async delete(id) {
122
+ let AWS = await import('@aws-sdk/client-s3');
123
+ const client = new AWS.S3Client(this.config);
124
+ await client.send(new AWS.DeleteObjectCommand({
125
+ Bucket: this.config.bucket,
126
+ Key: id
127
+ }));
128
+ }
129
+ }