Apply `strictNullChecks` to `src/utils/exportUtils` (#10379)

* Apply `strictNullChecks` to `src/utils/exportUtils`

* strict fix

* test coverage

* lint

* test coverage

* one more test
pull/28788/head^2
Kerry 2023-03-30 10:47:07 +13:00 committed by GitHub
parent 1447829543
commit 9a733a6444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 178 additions and 9 deletions

View File

@ -47,7 +47,8 @@ export class DecryptError extends Error {
* @param {IMediaEventInfo} info The info parameter taken from the matrix event. * @param {IMediaEventInfo} info The info parameter taken from the matrix event.
* @returns {Promise<Blob>} Resolves to a Blob of the file. * @returns {Promise<Blob>} Resolves to a Blob of the file.
*/ */
export async function decryptFile(file: IEncryptedFile, info?: IMediaEventInfo): Promise<Blob> { export async function decryptFile(file?: IEncryptedFile, info?: IMediaEventInfo): Promise<Blob> {
// throws if file is falsy
const media = mediaFromContent({ file }); const media = mediaFromContent({ file });
let responseData: ArrayBuffer; let responseData: ArrayBuffer;
@ -64,7 +65,7 @@ export async function decryptFile(file: IEncryptedFile, info?: IMediaEventInfo):
try { try {
// Decrypt the array buffer using the information taken from the event content. // Decrypt the array buffer using the information taken from the event content.
const dataArray = await encrypt.decryptAttachment(responseData, file); const dataArray = await encrypt.decryptAttachment(responseData, file!);
// Turn the array into a Blob and give it the correct MIME-type. // Turn the array into a Blob and give it the correct MIME-type.
// IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise

View File

@ -51,7 +51,8 @@ export default abstract class Exporter {
if ( if (
exportOptions.maxSize < 1 * 1024 * 1024 || // Less than 1 MB exportOptions.maxSize < 1 * 1024 * 1024 || // Less than 1 MB
exportOptions.maxSize > 8000 * 1024 * 1024 || // More than 8 GB exportOptions.maxSize > 8000 * 1024 * 1024 || // More than 8 GB
exportOptions.numberOfMessages > 10 ** 8 (!!exportOptions.numberOfMessages && exportOptions.numberOfMessages > 10 ** 8) ||
(exportType === ExportType.LastNMessages && !exportOptions.numberOfMessages)
) { ) {
throw new Error("Invalid export options"); throw new Error("Invalid export options");
} }
@ -123,10 +124,11 @@ export default abstract class Exporter {
} }
protected setEventMetadata(event: MatrixEvent): MatrixEvent { protected setEventMetadata(event: MatrixEvent): MatrixEvent {
const roomState = this.client.getRoom(this.room.roomId).currentState; const roomState = this.client.getRoom(this.room.roomId)?.currentState;
event.sender = roomState.getSentinelMember(event.getSender()); const sender = event.getSender();
event.sender = (!!sender && roomState?.getSentinelMember(sender)) || null;
if (event.getType() === "m.room.member") { if (event.getType() === "m.room.member") {
event.target = roomState.getSentinelMember(event.getStateKey()); event.target = roomState?.getSentinelMember(event.getStateKey()!) ?? null;
} }
return event; return event;
} }
@ -135,6 +137,8 @@ export default abstract class Exporter {
let limit: number; let limit: number;
switch (this.exportType) { switch (this.exportType) {
case ExportType.LastNMessages: case ExportType.LastNMessages:
// validated in constructor that numberOfMessages is defined
// when export type is LastNMessages
limit = this.exportOptions.numberOfMessages!; limit = this.exportOptions.numberOfMessages!;
break; break;
case ExportType.Timeline: case ExportType.Timeline:
@ -221,8 +225,14 @@ export default abstract class Exporter {
return events; return events;
} }
protected async getMediaBlob(event: MatrixEvent): Promise<Blob | undefined> { /**
let blob: Blob | undefined; * Decrypts if necessary, and fetches media from a matrix event
* @param event - matrix event with media event content
* @resolves when media has been fetched
* @throws if media was unable to be fetched
*/
protected async getMediaBlob(event: MatrixEvent): Promise<Blob> {
let blob: Blob | undefined = undefined;
try { try {
const isEncrypted = event.isEncrypted(); const isEncrypted = event.isEncrypted();
const content = event.getContent<IMediaEventContent>(); const content = event.getContent<IMediaEventContent>();
@ -231,12 +241,18 @@ export default abstract class Exporter {
blob = await decryptFile(content.file); blob = await decryptFile(content.file);
} else { } else {
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (!media.srcHttp) {
throw new Error("Cannot fetch without srcHttp");
}
const image = await fetch(media.srcHttp); const image = await fetch(media.srcHttp);
blob = await image.blob(); blob = await image.blob();
} }
} catch (err) { } catch (err) {
logger.log("Error decrypting media"); logger.log("Error decrypting media");
} }
if (!blob) {
throw new Error("Unable to fetch file");
}
return blob; return blob;
} }

View File

@ -14,7 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventType, IRoomEvent, MatrixClient, MatrixEvent, MsgType, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import {
EventType,
IRoomEvent,
MatrixClient,
MatrixEvent,
MsgType,
Room,
RoomMember,
RoomState,
} from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest"; import fetchMock from "fetch-mock-jest";
import { filterConsole, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../test-utils"; import { filterConsole, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../test-utils";
@ -51,6 +60,20 @@ const EVENT_ATTACHMENT: IRoomEvent = {
}, },
}; };
const EVENT_ATTACHMENT_MALFORMED: IRoomEvent = {
event_id: "$2",
type: EventType.RoomMessage,
sender: "@alice:example.com",
origin_server_ts: 1,
content: {
msgtype: MsgType.File,
body: "hello.txt",
file: {
url: undefined,
},
},
};
describe("HTMLExport", () => { describe("HTMLExport", () => {
let client: jest.Mocked<MatrixClient>; let client: jest.Mocked<MatrixClient>;
let room: Room; let room: Room;
@ -107,6 +130,22 @@ describe("HTMLExport", () => {
fetchMock.get(media.srcHttp!, body); fetchMock.get(media.srcHttp!, body);
} }
it("should throw when created with invalid config for LastNMessages", async () => {
expect(
() =>
new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: undefined,
},
() => {},
),
).toThrow("Invalid export options");
});
it("should have an SDK-branded destination file name", () => { it("should have an SDK-branded destination file name", () => {
const roomName = "My / Test / Room: Welcome"; const roomName = "My / Test / Room: Welcome";
const stubOptions: IExportOptions = { const stubOptions: IExportOptions = {
@ -266,6 +305,56 @@ describe("HTMLExport", () => {
expect(await file.text()).toBe(avatarContent); expect(await file.text()).toBe(avatarContent);
}); });
it("should handle when an event has no sender", async () => {
const EVENT_MESSAGE_NO_SENDER: IRoomEvent = {
event_id: "$1",
type: EventType.RoomMessage,
sender: "",
origin_server_ts: 0,
content: {
msgtype: "m.text",
body: "Message with no sender",
},
};
mockMessages(EVENT_MESSAGE_NO_SENDER);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain(EVENT_MESSAGE_NO_SENDER.content.body);
});
it("should handle when events sender cannot be found in room state", async () => {
mockMessages(EVENT_MESSAGE);
jest.spyOn(RoomState.prototype, "getSentinelMember").mockReturnValue(null);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain(EVENT_MESSAGE.content.body);
});
it("should include attachments", async () => { it("should include attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT); mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet"; const attachmentBody = "Lorem ipsum dolor sit amet";
@ -294,6 +383,68 @@ describe("HTMLExport", () => {
expect(text).toBe(attachmentBody); expect(text).toBe(attachmentBody);
}); });
it("should handle when attachment cannot be fetched", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT_MALFORMED, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
// good attachment present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).not.toBeUndefined();
// Ensure that the attachment has the expected content
const text = await file.text();
expect(text).toBe(attachmentBody);
// messages export still successful
const messagesFile = getMessageFile(exporter);
expect(await messagesFile.text()).toBeTruthy();
});
it("should handle when attachment srcHttp is falsy", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
jest.spyOn(client, "mxcUrlToHttp").mockReturnValue(null);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
// attachment not present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).toBeUndefined();
// messages export still successful
const messagesFile = getMessageFile(exporter);
expect(await messagesFile.text()).toBeTruthy();
});
it("should omit attachments", async () => { it("should omit attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT); mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
@ -323,6 +474,7 @@ describe("HTMLExport", () => {
{ {
attachmentsIncluded: false, attachmentsIncluded: false,
maxSize: 1_024 * 1_024, maxSize: 1_024 * 1_024,
numberOfMessages: 5000,
}, },
() => {}, () => {},
); );