local-web-services-javascript-sdk 0.1.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/package.json +56 -0
- package/src/builders/chaos.js +50 -0
- package/src/builders/iam.js +81 -0
- package/src/builders/mock.js +74 -0
- package/src/index.js +23 -0
- package/src/logs.js +76 -0
- package/src/resources/dynamodb.js +72 -0
- package/src/resources/s3.js +90 -0
- package/src/resources/sqs.js +70 -0
- package/src/session.js +455 -0
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "local-web-services-javascript-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JavaScript testing SDK for local-web-services — subprocess-based AWS service fixtures for testing",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/**/*"
|
|
8
|
+
],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "jest --forceExit",
|
|
11
|
+
"lint": "eslint src tests"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"aws",
|
|
15
|
+
"testing",
|
|
16
|
+
"jest",
|
|
17
|
+
"dynamodb",
|
|
18
|
+
"sqs",
|
|
19
|
+
"s3",
|
|
20
|
+
"local",
|
|
21
|
+
"mocking"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/local-web-services/local-web-services-javascript-sdk"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/local-web-services/local-web-services-javascript-sdk#readme",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@aws-sdk/client-dynamodb": "^3.600.0",
|
|
35
|
+
"@aws-sdk/client-s3": "^3.600.0",
|
|
36
|
+
"@aws-sdk/client-sqs": "^3.600.0",
|
|
37
|
+
"@aws-sdk/client-sns": "^3.600.0",
|
|
38
|
+
"@aws-sdk/client-ssm": "^3.600.0",
|
|
39
|
+
"@aws-sdk/client-secrets-manager": "^3.600.0",
|
|
40
|
+
"@aws-sdk/client-sfn": "^3.600.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"eslint": "^8.57.0",
|
|
44
|
+
"jest": "^29.7.0"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18.0.0"
|
|
48
|
+
},
|
|
49
|
+
"jest": {
|
|
50
|
+
"testEnvironment": "node",
|
|
51
|
+
"testMatch": [
|
|
52
|
+
"**/tests/**/*.test.js"
|
|
53
|
+
],
|
|
54
|
+
"testTimeout": 60000
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
class ChaosBuilder {
|
|
4
|
+
constructor(service, mgmtPort) {
|
|
5
|
+
this.service = service;
|
|
6
|
+
this.mgmtPort = mgmtPort;
|
|
7
|
+
this.config = { enabled: true };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
errorRate(rate) {
|
|
11
|
+
this.config.error_rate = rate;
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
latency(minMs, maxMs) {
|
|
16
|
+
this.config.latency_min_ms = minMs;
|
|
17
|
+
this.config.latency_max_ms = maxMs;
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
connectionResetRate(rate) {
|
|
22
|
+
this.config.connection_reset_rate = rate;
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
timeoutRate(rate) {
|
|
27
|
+
this.config.timeout_rate = rate;
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async apply() {
|
|
32
|
+
await fetch(`http://127.0.0.1:${this.mgmtPort}/_ldk/chaos`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ [this.service]: this.config }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async clear() {
|
|
40
|
+
await fetch(`http://127.0.0.1:${this.mgmtPort}/_ldk/chaos`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
[this.service]: { enabled: false, error_rate: 0.0 },
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { ChaosBuilder };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
class IamBuilder {
|
|
4
|
+
constructor(mgmtPort) {
|
|
5
|
+
this.mgmtPort = mgmtPort;
|
|
6
|
+
this.updates = {};
|
|
7
|
+
this.identities = {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
mode(mode) {
|
|
11
|
+
this.updates.mode = mode;
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
defaultIdentity(name) {
|
|
16
|
+
this.updates.default_identity = name;
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
identity(name) {
|
|
21
|
+
return new IdentityBuilder(this, name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_registerIdentity(name, config) {
|
|
25
|
+
this.identities[name] = config;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async apply() {
|
|
29
|
+
const payload = { ...this.updates };
|
|
30
|
+
if (Object.keys(this.identities).length > 0) {
|
|
31
|
+
payload.identities = this.identities;
|
|
32
|
+
}
|
|
33
|
+
await fetch(`http://127.0.0.1:${this.mgmtPort}/_ldk/iam-auth`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify(payload),
|
|
37
|
+
});
|
|
38
|
+
this.updates = {};
|
|
39
|
+
this.identities = {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class IdentityBuilder {
|
|
44
|
+
constructor(parent, name) {
|
|
45
|
+
this.parent = parent;
|
|
46
|
+
this.name = name;
|
|
47
|
+
this.inlinePolicies = [];
|
|
48
|
+
this.boundary = undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
allow(actions, resource = "*") {
|
|
52
|
+
this.inlinePolicies.push({
|
|
53
|
+
name: `inline-${this.inlinePolicies.length}`,
|
|
54
|
+
document: {
|
|
55
|
+
Version: "2012-10-17",
|
|
56
|
+
Statement: [{ Effect: "Allow", Action: actions, Resource: resource }],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
deny(actions, resource = "*") {
|
|
63
|
+
this.inlinePolicies.push({
|
|
64
|
+
name: `inline-deny-${this.inlinePolicies.length}`,
|
|
65
|
+
document: {
|
|
66
|
+
Version: "2012-10-17",
|
|
67
|
+
Statement: [{ Effect: "Deny", Action: actions, Resource: resource }],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
apply() {
|
|
74
|
+
const config = { inline_policies: this.inlinePolicies };
|
|
75
|
+
if (this.boundary) config.boundary_policy = this.boundary;
|
|
76
|
+
this.parent._registerIdentity(this.name, config);
|
|
77
|
+
return this.parent;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { IamBuilder, IdentityBuilder };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
class MockBuilder {
|
|
4
|
+
constructor(service, mgmtPort) {
|
|
5
|
+
this.service = service;
|
|
6
|
+
this.mgmtPort = mgmtPort;
|
|
7
|
+
this.rules = [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
operation(operationName) {
|
|
11
|
+
return new MockRuleBuilder(this, operationName);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async _addRule(rule) {
|
|
15
|
+
this.rules.push(rule);
|
|
16
|
+
await this._apply();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async _apply() {
|
|
20
|
+
await fetch(`http://127.0.0.1:${this.mgmtPort}/_ldk/aws-mock`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
body: JSON.stringify({ [this.service]: { enabled: true, rules: this.rules } }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async clear() {
|
|
28
|
+
this.rules = [];
|
|
29
|
+
await fetch(`http://127.0.0.1:${this.mgmtPort}/_ldk/aws-mock`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ [this.service]: { enabled: false, rules: [] } }),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class MockRuleBuilder {
|
|
38
|
+
constructor(parent, operation) {
|
|
39
|
+
this.parent = parent;
|
|
40
|
+
this.operation = operation;
|
|
41
|
+
this.matchHeaders = {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
withHeader(name, value) {
|
|
45
|
+
this.matchHeaders[name] = value;
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async respond(opts) {
|
|
50
|
+
const body =
|
|
51
|
+
typeof opts.body === "object" ? JSON.stringify(opts.body) : opts.body ?? "";
|
|
52
|
+
const rule = {
|
|
53
|
+
operation: this.operation,
|
|
54
|
+
match_headers: this.matchHeaders,
|
|
55
|
+
response: {
|
|
56
|
+
status: opts.status ?? 200,
|
|
57
|
+
content_type: opts.contentType ?? "application/json",
|
|
58
|
+
delay_ms: opts.delayMs ?? 0,
|
|
59
|
+
body,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
await this.parent._addRule(rule);
|
|
63
|
+
return this.parent;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async error(errorType, message = "", status = 400) {
|
|
67
|
+
return this.respond({
|
|
68
|
+
status,
|
|
69
|
+
body: JSON.stringify({ __type: errorType, message }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { MockBuilder, MockRuleBuilder };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { LwsSession } = require("./session");
|
|
4
|
+
const { DynamoDBHelper } = require("./resources/dynamodb");
|
|
5
|
+
const { SQSHelper } = require("./resources/sqs");
|
|
6
|
+
const { S3Helper } = require("./resources/s3");
|
|
7
|
+
const { MockBuilder, MockRuleBuilder } = require("./builders/mock");
|
|
8
|
+
const { ChaosBuilder } = require("./builders/chaos");
|
|
9
|
+
const { IamBuilder, IdentityBuilder } = require("./builders/iam");
|
|
10
|
+
const { LogCapture } = require("./logs");
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
LwsSession,
|
|
14
|
+
DynamoDBHelper,
|
|
15
|
+
SQSHelper,
|
|
16
|
+
S3Helper,
|
|
17
|
+
MockBuilder,
|
|
18
|
+
MockRuleBuilder,
|
|
19
|
+
ChaosBuilder,
|
|
20
|
+
IamBuilder,
|
|
21
|
+
IdentityBuilder,
|
|
22
|
+
LogCapture,
|
|
23
|
+
};
|
package/src/logs.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
class LogCapture {
|
|
4
|
+
constructor(mgmtPort) {
|
|
5
|
+
this.mgmtPort = mgmtPort;
|
|
6
|
+
this.entries = [];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
start() {
|
|
10
|
+
this.entries = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async stop() {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`http://127.0.0.1:${this.mgmtPort}/_ldk/status`);
|
|
16
|
+
if (res.ok) {
|
|
17
|
+
// Logs collected via WebSocket during the test.
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get all() {
|
|
25
|
+
return [...this.entries];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
forService(service) {
|
|
29
|
+
return this.entries.filter(
|
|
30
|
+
(e) => (e.service ?? "").toLowerCase() === service.toLowerCase()
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
forOperation(operation) {
|
|
35
|
+
return this.entries.filter((e) => e.operation === operation);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
assertCalled(service, operation) {
|
|
39
|
+
const matching = this.entries.filter(
|
|
40
|
+
(e) =>
|
|
41
|
+
(e.service ?? "").toLowerCase() === service.toLowerCase() &&
|
|
42
|
+
e.operation === operation
|
|
43
|
+
);
|
|
44
|
+
if (matching.length === 0) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Expected ${service}.${operation} to have been called, ` +
|
|
47
|
+
`but no matching log entry was found. Captured: ${JSON.stringify(this.entries)}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
assertNotCalled(service, operation) {
|
|
53
|
+
const matching = this.entries.filter(
|
|
54
|
+
(e) =>
|
|
55
|
+
(e.service ?? "").toLowerCase() === service.toLowerCase() &&
|
|
56
|
+
e.operation === operation
|
|
57
|
+
);
|
|
58
|
+
if (matching.length > 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Expected ${service}.${operation} NOT to have been called, ` +
|
|
61
|
+
`but found ${matching.length} matching log entry/entries.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assertNoErrors() {
|
|
67
|
+
const errors = this.entries.filter((e) => e.level === "ERROR");
|
|
68
|
+
if (errors.length > 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Expected no ERROR log entries, but found ${errors.length}: ${JSON.stringify(errors)}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { LogCapture };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
DynamoDBClient,
|
|
5
|
+
PutItemCommand,
|
|
6
|
+
GetItemCommand,
|
|
7
|
+
DeleteItemCommand,
|
|
8
|
+
ScanCommand,
|
|
9
|
+
} = require("@aws-sdk/client-dynamodb");
|
|
10
|
+
|
|
11
|
+
class DynamoDBHelper {
|
|
12
|
+
constructor(tableName, client) {
|
|
13
|
+
this.tableName = tableName;
|
|
14
|
+
this.client = client;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async put(item) {
|
|
18
|
+
await this.client.send(new PutItemCommand({ TableName: this.tableName, Item: item }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async get(key) {
|
|
22
|
+
const response = await this.client.send(
|
|
23
|
+
new GetItemCommand({ TableName: this.tableName, Key: key })
|
|
24
|
+
);
|
|
25
|
+
return response.Item;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async delete(key) {
|
|
29
|
+
await this.client.send(
|
|
30
|
+
new DeleteItemCommand({ TableName: this.tableName, Key: key })
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async scan() {
|
|
35
|
+
const items = [];
|
|
36
|
+
let lastKey;
|
|
37
|
+
do {
|
|
38
|
+
const result = await this.client.send(
|
|
39
|
+
new ScanCommand({
|
|
40
|
+
TableName: this.tableName,
|
|
41
|
+
...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
items.push(...(result.Items ?? []));
|
|
45
|
+
lastKey = result.LastEvaluatedKey;
|
|
46
|
+
} while (lastKey);
|
|
47
|
+
return items;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async assertItemExists(key) {
|
|
51
|
+
const item = await this.get(key);
|
|
52
|
+
if (!item) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Expected item with key ${JSON.stringify(key)} to exist in table "${this.tableName}", but it was not found.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return item;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async assertItemCount(expectedCount) {
|
|
61
|
+
const items = await this.scan();
|
|
62
|
+
if (items.length !== expectedCount) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Expected ${expectedCount} item(s) in table "${this.tableName}", but found ${items.length}.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
void DynamoDBClient;
|
|
71
|
+
|
|
72
|
+
module.exports = { DynamoDBHelper };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
S3Client,
|
|
5
|
+
PutObjectCommand,
|
|
6
|
+
GetObjectCommand,
|
|
7
|
+
DeleteObjectCommand,
|
|
8
|
+
ListObjectsV2Command,
|
|
9
|
+
} = require("@aws-sdk/client-s3");
|
|
10
|
+
|
|
11
|
+
class S3Helper {
|
|
12
|
+
constructor(bucketName, client) {
|
|
13
|
+
this.bucketName = bucketName;
|
|
14
|
+
this.client = client;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async put(key, body, contentType) {
|
|
18
|
+
await this.client.send(
|
|
19
|
+
new PutObjectCommand({
|
|
20
|
+
Bucket: this.bucketName,
|
|
21
|
+
Key: key,
|
|
22
|
+
Body: typeof body === "string" ? Buffer.from(body) : body,
|
|
23
|
+
...(contentType ? { ContentType: contentType } : {}),
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async get(key) {
|
|
29
|
+
const result = await this.client.send(
|
|
30
|
+
new GetObjectCommand({ Bucket: this.bucketName, Key: key })
|
|
31
|
+
);
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for await (const chunk of result.Body) {
|
|
34
|
+
chunks.push(chunk);
|
|
35
|
+
}
|
|
36
|
+
return Buffer.concat(chunks);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getText(key, encoding = "utf-8") {
|
|
40
|
+
return (await this.get(key)).toString(encoding);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async delete(key) {
|
|
44
|
+
await this.client.send(
|
|
45
|
+
new DeleteObjectCommand({ Bucket: this.bucketName, Key: key })
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async listKeys(prefix) {
|
|
50
|
+
const keys = [];
|
|
51
|
+
let token;
|
|
52
|
+
do {
|
|
53
|
+
const result = await this.client.send(
|
|
54
|
+
new ListObjectsV2Command({
|
|
55
|
+
Bucket: this.bucketName,
|
|
56
|
+
...(prefix ? { Prefix: prefix } : {}),
|
|
57
|
+
...(token ? { ContinuationToken: token } : {}),
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
keys.push(...(result.Contents ?? []).map((obj) => obj.Key));
|
|
61
|
+
token = result.NextContinuationToken;
|
|
62
|
+
} while (token);
|
|
63
|
+
return keys;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async assertObjectExists(key) {
|
|
67
|
+
const keys = await this.listKeys();
|
|
68
|
+
if (!keys.includes(key)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Expected object "${key}" to exist in bucket "${this.bucketName}", ` +
|
|
71
|
+
`but it was not found. Existing keys: ${JSON.stringify(keys)}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async assertObjectCount(expectedCount, prefix) {
|
|
77
|
+
const keys = await this.listKeys(prefix);
|
|
78
|
+
if (keys.length !== expectedCount) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Expected ${expectedCount} object(s) in bucket "${this.bucketName}"` +
|
|
81
|
+
(prefix ? ` with prefix "${prefix}"` : "") +
|
|
82
|
+
`, but found ${keys.length}.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
void S3Client;
|
|
89
|
+
|
|
90
|
+
module.exports = { S3Helper };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SQSClient,
|
|
5
|
+
SendMessageCommand,
|
|
6
|
+
ReceiveMessageCommand,
|
|
7
|
+
PurgeQueueCommand,
|
|
8
|
+
GetQueueAttributesCommand,
|
|
9
|
+
} = require("@aws-sdk/client-sqs");
|
|
10
|
+
|
|
11
|
+
class SQSHelper {
|
|
12
|
+
constructor(queueName, client, port) {
|
|
13
|
+
this.queueName = queueName;
|
|
14
|
+
this.client = client;
|
|
15
|
+
this.queueUrl = `http://127.0.0.1:${port}/000000000000/${queueName}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get url() {
|
|
19
|
+
return this.queueUrl;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async send(body, opts = {}) {
|
|
23
|
+
const messageBody = typeof body === "string" ? body : JSON.stringify(body);
|
|
24
|
+
const result = await this.client.send(
|
|
25
|
+
new SendMessageCommand({
|
|
26
|
+
QueueUrl: this.queueUrl,
|
|
27
|
+
MessageBody: messageBody,
|
|
28
|
+
...(opts.messageGroupId ? { MessageGroupId: opts.messageGroupId } : {}),
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
return result.MessageId ?? "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async receive(maxMessages = 10, waitSeconds = 0) {
|
|
35
|
+
const result = await this.client.send(
|
|
36
|
+
new ReceiveMessageCommand({
|
|
37
|
+
QueueUrl: this.queueUrl,
|
|
38
|
+
MaxNumberOfMessages: Math.min(maxMessages, 10),
|
|
39
|
+
WaitTimeSeconds: waitSeconds,
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
return result.Messages ?? [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async purge() {
|
|
46
|
+
await this.client.send(new PurgeQueueCommand({ QueueUrl: this.queueUrl }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async assertMessageCount(expectedCount) {
|
|
50
|
+
const result = await this.client.send(
|
|
51
|
+
new GetQueueAttributesCommand({
|
|
52
|
+
QueueUrl: this.queueUrl,
|
|
53
|
+
AttributeNames: ["ApproximateNumberOfMessages"],
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
const actualCount = parseInt(
|
|
57
|
+
result.Attributes?.ApproximateNumberOfMessages ?? "0",
|
|
58
|
+
10
|
|
59
|
+
);
|
|
60
|
+
if (actualCount !== expectedCount) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Expected ${expectedCount} message(s) in queue "${this.queueName}", but found approximately ${actualCount}.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
void SQSClient;
|
|
69
|
+
|
|
70
|
+
module.exports = { SQSHelper };
|
package/src/session.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LwsSession — main entry point for the lws JavaScript testing SDK.
|
|
5
|
+
*
|
|
6
|
+
* Spawns `ldk dev` in a background process and provides pre-configured
|
|
7
|
+
* AWS SDK v3 clients pointing at the local services.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { spawn } = require("child_process");
|
|
11
|
+
const fs = require("fs/promises");
|
|
12
|
+
const net = require("net");
|
|
13
|
+
const os = require("os");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
const { DynamoDBHelper } = require("./resources/dynamodb");
|
|
17
|
+
const { SQSHelper } = require("./resources/sqs");
|
|
18
|
+
const { S3Helper } = require("./resources/s3");
|
|
19
|
+
const { MockBuilder } = require("./builders/mock");
|
|
20
|
+
const { ChaosBuilder } = require("./builders/chaos");
|
|
21
|
+
const { IamBuilder } = require("./builders/iam");
|
|
22
|
+
const { LogCapture } = require("./logs");
|
|
23
|
+
|
|
24
|
+
// Port offsets relative to the base port (matches ldk.py _create_providers)
|
|
25
|
+
const SERVICE_OFFSETS = {
|
|
26
|
+
dynamodb: 1,
|
|
27
|
+
sqs: 2,
|
|
28
|
+
s3: 3,
|
|
29
|
+
sns: 4,
|
|
30
|
+
eventbridge: 5,
|
|
31
|
+
stepfunctions: 6,
|
|
32
|
+
ssm: 12,
|
|
33
|
+
secretsmanager: 13,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Maps service name → AWS SDK v3 endpoint URL env var.
|
|
37
|
+
// Note: AWS SDK v3 uses "STATES" for Step Functions and "SECRETS_MANAGER"
|
|
38
|
+
// (with underscore) for Secrets Manager — different from boto3's names.
|
|
39
|
+
const SERVICE_ENV_VARS = {
|
|
40
|
+
dynamodb: "AWS_ENDPOINT_URL_DYNAMODB",
|
|
41
|
+
sqs: "AWS_ENDPOINT_URL_SQS",
|
|
42
|
+
s3: "AWS_ENDPOINT_URL_S3",
|
|
43
|
+
sns: "AWS_ENDPOINT_URL_SNS",
|
|
44
|
+
stepfunctions: "AWS_ENDPOINT_URL_STATES",
|
|
45
|
+
ssm: "AWS_ENDPOINT_URL_SSM",
|
|
46
|
+
secretsmanager: "AWS_ENDPOINT_URL_SECRETS_MANAGER",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const TEST_CREDENTIALS = {
|
|
50
|
+
AWS_ACCESS_KEY_ID: "test",
|
|
51
|
+
AWS_SECRET_ACCESS_KEY: "test",
|
|
52
|
+
AWS_DEFAULT_REGION: "us-east-1",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function freePort() {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const server = net.createServer();
|
|
58
|
+
server.listen(0, "127.0.0.1", () => {
|
|
59
|
+
const addr = server.address();
|
|
60
|
+
server.close(() => resolve(addr.port));
|
|
61
|
+
});
|
|
62
|
+
server.on("error", reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function waitForReady(managementUrl, timeoutMs = 30000, intervalMs = 200) {
|
|
67
|
+
const deadline = Date.now() + timeoutMs;
|
|
68
|
+
while (Date.now() < deadline) {
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`${managementUrl}/_ldk/status`);
|
|
71
|
+
if (res.ok) return;
|
|
72
|
+
} catch {
|
|
73
|
+
// not ready yet
|
|
74
|
+
}
|
|
75
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
76
|
+
}
|
|
77
|
+
throw new Error(
|
|
78
|
+
`ldk dev did not become ready within ${timeoutMs}ms. ` +
|
|
79
|
+
"Check that local-web-services is installed (pip install local-web-services)."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function generateTerraformConfig(dir, spec) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
|
|
86
|
+
for (const tableSpec of spec.tables ?? []) {
|
|
87
|
+
const name = typeof tableSpec === "string" ? tableSpec : tableSpec.name;
|
|
88
|
+
const pk = typeof tableSpec === "string" ? "id" : tableSpec.partitionKey;
|
|
89
|
+
const sk = typeof tableSpec === "string" ? undefined : tableSpec.sortKey;
|
|
90
|
+
const logicalId = name.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
91
|
+
|
|
92
|
+
lines.push(`resource "aws_dynamodb_table" "${logicalId}" {`);
|
|
93
|
+
lines.push(` name = "${name}"`);
|
|
94
|
+
lines.push(` hash_key = "${pk}"`);
|
|
95
|
+
lines.push(` billing_mode = "PAY_PER_REQUEST"`);
|
|
96
|
+
lines.push(` attribute { name = "${pk}" type = "S" }`);
|
|
97
|
+
if (sk) {
|
|
98
|
+
lines.push(` range_key = "${sk}"`);
|
|
99
|
+
lines.push(` attribute { name = "${sk}" type = "S" }`);
|
|
100
|
+
}
|
|
101
|
+
lines.push(`}`);
|
|
102
|
+
lines.push("");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const queueSpec of spec.queues ?? []) {
|
|
106
|
+
const name = typeof queueSpec === "string" ? queueSpec : queueSpec.name;
|
|
107
|
+
const isFifo = typeof queueSpec !== "string" && queueSpec.isFifo;
|
|
108
|
+
const logicalId = name.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
109
|
+
lines.push(`resource "aws_sqs_queue" "${logicalId}" {`);
|
|
110
|
+
lines.push(` name = "${name}"`);
|
|
111
|
+
if (isFifo) lines.push(` fifo_queue = true`);
|
|
112
|
+
lines.push(`}`);
|
|
113
|
+
lines.push("");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const bucketSpec of spec.buckets ?? []) {
|
|
117
|
+
const name = typeof bucketSpec === "string" ? bucketSpec : bucketSpec.name;
|
|
118
|
+
const logicalId = name.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
119
|
+
lines.push(`resource "aws_s3_bucket" "${logicalId}" {`);
|
|
120
|
+
lines.push(` bucket = "${name}"`);
|
|
121
|
+
lines.push(`}`);
|
|
122
|
+
lines.push("");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const topicSpec of spec.topics ?? []) {
|
|
126
|
+
const name = typeof topicSpec === "string" ? topicSpec : topicSpec.name;
|
|
127
|
+
const logicalId = name.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
128
|
+
lines.push(`resource "aws_sns_topic" "${logicalId}" {`);
|
|
129
|
+
lines.push(` name = "${name}"`);
|
|
130
|
+
lines.push(`}`);
|
|
131
|
+
lines.push("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await fs.writeFile(path.join(dir, "main.tf"), lines.join("\n"), "utf8");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class LwsSession {
|
|
138
|
+
constructor(basePort, projectDir, spec) {
|
|
139
|
+
this._basePort = basePort;
|
|
140
|
+
this._projectDir = projectDir;
|
|
141
|
+
this._spec = spec;
|
|
142
|
+
this._process = null;
|
|
143
|
+
this._tempDir = null;
|
|
144
|
+
this._savedEnv = {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Constructors ────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create a session from an explicit resource specification.
|
|
151
|
+
*
|
|
152
|
+
* Generates a temporary Terraform project from the spec and starts `ldk dev`.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} [spec={}]
|
|
155
|
+
* @returns {Promise<LwsSession>}
|
|
156
|
+
*/
|
|
157
|
+
static async create(spec = {}) {
|
|
158
|
+
const basePort = await freePort();
|
|
159
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "lws-testing-"));
|
|
160
|
+
await generateTerraformConfig(tempDir, spec);
|
|
161
|
+
|
|
162
|
+
const session = new LwsSession(basePort, tempDir, spec);
|
|
163
|
+
session._tempDir = tempDir;
|
|
164
|
+
await session._start();
|
|
165
|
+
return session;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create a session by discovering resources from a CDK project.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} [projectDir="."]
|
|
172
|
+
* @returns {Promise<LwsSession>}
|
|
173
|
+
*/
|
|
174
|
+
static async fromCdk(projectDir = ".") {
|
|
175
|
+
const basePort = await freePort();
|
|
176
|
+
const session = new LwsSession(basePort, projectDir, {});
|
|
177
|
+
await session._start("cdk");
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create a session by discovering resources from a Terraform / HCL project.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} [projectDir="."]
|
|
185
|
+
* @returns {Promise<LwsSession>}
|
|
186
|
+
*/
|
|
187
|
+
static async fromHcl(projectDir = ".") {
|
|
188
|
+
const basePort = await freePort();
|
|
189
|
+
const session = new LwsSession(basePort, projectDir, {});
|
|
190
|
+
await session._start("terraform");
|
|
191
|
+
return session;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
async _start(mode) {
|
|
197
|
+
const args = ["dev", "--project-dir", this._projectDir, "--port", String(this._basePort)];
|
|
198
|
+
if (mode) {
|
|
199
|
+
args.push("--mode", mode);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this._process = spawn("ldk", args, {
|
|
203
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
204
|
+
detached: false,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
this._process.on("error", (err) => {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Failed to start ldk: ${err.message}. ` +
|
|
210
|
+
"Ensure local-web-services is installed: pip install local-web-services"
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const managementUrl = `http://127.0.0.1:${this._basePort}`;
|
|
215
|
+
await waitForReady(managementUrl);
|
|
216
|
+
this._patchEnv();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Stop the ldk dev process and clean up any temporary files. */
|
|
220
|
+
async close() {
|
|
221
|
+
this._restoreEnv();
|
|
222
|
+
|
|
223
|
+
if (this._process) {
|
|
224
|
+
this._process.kill("SIGTERM");
|
|
225
|
+
await new Promise((resolve) => {
|
|
226
|
+
this._process.once("exit", () => resolve());
|
|
227
|
+
setTimeout(resolve, 5000);
|
|
228
|
+
});
|
|
229
|
+
this._process = null;
|
|
230
|
+
}
|
|
231
|
+
if (this._tempDir) {
|
|
232
|
+
await fs.rm(this._tempDir, { recursive: true, force: true });
|
|
233
|
+
this._tempDir = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Environment patching ────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Set AWS SDK v3 endpoint env vars so any client created in this process
|
|
241
|
+
* hits the local LWS services — no production-code changes required.
|
|
242
|
+
*/
|
|
243
|
+
_patchEnv() {
|
|
244
|
+
for (const [service, envVar] of Object.entries(SERVICE_ENV_VARS)) {
|
|
245
|
+
const offset = SERVICE_OFFSETS[service];
|
|
246
|
+
if (offset === undefined) continue;
|
|
247
|
+
this._savedEnv[envVar] = process.env[envVar];
|
|
248
|
+
process.env[envVar] = `http://127.0.0.1:${this._basePort + offset}`;
|
|
249
|
+
}
|
|
250
|
+
for (const [key, val] of Object.entries(TEST_CREDENTIALS)) {
|
|
251
|
+
this._savedEnv[key] = process.env[key];
|
|
252
|
+
process.env[key] = val;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Restore all env vars overridden by _patchEnv. */
|
|
257
|
+
_restoreEnv() {
|
|
258
|
+
for (const [key, savedVal] of Object.entries(this._savedEnv)) {
|
|
259
|
+
if (savedVal === undefined) {
|
|
260
|
+
delete process.env[key];
|
|
261
|
+
} else {
|
|
262
|
+
process.env[key] = savedVal;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this._savedEnv = {};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Return the local SQS URL for queueName.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} queueName
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
queueUrl(queueName) {
|
|
275
|
+
return `http://127.0.0.1:${this._basePort + SERVICE_OFFSETS.sqs}/000000000000/${queueName}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── AWS client factory ──────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Return a pre-configured AWS SDK v3 client pointing at the local service.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} service e.g. "dynamodb", "s3", "sqs"
|
|
284
|
+
* @returns {object}
|
|
285
|
+
*/
|
|
286
|
+
client(service) {
|
|
287
|
+
const offset = SERVICE_OFFSETS[service.toLowerCase()];
|
|
288
|
+
if (offset === undefined) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Service "${service}" is not supported. Available: ${Object.keys(SERVICE_OFFSETS).join(", ")}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const port = this._basePort + offset;
|
|
294
|
+
const endpointUrl = `http://127.0.0.1:${port}`;
|
|
295
|
+
const credentials = { accessKeyId: "test", secretAccessKey: "test" };
|
|
296
|
+
const region = "us-east-1";
|
|
297
|
+
|
|
298
|
+
switch (service.toLowerCase()) {
|
|
299
|
+
case "dynamodb": {
|
|
300
|
+
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
|
|
301
|
+
return new DynamoDBClient({ endpoint: endpointUrl, credentials, region });
|
|
302
|
+
}
|
|
303
|
+
case "s3": {
|
|
304
|
+
const { S3Client } = require("@aws-sdk/client-s3");
|
|
305
|
+
return new S3Client({ endpoint: endpointUrl, credentials, region, forcePathStyle: true });
|
|
306
|
+
}
|
|
307
|
+
case "sqs": {
|
|
308
|
+
const { SQSClient } = require("@aws-sdk/client-sqs");
|
|
309
|
+
return new SQSClient({ endpoint: endpointUrl, credentials, region });
|
|
310
|
+
}
|
|
311
|
+
case "sns": {
|
|
312
|
+
const { SNSClient } = require("@aws-sdk/client-sns");
|
|
313
|
+
return new SNSClient({ endpoint: endpointUrl, credentials, region });
|
|
314
|
+
}
|
|
315
|
+
case "ssm": {
|
|
316
|
+
const { SSMClient } = require("@aws-sdk/client-ssm");
|
|
317
|
+
return new SSMClient({ endpoint: endpointUrl, credentials, region });
|
|
318
|
+
}
|
|
319
|
+
case "secretsmanager": {
|
|
320
|
+
const { SecretsManagerClient } = require("@aws-sdk/client-secrets-manager");
|
|
321
|
+
return new SecretsManagerClient({ endpoint: endpointUrl, credentials, region });
|
|
322
|
+
}
|
|
323
|
+
case "stepfunctions": {
|
|
324
|
+
const { SFNClient } = require("@aws-sdk/client-sfn");
|
|
325
|
+
return new SFNClient({ endpoint: endpointUrl, credentials, region });
|
|
326
|
+
}
|
|
327
|
+
default:
|
|
328
|
+
throw new Error(`No SDK client implementation for service "${service}"`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── State management ────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
/** Clear all in-memory state. Call between tests for isolation. */
|
|
335
|
+
async reset() {
|
|
336
|
+
await this._resetDynamoDB();
|
|
337
|
+
await this._resetSqs();
|
|
338
|
+
await this._resetS3();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async _resetDynamoDB() {
|
|
342
|
+
const { DynamoDBClient, ScanCommand, DeleteItemCommand } = require("@aws-sdk/client-dynamodb");
|
|
343
|
+
const dynamo = this.client("dynamodb");
|
|
344
|
+
for (const tableSpec of this._spec.tables ?? []) {
|
|
345
|
+
const name = typeof tableSpec === "string" ? tableSpec : tableSpec.name;
|
|
346
|
+
const partitionKey = typeof tableSpec === "string" ? "id" : tableSpec.partitionKey;
|
|
347
|
+
const sortKey = typeof tableSpec === "string" ? undefined : tableSpec.sortKey;
|
|
348
|
+
const keyNames = [partitionKey, ...(sortKey ? [sortKey] : [])];
|
|
349
|
+
const projection = keyNames.map((k, i) => `#k${i}`).join(", ");
|
|
350
|
+
const exprNames = Object.fromEntries(keyNames.map((k, i) => [`#k${i}`, k]));
|
|
351
|
+
|
|
352
|
+
let lastKey;
|
|
353
|
+
do {
|
|
354
|
+
const scanInput = {
|
|
355
|
+
TableName: name,
|
|
356
|
+
ProjectionExpression: projection,
|
|
357
|
+
ExpressionAttributeNames: exprNames,
|
|
358
|
+
};
|
|
359
|
+
if (lastKey) scanInput.ExclusiveStartKey = lastKey;
|
|
360
|
+
|
|
361
|
+
const result = await dynamo.send(new ScanCommand(scanInput));
|
|
362
|
+
for (const item of result.Items ?? []) {
|
|
363
|
+
const key = Object.fromEntries(keyNames.map((k) => [k, item[k]]));
|
|
364
|
+
await dynamo.send(new DeleteItemCommand({ TableName: name, Key: key }));
|
|
365
|
+
}
|
|
366
|
+
lastKey = result.LastEvaluatedKey;
|
|
367
|
+
} while (lastKey);
|
|
368
|
+
}
|
|
369
|
+
void DynamoDBClient;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async _resetSqs() {
|
|
373
|
+
const { SQSClient, PurgeQueueCommand } = require("@aws-sdk/client-sqs");
|
|
374
|
+
const sqs = this.client("sqs");
|
|
375
|
+
const port = this._basePort + SERVICE_OFFSETS.sqs;
|
|
376
|
+
for (const queueSpec of this._spec.queues ?? []) {
|
|
377
|
+
const name = typeof queueSpec === "string" ? queueSpec : queueSpec.name;
|
|
378
|
+
const queueUrl = `http://127.0.0.1:${port}/000000000000/${name}`;
|
|
379
|
+
try {
|
|
380
|
+
await sqs.send(new PurgeQueueCommand({ QueueUrl: queueUrl }));
|
|
381
|
+
} catch {
|
|
382
|
+
// ignore if queue doesn't exist
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
void SQSClient;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async _resetS3() {
|
|
389
|
+
const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require("@aws-sdk/client-s3");
|
|
390
|
+
const s3 = this.client("s3");
|
|
391
|
+
for (const bucketSpec of this._spec.buckets ?? []) {
|
|
392
|
+
const name = typeof bucketSpec === "string" ? bucketSpec : bucketSpec.name;
|
|
393
|
+
let token;
|
|
394
|
+
do {
|
|
395
|
+
const listInput = { Bucket: name };
|
|
396
|
+
if (token) listInput.ContinuationToken = token;
|
|
397
|
+
const result = await s3.send(new ListObjectsV2Command(listInput));
|
|
398
|
+
for (const obj of result.Contents ?? []) {
|
|
399
|
+
await s3.send(new DeleteObjectCommand({ Bucket: name, Key: obj.Key }));
|
|
400
|
+
}
|
|
401
|
+
token = result.NextContinuationToken;
|
|
402
|
+
} while (token);
|
|
403
|
+
}
|
|
404
|
+
void S3Client;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Resource helpers ────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
dynamodb(tableName) {
|
|
410
|
+
return new DynamoDBHelper(tableName, this.client("dynamodb"));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
sqs(queueName) {
|
|
414
|
+
const port = this._basePort + SERVICE_OFFSETS.sqs;
|
|
415
|
+
return new SQSHelper(queueName, this.client("sqs"), port);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
s3(bucketName) {
|
|
419
|
+
return new S3Helper(bucketName, this.client("s3"));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Mock / chaos / IAM builders ─────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
mock(service) {
|
|
425
|
+
return new MockBuilder(service, this._basePort);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
chaos(service) {
|
|
429
|
+
return new ChaosBuilder(service, this._basePort);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
get iam() {
|
|
433
|
+
return new IamBuilder(this._basePort);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Log capture ─────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
captureLogsStart() {
|
|
439
|
+
const capture = new LogCapture(this._basePort);
|
|
440
|
+
capture.start();
|
|
441
|
+
return capture;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Port info ───────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
portFor(service) {
|
|
447
|
+
const offset = SERVICE_OFFSETS[service.toLowerCase()];
|
|
448
|
+
if (offset === undefined) {
|
|
449
|
+
throw new Error(`Unknown service: ${service}`);
|
|
450
|
+
}
|
|
451
|
+
return this._basePort + offset;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
module.exports = { LwsSession };
|