diff --git a/packages/@winglang/sdk/src/target-sim/api.inflight.ts b/packages/@winglang/sdk/src/target-sim/api.inflight.ts index 44e66385430..9dce3682bcb 100644 --- a/packages/@winglang/sdk/src/target-sim/api.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/api.inflight.ts @@ -1,6 +1,5 @@ import * as fs from "fs"; import { Server } from "http"; -import { AddressInfo, Socket } from "net"; import { join } from "path"; import express from "express"; import { IEventPublisher } from "./event-mapping"; @@ -10,7 +9,7 @@ import { ApiRoute, EventSubscription, } from "./schema-resources"; -import { exists } from "./util"; +import { exists, isPortAvailable, listenExpress } from "./util"; import { API_FQN, ApiRequest, @@ -28,8 +27,6 @@ import { } from "../simulator/simulator"; import { LogLevel, Json, TraceType } from "../std"; -const LOCALHOST_ADDRESS = "127.0.0.1"; - const STATE_FILENAME = "state.json"; /** @@ -114,21 +111,10 @@ export class Api } } - // `server.address()` returns `null` until the server is listening - // on a port. We use a promise to wait for the server to start - // listening before returning the URL. - const addrInfo: AddressInfo = await new Promise((resolve, reject) => { - this.server = this.app.listen(lastPort ?? 0, LOCALHOST_ADDRESS, () => { - const addr = this.server?.address(); - if (addr && typeof addr === "object" && (addr as AddressInfo).port) { - resolve(addr); - } else { - reject(new Error("No address found")); - } - }); - }); - this.port = addrInfo.port; - this.url = `http://${addrInfo.address}:${addrInfo.port}`; + const { server, address } = await listenExpress(this.app, lastPort); + this.server = server; + this.port = address.port; + this.url = `http://${address.address}:${address.port}`; this.addTrace(`Server listening on ${this.url}`, LogLevel.VERBOSE); @@ -343,26 +329,3 @@ function asyncMiddleware( Promise.resolve(fn(req, res, next)).catch(next); }; } - -async function isPortAvailable(port: number): Promise { - return new Promise((resolve, _reject) => { - const s = new Socket(); - s.once("error", (err) => { - s.destroy(); - if ((err as any).code !== "ECONNREFUSED") { - resolve(false); - } else { - // connection refused means the port is not used - resolve(true); - } - }); - - s.once("connect", () => { - s.destroy(); - // connection successful means the port is used - resolve(false); - }); - - s.connect(port, LOCALHOST_ADDRESS); - }); -} diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 35abd33fec0..35fa67ccd5c 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -1,10 +1,12 @@ import * as crypto from "crypto"; import * as fs from "fs"; +import { Server } from "http"; import { dirname, join } from "path"; -import * as url from "url"; +import { pathToFileURL } from "url"; +import express from "express"; import mime from "mime-types"; import { BucketAttributes, BucketSchema } from "./schema-resources"; -import { exists } from "./util"; +import { exists, isPortAvailable, listenExpress } from "./util"; import { ITopicClient, BucketSignedUrlOptions, @@ -16,6 +18,8 @@ import { BucketGetOptions, BucketTryGetOptions, BUCKET_FQN, + BucketSignedUrlAction, + CorsHeaders, } from "../cloud"; import { deserialize, serialize } from "../simulator/serialization"; import { @@ -27,6 +31,18 @@ import { Datetime, Json, LogLevel, TraceType } from "../std"; export const METADATA_FILENAME = "metadata.json"; +const STATE_FILENAME = "state.json"; + +/** + * Contents of the state file for this resource. + */ +interface StateFileContents { + /** + * The last port used by the API server on a previous simulator run. + */ + readonly lastPort?: number; +} + export class Bucket implements IBucketClient, ISimulatorResourceInstance { private _fileDir!: string; private _context: ISimulatorContext | undefined; @@ -34,12 +50,114 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { private readonly _public: boolean; private readonly topicHandlers: Partial>; private _metadata: Map; + private readonly app: express.Application; + private server: Server | undefined; + private url: string | undefined; + private port: number | undefined; public constructor(props: BucketSchema) { this.initialObjects = props.initialObjects ?? {}; this._public = props.public ?? false; this.topicHandlers = props.topics; this._metadata = new Map(); + + this.app = express(); + + // Enable cors for all requests. + this.app.use((req, res, next) => { + const corsHeaders: CorsHeaders = { + defaultResponse: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Headers": "*", + }, + optionsResponse: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", + }, + }; + const method = + req.method && req.method.toUpperCase && req.method.toUpperCase(); + + if (method === "OPTIONS") { + for (const [key, value] of Object.entries( + corsHeaders.optionsResponse + )) { + res.setHeader(key, value); + } + res.status(204).send(); + } else { + for (const [key, value] of Object.entries( + corsHeaders.defaultResponse + )) { + res.setHeader(key, value); + } + next(); + } + }); + + // Handle signed URL uploads. + this.app.put("*", (req, res) => { + const action = req.query.action; + if (action !== BucketSignedUrlAction.UPLOAD) { + return res.status(403).send("Operation not allowed"); + } + + const validUntil = req.query.validUntil?.toString(); + if (!validUntil || Date.now() > parseInt(validUntil)) { + return res.status(403).send("Signed URL has expired"); + } + + const key = req.path.slice(1); // remove leading slash + const hash = this.hashKey(key); + const filename = join(this._fileDir, hash); + + const actionType: BucketEventType = this._metadata.has(key) + ? BucketEventType.UPDATE + : BucketEventType.CREATE; + + const contentType = req.header("content-type"); + if (contentType?.startsWith("multipart/form-data")) { + return res.status(400).send("Multipart uploads not supported"); + } + + const fileStream = fs.createWriteStream(filename); + req.pipe(fileStream); + + fileStream.on("error", () => { + res.status(500).send("Failed to save the file."); + }); + + fileStream.on("finish", () => { + void this.updateMetadataAndNotify(key, actionType, contentType).then( + () => { + res.status(200).send(); + } + ); + }); + + return; + }); + + // Handle signed URL downloads. + this.app.get("*", (req, res) => { + const action = req.query.action; + if (action !== BucketSignedUrlAction.DOWNLOAD) { + return res.status(403).send("Operation not allowed"); + } + + const validUntil = req.query.validUntil?.toString(); + if (!validUntil || Date.now() > parseInt(validUntil)) { + return res.status(403).send("Signed URL has expired"); + } + + const key = req.path.slice(1); // remove leading slash + const hash = this.hashKey(key); + const filename = join(this._fileDir, hash); + return res.download(filename); + }); } private get context(): ISimulatorContext { @@ -86,15 +204,70 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { }); } - return {}; + // Check for a previous state file to see if there was a port that was previously being used + // if so, try to use it out of convenience + let lastPort: number | undefined; + const state: StateFileContents = await this.loadState(); + if (state.lastPort) { + const portAvailable = await isPortAvailable(state.lastPort); + if (portAvailable) { + lastPort = state.lastPort; + } + } + + const { server, address } = await listenExpress(this.app, lastPort); + this.server = server; + this.port = address.port; + this.url = `http://${address.address}:${address.port}`; + + this.addTrace(`Server listening on ${this.url}`, LogLevel.VERBOSE); + + return { + url: this.url, + }; } - public async cleanup(): Promise {} + public async cleanup(): Promise { + this.addTrace(`Closing server on ${this.url}`, LogLevel.VERBOSE); + + return new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + return reject(err); + } + + this.server?.closeAllConnections(); + return resolve(); + }); + }); + } public async plan() { return UpdatePlan.AUTO; } + private async loadState(): Promise { + const stateFileExists = await exists( + join(this.context.statedir, STATE_FILENAME) + ); + if (stateFileExists) { + const stateFileContents = await fs.promises.readFile( + join(this.context.statedir, STATE_FILENAME), + "utf-8" + ); + return JSON.parse(stateFileContents); + } else { + return {}; + } + } + + private async saveState(state: StateFileContents): Promise { + fs.writeFileSync( + join(this.context.statedir, STATE_FILENAME), + JSON.stringify(state) + ); + } + public async save(): Promise { // no need to save individual files, since they are already persisted in the state dir // during the bucket's lifecycle @@ -102,6 +275,8 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { join(this.context.statedir, METADATA_FILENAME), serialize(Array.from(this._metadata.entries())) // metadata contains Datetime values, so we need to serialize it ); + + await this.saveState({ lastPort: this.port }); } private async notifyListeners( @@ -291,19 +466,36 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { ); } - return url.pathToFileURL(filePath).href; + return pathToFileURL(filePath).href; }, }); } public async signedUrl(key: string, options?: BucketSignedUrlOptions) { - options; return this.context.withTrace({ - message: `Signed URL (key=${key})`, + message: `Signed URL (key=${key}).`, activity: async () => { - throw new Error( - `signedUrl is not implemented yet for the simulator (key=${key})` + const action = options?.action ?? BucketSignedUrlAction.DOWNLOAD; + // BUG: The `options?.duration` is supposed to be an instance of `Duration` but it is not. It's just + // a POJO with seconds, but TypeScript thinks otherwise. + const duration = options?.duration?.seconds ?? 900; + + if ( + action === BucketSignedUrlAction.DOWNLOAD && + !(await this.exists(key)) + ) { + throw new Error( + `Cannot provide signed url for a non-existent key (key=${key})` + ); + } + + const url = new URL(key, this.url); + url.searchParams.set("action", action); + url.searchParams.set( + "validUntil", + String(Date.now() + duration * 1000) ); + return url.toString(); }, }); } @@ -370,10 +562,19 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { await fs.promises.mkdir(dirName, { recursive: true }); await fs.promises.writeFile(filename, value); + await this.updateMetadataAndNotify(key, actionType, contentType); + } + + private async updateMetadataAndNotify( + key: string, + actionType: BucketEventType, + contentType?: string + ): Promise { + const hash = this.hashKey(key); + const filename = join(this._fileDir, hash); const filestat = await fs.promises.stat(filename); const determinedContentType = - (contentType ?? mime.lookup(key)) || "application/octet-stream"; - + contentType ?? (mime.lookup(key) || "application/octet-stream"); this._metadata.set(key, { size: filestat.size, lastModified: Datetime.fromDate(filestat.mtime), diff --git a/packages/@winglang/sdk/src/target-sim/util.ts b/packages/@winglang/sdk/src/target-sim/util.ts index fb6a3fff77e..987e8fa6910 100644 --- a/packages/@winglang/sdk/src/target-sim/util.ts +++ b/packages/@winglang/sdk/src/target-sim/util.ts @@ -1,6 +1,9 @@ import { access, constants } from "fs"; +import { Server } from "http"; +import { AddressInfo, Socket } from "net"; import { promisify } from "util"; import { IConstruct } from "constructs"; +import type { Application } from "express"; import { isSimulatorInflightHost } from "./resource"; import { simulatorHandleToken } from "./tokens"; import { Duration, IInflightHost, Resource } from "../std"; @@ -171,3 +174,51 @@ export function convertDurationToCronExpression(dur: Duration): string { const cronString = `${minute} ${hour} ${dayInMonth} ${month} ${dayOfWeek}`; return cronString; } + +const LOCALHOST_ADDRESS = "127.0.0.1"; + +export async function isPortAvailable(port: number): Promise { + return new Promise((resolve, _reject) => { + const s = new Socket(); + s.once("error", (err) => { + s.destroy(); + if ((err as any).code !== "ECONNREFUSED") { + resolve(false); + } else { + // connection refused means the port is not used + resolve(true); + } + }); + + s.once("connect", () => { + s.destroy(); + // connection successful means the port is used + resolve(false); + }); + + s.connect(port, LOCALHOST_ADDRESS); + }); +} + +export async function listenExpress( + app: Application, + port?: number +): Promise<{ server: Server; address: AddressInfo }> { + // `server.address()` returns `null` until the server is listening + // on a port. We use a promise to wait for the server to start + // listening before returning the URL. + return new Promise((resolve, reject) => { + const server = app.listen(port ?? 0, LOCALHOST_ADDRESS, () => { + const address = server.address(); + if ( + address && + typeof address === "object" && + (address as AddressInfo).port + ) { + resolve({ server, address }); + } else { + reject(new Error("No address found")); + } + }); + }); +} diff --git a/packages/@winglang/sdk/test/simulator/reload.test.ts b/packages/@winglang/sdk/test/simulator/reload.test.ts index 4ddcf9df6e8..bc0fd3096fb 100644 --- a/packages/@winglang/sdk/test/simulator/reload.test.ts +++ b/packages/@winglang/sdk/test/simulator/reload.test.ts @@ -47,9 +47,9 @@ test("traces are cleared when reloading the simulator with reset state set to tr await s.start(); const client = s.getResource("/my_bucket") as IBucketClient; await client.put("traces.txt", "Hello world!"); - expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(1); + expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(2); // Reload the simulator and reset state await s.reload(true); - expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(0); + expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(1); }); diff --git a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap index 98d98391b5a..cf6f4dbd626 100644 --- a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap +++ b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap @@ -5,8 +5,10 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in "root/my_bucket/OnCreate started", "root/my_bucket/OnUpdate started", "root/my_bucket/OnDelete started", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", + "Server listening on http://127.0.0.1:", "root/log_bucket started", "root/my_bucket/OnCreate/OnMessage0 started", "root/my_bucket/OnCreate/Policy started", @@ -40,6 +42,7 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in "I am done", "Get (key=a).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", "root/my_bucket/OnCreate/Policy stopped", "root/my_bucket/OnCreate/TopicEventMapping0 stopped", @@ -54,6 +57,7 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in "root/my_bucket/OnUpdate/OnMessage0 stopped", "root/my_bucket/OnDelete/OnMessage0 stopped", "root/log_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/log_bucket stopped", ] `; @@ -61,12 +65,14 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in exports[`can add file in preflight 1`] = ` [ "Adding object from preflight (key=test.txt).", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Get (key=test.txt).", "Get (key=test.txt).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; @@ -224,12 +230,14 @@ exports[`can add file in preflight 2`] = ` exports[`can add object in preflight 1`] = ` [ "Adding object from preflight (key=greeting.txt).", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Get (key=greeting.txt).", "Get (key=greeting.txt).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; @@ -534,10 +542,12 @@ exports[`create a bucket 1`] = ` exports[`get invalid object throws an error 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Error: Object does not exist (key=unknown.txt). (Get (key=unknown.txt).)", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; @@ -692,6 +702,7 @@ exports[`get invalid object throws an error 2`] = ` exports[`list respects prefixes 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=path/dir1/file1.txt).", @@ -701,12 +712,14 @@ exports[`list respects prefixes 1`] = ` "List (prefix=path/dir1).", "List (prefix=path/dir2).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`objects can have keys that look like directories 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=foo).", @@ -721,12 +734,14 @@ exports[`objects can have keys that look like directories 1`] = ` "List (prefix=foo/bar/).", "List (prefix=foo/bar/baz).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put and get metadata of objects from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=file1.main.w).", @@ -736,23 +751,27 @@ exports[`put and get metadata of objects from bucket 1`] = ` "Metadata (key=file2.txt).", "Metadata (key=file3.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put and get object from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=greeting.txt).", "Get (key=greeting.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put multiple json objects and list all from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put Json (key=greeting1.json).", @@ -760,12 +779,14 @@ exports[`put multiple json objects and list all from bucket 1`] = ` "Put Json (key=greeting3.json).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put multiple objects and list all from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=greeting1.txt).", @@ -773,28 +794,33 @@ exports[`put multiple objects and list all from bucket 1`] = ` "Put (key=greeting3.txt).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`remove object from a bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=unknown.txt).", "Delete (key=unknown.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`remove object from a bucket with mustExist as option 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=unknown.txt).", "Delete (key=unknown.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; @@ -802,6 +828,7 @@ exports[`remove object from a bucket with mustExist as option 1`] = ` exports[`removing a key will call onDelete method 1`] = ` [ "root/my_bucket/OnDelete started", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "root/my_bucket/OnDelete/OnMessage0 started", @@ -814,6 +841,7 @@ exports[`removing a key will call onDelete method 1`] = ` "Delete (key=unknown.txt).", "Received unknown.txt", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", "root/my_bucket/OnDelete/Policy stopped", "root/my_bucket/OnDelete/TopicEventMapping0 stopped", @@ -825,6 +853,7 @@ exports[`removing a key will call onDelete method 1`] = ` exports[`update an object in bucket 1`] = ` [ "root/my_bucket/OnCreate started", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "root/my_bucket/OnCreate/OnMessage0 started", @@ -837,6 +866,7 @@ exports[`update an object in bucket 1`] = ` "Put (key=1.txt).", "I am done", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", "root/my_bucket/OnCreate/Policy stopped", "root/my_bucket/OnCreate/TopicEventMapping0 stopped", diff --git a/packages/@winglang/sdk/test/target-sim/bucket.test.ts b/packages/@winglang/sdk/test/target-sim/bucket.test.ts index 8f86c51061b..01085cd25f0 100644 --- a/packages/@winglang/sdk/test/target-sim/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-sim/bucket.test.ts @@ -20,6 +20,7 @@ test("create a bucket", async () => { expect(s.getResourceConfig("/my_bucket")).toEqual({ attrs: { handle: expect.any(String), + url: expect.any(String), }, path: "root/my_bucket", addr: expect.any(String), @@ -333,7 +334,9 @@ test("get invalid object throws an error", async () => { await s.stop(); expect(listMessages(s)).toMatchSnapshot(); - expect(s.listTraces()[2].data.status).toEqual("failure"); + expect( + s.listTraces().filter((t) => t.data.status === "failure") + ).toHaveLength(1); expect(app.snapshot()).toMatchSnapshot(); }); @@ -938,7 +941,7 @@ test("bucket ignores corrupted state file", async () => { expect(files).toEqual(["b"]); }); -test("signedUrl is not implemented for the simulator", async () => { +test("signedUrl accepts simple uploads", async () => { // GIVEN const app = new SimApp(); new cloud.Bucket(app, "my_bucket"); @@ -946,10 +949,49 @@ test("signedUrl is not implemented for the simulator", async () => { const s = await app.startSimulator(); const client = s.getResource("/my_bucket") as cloud.IBucketClient; + // WHEN + const signedUrl = await client.signedUrl("key", { + action: cloud.BucketSignedUrlAction.UPLOAD, + }); + const response = await fetch(signedUrl, { + method: "PUT", + body: new Blob(["Hello, World!"], { type: "text/utf8" }), + }); + // THEN - await expect(() => client.signedUrl("key")).rejects.toThrowError( - "signedUrl is not implemented yet" + expect(response.ok).toBe(true); + await expect(client.get("key")).resolves.toBe("Hello, World!"); + + await s.stop(); +}); + +test("signedUrl doesn't accept multipart uploads yet", async () => { + // GIVEN + const app = new SimApp(); + new cloud.Bucket(app, "my_bucket"); + + const s = await app.startSimulator(); + const client = s.getResource("/my_bucket") as cloud.IBucketClient; + + // WHEN + const signedUrl = await client.signedUrl("key", { + action: cloud.BucketSignedUrlAction.UPLOAD, + }); + const response = await fetch(signedUrl, { + method: "PUT", + body: (() => { + const formData = new FormData(); + formData.set("file", new Blob(["Hello, World!"], { type: "text/utf8" })); + return formData; + })(), + }); + + // THEN + expect(response.ok).toBe(false); + await expect(response.text()).resolves.toBe( + "Multipart uploads not supported" ); + await s.stop(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b248ac1193..3bdb1046a90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10717,7 +10717,7 @@ packages: /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: - '@types/node': 20.14.8 + '@types/node': 22.5.5 dev: true /@types/hast@3.0.4: @@ -11091,7 +11091,7 @@ packages: resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} requiresBuild: true dependencies: - '@types/node': 20.14.8 + '@types/node': 22.5.5 dev: true optional: true @@ -14299,7 +14299,7 @@ packages: dependencies: semver: 7.6.3 shelljs: 0.8.5 - typescript: 5.7.0-dev.20240916 + typescript: 5.7.0-dev.20240917 dev: true /dset@3.1.3: @@ -17281,7 +17281,7 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 20.14.8 + '@types/node': 22.5.5 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -22621,8 +22621,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - /typescript@5.7.0-dev.20240916: - resolution: {integrity: sha512-PvqtY2wVBTIlHc0+Vs3w3UNntEzSho0uNm56c8D3mFINC/WFcEyOszkwxjSKyqVwEv62zl6xg+p7Ilt3H6yOJA==} + /typescript@5.7.0-dev.20240917: + resolution: {integrity: sha512-LoeyRa9RbB6lMasZvBmepDPrI6KNHURSleqbQ4BL4lxvXbCAQkMgmRzqyjA2DJE0ytuj5OEPFU5k/g3f9GaQKA==} engines: {node: '>=14.17'} hasBin: true dev: true diff --git a/tests/sdk_tests/bucket/signed_url.test.w b/tests/sdk_tests/bucket/signed_url.test.w index 5df8b055127..3b36b3eb0c7 100644 --- a/tests/sdk_tests/bucket/signed_url.test.w +++ b/tests/sdk_tests/bucket/signed_url.test.w @@ -5,98 +5,96 @@ bring expect; let bucket = new cloud.Bucket(); -// TODO: signedUrl is not implemented for the simulator yet -// https://github.com/winglang/wing/issues/1383 -if util.env("WING_TARGET") != "sim" { - test "signedUrl GET (implicit)" { - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; +test "signedUrl GET (implicit)" { + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - bucket.put(KEY, VALUE); + bucket.put(KEY, VALUE); - let getSignedUrl = bucket.signedUrl(KEY); + let getSignedUrl = bucket.signedUrl(KEY); - // Download file from private bucket using GET presigned URL - let output = util.shell("curl \"{getSignedUrl}\""); + // Download file from private bucket using GET presigned URL + let output = util.shell("curl \"{getSignedUrl}\""); - expect.equal(output, VALUE); - } - - test "signedUrl GET (explicit)" { - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; + expect.equal(output, VALUE); +} - bucket.put(KEY, VALUE); +test "signedUrl GET (explicit)" { + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - let getSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.DOWNLOAD }); + bucket.put(KEY, VALUE); - // Download file from private bucket using GET presigned URL - let output = util.shell("curl \"{getSignedUrl}\""); + let getSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.DOWNLOAD }); - expect.equal(output, VALUE); - } + // Download file from private bucket using GET presigned URL + let output = util.shell("curl \"{getSignedUrl}\""); - test "signedUrl GET with non-existent key" { - let assertThrows = (expected: str, block: (): void) => { - let var error = false; - try { - block(); - } catch actual { - expect.equal(actual, expected); - error = true; - } - expect.equal(error, true); - }; - let UNEXISTING_KEY = "no-such-file.txt"; - let OBJECT_DOES_NOT_EXIST_ERROR = "Cannot provide signed url for a non-existent key (key={UNEXISTING_KEY})"; + expect.equal(output, VALUE); +} - assertThrows(OBJECT_DOES_NOT_EXIST_ERROR, () => { - bucket.signedUrl(UNEXISTING_KEY); - }); - } +test "signedUrl GET with non-existent key" { + let assertThrows = (expected: str, block: (): void) => { + let var error = false; + try { + block(); + } catch actual { + expect.equal(actual, expected); + error = true; + } + expect.equal(error, true); + }; + let UNEXISTING_KEY = "no-such-file.txt"; + let OBJECT_DOES_NOT_EXIST_ERROR = "Cannot provide signed url for a non-existent key (key={UNEXISTING_KEY})"; + + assertThrows(OBJECT_DOES_NOT_EXIST_ERROR, () => { + bucket.signedUrl(UNEXISTING_KEY); + }); +} - test "signedUrl PUT" { - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; +test "signedUrl PUT" { + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - let tempDir = fs.mkdtemp(); - let tempFile = fs.join(tempDir, KEY); - fs.writeFile(tempFile, VALUE); + let tempDir = fs.mkdtemp(); + let tempFile = fs.join(tempDir, KEY); + fs.writeFile(tempFile, VALUE); - let putSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.UPLOAD }); + let putSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.UPLOAD }); - // Upload file to private bucket using PUT presigned URL - util.shell("curl -X PUT -T \"{tempFile}\" \"{putSignedUrl}\""); + // Upload file to private bucket using PUT presigned URL + util.shell("curl -X PUT -T \"{tempFile}\" \"{putSignedUrl}\""); - expect.equal(bucket.get(KEY), VALUE); - } + expect.equal(bucket.get(KEY), VALUE); +} - test "signedUrl duration option is respected" { - let isExpiredTokenError = (output: str) => { - let target = util.env("WING_TARGET"); - let var result = false; +test "signedUrl duration option is respected" { + let isExpiredTokenError = (output: str) => { + let target = util.env("WING_TARGET"); + let var result = false; - if target == "tf-aws" { - result = output.contains("AccessDeniedRequest has expired"); - } else if target == "tf-gcp" { - result = output.contains("ExpiredTokenInvalid argument."); - } + if target == "tf-aws" { + result = output.contains("AccessDeniedRequest has expired"); + } else if target == "tf-gcp" { + result = output.contains("ExpiredTokenInvalid argument."); + } else if target == "sim" { + result = output.contains("Signed URL has expired"); + } - return result; - }; + return result; + }; - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - bucket.put(KEY, VALUE); + bucket.put(KEY, VALUE); - let getSignedUrl = bucket.signedUrl(KEY, { duration: 1s }); + let getSignedUrl = bucket.signedUrl(KEY, { duration: 1s }); - util.sleep(2s); + util.sleep(2s); - // Download file from private bucket using expired GET presigned URL - let output = util.shell("curl \"{getSignedUrl}\""); + // Download file from private bucket using expired GET presigned URL + let output = util.shell("curl \"{getSignedUrl}\""); - expect.equal(isExpiredTokenError(output), true); - } + expect.equal(isExpiredTokenError(output), true); } diff --git a/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md b/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md index b39fe7d0e69..612e92db068 100644 --- a/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md +++ b/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md @@ -2,9 +2,13 @@ ## stdout.log ```log -pass ─ signed_url.test.wsim (no tests) +pass ─ signed_url.test.wsim » root/Default/test:signedUrl duration option is respected +pass ─ signed_url.test.wsim » root/Default/test:signedUrl GET (explicit) +pass ─ signed_url.test.wsim » root/Default/test:signedUrl GET (implicit) +pass ─ signed_url.test.wsim » root/Default/test:signedUrl GET with non-existent key +pass ─ signed_url.test.wsim » root/Default/test:signedUrl PUT -Tests 1 passed (1) +Tests 5 passed (5) Snapshots 1 skipped Test Files 1 passed (1) Duration