|
|
@@ -1,5 +1,10 @@
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
import { ModuleStorageError } from "@ekklesia/contracts";
|
|
|
|
import { createServer, type Server, type Socket } from "node:net";
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
|
|
type ModuleStorage,
|
|
|
|
|
|
|
|
type ModuleStorageBucket,
|
|
|
|
|
|
|
|
ModuleStorageError,
|
|
|
|
|
|
|
|
} from "@ekklesia/contracts";
|
|
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
|
|
import { physicalBucketName } from "../src/storage/binding.js";
|
|
|
|
import { physicalBucketName } from "../src/storage/binding.js";
|
|
|
|
import { createStorageProviderConfig } from "../src/storage/config.js";
|
|
|
|
import { createStorageProviderConfig } from "../src/storage/config.js";
|
|
|
@@ -21,6 +26,22 @@ import { createS3StorageProvider } from "../src/storage/s3-provider.js";
|
|
|
|
* (ADR 0004) and because the repository's Compose file already pins and provisions it, so this adds
|
|
|
|
* (ADR 0004) and because the repository's Compose file already pins and provisions it, so this adds
|
|
|
|
* no new CI infrastructure. Nothing here is SeaweedFS-specific in the *contract* — the same suite
|
|
|
|
* no new CI infrastructure. Nothing here is SeaweedFS-specific in the *contract* — the same suite
|
|
|
|
* would pass against MinIO or AWS S3, which is the portability claim being tested.
|
|
|
|
* would pass against MinIO or AWS S3, which is the portability claim being tested.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* ## Why this suite is frugal with buckets
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* **A bucket is a scarce resource on a real server, so tests share one.** On the pinned SeaweedFS
|
|
|
|
|
|
|
|
* image every bucket becomes its own storage *collection*, and each collection immediately claims a
|
|
|
|
|
|
|
|
* block of volumes out of a budget the server derives from free disk. A suite that provisioned a
|
|
|
|
|
|
|
|
* fresh bucket per test therefore consumed that budget in proportion to its own size, and once the
|
|
|
|
|
|
|
|
* budget was gone the server did not fail the write — it accepted the connection and retried
|
|
|
|
|
|
|
|
* allocation internally, forever. That is environment-dependent by construction: it passed on a
|
|
|
|
|
|
|
|
* roomy disk and hung on a smaller one, which is exactly how it reached CI.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* So bucket identity is provisioned only where bucket identity is the thing under test — module
|
|
|
|
|
|
|
|
* isolation, the longest representable name, and the never-delete invariant. Everything else shares
|
|
|
|
|
|
|
|
* one bucket and isolates by **key namespace**, which tests prefix scoping honestly at the same
|
|
|
|
|
|
|
|
* time. Keep it that way: a new test should reach for `sharedBucket()` and a `keyNamespace()`
|
|
|
|
|
|
|
|
* unless it genuinely needs a bucket of its own.
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
const endpointEnvironment = "EKKLESIA_TEST_STORAGE_ENDPOINT";
|
|
|
|
const endpointEnvironment = "EKKLESIA_TEST_STORAGE_ENDPOINT";
|
|
|
|
const accessKeyEnvironment = "EKKLESIA_TEST_STORAGE_ACCESS_KEY";
|
|
|
|
const accessKeyEnvironment = "EKKLESIA_TEST_STORAGE_ACCESS_KEY";
|
|
|
@@ -34,8 +55,7 @@ class IntegrationHarnessError extends Error {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function providerConfig() {
|
|
|
|
function providerConfig(endpoint = process.env[endpointEnvironment]) {
|
|
|
|
const endpoint = process.env[endpointEnvironment];
|
|
|
|
|
|
|
|
if (typeof endpoint !== "string" || endpoint.trim() === "") {
|
|
|
|
if (typeof endpoint !== "string" || endpoint.trim() === "") {
|
|
|
|
throw new IntegrationHarnessError("The storage integration endpoint is not configured.");
|
|
|
|
throw new IntegrationHarnessError("The storage integration endpoint is not configured.");
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -69,6 +89,39 @@ function provider(): StorageProvider {
|
|
|
|
return created;
|
|
|
|
return created;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** The prefix every shared-bucket test binds through. */
|
|
|
|
|
|
|
|
const runPrefix = disposablePrefix();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let cachedProvider: StorageProvider | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** One client for the whole file. Each `S3Client` carries its own connection pool, so building one
|
|
|
|
|
|
|
|
* per test spent sockets to prove nothing. */
|
|
|
|
|
|
|
|
function sharedProvider(): StorageProvider {
|
|
|
|
|
|
|
|
cachedProvider ??= provider();
|
|
|
|
|
|
|
|
return cachedProvider;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let cachedStorage: ModuleStorage | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function sharedBucket(): ModuleStorageBucket {
|
|
|
|
|
|
|
|
cachedStorage ??= createModuleStorage({
|
|
|
|
|
|
|
|
bucketPrefix: runPrefix,
|
|
|
|
|
|
|
|
logicalBucketIds: ["announcement-assets"],
|
|
|
|
|
|
|
|
moduleId: "announcements",
|
|
|
|
|
|
|
|
provider: sharedProvider(),
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
return cachedStorage.bucket("announcement-assets");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let namespaceCounter = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** A key namespace private to one test. `describe.sequential` means these never interleave, so a
|
|
|
|
|
|
|
|
* counter is both sufficient and readable in a failure message. */
|
|
|
|
|
|
|
|
function keyNamespace(): string {
|
|
|
|
|
|
|
|
namespaceCounter += 1;
|
|
|
|
|
|
|
|
return `case-${namespaceCounter}`;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
afterAll(async () => {
|
|
|
|
for (const open of openProviders.splice(0)) {
|
|
|
|
for (const open of openProviders.splice(0)) {
|
|
|
|
await open.close().catch(() => undefined);
|
|
|
|
await open.close().catch(() => undefined);
|
|
|
@@ -77,33 +130,26 @@ afterAll(async () => {
|
|
|
|
|
|
|
|
|
|
|
|
describe.sequential("live S3-compatible module storage", () => {
|
|
|
|
describe.sequential("live S3-compatible module storage", () => {
|
|
|
|
it("creates a module's bucket idempotently, across both provider dialects", async () => {
|
|
|
|
it("creates a module's bucket idempotently, across both provider dialects", async () => {
|
|
|
|
const shared = provider();
|
|
|
|
const bucket = physicalBucketName(runPrefix, "announcements", "announcement-assets");
|
|
|
|
const prefix = disposablePrefix();
|
|
|
|
|
|
|
|
const bucket = physicalBucketName(prefix, "announcements", "announcement-assets");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await shared.ensureBucket(bucket);
|
|
|
|
await sharedProvider().ensureBucket(bucket);
|
|
|
|
// The assertion this whole suite exists for: the pinned SeaweedFS image answers a repeat
|
|
|
|
// The assertion this whole suite exists for: the pinned SeaweedFS image answers a repeat
|
|
|
|
// `CreateBucket` with `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`, and the
|
|
|
|
// `CreateBucket` with `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`, and the
|
|
|
|
// provider must treat both as success. Every restart depends on this.
|
|
|
|
// provider must treat both as success. Every restart depends on this.
|
|
|
|
await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined();
|
|
|
|
await expect(sharedProvider().ensureBucket(bucket)).resolves.toBeUndefined();
|
|
|
|
await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined();
|
|
|
|
await expect(sharedProvider().ensureBucket(bucket)).resolves.toBeUndefined();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("round-trips an object through the scoped facade with its content type preserved", async () => {
|
|
|
|
it("round-trips an object through the scoped facade with its content type preserved", async () => {
|
|
|
|
const prefix = disposablePrefix();
|
|
|
|
const bucket = sharedBucket();
|
|
|
|
const storage = createModuleStorage({
|
|
|
|
const namespace = keyNamespace();
|
|
|
|
bucketPrefix: prefix,
|
|
|
|
const key = `${namespace}/slides/welcome.png`;
|
|
|
|
logicalBucketIds: ["announcement-assets"],
|
|
|
|
|
|
|
|
moduleId: "announcements",
|
|
|
|
|
|
|
|
provider: provider(),
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
const bucket = storage.bucket("announcement-assets");
|
|
|
|
|
|
|
|
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
|
|
|
|
|
|
|
|
const written = await bucket.put("slides/welcome.png", png, { contentType: "image/png" });
|
|
|
|
const written = await bucket.put(key, png, { contentType: "image/png" });
|
|
|
|
expect(written.key).toBe("slides/welcome.png");
|
|
|
|
expect(written.key).toBe(key);
|
|
|
|
|
|
|
|
|
|
|
|
const object = await bucket.get("slides/welcome.png");
|
|
|
|
const object = await bucket.get(key);
|
|
|
|
expect(object.contentType).toBe("image/png");
|
|
|
|
expect(object.contentType).toBe("image/png");
|
|
|
|
expect(object.contentLength).toBe(png.byteLength);
|
|
|
|
expect(object.contentLength).toBe(png.byteLength);
|
|
|
|
// Unquoted for the caller — providers return a quoted ETag, and every module learning to strip
|
|
|
|
// Unquoted for the caller — providers return a quoted ETag, and every module learning to strip
|
|
|
@@ -113,37 +159,27 @@ describe.sequential("live S3-compatible module storage", () => {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("streams a body without buffering, when the length is supplied", async () => {
|
|
|
|
it("streams a body without buffering, when the length is supplied", async () => {
|
|
|
|
const prefix = disposablePrefix();
|
|
|
|
const bucket = sharedBucket();
|
|
|
|
const bucket = createModuleStorage({
|
|
|
|
const key = `${keyNamespace()}/large.bin`;
|
|
|
|
bucketPrefix: prefix,
|
|
|
|
|
|
|
|
logicalBucketIds: ["assets"],
|
|
|
|
|
|
|
|
moduleId: "media",
|
|
|
|
|
|
|
|
provider: provider(),
|
|
|
|
|
|
|
|
}).bucket("assets");
|
|
|
|
|
|
|
|
const payload = "x".repeat(64 * 1024);
|
|
|
|
const payload = "x".repeat(64 * 1024);
|
|
|
|
const stream = new Blob([payload]).stream() as ReadableStream<Uint8Array>;
|
|
|
|
const stream = new Blob([payload]).stream() as ReadableStream<Uint8Array>;
|
|
|
|
|
|
|
|
|
|
|
|
await bucket.put("large.bin", stream, {
|
|
|
|
await bucket.put(key, stream, {
|
|
|
|
contentLength: payload.length,
|
|
|
|
contentLength: payload.length,
|
|
|
|
contentType: "application/octet-stream",
|
|
|
|
contentType: "application/octet-stream",
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const object = await bucket.get("large.bin");
|
|
|
|
const object = await bucket.get(key);
|
|
|
|
expect(object.contentLength).toBe(payload.length);
|
|
|
|
expect(object.contentLength).toBe(payload.length);
|
|
|
|
expect(await new Response(object.body).text()).toBe(payload);
|
|
|
|
expect(await new Response(object.body).text()).toBe(payload);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns a Web stream a module can hand straight to a Response", async () => {
|
|
|
|
it("returns a Web stream a module can hand straight to a Response", async () => {
|
|
|
|
const prefix = disposablePrefix();
|
|
|
|
const bucket = sharedBucket();
|
|
|
|
const bucket = createModuleStorage({
|
|
|
|
const key = `${keyNamespace()}/welcome.png`;
|
|
|
|
bucketPrefix: prefix,
|
|
|
|
await bucket.put(key, new Uint8Array([1, 2, 3]), { contentType: "image/png" });
|
|
|
|
logicalBucketIds: ["assets"],
|
|
|
|
|
|
|
|
moduleId: "announcements",
|
|
|
|
|
|
|
|
provider: provider(),
|
|
|
|
|
|
|
|
}).bucket("assets");
|
|
|
|
|
|
|
|
await bucket.put("welcome.png", new Uint8Array([1, 2, 3]), { contentType: "image/png" });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const object = await bucket.get("welcome.png");
|
|
|
|
const object = await bucket.get(key);
|
|
|
|
expect(object.body).toBeInstanceOf(ReadableStream);
|
|
|
|
expect(object.body).toBeInstanceOf(ReadableStream);
|
|
|
|
// The browser-delivery shape this pass settles on: browser → module route → scoped storage,
|
|
|
|
// The browser-delivery shape this pass settles on: browser → module route → scoped storage,
|
|
|
|
// with no S3 knowledge or credential anywhere near the browser.
|
|
|
|
// with no S3 knowledge or credential anywhere near the browser.
|
|
|
@@ -155,110 +191,101 @@ describe.sequential("live S3-compatible module storage", () => {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("heads, lists with a prefix, and deletes", async () => {
|
|
|
|
it("heads, lists with a prefix, and deletes", async () => {
|
|
|
|
const prefix = disposablePrefix();
|
|
|
|
const bucket = sharedBucket();
|
|
|
|
const bucket = createModuleStorage({
|
|
|
|
const namespace = keyNamespace();
|
|
|
|
bucketPrefix: prefix,
|
|
|
|
|
|
|
|
logicalBucketIds: ["assets"],
|
|
|
|
|
|
|
|
moduleId: "announcements",
|
|
|
|
|
|
|
|
provider: provider(),
|
|
|
|
|
|
|
|
}).bucket("assets");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await bucket.put("slides/a.png", "a", { contentType: "image/png" });
|
|
|
|
await bucket.put(`${namespace}/slides/a.png`, "a", { contentType: "image/png" });
|
|
|
|
await bucket.put("slides/b.png", "b", { contentType: "image/png" });
|
|
|
|
await bucket.put(`${namespace}/slides/b.png`, "b", { contentType: "image/png" });
|
|
|
|
await bucket.put("other/c.txt", "c");
|
|
|
|
await bucket.put(`${namespace}/other/c.txt`, "c");
|
|
|
|
|
|
|
|
|
|
|
|
const metadata = await bucket.head("slides/a.png");
|
|
|
|
const metadata = await bucket.head(`${namespace}/slides/a.png`);
|
|
|
|
expect(metadata.contentType).toBe("image/png");
|
|
|
|
expect(metadata.contentType).toBe("image/png");
|
|
|
|
expect(metadata.contentLength).toBe(1);
|
|
|
|
expect(metadata.contentLength).toBe(1);
|
|
|
|
expect(metadata.lastModified).toBeInstanceOf(Date);
|
|
|
|
expect(metadata.lastModified).toBeInstanceOf(Date);
|
|
|
|
|
|
|
|
|
|
|
|
const listing = await bucket.list({ prefix: "slides/" });
|
|
|
|
// The sibling `other/` key proves the prefix genuinely filters rather than the bucket happening
|
|
|
|
expect(listing.objects.map((object) => object.key)).toEqual(["slides/a.png", "slides/b.png"]);
|
|
|
|
// to hold only matching keys.
|
|
|
|
|
|
|
|
const listing = await bucket.list({ prefix: `${namespace}/slides/` });
|
|
|
|
|
|
|
|
expect(listing.objects.map((object) => object.key)).toEqual([
|
|
|
|
|
|
|
|
`${namespace}/slides/a.png`,
|
|
|
|
|
|
|
|
`${namespace}/slides/b.png`,
|
|
|
|
|
|
|
|
]);
|
|
|
|
expect(listing.objects[0]?.contentLength).toBe(1);
|
|
|
|
expect(listing.objects[0]?.contentLength).toBe(1);
|
|
|
|
// A complete listing carries no cursor, so an absent cursor always means "done".
|
|
|
|
// A complete listing carries no cursor, so an absent cursor always means "done".
|
|
|
|
expect(listing.cursor).toBeUndefined();
|
|
|
|
expect(listing.cursor).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
|
|
await bucket.delete("slides/a.png");
|
|
|
|
await bucket.delete(`${namespace}/slides/a.png`);
|
|
|
|
expect((await bucket.list({ prefix: "slides/" })).objects.map((o) => o.key)).toEqual([
|
|
|
|
expect(
|
|
|
|
"slides/b.png",
|
|
|
|
(await bucket.list({ prefix: `${namespace}/slides/` })).objects.map((o) => o.key),
|
|
|
|
]);
|
|
|
|
).toEqual([`${namespace}/slides/b.png`]);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("paginates a listing rather than silently truncating it", async () => {
|
|
|
|
it("paginates a listing rather than silently truncating it", async () => {
|
|
|
|
const prefix = disposablePrefix();
|
|
|
|
const bucket = sharedBucket();
|
|
|
|
const bucket = createModuleStorage({
|
|
|
|
const namespace = keyNamespace();
|
|
|
|
bucketPrefix: prefix,
|
|
|
|
|
|
|
|
logicalBucketIds: ["assets"],
|
|
|
|
|
|
|
|
moduleId: "paged",
|
|
|
|
|
|
|
|
provider: provider(),
|
|
|
|
|
|
|
|
}).bucket("assets");
|
|
|
|
|
|
|
|
for (const index of [0, 1, 2, 3, 4]) {
|
|
|
|
for (const index of [0, 1, 2, 3, 4]) {
|
|
|
|
await bucket.put(`object-${index}.txt`, String(index));
|
|
|
|
await bucket.put(`${namespace}/object-${index}.txt`, String(index));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const first = await bucket.list({ limit: 2 });
|
|
|
|
const first = await bucket.list({ limit: 2, prefix: `${namespace}/` });
|
|
|
|
expect(first.objects).toHaveLength(2);
|
|
|
|
expect(first.objects).toHaveLength(2);
|
|
|
|
expect(first.cursor).toBeDefined();
|
|
|
|
expect(first.cursor).toBeDefined();
|
|
|
|
|
|
|
|
|
|
|
|
const keys = [...first.objects.map((object) => object.key)];
|
|
|
|
const keys = [...first.objects.map((object) => object.key)];
|
|
|
|
let cursor = first.cursor;
|
|
|
|
let cursor = first.cursor;
|
|
|
|
while (cursor !== undefined) {
|
|
|
|
while (cursor !== undefined) {
|
|
|
|
const page = await bucket.list({ cursor, limit: 2 });
|
|
|
|
const page = await bucket.list({ cursor, limit: 2, prefix: `${namespace}/` });
|
|
|
|
keys.push(...page.objects.map((object) => object.key));
|
|
|
|
keys.push(...page.objects.map((object) => object.key));
|
|
|
|
cursor = page.cursor;
|
|
|
|
cursor = page.cursor;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expect(keys.sort()).toEqual([
|
|
|
|
expect(keys.sort()).toEqual([
|
|
|
|
"object-0.txt",
|
|
|
|
`${namespace}/object-0.txt`,
|
|
|
|
"object-1.txt",
|
|
|
|
`${namespace}/object-1.txt`,
|
|
|
|
"object-2.txt",
|
|
|
|
`${namespace}/object-2.txt`,
|
|
|
|
"object-3.txt",
|
|
|
|
`${namespace}/object-3.txt`,
|
|
|
|
"object-4.txt",
|
|
|
|
`${namespace}/object-4.txt`,
|
|
|
|
]);
|
|
|
|
]);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("maps a missing object to a structured not-found error, never a provider exception", async () => {
|
|
|
|
it("maps a missing object to a structured not-found error, never a provider exception", async () => {
|
|
|
|
const prefix = disposablePrefix();
|
|
|
|
const bucket = sharedBucket();
|
|
|
|
const bucket = createModuleStorage({
|
|
|
|
const namespace = keyNamespace();
|
|
|
|
bucketPrefix: prefix,
|
|
|
|
await bucket.put(`${namespace}/present.txt`, "x");
|
|
|
|
logicalBucketIds: ["assets"],
|
|
|
|
|
|
|
|
moduleId: "announcements",
|
|
|
|
|
|
|
|
provider: provider(),
|
|
|
|
|
|
|
|
}).bucket("assets");
|
|
|
|
|
|
|
|
await bucket.put("present.txt", "x");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let caught: unknown;
|
|
|
|
let caught: unknown;
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
await bucket.get("absent.txt");
|
|
|
|
await bucket.get(`${namespace}/absent.txt`);
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
caught = error;
|
|
|
|
caught = error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expect(caught).toBeInstanceOf(ModuleStorageError);
|
|
|
|
expect(caught).toBeInstanceOf(ModuleStorageError);
|
|
|
|
expect((caught as ModuleStorageError).code).toBe("STORAGE_OBJECT_NOT_FOUND");
|
|
|
|
expect((caught as ModuleStorageError).code).toBe("STORAGE_OBJECT_NOT_FOUND");
|
|
|
|
expect((caught as ModuleStorageError).key).toBe("absent.txt");
|
|
|
|
expect((caught as ModuleStorageError).key).toBe(`${namespace}/absent.txt`);
|
|
|
|
// Nothing from the provider survives translation: no endpoint, no bucket, no request id, no XML.
|
|
|
|
// Nothing from the provider survives translation: no endpoint, no bucket, no request id, no XML.
|
|
|
|
const rendered = `${String(caught)} ${JSON.stringify(caught)}`;
|
|
|
|
const rendered = `${String(caught)} ${JSON.stringify(caught)}`;
|
|
|
|
expect(rendered).not.toContain("http");
|
|
|
|
expect(rendered).not.toContain("http");
|
|
|
|
expect(rendered).not.toContain(prefix);
|
|
|
|
expect(rendered).not.toContain(runPrefix);
|
|
|
|
expect(rendered).not.toContain("<?xml");
|
|
|
|
expect(rendered).not.toContain("<?xml");
|
|
|
|
expect(rendered).not.toContain(process.env[accessKeyEnvironment] ?? " |