Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move AWS secrets from .env to lookit-api and add S3 error handling #85

Merged
merged 8 commits into from
Oct 29, 2024
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(),
...config.plugins,
],
};
Expand Down
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