@open-kingdom/shared-backend-integration-test-doubles 0.0.2-17 → 0.0.2-19
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/README.md +75 -5
- package/dist/gcs/gcs-error-mode.d.ts +161 -0
- package/dist/gcs/gcs-error-mode.d.ts.map +1 -0
- package/dist/gcs/gcs-error-mode.js +256 -0
- package/dist/gcs/gcs-error-mode.js.map +1 -0
- package/dist/gcs/gcs-error-simulation.interceptor.d.ts +21 -0
- package/dist/gcs/gcs-error-simulation.interceptor.d.ts.map +1 -0
- package/dist/gcs/gcs-error-simulation.interceptor.js +40 -0
- package/dist/gcs/gcs-error-simulation.interceptor.js.map +1 -0
- package/dist/gcs/gcs-twin.d.ts +1 -0
- package/dist/gcs/gcs-twin.d.ts.map +1 -1
- package/dist/gcs/gcs-twin.js +19 -1
- package/dist/gcs/gcs-twin.js.map +1 -1
- package/dist/gcs/index.d.ts +3 -0
- package/dist/gcs/index.d.ts.map +1 -1
- package/dist/gcs/index.js +2 -0
- package/dist/gcs/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptor/http-helper.d.ts +37 -0
- package/dist/interceptor/http-helper.d.ts.map +1 -0
- package/dist/interceptor/http-helper.js +109 -0
- package/dist/interceptor/http-helper.js.map +1 -0
- package/dist/interceptor/index.d.ts +4 -0
- package/dist/interceptor/index.d.ts.map +1 -0
- package/dist/interceptor/index.js +5 -0
- package/dist/interceptor/index.js.map +1 -0
- package/dist/interceptor/node-interceptor.d.ts +40 -0
- package/dist/interceptor/node-interceptor.d.ts.map +1 -0
- package/dist/interceptor/node-interceptor.js +188 -0
- package/dist/interceptor/node-interceptor.js.map +1 -0
- package/dist/interceptor/routing-table.config.d.ts +3 -0
- package/dist/interceptor/routing-table.config.d.ts.map +1 -0
- package/dist/interceptor/routing-table.config.js +25 -0
- package/dist/interceptor/routing-table.config.js.map +1 -0
- package/dist/interceptor/routing-table.d.ts +15 -0
- package/dist/interceptor/routing-table.d.ts.map +1 -0
- package/dist/interceptor/routing-table.js +44 -0
- package/dist/interceptor/routing-table.js.map +1 -0
- package/dist/shared/constants.d.ts +4 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +3 -1
- package/dist/shared/constants.js.map +1 -1
- package/dist/youtube/index.d.ts +15 -0
- package/dist/youtube/index.d.ts.map +1 -0
- package/dist/youtube/index.js +11 -0
- package/dist/youtube/index.js.map +1 -0
- package/dist/youtube/msw-handlers.d.ts +40 -0
- package/dist/youtube/msw-handlers.d.ts.map +1 -0
- package/dist/youtube/msw-handlers.js +45 -0
- package/dist/youtube/msw-handlers.js.map +1 -0
- package/dist/youtube/player-shim.d.ts +3 -0
- package/dist/youtube/player-shim.d.ts.map +1 -0
- package/dist/youtube/player-shim.js +296 -0
- package/dist/youtube/player-shim.js.map +1 -0
- package/dist/youtube/search-fixtures.d.ts +29 -0
- package/dist/youtube/search-fixtures.d.ts.map +1 -0
- package/dist/youtube/search-fixtures.js +392 -0
- package/dist/youtube/search-fixtures.js.map +1 -0
- package/dist/youtube/search-response.d.ts +51 -0
- package/dist/youtube/search-response.d.ts.map +1 -0
- package/dist/youtube/search-response.js +53 -0
- package/dist/youtube/search-response.js.map +1 -0
- package/dist/youtube/youtube-error-mode.d.ts +36 -0
- package/dist/youtube/youtube-error-mode.d.ts.map +1 -0
- package/dist/youtube/youtube-error-mode.js +87 -0
- package/dist/youtube/youtube-error-mode.js.map +1 -0
- package/dist/youtube/youtube-twin.config.d.ts +13 -0
- package/dist/youtube/youtube-twin.config.d.ts.map +1 -0
- package/dist/youtube/youtube-twin.config.js +32 -0
- package/dist/youtube/youtube-twin.config.js.map +1 -0
- package/dist/youtube/youtube-twin.d.ts +14 -0
- package/dist/youtube/youtube-twin.d.ts.map +1 -0
- package/dist/youtube/youtube-twin.js +162 -0
- package/dist/youtube/youtube-twin.js.map +1 -0
- package/package.json +15 -2
- package/test-assets/youtube/sample.mp4 +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-01.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-02.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-03.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-04.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-05.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-06.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-07.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-08.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-09.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-10.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-11.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-12.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-13.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-14.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-15.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-16.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-17.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-18.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-19.jpg +0 -0
- package/test-assets/youtube/thumbnails/thumbnail-20.jpg +0 -0
package/README.md
CHANGED
|
@@ -37,6 +37,30 @@ const client = new Storage({
|
|
|
37
37
|
await twin.stop();
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
### Using YouTube Twin (Server-Side Interception)
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { YoutubeTwin, NodeInterceptor, RoutingTable, defaultRoutingEntries } from '@open-kingdom/shared-backend-integration-test-doubles';
|
|
44
|
+
|
|
45
|
+
// Start the twin (default port 9016)
|
|
46
|
+
const twin = new YoutubeTwin();
|
|
47
|
+
await twin.start();
|
|
48
|
+
|
|
49
|
+
// Install the global fetch interceptor
|
|
50
|
+
const routingTable = new RoutingTable(defaultRoutingEntries);
|
|
51
|
+
const interceptor = new NodeInterceptor(routingTable);
|
|
52
|
+
interceptor.install();
|
|
53
|
+
|
|
54
|
+
// Call the production YouTube API endpoint (internally routed to twin)
|
|
55
|
+
const res = await fetch('https://www.googleapis.com/youtube/v3/search?q=yoga&key=valid-key');
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
console.log(data.kind); // "youtube#searchListResponse"
|
|
58
|
+
|
|
59
|
+
// Clean up
|
|
60
|
+
interceptor.uninstall();
|
|
61
|
+
await twin.stop();
|
|
62
|
+
```
|
|
63
|
+
|
|
40
64
|
### NestJS Integration
|
|
41
65
|
|
|
42
66
|
Use the `TwinService` to manage the twin lifecycle within a NestJS application:
|
|
@@ -93,6 +117,52 @@ interface BucketConfig {
|
|
|
93
117
|
}
|
|
94
118
|
```
|
|
95
119
|
|
|
120
|
+
### YouTubeTwin & Network Interceptor
|
|
121
|
+
|
|
122
|
+
#### YouTubeTwin Constructor
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
constructor(overrides?: Partial<YoutubeTwinConfig>)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- `overrides.port`: Port to run the twin on (default: 9016, or from `YOUTUBE_TWIN_PORT` env var; must be within 9010-9020)
|
|
129
|
+
- `overrides.externalUrl`: External URL for the twin (default: `http://localhost:{port}`)
|
|
130
|
+
|
|
131
|
+
#### YouTubeTwin Methods
|
|
132
|
+
|
|
133
|
+
- `start()`: Start the Express server
|
|
134
|
+
- `stop()`: Stop the Express server
|
|
135
|
+
- `reset()`: Reset search fixtures to defaults and clear any active error mode
|
|
136
|
+
- `getEmulatorHost()`: Get the twin URL (e.g., `http://localhost:9016`)
|
|
137
|
+
- `isHealthy()`: Check if the twin is running and responding
|
|
138
|
+
|
|
139
|
+
#### NodeInterceptor & RoutingTable
|
|
140
|
+
|
|
141
|
+
- `new NodeInterceptor(routingTable)`: Instantiate interceptor
|
|
142
|
+
- `install()`: Patches `globalThis.fetch` to intercept matching domains
|
|
143
|
+
- `uninstall()`: Restores `globalThis.fetch` to its original reference
|
|
144
|
+
- `isActive()`: Check if interceptor is currently active
|
|
145
|
+
|
|
146
|
+
#### Browser-Side MSW Interception
|
|
147
|
+
|
|
148
|
+
Use `getYoutubeMswHandlerConfigs(twinBaseUrl)` to export mock route definitions for browser-side testing frameworks like MSW:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { http, HttpResponse } from 'msw';
|
|
152
|
+
import { getYoutubeMswHandlerConfigs } from '@open-kingdom/shared-backend-integration-test-doubles';
|
|
153
|
+
|
|
154
|
+
const configs = getYoutubeMswHandlerConfigs('http://localhost:9016');
|
|
155
|
+
const handlers = configs.map((config) =>
|
|
156
|
+
http.get(config.url, async () => {
|
|
157
|
+
const res = await fetch(config.proxyTo);
|
|
158
|
+
const body = await res.text();
|
|
159
|
+
return new HttpResponse(body, {
|
|
160
|
+
headers: { 'Content-Type': config.contentType },
|
|
161
|
+
});
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
```
|
|
165
|
+
|
|
96
166
|
### Exports
|
|
97
167
|
|
|
98
168
|
```typescript
|
|
@@ -127,7 +197,7 @@ The library reserves ports 9010-9020 for test doubles:
|
|
|
127
197
|
| GCS | 9013 |
|
|
128
198
|
| Gmail | 9014 (future) |
|
|
129
199
|
| Google Auth | 9015 (future) |
|
|
130
|
-
| YouTube | 9016
|
|
200
|
+
| YouTube | 9016 |
|
|
131
201
|
| Google Calendar | 9017 (future) |
|
|
132
202
|
| Spotify | 9018 (future) |
|
|
133
203
|
|
|
@@ -145,14 +215,14 @@ Port validation ensures the value is within the reserved range (9010-9020).
|
|
|
145
215
|
|
|
146
216
|
## Scripts
|
|
147
217
|
|
|
148
|
-
### Start
|
|
218
|
+
### Start Twins Manually
|
|
149
219
|
|
|
150
220
|
```bash
|
|
151
|
-
# Start on default port (9013)
|
|
221
|
+
# Start GCS twin manually on default port (9013)
|
|
152
222
|
npx tsx scripts/start-twin.ts
|
|
153
223
|
|
|
154
|
-
# Start on
|
|
155
|
-
npx
|
|
224
|
+
# Start YouTube twin manually on default port (9016)
|
|
225
|
+
npx nx run integration-test-doubles:start:youtube-twin
|
|
156
226
|
```
|
|
157
227
|
|
|
158
228
|
The script outputs the twin URL and sets `GCS_EMULATOR_URL`. Keeps running until Ctrl+C.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GCS error mode simulation — pure state management and request matching.
|
|
3
|
+
*
|
|
4
|
+
* The GcsErrorModeManager knows nothing about HTTP, NestJS, or networking.
|
|
5
|
+
* It holds the currently active error mode, matches incoming requests against
|
|
6
|
+
* the mode's criteria, and returns the appropriate GCS-shaped error response.
|
|
7
|
+
*
|
|
8
|
+
* Design decisions:
|
|
9
|
+
* - Single-mode constraint: only one error mode can be active at a time.
|
|
10
|
+
* setMode() replaces any existing mode.
|
|
11
|
+
* - Intermittent-failure uses a deterministic counter (every Nth request fails),
|
|
12
|
+
* not random percentage, for reproducible test results.
|
|
13
|
+
* - reset() and clearMode() clear the mode and counter, ensuring no leakage
|
|
14
|
+
* between test runs.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The four supported GCS error simulation modes.
|
|
18
|
+
*/
|
|
19
|
+
export type GcsErrorModeType = 'bucket-not-found' | 'quota-exceeded' | 'permission-denied' | 'intermittent-failure';
|
|
20
|
+
/**
|
|
21
|
+
* Configuration for bucket-not-found mode.
|
|
22
|
+
* Matches requests targeting a specific bucket name.
|
|
23
|
+
*/
|
|
24
|
+
export interface BucketNotFoundConfig {
|
|
25
|
+
type: 'bucket-not-found';
|
|
26
|
+
/** The bucket name that will trigger the 404 response. */
|
|
27
|
+
bucketName: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Configuration for quota-exceeded mode.
|
|
31
|
+
* Matches upload/create operations (POST to upload paths).
|
|
32
|
+
*/
|
|
33
|
+
export interface QuotaExceededConfig {
|
|
34
|
+
type: 'quota-exceeded';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Configuration for permission-denied mode.
|
|
38
|
+
* Matches all GCS data operations.
|
|
39
|
+
*/
|
|
40
|
+
export interface PermissionDeniedConfig {
|
|
41
|
+
type: 'permission-denied';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Configuration for intermittent-failure mode.
|
|
45
|
+
* Every Nth request fails with 500, deterministic counter.
|
|
46
|
+
*/
|
|
47
|
+
export interface IntermittentFailureConfig {
|
|
48
|
+
type: 'intermittent-failure';
|
|
49
|
+
/** Fail every Nth request. Defaults to 2 (every other request). */
|
|
50
|
+
every?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Discriminated union of all error mode configurations.
|
|
54
|
+
* The `type` field discriminates which kind of mode is active.
|
|
55
|
+
*/
|
|
56
|
+
export type GcsErrorModeConfig = BucketNotFoundConfig | QuotaExceededConfig | PermissionDeniedConfig | IntermittentFailureConfig;
|
|
57
|
+
/**
|
|
58
|
+
* A GCS-shaped error response that the NestJS interceptor will use
|
|
59
|
+
* to short-circuit matching requests.
|
|
60
|
+
*/
|
|
61
|
+
export interface GcsErrorSimulationResult {
|
|
62
|
+
/** HTTP status code to respond with. */
|
|
63
|
+
status: number;
|
|
64
|
+
/** GCS-shaped JSON error body. */
|
|
65
|
+
body: {
|
|
66
|
+
error: {
|
|
67
|
+
code: number;
|
|
68
|
+
message: string;
|
|
69
|
+
errors: Array<{
|
|
70
|
+
domain: string;
|
|
71
|
+
reason: string;
|
|
72
|
+
message?: string;
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Extract the bucket name from a GCS API path.
|
|
79
|
+
*
|
|
80
|
+
* Supports both path-style and virtual-hosted-style paths:
|
|
81
|
+
* - `/storage/v1/b/my-bucket/o` → "my-bucket"
|
|
82
|
+
* - `/storage/v1/b/my-bucket` → "my-bucket"
|
|
83
|
+
* - `/upload/storage/v1/b/my-bucket/o` → "my-bucket"
|
|
84
|
+
* - `/download/storage/v1/b/my-bucket/o` → "my-bucket"
|
|
85
|
+
* - `/b/my-bucket/o` → "my-bucket"
|
|
86
|
+
*
|
|
87
|
+
* Returns undefined if the path doesn't match any known GCS pattern.
|
|
88
|
+
*/
|
|
89
|
+
export declare function extractBucketName(path: string): string | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Determine if a request is an upload/create operation.
|
|
92
|
+
*
|
|
93
|
+
* Upload operations are POST requests to paths containing "/upload/" or
|
|
94
|
+
* POST requests to bucket or object creation endpoints.
|
|
95
|
+
*/
|
|
96
|
+
export declare function isUploadOperation(method: string, path: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Determine if a request is a GCS data operation.
|
|
99
|
+
*
|
|
100
|
+
* GCS data operations include CRUD on buckets and objects.
|
|
101
|
+
* They exclude twin control endpoints (/api/twin/...) and health checks.
|
|
102
|
+
*/
|
|
103
|
+
export declare function isGcsDataOperation(method: string, path: string): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Pure state manager for GCS error simulation modes.
|
|
106
|
+
*
|
|
107
|
+
* Tracks the active error mode and matches incoming requests against it.
|
|
108
|
+
* Returns null for pass-through when no mode is active.
|
|
109
|
+
*
|
|
110
|
+
* Thread-safety: this class is NOT thread-safe. In NestJS, requests are
|
|
111
|
+
* handled concurrently, so the counter for intermittent-failure mode must
|
|
112
|
+
* be managed with atomic operations or a mutex in the interceptor layer.
|
|
113
|
+
* For now, we use a simple counter since NestJS interceptors run on the
|
|
114
|
+
* event loop and the matching logic is synchronous.
|
|
115
|
+
*/
|
|
116
|
+
export declare class GcsErrorModeManager {
|
|
117
|
+
private activeMode;
|
|
118
|
+
private requestCounter;
|
|
119
|
+
/**
|
|
120
|
+
* Activate an error mode. Replaces any existing mode (single-mode constraint).
|
|
121
|
+
* Resets the request counter for intermittent-failure mode.
|
|
122
|
+
*/
|
|
123
|
+
setMode(config: GcsErrorModeConfig): void;
|
|
124
|
+
/**
|
|
125
|
+
* Deactivate the current error mode. Returns to pass-through.
|
|
126
|
+
* Resets the request counter.
|
|
127
|
+
*/
|
|
128
|
+
clearMode(): void;
|
|
129
|
+
/**
|
|
130
|
+
* Get the currently active error mode configuration.
|
|
131
|
+
* Returns null when no mode is active (pass-through).
|
|
132
|
+
*/
|
|
133
|
+
getMode(): GcsErrorModeConfig | null;
|
|
134
|
+
/**
|
|
135
|
+
* Match an incoming request against the active error mode.
|
|
136
|
+
*
|
|
137
|
+
* @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
138
|
+
* @param path - Request path (e.g., /storage/v1/b/my-bucket/o)
|
|
139
|
+
* @returns GcsErrorSimulationResult if the request matches, null for pass-through
|
|
140
|
+
*/
|
|
141
|
+
matchRequest(method: string, path: string): GcsErrorSimulationResult | null;
|
|
142
|
+
/**
|
|
143
|
+
* Simulate a sample error for the currently active mode.
|
|
144
|
+
* This produces the same GcsErrorSimulationResult that the interceptor would
|
|
145
|
+
* return for a matching request, without mutating the request counter.
|
|
146
|
+
*
|
|
147
|
+
* Used by the GET /api/twin/gcs/error-mode endpoint so the frontend can
|
|
148
|
+
* display the actual error response the backend will produce.
|
|
149
|
+
*/
|
|
150
|
+
simulateSampleError(): GcsErrorSimulationResult | null;
|
|
151
|
+
/**
|
|
152
|
+
* Reset all state: clear the active mode and reset the request counter.
|
|
153
|
+
* Called by GcsTwin.reset() and GcsTwin.stop() to prevent leakage between tests.
|
|
154
|
+
*/
|
|
155
|
+
reset(): void;
|
|
156
|
+
private matchBucketNotFound;
|
|
157
|
+
private matchQuotaExceeded;
|
|
158
|
+
private matchPermissionDenied;
|
|
159
|
+
private matchIntermittentFailure;
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=gcs-error-mode.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gcs-error-mode.d.ts","sourceRoot":"","sources":["../../src/gcs/gcs-error-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAMH;;GAEG;AACH,MAAM,MAAM,gBAAgB,GACxB,kBAAkB,GAClB,gBAAgB,GAChB,mBAAmB,GACnB,sBAAsB,CAAC;AAE3B;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,kBAAkB,CAAC;IACzB,0DAA0D;IAC1D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,mBAAmB,CAAC;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,sBAAsB,CAAC;IAC7B,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAC1B,oBAAoB,GACpB,mBAAmB,GACnB,sBAAsB,GACtB,yBAAyB,CAAC;AAE9B;;;GAGG;AACH,MAAM,WAAW,wBAAwB;IACvC,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,kCAAkC;IAClC,IAAI,EAAE;QACJ,KAAK,EAAE;YACL,IAAI,EAAE,MAAM,CAAC;YACb,OAAO,EAAE,MAAM,CAAC;YAChB,MAAM,EAAE,KAAK,CAAC;gBACZ,MAAM,EAAE,MAAM,CAAC;gBACf,MAAM,EAAE,MAAM,CAAC;gBACf,OAAO,CAAC,EAAE,MAAM,CAAC;aAClB,CAAC,CAAC;SACJ,CAAC;KACH,CAAC;CACH;AAmCD;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAsClE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAuBvE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CA8BxE;AAMD;;;;;;;;;;;GAWG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,cAAc,CAAK;IAE3B;;;OAGG;IACH,OAAO,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAKzC;;;OAGG;IACH,SAAS,IAAI,IAAI;IAKjB;;;OAGG;IACH,OAAO,IAAI,kBAAkB,GAAG,IAAI;IAIpC;;;;;;OAMG;IACH,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,wBAAwB,GAAG,IAAI;IAe3E;;;;;;;OAOG;IACH,mBAAmB,IAAI,wBAAwB,GAAG,IAAI;IAgBtD;;;OAGG;IACH,KAAK,IAAI,IAAI;IASb,OAAO,CAAC,mBAAmB;IAY3B,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,qBAAqB;IAW7B,OAAO,CAAC,wBAAwB;CAkBjC"}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GCS error mode simulation — pure state management and request matching.
|
|
3
|
+
*
|
|
4
|
+
* The GcsErrorModeManager knows nothing about HTTP, NestJS, or networking.
|
|
5
|
+
* It holds the currently active error mode, matches incoming requests against
|
|
6
|
+
* the mode's criteria, and returns the appropriate GCS-shaped error response.
|
|
7
|
+
*
|
|
8
|
+
* Design decisions:
|
|
9
|
+
* - Single-mode constraint: only one error mode can be active at a time.
|
|
10
|
+
* setMode() replaces any existing mode.
|
|
11
|
+
* - Intermittent-failure uses a deterministic counter (every Nth request fails),
|
|
12
|
+
* not random percentage, for reproducible test results.
|
|
13
|
+
* - reset() and clearMode() clear the mode and counter, ensuring no leakage
|
|
14
|
+
* between test runs.
|
|
15
|
+
*/ // ---------------------------------------------------------------------------
|
|
16
|
+
// Type definitions
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* The four supported GCS error simulation modes.
|
|
20
|
+
*/ // ---------------------------------------------------------------------------
|
|
21
|
+
// GCS error response factory
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Creates a standard GCS error response shape. */ function gcsError(status, message, reason, detailMessage) {
|
|
24
|
+
return {
|
|
25
|
+
status,
|
|
26
|
+
body: {
|
|
27
|
+
error: {
|
|
28
|
+
code: status,
|
|
29
|
+
message,
|
|
30
|
+
errors: [
|
|
31
|
+
{
|
|
32
|
+
domain: 'global',
|
|
33
|
+
reason,
|
|
34
|
+
message: detailMessage != null ? detailMessage : message
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Request-matching helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* Extract the bucket name from a GCS API path.
|
|
46
|
+
*
|
|
47
|
+
* Supports both path-style and virtual-hosted-style paths:
|
|
48
|
+
* - `/storage/v1/b/my-bucket/o` → "my-bucket"
|
|
49
|
+
* - `/storage/v1/b/my-bucket` → "my-bucket"
|
|
50
|
+
* - `/upload/storage/v1/b/my-bucket/o` → "my-bucket"
|
|
51
|
+
* - `/download/storage/v1/b/my-bucket/o` → "my-bucket"
|
|
52
|
+
* - `/b/my-bucket/o` → "my-bucket"
|
|
53
|
+
*
|
|
54
|
+
* Returns undefined if the path doesn't match any known GCS pattern.
|
|
55
|
+
*/ export function extractBucketName(path) {
|
|
56
|
+
// Strip leading slash for consistent processing
|
|
57
|
+
const normalized = path.startsWith('/') ? path.slice(1) : path;
|
|
58
|
+
// Match /api/gcs/files?bucket=<name> (query-string style)
|
|
59
|
+
const apiGcsBucketMatch = normalized.match(/api\/gcs\/files[?&]bucket=([^&]+)/);
|
|
60
|
+
if (apiGcsBucketMatch) {
|
|
61
|
+
return decodeURIComponent(apiGcsBucketMatch[1]);
|
|
62
|
+
}
|
|
63
|
+
// Match /api/gcs/files/<bucket>/<name>/... or /api/gcs/files/<bucket>/<name>
|
|
64
|
+
const apiGcsPathMatch = normalized.match(/api\/gcs\/files\/([^/]+)/);
|
|
65
|
+
if (apiGcsPathMatch) {
|
|
66
|
+
return decodeURIComponent(apiGcsPathMatch[1]);
|
|
67
|
+
}
|
|
68
|
+
// Match /api/gcs/upload (uses query param or default bucket — derive from body is not possible here)
|
|
69
|
+
// Uploads via /api/gcs/upload use the bucket in the request body, not URL. Return undefined for now.
|
|
70
|
+
// Match patterns with optional prefixes (upload/, download/) followed by
|
|
71
|
+
// the GCS API path structure.
|
|
72
|
+
const patterns = [
|
|
73
|
+
// Full v1 API paths: storage/v1/b/{bucket}/... or upload/storage/v1/b/{bucket}/...
|
|
74
|
+
/(?:upload\/|download\/)?storage\/v1\/b\/([^/]+)/,
|
|
75
|
+
// Short form: b/{bucket}/...
|
|
76
|
+
/b\/([^/]+)/
|
|
77
|
+
];
|
|
78
|
+
for (const pattern of patterns){
|
|
79
|
+
const match = normalized.match(pattern);
|
|
80
|
+
if (match) {
|
|
81
|
+
return match[1];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Determine if a request is an upload/create operation.
|
|
88
|
+
*
|
|
89
|
+
* Upload operations are POST requests to paths containing "/upload/" or
|
|
90
|
+
* POST requests to bucket or object creation endpoints.
|
|
91
|
+
*/ export function isUploadOperation(method, path) {
|
|
92
|
+
if (method.toUpperCase() !== 'POST') return false;
|
|
93
|
+
const normalized = path.toLowerCase();
|
|
94
|
+
// /api/gcs/upload — the NestJS GCS upload endpoint
|
|
95
|
+
if (normalized.includes('/api/gcs/upload')) return true;
|
|
96
|
+
// /api/gcs/files/<bucket>/<name>/delete — treated as a write operation for permission-denied
|
|
97
|
+
// (not an upload, but this function is specifically for quota-exceeded which targets uploads)
|
|
98
|
+
// Direct upload path prefix (GCS native)
|
|
99
|
+
if (normalized.includes('/upload/storage/')) return true;
|
|
100
|
+
// Object insert via bucket's objects collection
|
|
101
|
+
// POST /storage/v1/b/{bucket}/o
|
|
102
|
+
if (normalized.includes('/b/') && normalized.includes('/o')) return true;
|
|
103
|
+
// Bucket creation
|
|
104
|
+
// POST /storage/v1/b
|
|
105
|
+
if (normalized.match(/\/storage\/v1\/b\/?$/)) return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Determine if a request is a GCS data operation.
|
|
110
|
+
*
|
|
111
|
+
* GCS data operations include CRUD on buckets and objects.
|
|
112
|
+
* They exclude twin control endpoints (/api/twin/...) and health checks.
|
|
113
|
+
*/ export function isGcsDataOperation(method, path) {
|
|
114
|
+
const normalized = path.toLowerCase();
|
|
115
|
+
// Exclude twin control endpoints
|
|
116
|
+
if (normalized.startsWith('/api/twin')) return false;
|
|
117
|
+
// Exclude error-mode control endpoints (control plane, not data plane)
|
|
118
|
+
if (normalized.startsWith('/api/gcs/error-mode')) return false;
|
|
119
|
+
// Exclude health checks and non-GCS paths
|
|
120
|
+
if (normalized === '/' || normalized === '/health' || normalized === '/healthz') {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
// NestJS GCS controller routes: /api/gcs/*
|
|
124
|
+
if (normalized.startsWith('/api/gcs')) return true;
|
|
125
|
+
// Include known GCS API paths
|
|
126
|
+
if (normalized.includes('/storage/v1/')) return true;
|
|
127
|
+
if (normalized.includes('/upload/storage/')) return true;
|
|
128
|
+
if (normalized.includes('/download/storage/')) return true;
|
|
129
|
+
// Include paths that match GCS bucket/object patterns
|
|
130
|
+
if (normalized.match(/\/b\/[^/]+/)) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// GcsErrorModeManager
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
/**
|
|
137
|
+
* Pure state manager for GCS error simulation modes.
|
|
138
|
+
*
|
|
139
|
+
* Tracks the active error mode and matches incoming requests against it.
|
|
140
|
+
* Returns null for pass-through when no mode is active.
|
|
141
|
+
*
|
|
142
|
+
* Thread-safety: this class is NOT thread-safe. In NestJS, requests are
|
|
143
|
+
* handled concurrently, so the counter for intermittent-failure mode must
|
|
144
|
+
* be managed with atomic operations or a mutex in the interceptor layer.
|
|
145
|
+
* For now, we use a simple counter since NestJS interceptors run on the
|
|
146
|
+
* event loop and the matching logic is synchronous.
|
|
147
|
+
*/ export class GcsErrorModeManager {
|
|
148
|
+
/**
|
|
149
|
+
* Activate an error mode. Replaces any existing mode (single-mode constraint).
|
|
150
|
+
* Resets the request counter for intermittent-failure mode.
|
|
151
|
+
*/ setMode(config) {
|
|
152
|
+
this.activeMode = config;
|
|
153
|
+
this.requestCounter = 0;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Deactivate the current error mode. Returns to pass-through.
|
|
157
|
+
* Resets the request counter.
|
|
158
|
+
*/ clearMode() {
|
|
159
|
+
this.activeMode = null;
|
|
160
|
+
this.requestCounter = 0;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get the currently active error mode configuration.
|
|
164
|
+
* Returns null when no mode is active (pass-through).
|
|
165
|
+
*/ getMode() {
|
|
166
|
+
return this.activeMode;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Match an incoming request against the active error mode.
|
|
170
|
+
*
|
|
171
|
+
* @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
172
|
+
* @param path - Request path (e.g., /storage/v1/b/my-bucket/o)
|
|
173
|
+
* @returns GcsErrorSimulationResult if the request matches, null for pass-through
|
|
174
|
+
*/ matchRequest(method, path) {
|
|
175
|
+
if (!this.activeMode) return null;
|
|
176
|
+
switch(this.activeMode.type){
|
|
177
|
+
case 'bucket-not-found':
|
|
178
|
+
return this.matchBucketNotFound(method, path, this.activeMode);
|
|
179
|
+
case 'quota-exceeded':
|
|
180
|
+
return this.matchQuotaExceeded(method, path, this.activeMode);
|
|
181
|
+
case 'permission-denied':
|
|
182
|
+
return this.matchPermissionDenied(method, path, this.activeMode);
|
|
183
|
+
case 'intermittent-failure':
|
|
184
|
+
return this.matchIntermittentFailure(method, path, this.activeMode);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Simulate a sample error for the currently active mode.
|
|
189
|
+
* This produces the same GcsErrorSimulationResult that the interceptor would
|
|
190
|
+
* return for a matching request, without mutating the request counter.
|
|
191
|
+
*
|
|
192
|
+
* Used by the GET /api/twin/gcs/error-mode endpoint so the frontend can
|
|
193
|
+
* display the actual error response the backend will produce.
|
|
194
|
+
*/ simulateSampleError() {
|
|
195
|
+
if (!this.activeMode) return null;
|
|
196
|
+
switch(this.activeMode.type){
|
|
197
|
+
case 'bucket-not-found':
|
|
198
|
+
// Representative: a GET to the configured bucket
|
|
199
|
+
return gcsError(404, 'Not Found', 'notFound');
|
|
200
|
+
case 'quota-exceeded':
|
|
201
|
+
return gcsError(403, 'Quota exceeded', 'quotaExceeded');
|
|
202
|
+
case 'permission-denied':
|
|
203
|
+
return gcsError(403, 'Forbidden', 'forbidden');
|
|
204
|
+
case 'intermittent-failure':
|
|
205
|
+
return gcsError(500, 'Internal Server Error', 'internalError');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Reset all state: clear the active mode and reset the request counter.
|
|
210
|
+
* Called by GcsTwin.reset() and GcsTwin.stop() to prevent leakage between tests.
|
|
211
|
+
*/ reset() {
|
|
212
|
+
this.activeMode = null;
|
|
213
|
+
this.requestCounter = 0;
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Private matching methods
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
matchBucketNotFound(_method, path, config) {
|
|
219
|
+
const bucket = extractBucketName(path);
|
|
220
|
+
if (bucket === config.bucketName) {
|
|
221
|
+
return gcsError(404, 'Not Found', 'notFound');
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
matchQuotaExceeded(method, path, _config) {
|
|
226
|
+
if (isUploadOperation(method, path)) {
|
|
227
|
+
return gcsError(403, 'Quota exceeded', 'quotaExceeded');
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
matchPermissionDenied(method, path, _config) {
|
|
232
|
+
if (isGcsDataOperation(method, path)) {
|
|
233
|
+
return gcsError(403, 'Forbidden', 'forbidden');
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
matchIntermittentFailure(method, path, config) {
|
|
238
|
+
// Only apply to GCS data operations
|
|
239
|
+
if (!isGcsDataOperation(method, path)) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
this.requestCounter++;
|
|
243
|
+
var _config_every;
|
|
244
|
+
const every = (_config_every = config.every) != null ? _config_every : 2;
|
|
245
|
+
if (this.requestCounter % every === 0) {
|
|
246
|
+
return gcsError(500, 'Internal Server Error', 'internalError');
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
constructor(){
|
|
251
|
+
this.activeMode = null;
|
|
252
|
+
this.requestCounter = 0;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
//# sourceMappingURL=gcs-error-mode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/gcs/gcs-error-mode.ts"],"sourcesContent":["/**\n * GCS error mode simulation — pure state management and request matching.\n *\n * The GcsErrorModeManager knows nothing about HTTP, NestJS, or networking.\n * It holds the currently active error mode, matches incoming requests against\n * the mode's criteria, and returns the appropriate GCS-shaped error response.\n *\n * Design decisions:\n * - Single-mode constraint: only one error mode can be active at a time.\n * setMode() replaces any existing mode.\n * - Intermittent-failure uses a deterministic counter (every Nth request fails),\n * not random percentage, for reproducible test results.\n * - reset() and clearMode() clear the mode and counter, ensuring no leakage\n * between test runs.\n */\n\n// ---------------------------------------------------------------------------\n// Type definitions\n// ---------------------------------------------------------------------------\n\n/**\n * The four supported GCS error simulation modes.\n */\nexport type GcsErrorModeType =\n | 'bucket-not-found'\n | 'quota-exceeded'\n | 'permission-denied'\n | 'intermittent-failure';\n\n/**\n * Configuration for bucket-not-found mode.\n * Matches requests targeting a specific bucket name.\n */\nexport interface BucketNotFoundConfig {\n type: 'bucket-not-found';\n /** The bucket name that will trigger the 404 response. */\n bucketName: string;\n}\n\n/**\n * Configuration for quota-exceeded mode.\n * Matches upload/create operations (POST to upload paths).\n */\nexport interface QuotaExceededConfig {\n type: 'quota-exceeded';\n}\n\n/**\n * Configuration for permission-denied mode.\n * Matches all GCS data operations.\n */\nexport interface PermissionDeniedConfig {\n type: 'permission-denied';\n}\n\n/**\n * Configuration for intermittent-failure mode.\n * Every Nth request fails with 500, deterministic counter.\n */\nexport interface IntermittentFailureConfig {\n type: 'intermittent-failure';\n /** Fail every Nth request. Defaults to 2 (every other request). */\n every?: number;\n}\n\n/**\n * Discriminated union of all error mode configurations.\n * The `type` field discriminates which kind of mode is active.\n */\nexport type GcsErrorModeConfig =\n | BucketNotFoundConfig\n | QuotaExceededConfig\n | PermissionDeniedConfig\n | IntermittentFailureConfig;\n\n/**\n * A GCS-shaped error response that the NestJS interceptor will use\n * to short-circuit matching requests.\n */\nexport interface GcsErrorSimulationResult {\n /** HTTP status code to respond with. */\n status: number;\n /** GCS-shaped JSON error body. */\n body: {\n error: {\n code: number;\n message: string;\n errors: Array<{\n domain: string;\n reason: string;\n message?: string;\n }>;\n };\n };\n}\n\n// ---------------------------------------------------------------------------\n// GCS error response factory\n// ---------------------------------------------------------------------------\n\n/** Creates a standard GCS error response shape. */\nfunction gcsError(\n status: number,\n message: string,\n reason: string,\n detailMessage?: string\n): GcsErrorSimulationResult {\n return {\n status,\n body: {\n error: {\n code: status,\n message,\n errors: [\n {\n domain: 'global',\n reason,\n message: detailMessage ?? message,\n },\n ],\n },\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Request-matching helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Extract the bucket name from a GCS API path.\n *\n * Supports both path-style and virtual-hosted-style paths:\n * - `/storage/v1/b/my-bucket/o` → \"my-bucket\"\n * - `/storage/v1/b/my-bucket` → \"my-bucket\"\n * - `/upload/storage/v1/b/my-bucket/o` → \"my-bucket\"\n * - `/download/storage/v1/b/my-bucket/o` → \"my-bucket\"\n * - `/b/my-bucket/o` → \"my-bucket\"\n *\n * Returns undefined if the path doesn't match any known GCS pattern.\n */\nexport function extractBucketName(path: string): string | undefined {\n // Strip leading slash for consistent processing\n const normalized = path.startsWith('/') ? path.slice(1) : path;\n\n // Match /api/gcs/files?bucket=<name> (query-string style)\n const apiGcsBucketMatch = normalized.match(\n /api\\/gcs\\/files[?&]bucket=([^&]+)/\n );\n if (apiGcsBucketMatch) {\n return decodeURIComponent(apiGcsBucketMatch[1]);\n }\n\n // Match /api/gcs/files/<bucket>/<name>/... or /api/gcs/files/<bucket>/<name>\n const apiGcsPathMatch = normalized.match(/api\\/gcs\\/files\\/([^/]+)/);\n if (apiGcsPathMatch) {\n return decodeURIComponent(apiGcsPathMatch[1]);\n }\n\n // Match /api/gcs/upload (uses query param or default bucket — derive from body is not possible here)\n // Uploads via /api/gcs/upload use the bucket in the request body, not URL. Return undefined for now.\n\n // Match patterns with optional prefixes (upload/, download/) followed by\n // the GCS API path structure.\n const patterns = [\n // Full v1 API paths: storage/v1/b/{bucket}/... or upload/storage/v1/b/{bucket}/...\n /(?:upload\\/|download\\/)?storage\\/v1\\/b\\/([^/]+)/,\n // Short form: b/{bucket}/...\n /b\\/([^/]+)/,\n ];\n\n for (const pattern of patterns) {\n const match = normalized.match(pattern);\n if (match) {\n return match[1];\n }\n }\n\n return undefined;\n}\n\n/**\n * Determine if a request is an upload/create operation.\n *\n * Upload operations are POST requests to paths containing \"/upload/\" or\n * POST requests to bucket or object creation endpoints.\n */\nexport function isUploadOperation(method: string, path: string): boolean {\n if (method.toUpperCase() !== 'POST') return false;\n\n const normalized = path.toLowerCase();\n\n // /api/gcs/upload — the NestJS GCS upload endpoint\n if (normalized.includes('/api/gcs/upload')) return true;\n\n // /api/gcs/files/<bucket>/<name>/delete — treated as a write operation for permission-denied\n // (not an upload, but this function is specifically for quota-exceeded which targets uploads)\n\n // Direct upload path prefix (GCS native)\n if (normalized.includes('/upload/storage/')) return true;\n\n // Object insert via bucket's objects collection\n // POST /storage/v1/b/{bucket}/o\n if (normalized.includes('/b/') && normalized.includes('/o')) return true;\n\n // Bucket creation\n // POST /storage/v1/b\n if (normalized.match(/\\/storage\\/v1\\/b\\/?$/)) return true;\n\n return false;\n}\n\n/**\n * Determine if a request is a GCS data operation.\n *\n * GCS data operations include CRUD on buckets and objects.\n * They exclude twin control endpoints (/api/twin/...) and health checks.\n */\nexport function isGcsDataOperation(method: string, path: string): boolean {\n const normalized = path.toLowerCase();\n\n // Exclude twin control endpoints\n if (normalized.startsWith('/api/twin')) return false;\n\n // Exclude error-mode control endpoints (control plane, not data plane)\n if (normalized.startsWith('/api/gcs/error-mode')) return false;\n\n // Exclude health checks and non-GCS paths\n if (\n normalized === '/' ||\n normalized === '/health' ||\n normalized === '/healthz'\n ) {\n return false;\n }\n\n // NestJS GCS controller routes: /api/gcs/*\n if (normalized.startsWith('/api/gcs')) return true;\n\n // Include known GCS API paths\n if (normalized.includes('/storage/v1/')) return true;\n if (normalized.includes('/upload/storage/')) return true;\n if (normalized.includes('/download/storage/')) return true;\n\n // Include paths that match GCS bucket/object patterns\n if (normalized.match(/\\/b\\/[^/]+/)) return true;\n\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// GcsErrorModeManager\n// ---------------------------------------------------------------------------\n\n/**\n * Pure state manager for GCS error simulation modes.\n *\n * Tracks the active error mode and matches incoming requests against it.\n * Returns null for pass-through when no mode is active.\n *\n * Thread-safety: this class is NOT thread-safe. In NestJS, requests are\n * handled concurrently, so the counter for intermittent-failure mode must\n * be managed with atomic operations or a mutex in the interceptor layer.\n * For now, we use a simple counter since NestJS interceptors run on the\n * event loop and the matching logic is synchronous.\n */\nexport class GcsErrorModeManager {\n private activeMode: GcsErrorModeConfig | null = null;\n private requestCounter = 0;\n\n /**\n * Activate an error mode. Replaces any existing mode (single-mode constraint).\n * Resets the request counter for intermittent-failure mode.\n */\n setMode(config: GcsErrorModeConfig): void {\n this.activeMode = config;\n this.requestCounter = 0;\n }\n\n /**\n * Deactivate the current error mode. Returns to pass-through.\n * Resets the request counter.\n */\n clearMode(): void {\n this.activeMode = null;\n this.requestCounter = 0;\n }\n\n /**\n * Get the currently active error mode configuration.\n * Returns null when no mode is active (pass-through).\n */\n getMode(): GcsErrorModeConfig | null {\n return this.activeMode;\n }\n\n /**\n * Match an incoming request against the active error mode.\n *\n * @param method - HTTP method (GET, POST, PUT, DELETE, etc.)\n * @param path - Request path (e.g., /storage/v1/b/my-bucket/o)\n * @returns GcsErrorSimulationResult if the request matches, null for pass-through\n */\n matchRequest(method: string, path: string): GcsErrorSimulationResult | null {\n if (!this.activeMode) return null;\n\n switch (this.activeMode.type) {\n case 'bucket-not-found':\n return this.matchBucketNotFound(method, path, this.activeMode);\n case 'quota-exceeded':\n return this.matchQuotaExceeded(method, path, this.activeMode);\n case 'permission-denied':\n return this.matchPermissionDenied(method, path, this.activeMode);\n case 'intermittent-failure':\n return this.matchIntermittentFailure(method, path, this.activeMode);\n }\n }\n\n /**\n * Simulate a sample error for the currently active mode.\n * This produces the same GcsErrorSimulationResult that the interceptor would\n * return for a matching request, without mutating the request counter.\n *\n * Used by the GET /api/twin/gcs/error-mode endpoint so the frontend can\n * display the actual error response the backend will produce.\n */\n simulateSampleError(): GcsErrorSimulationResult | null {\n if (!this.activeMode) return null;\n\n switch (this.activeMode.type) {\n case 'bucket-not-found':\n // Representative: a GET to the configured bucket\n return gcsError(404, 'Not Found', 'notFound');\n case 'quota-exceeded':\n return gcsError(403, 'Quota exceeded', 'quotaExceeded');\n case 'permission-denied':\n return gcsError(403, 'Forbidden', 'forbidden');\n case 'intermittent-failure':\n return gcsError(500, 'Internal Server Error', 'internalError');\n }\n }\n\n /**\n * Reset all state: clear the active mode and reset the request counter.\n * Called by GcsTwin.reset() and GcsTwin.stop() to prevent leakage between tests.\n */\n reset(): void {\n this.activeMode = null;\n this.requestCounter = 0;\n }\n\n // ---------------------------------------------------------------------------\n // Private matching methods\n // ---------------------------------------------------------------------------\n\n private matchBucketNotFound(\n _method: string,\n path: string,\n config: BucketNotFoundConfig\n ): GcsErrorSimulationResult | null {\n const bucket = extractBucketName(path);\n if (bucket === config.bucketName) {\n return gcsError(404, 'Not Found', 'notFound');\n }\n return null;\n }\n\n private matchQuotaExceeded(\n method: string,\n path: string,\n _config: QuotaExceededConfig\n ): GcsErrorSimulationResult | null {\n if (isUploadOperation(method, path)) {\n return gcsError(403, 'Quota exceeded', 'quotaExceeded');\n }\n return null;\n }\n\n private matchPermissionDenied(\n method: string,\n path: string,\n _config: PermissionDeniedConfig\n ): GcsErrorSimulationResult | null {\n if (isGcsDataOperation(method, path)) {\n return gcsError(403, 'Forbidden', 'forbidden');\n }\n return null;\n }\n\n private matchIntermittentFailure(\n method: string,\n path: string,\n config: IntermittentFailureConfig\n ): GcsErrorSimulationResult | null {\n // Only apply to GCS data operations\n if (!isGcsDataOperation(method, path)) {\n return null;\n }\n\n this.requestCounter++;\n const every = config.every ?? 2;\n\n if (this.requestCounter % every === 0) {\n return gcsError(500, 'Internal Server Error', 'internalError');\n }\n return null;\n }\n}\n"],"names":["gcsError","status","message","reason","detailMessage","body","error","code","errors","domain","extractBucketName","path","normalized","startsWith","slice","apiGcsBucketMatch","match","decodeURIComponent","apiGcsPathMatch","patterns","pattern","undefined","isUploadOperation","method","toUpperCase","toLowerCase","includes","isGcsDataOperation","GcsErrorModeManager","setMode","config","activeMode","requestCounter","clearMode","getMode","matchRequest","type","matchBucketNotFound","matchQuotaExceeded","matchPermissionDenied","matchIntermittentFailure","simulateSampleError","reset","_method","bucket","bucketName","_config","every"],"rangeMappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","mappings":"AAAA;;;;;;;;;;;;;;CAcC,GAED,8EAA8E;AAC9E,mBAAmB;AACnB,8EAA8E;AAE9E;;CAEC,GA0ED,8EAA8E;AAC9E,6BAA6B;AAC7B,8EAA8E;AAE9E,iDAAiD,GACjD,SAASA,SACPC,MAAc,EACdC,OAAe,EACfC,MAAc,EACdC,aAAsB;IAEtB,OAAO;QACLH;QACAI,MAAM;YACJC,OAAO;gBACLC,MAAMN;gBACNC;gBACAM,QAAQ;oBACN;wBACEC,QAAQ;wBACRN;wBACAD,SAASE,wBAAAA,gBAAiBF;oBAC5B;iBACD;YACH;QACF;IACF;AACF;AAEA,8EAA8E;AAC9E,2BAA2B;AAC3B,8EAA8E;AAE9E;;;;;;;;;;;CAWC,GACD,OAAO,SAASQ,kBAAkBC,IAAY;IAC5C,gDAAgD;IAChD,MAAMC,aAAaD,KAAKE,UAAU,CAAC,OAAOF,KAAKG,KAAK,CAAC,KAAKH;IAE1D,0DAA0D;IAC1D,MAAMI,oBAAoBH,WAAWI,KAAK,CACxC;IAEF,IAAID,mBAAmB;QACrB,OAAOE,mBAAmBF,iBAAiB,CAAC,EAAE;IAChD;IAEA,6EAA6E;IAC7E,MAAMG,kBAAkBN,WAAWI,KAAK,CAAC;IACzC,IAAIE,iBAAiB;QACnB,OAAOD,mBAAmBC,eAAe,CAAC,EAAE;IAC9C;IAEA,qGAAqG;IACrG,qGAAqG;IAErG,yEAAyE;IACzE,8BAA8B;IAC9B,MAAMC,WAAW;QACf,mFAAmF;QACnF;QACA,6BAA6B;QAC7B;KACD;IAED,KAAK,MAAMC,WAAWD,SAAU;QAC9B,MAAMH,QAAQJ,WAAWI,KAAK,CAACI;QAC/B,IAAIJ,OAAO;YACT,OAAOA,KAAK,CAAC,EAAE;QACjB;IACF;IAEA,OAAOK;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAASC,kBAAkBC,MAAc,EAAEZ,IAAY;IAC5D,IAAIY,OAAOC,WAAW,OAAO,QAAQ,OAAO;IAE5C,MAAMZ,aAAaD,KAAKc,WAAW;IAEnC,mDAAmD;IACnD,IAAIb,WAAWc,QAAQ,CAAC,oBAAoB,OAAO;IAEnD,6FAA6F;IAC7F,8FAA8F;IAE9F,yCAAyC;IACzC,IAAId,WAAWc,QAAQ,CAAC,qBAAqB,OAAO;IAEpD,gDAAgD;IAChD,gCAAgC;IAChC,IAAId,WAAWc,QAAQ,CAAC,UAAUd,WAAWc,QAAQ,CAAC,OAAO,OAAO;IAEpE,kBAAkB;IAClB,qBAAqB;IACrB,IAAId,WAAWI,KAAK,CAAC,yBAAyB,OAAO;IAErD,OAAO;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAASW,mBAAmBJ,MAAc,EAAEZ,IAAY;IAC7D,MAAMC,aAAaD,KAAKc,WAAW;IAEnC,iCAAiC;IACjC,IAAIb,WAAWC,UAAU,CAAC,cAAc,OAAO;IAE/C,uEAAuE;IACvE,IAAID,WAAWC,UAAU,CAAC,wBAAwB,OAAO;IAEzD,0CAA0C;IAC1C,IACED,eAAe,OACfA,eAAe,aACfA,eAAe,YACf;QACA,OAAO;IACT;IAEA,2CAA2C;IAC3C,IAAIA,WAAWC,UAAU,CAAC,aAAa,OAAO;IAE9C,8BAA8B;IAC9B,IAAID,WAAWc,QAAQ,CAAC,iBAAiB,OAAO;IAChD,IAAId,WAAWc,QAAQ,CAAC,qBAAqB,OAAO;IACpD,IAAId,WAAWc,QAAQ,CAAC,uBAAuB,OAAO;IAEtD,sDAAsD;IACtD,IAAId,WAAWI,KAAK,CAAC,eAAe,OAAO;IAE3C,OAAO;AACT;AAEA,8EAA8E;AAC9E,sBAAsB;AACtB,8EAA8E;AAE9E;;;;;;;;;;;CAWC,GACD,OAAO,MAAMY;IAIX;;;GAGC,GACDC,QAAQC,MAA0B,EAAQ;QACxC,IAAI,CAACC,UAAU,GAAGD;QAClB,IAAI,CAACE,cAAc,GAAG;IACxB;IAEA;;;GAGC,GACDC,YAAkB;QAChB,IAAI,CAACF,UAAU,GAAG;QAClB,IAAI,CAACC,cAAc,GAAG;IACxB;IAEA;;;GAGC,GACDE,UAAqC;QACnC,OAAO,IAAI,CAACH,UAAU;IACxB;IAEA;;;;;;GAMC,GACDI,aAAaZ,MAAc,EAAEZ,IAAY,EAAmC;QAC1E,IAAI,CAAC,IAAI,CAACoB,UAAU,EAAE,OAAO;QAE7B,OAAQ,IAAI,CAACA,UAAU,CAACK,IAAI;YAC1B,KAAK;gBACH,OAAO,IAAI,CAACC,mBAAmB,CAACd,QAAQZ,MAAM,IAAI,CAACoB,UAAU;YAC/D,KAAK;gBACH,OAAO,IAAI,CAACO,kBAAkB,CAACf,QAAQZ,MAAM,IAAI,CAACoB,UAAU;YAC9D,KAAK;gBACH,OAAO,IAAI,CAACQ,qBAAqB,CAAChB,QAAQZ,MAAM,IAAI,CAACoB,UAAU;YACjE,KAAK;gBACH,OAAO,IAAI,CAACS,wBAAwB,CAACjB,QAAQZ,MAAM,IAAI,CAACoB,UAAU;QACtE;IACF;IAEA;;;;;;;GAOC,GACDU,sBAAuD;QACrD,IAAI,CAAC,IAAI,CAACV,UAAU,EAAE,OAAO;QAE7B,OAAQ,IAAI,CAACA,UAAU,CAACK,IAAI;YAC1B,KAAK;gBACH,iDAAiD;gBACjD,OAAOpC,SAAS,KAAK,aAAa;YACpC,KAAK;gBACH,OAAOA,SAAS,KAAK,kBAAkB;YACzC,KAAK;gBACH,OAAOA,SAAS,KAAK,aAAa;YACpC,KAAK;gBACH,OAAOA,SAAS,KAAK,yBAAyB;QAClD;IACF;IAEA;;;GAGC,GACD0C,QAAc;QACZ,IAAI,CAACX,UAAU,GAAG;QAClB,IAAI,CAACC,cAAc,GAAG;IACxB;IAEA,8EAA8E;IAC9E,2BAA2B;IAC3B,8EAA8E;IAEtEK,oBACNM,OAAe,EACfhC,IAAY,EACZmB,MAA4B,EACK;QACjC,MAAMc,SAASlC,kBAAkBC;QACjC,IAAIiC,WAAWd,OAAOe,UAAU,EAAE;YAChC,OAAO7C,SAAS,KAAK,aAAa;QACpC;QACA,OAAO;IACT;IAEQsC,mBACNf,MAAc,EACdZ,IAAY,EACZmC,OAA4B,EACK;QACjC,IAAIxB,kBAAkBC,QAAQZ,OAAO;YACnC,OAAOX,SAAS,KAAK,kBAAkB;QACzC;QACA,OAAO;IACT;IAEQuC,sBACNhB,MAAc,EACdZ,IAAY,EACZmC,OAA+B,EACE;QACjC,IAAInB,mBAAmBJ,QAAQZ,OAAO;YACpC,OAAOX,SAAS,KAAK,aAAa;QACpC;QACA,OAAO;IACT;IAEQwC,yBACNjB,MAAc,EACdZ,IAAY,EACZmB,MAAiC,EACA;QACjC,oCAAoC;QACpC,IAAI,CAACH,mBAAmBJ,QAAQZ,OAAO;YACrC,OAAO;QACT;QAEA,IAAI,CAACqB,cAAc;YACLF;QAAd,MAAMiB,QAAQjB,CAAAA,gBAAAA,OAAOiB,KAAK,YAAZjB,gBAAgB;QAE9B,IAAI,IAAI,CAACE,cAAc,GAAGe,UAAU,GAAG;YACrC,OAAO/C,SAAS,KAAK,yBAAyB;QAChD;QACA,OAAO;IACT;;aA3IQ+B,aAAwC;aACxCC,iBAAiB;;AA2I3B"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
import { GcsErrorModeManager } from './gcs-error-mode.js';
|
|
4
|
+
/**
|
|
5
|
+
* NestJS interceptor that checks the active GCS error mode and short-circuits
|
|
6
|
+
* matching requests with GCS-shaped error responses.
|
|
7
|
+
*
|
|
8
|
+
* Registered as APP_INTERCEPTOR (global) but exits immediately for non-GCS routes.
|
|
9
|
+
* The route-prefix check is a cheap string comparison before any mode lookup.
|
|
10
|
+
*
|
|
11
|
+
* When an error mode matches, the interceptor throws an HttpException with the
|
|
12
|
+
* GCS error status code (403, 404, 500) so that the frontend receives a proper
|
|
13
|
+
* HTTP error response — not a 200 with an error body.
|
|
14
|
+
*/
|
|
15
|
+
export declare class GcsErrorSimulationInterceptor implements NestInterceptor {
|
|
16
|
+
private readonly errorModeManager;
|
|
17
|
+
private readonly logger;
|
|
18
|
+
constructor(errorModeManager: GcsErrorModeManager);
|
|
19
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=gcs-error-simulation.interceptor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gcs-error-simulation.interceptor.d.ts","sourceRoot":"","sources":["../../src/gcs/gcs-error-simulation.interceptor.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,eAAe,EACf,gBAAgB,EAChB,WAAW,EAGZ,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAClC,OAAO,EAAE,mBAAmB,EAAsB,MAAM,qBAAqB,CAAC;AAE9E;;;;;;;;;;GAUG;AACH,qBACa,6BAA8B,YAAW,eAAe;IAGvD,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IAF7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkD;gBAE5C,gBAAgB,EAAE,mBAAmB;IAElE,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CAAC,OAAO,CAAC;CAgC7E"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { _ as _ts_decorate } from "@swc/helpers/_/_ts_decorate";
|
|
2
|
+
import { _ as _ts_metadata } from "@swc/helpers/_/_ts_metadata";
|
|
3
|
+
import { Injectable, Logger, HttpException } from '@nestjs/common';
|
|
4
|
+
import { GcsErrorModeManager, isGcsDataOperation } from './gcs-error-mode.js';
|
|
5
|
+
export class GcsErrorSimulationInterceptor {
|
|
6
|
+
intercept(context, next) {
|
|
7
|
+
const request = context.switchToHttp().getRequest();
|
|
8
|
+
const method = request.method;
|
|
9
|
+
const path = request.url;
|
|
10
|
+
// Fast exit: only intercept GCS data operation routes
|
|
11
|
+
if (!isGcsDataOperation(method, path)) {
|
|
12
|
+
return next.handle();
|
|
13
|
+
}
|
|
14
|
+
const result = this.errorModeManager.matchRequest(method, path);
|
|
15
|
+
if (result) {
|
|
16
|
+
this.logger.warn(`Error mode intercepted ${method} ${path} → ${result.status} (${result.body.error.errors[0].reason})`);
|
|
17
|
+
// Throw with the real HTTP status code so the client gets a proper error response
|
|
18
|
+
throw new HttpException(result.body, result.status);
|
|
19
|
+
}
|
|
20
|
+
// Debug-level log when a mode is active but this request passed through
|
|
21
|
+
const activeMode = this.errorModeManager.getMode();
|
|
22
|
+
if (activeMode) {
|
|
23
|
+
this.logger.debug(`Error mode '${activeMode.type}' active but ${method} ${path} passed through`);
|
|
24
|
+
}
|
|
25
|
+
return next.handle();
|
|
26
|
+
}
|
|
27
|
+
constructor(errorModeManager){
|
|
28
|
+
this.errorModeManager = errorModeManager;
|
|
29
|
+
this.logger = new Logger(GcsErrorSimulationInterceptor.name);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
GcsErrorSimulationInterceptor = _ts_decorate([
|
|
33
|
+
Injectable(),
|
|
34
|
+
_ts_metadata("design:type", Function),
|
|
35
|
+
_ts_metadata("design:paramtypes", [
|
|
36
|
+
typeof GcsErrorModeManager === "undefined" ? Object : GcsErrorModeManager
|
|
37
|
+
])
|
|
38
|
+
], GcsErrorSimulationInterceptor);
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=gcs-error-simulation.interceptor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/gcs/gcs-error-simulation.interceptor.ts"],"sourcesContent":["import {\n Injectable,\n NestInterceptor,\n ExecutionContext,\n CallHandler,\n Logger,\n HttpException,\n} from '@nestjs/common';\nimport { Observable } from 'rxjs';\nimport { GcsErrorModeManager, isGcsDataOperation } from './gcs-error-mode.js';\n\n/**\n * NestJS interceptor that checks the active GCS error mode and short-circuits\n * matching requests with GCS-shaped error responses.\n *\n * Registered as APP_INTERCEPTOR (global) but exits immediately for non-GCS routes.\n * The route-prefix check is a cheap string comparison before any mode lookup.\n *\n * When an error mode matches, the interceptor throws an HttpException with the\n * GCS error status code (403, 404, 500) so that the frontend receives a proper\n * HTTP error response — not a 200 with an error body.\n */\n@Injectable()\nexport class GcsErrorSimulationInterceptor implements NestInterceptor {\n private readonly logger = new Logger(GcsErrorSimulationInterceptor.name);\n\n constructor(private readonly errorModeManager: GcsErrorModeManager) {}\n\n intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {\n const request = context\n .switchToHttp()\n .getRequest<{ method: string; url: string }>();\n const method: string = request.method;\n const path: string = request.url;\n\n // Fast exit: only intercept GCS data operation routes\n if (!isGcsDataOperation(method, path)) {\n return next.handle();\n }\n\n const result = this.errorModeManager.matchRequest(method, path);\n\n if (result) {\n this.logger.warn(\n `Error mode intercepted ${method} ${path} → ${result.status} (${result.body.error.errors[0].reason})`\n );\n // Throw with the real HTTP status code so the client gets a proper error response\n throw new HttpException(result.body, result.status);\n }\n\n // Debug-level log when a mode is active but this request passed through\n const activeMode = this.errorModeManager.getMode();\n if (activeMode) {\n this.logger.debug(\n `Error mode '${activeMode.type}' active but ${method} ${path} passed through`\n );\n }\n\n return next.handle();\n }\n}\n"],"names":["Injectable","Logger","HttpException","GcsErrorModeManager","isGcsDataOperation","GcsErrorSimulationInterceptor","intercept","context","next","request","switchToHttp","getRequest","method","path","url","handle","result","errorModeManager","matchRequest","logger","warn","status","body","error","errors","reason","activeMode","getMode","debug","type","constructor","name"],"rangeMappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","mappings":";;AAAA,SACEA,UAAU,EAIVC,MAAM,EACNC,aAAa,QACR,iBAAiB;AAExB,SAASC,mBAAmB,EAAEC,kBAAkB,QAAQ,sBAAsB;AAc9E,OAAO,MAAMC;IAKXC,UAAUC,OAAyB,EAAEC,IAAiB,EAAuB;QAC3E,MAAMC,UAAUF,QACbG,YAAY,GACZC,UAAU;QACb,MAAMC,SAAiBH,QAAQG,MAAM;QACrC,MAAMC,OAAeJ,QAAQK,GAAG;QAEhC,sDAAsD;QACtD,IAAI,CAACV,mBAAmBQ,QAAQC,OAAO;YACrC,OAAOL,KAAKO,MAAM;QACpB;QAEA,MAAMC,SAAS,IAAI,CAACC,gBAAgB,CAACC,YAAY,CAACN,QAAQC;QAE1D,IAAIG,QAAQ;YACV,IAAI,CAACG,MAAM,CAACC,IAAI,CACd,CAAC,uBAAuB,EAAER,OAAO,CAAC,EAAEC,KAAK,GAAG,EAAEG,OAAOK,MAAM,CAAC,EAAE,EAAEL,OAAOM,IAAI,CAACC,KAAK,CAACC,MAAM,CAAC,EAAE,CAACC,MAAM,CAAC,CAAC,CAAC;YAEvG,kFAAkF;YAClF,MAAM,IAAIvB,cAAcc,OAAOM,IAAI,EAAEN,OAAOK,MAAM;QACpD;QAEA,wEAAwE;QACxE,MAAMK,aAAa,IAAI,CAACT,gBAAgB,CAACU,OAAO;QAChD,IAAID,YAAY;YACd,IAAI,CAACP,MAAM,CAACS,KAAK,CACf,CAAC,YAAY,EAAEF,WAAWG,IAAI,CAAC,aAAa,EAAEjB,OAAO,CAAC,EAAEC,KAAK,eAAe,CAAC;QAEjF;QAEA,OAAOL,KAAKO,MAAM;IACpB;IAjCAe,YAAY,AAAiBb,gBAAqC,CAAE;aAAvCA,mBAAAA;aAFZE,SAAS,IAAIlB,OAAOI,8BAA8B0B,IAAI;IAEF;AAkCvE;AArCa1B;IADZL;;;eAIgD,+CAAA;;GAHpCK"}
|