Skip to content

Commit

Permalink
Move AWS secrets from .env to lookit-api and add S3 error handling (#…
Browse files Browse the repository at this point in the history
…85)

* move AWS credentials from .env to lookit-api template, get credentials from document, throw errors for expired credentials

* move creation of input and command into completeUpload try/catch in case one of the uploadPart promises throws an error, and catch an ExpiredCredentials error from uploadPart in the completeUpload catch block

* add tests for missing and expired credentials in LookitS3 constructor

* add tests for expired credentials errors in createUpload, uploadPart, and completeUpload

* add changeset

* Removed unneeded environment type file

---------

Co-authored-by: CJ Green <[email protected]>
  • Loading branch information
becky-gilbert and okaycj authored Oct 29, 2024
1 parent 7230f51 commit 9605ac4
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 51 deletions.
5 changes: 5 additions & 0 deletions .changeset/slimy-grapes-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lookit/data": minor
---

Move AWS secrets for video uploading from .env to lookit-api.
3 changes: 0 additions & 3 deletions packages/data/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import dotenv from "rollup-plugin-dotenv";
import { makeRollupConfig } from "../../rollup.mjs";

export default makeRollupConfig("chsData").map((config) => {
Expand All @@ -8,8 +7,6 @@ export default makeRollupConfig("chsData").map((config) => {
plugins: [
// Resolve node dependencies to be used in a browser.
nodeResolve({ browser: true, preferBuiltins: false }),
// Add support for .env files
dotenv({ cwd: "../../" }),
...config.plugins,
],
};
Expand Down
12 changes: 0 additions & 12 deletions packages/data/src/environment.d.ts

This file was deleted.

34 changes: 31 additions & 3 deletions packages/data/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ export class AWSConfigError extends Error {
* AWS cofiguration error. This could be due to incorrect credentials, bucket
* name, and/or region.
*
* @param errorMsg - Message property of error object from the AWS response.
* @param error - Error object from the AWS response.
*/
public constructor(errorMsg: string) {
super(`AWS configuration error: ${errorMsg}`);
public constructor(error: unknown) {
let err_msg = "";
if (error instanceof Error) {
err_msg = error.message;
}
super(`AWS configuration error: ${err_msg}`);
this.name = "AWSConfigError";
}
}

/** Error for when URL from Django API is not formatted as expected. */
export class URLWrongError extends Error {
/** Throw error when URL is not formatted correctly. */
Expand All @@ -45,3 +50,26 @@ export class URLWrongError extends Error {
this.name = "URLWrongError";
}
}

/** Error for when temporary AWS credentials are not found on the page. */
export class MissingCredentials extends Error {
/** Throw error when AWS credentials are not found. */
public constructor() {
super("AWS credentials for video uploading not found.");
this.name = "MissingCredentials";
}
}

/** Error for when temporary AWS credentials have expired. */
export class ExpiredCredentials extends Error {
/** Throw error when temporary AWS credentials have expired. */
public constructor() {
super(
"The video upload credentials have expired. Please re-start the experiment on the CHS website.",
);
alert(
"Your credentials have expired. Please re-start the experiment on the CHS website.",
);
this.name = "ExpiredCredentials";
}
}
101 changes: 101 additions & 0 deletions packages/data/src/lookitS3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@ import {
CreateMultipartUploadCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { ExpiredCredentials } from "./errors";
import LookitS3 from "./lookitS3";

/** Mock for the AWS expired token error that can be received from s3.send. */
class MockExpiredTokenError extends Error {
/**
* Constructor
*
* @param message - Error message.
*/
public constructor(message: string) {
super(message);
this.name = "ExpiredTokenException";
}
}

let mockSendRtn: { UploadId?: string; ETag?: string };
const largeBlob = new Blob(["x".repeat(LookitS3.minUploadSize + 1)]);

Expand All @@ -17,6 +31,18 @@ jest.mock("@aws-sdk/client-s3", () => ({
CompleteMultipartUploadCommand: jest.fn().mockImplementation(),
}));

// Mock the retrieval of AWS variables passed from lookit-api into the jsPsych study template. The variables are stored in a script element that is retrieved with document.getElementById in the LookitS3 constructor. This mock is currently needed for all tests in this file, but could potentially cause problems if document.getElementById is used for other purposes during these tests.
beforeEach(() => {
Object.assign(document, {
getElementById: jest.fn().mockImplementation(() => {
return {
textContent:
'{"JSPSYCH_S3_REGION":"region","JSPSYCH_S3_ACCESS_KEY_ID":"keyId","JSPSYCH_S3_SECRET_ACCESS_KEY":"key","JSPSYCH_S3_BUCKET":"bucket","JSPSYCH_S3_SESSION_TOKEN":"token","JSPSYCH_S3_EXPIRATION":"datestring"}',
};
}),
});
});

afterEach(() => {
jest.clearAllMocks();
});
Expand Down Expand Up @@ -102,3 +128,78 @@ test("Upload in progress", async () => {
await s3.completeUpload();
expect(s3.uploadInProgress).toBe(false);
});

test("Create upload throws expired credentials error", () => {
window.alert = jest.fn();
const s3 = new LookitS3("key value");

// Implement the token expired error for s3.send
s3["s3"]["send"] = jest.fn(() => {
throw new MockExpiredTokenError("");
});

expect(async () => await s3.createUpload()).rejects.toThrow(
ExpiredCredentials,
);
expect(async () => await s3.createUpload()).rejects.toThrow(
"The video upload credentials have expired. Please re-start the experiment on the CHS website.",
);
expect(window.alert).toHaveBeenCalledTimes(2);
expect(window.alert).toHaveBeenCalledWith(
"Your credentials have expired. Please re-start the experiment on the CHS website.",
);
});

test("Upload part throws expired credentials error", async () => {
window.alert = jest.fn();
mockSendRtn = { UploadId: "upload id", ETag: "etag" };
const s3 = new LookitS3("key value");

// Create the upload without errors
await s3.createUpload();
s3.onDataAvailable(largeBlob);

// Implement the token expired error for s3.send
s3["s3"]["send"] = jest.fn(() => {
throw new MockExpiredTokenError("");
});

// completeUpload calls uploadPart
expect(async () => {
await s3.completeUpload();
}).rejects.toThrow(ExpiredCredentials);
expect(async () => {
await s3.completeUpload();
}).rejects.toThrow(
"The video upload credentials have expired. Please re-start the experiment on the CHS website.",
);
expect(window.alert).toHaveBeenCalledTimes(2);
expect(window.alert).toHaveBeenCalledWith(
"Your credentials have expired. Please re-start the experiment on the CHS website.",
);
});

test("Complete upload throws expired credentials", async () => {
mockSendRtn = { UploadId: "upload id", ETag: "etag" };
const s3 = new LookitS3("key value");
await s3.createUpload();

// Mock addUploadPartPromise because completeUpload will trigger uploadPart and s3.send for the part, before sending the complete upload command. If we just mock the s3.send error and call completeUpload, the error will always be thrown for uploadPart and the code will never reach the second s3.send command to complete the upload.
s3["uploadPart"] = jest.fn().mockImplementation(() => {
return Promise.resolve({ PartNumber: 1, ETag: "etag" });
});

// Implement the token expired error for s3.send
s3["s3"]["send"] = jest.fn(() => {
throw new MockExpiredTokenError("");
});

expect(async () => {
await s3.completeUpload();
}).rejects.toThrow(ExpiredCredentials);
expect(async () => {
await s3.completeUpload();
}).rejects.toThrow(
"The video upload credentials have expired. Please re-start the experiment on the CHS website.",
);
});
121 changes: 90 additions & 31 deletions packages/data/src/lookitS3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import {
S3Client,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { AWSConfigError, AWSMissingAttrError, UploadPartError } from "./errors";
import {
AWSConfigError,
AWSMissingAttrError,
ExpiredCredentials,
MissingCredentials,
UploadPartError,
} from "./errors";
import { awsVars } from "./types";

/** Provides functionality to upload videos incrementally to an AWS S3 Bucket. */
class LookitS3 {
Expand All @@ -15,7 +22,8 @@ class LookitS3 {
private s3: S3Client;
private uploadId: string = "";
private key: string;
private bucket: string = process.env.JSPSYCH_S3_BUCKET;
private bucket: string;
private awsVars: awsVars;
private complete: boolean = false;

public static readonly minUploadSize: number = 5 * 1024 * 1024;
Expand All @@ -29,20 +37,29 @@ class LookitS3 {
public constructor(key: string) {
this.key = key;
try {
this.awsVars = JSON.parse(
document.getElementById("aws-vars")!.textContent!,
);
} catch {
throw new MissingCredentials();
}
try {
this.bucket = this.awsVars.JSPSYCH_S3_BUCKET;
this.s3 = new S3Client({
region: process.env.JSPSYCH_S3_REGION,
region: this.awsVars.JSPSYCH_S3_REGION,
credentials: {
accessKeyId: process.env.JSPSYCH_S3_ACCESS_KEY_ID,
secretAccessKey: process.env.JSPSYCH_S3_SECRET_ACCESS_KEY,
accessKeyId: this.awsVars.JSPSYCH_S3_ACCESS_KEY_ID,
secretAccessKey: this.awsVars.JSPSYCH_S3_SECRET_ACCESS_KEY,
sessionToken: this.awsVars.JSPSYCH_S3_SESSION_TOKEN,
},
});
} catch (e) {
console.error(`Error setting up S3 client: ${e}`);
let err_msg = "";
if (e instanceof Error) {
err_msg = e.message;
this.logRecordingEvent(`Error setting up the S3 client.\nError: ${e}`);
if (this.awsExpiredToken(e)) {
throw new ExpiredCredentials();
} else {
throw new AWSConfigError(e);
}
throw new AWSConfigError(err_msg);
}
}

Expand Down Expand Up @@ -86,13 +103,23 @@ class LookitS3 {
ContentType: "video/webm", // TO DO: check browser support for type/codec and set the actual value here
});

const response = await this.s3.send(command);
if (!response.UploadId) {
throw new AWSMissingAttrError("UploadId");
try {
const response = await this.s3.send(command);
if (!response.UploadId) {
throw new AWSMissingAttrError("UploadId");
}
this.uploadId = response.UploadId;
this.logRecordingEvent(`Connection established.`);
} catch (error) {
this.logRecordingEvent(
`Error creating upload ${this.key}.\nError: ${error}`,
);
if (this.awsExpiredToken(error)) {
throw new ExpiredCredentials();
} else {
throw error;
}
}

this.uploadId = response.UploadId;
this.logRecordingEvent(`Connection established.`);
}

/**
Expand Down Expand Up @@ -133,8 +160,12 @@ class LookitS3 {
this.logRecordingEvent(
`Error uploading part ${partNumber}.\nError: ${_err}`,
);
err = _err as Error;
retry += 1;
if (this.awsExpiredToken(_err)) {
throw new ExpiredCredentials();
} else {
err = _err as Error;
retry += 1;
}
}
}

Expand All @@ -148,19 +179,29 @@ class LookitS3 {
public async completeUpload() {
this.addUploadPartPromise();

const input = {
Bucket: this.bucket,
Key: this.key,
MultipartUpload: {
Parts: await Promise.all(this.promises),
},
UploadId: this.uploadId,
};
const command = new CompleteMultipartUploadCommand(input);
const response = await this.s3.send(command);
this.complete = true;

this.logRecordingEvent(`Upload complete: ${response.Location}`);
try {
const input = {
Bucket: this.bucket,
Key: this.key,
MultipartUpload: {
Parts: await Promise.all(this.promises),
},
UploadId: this.uploadId,
};
const command = new CompleteMultipartUploadCommand(input);
const response = await this.s3.send(command);
this.complete = true;
this.logRecordingEvent(`Upload complete: ${response.Location}`);
} catch (error) {
this.logRecordingEvent(
`Error completing upload ${this.key}.\nError: ${error}`,
);
if (this.awsExpiredToken(error) || error instanceof ExpiredCredentials) {
throw new ExpiredCredentials();
} else {
throw error;
}
}
}

/**
Expand Down Expand Up @@ -197,6 +238,24 @@ class LookitS3 {
public get uploadInProgress(): boolean {
return this.uploadId !== "" && !this.complete;
}

/**
* Check whether an AWS S3 error is due to expired credentials/token.
*
* @param error - Error returned from S3.
* @returns Boolean indicating whether or not this is an Expired Token error.
*/
private awsExpiredToken(error: unknown) {
if (
error instanceof Object &&
"name" in error &&
error.name === "ExpiredTokenException"
) {
return true;
} else {
return false;
}
}
}

export default LookitS3;
Loading

0 comments on commit 9605ac4

Please sign in to comment.