Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,24 @@ function mockSafeExecuteJavaScript(
return { success: true, value: false };
case "countSelectableItems":
return { success: true, value: itemsPerBatch };
case "describeBlockingUI":
// A password confirmation modal over the activity log
return {
success: true,
value: {
url: "https://www.facebook.com/123/allactivity",
passwordInputCount: 1,
passwordInputs: [{ name: "pass", ariaLabel: "Password" }],
dialogs: [
{
ariaLabel: "Confirm password",
hasPasswordInput: true,
buttons: ["Continue"],
text: "Please re-enter your password",
},
],
},
};
case "clickDeletePostsOption":
return { success: true, value: clickTrashSuccess };
case "confirmDeletion":
Expand Down Expand Up @@ -280,5 +298,62 @@ describe("FacebookViewModel Delete Jobs", () => {
// The job errored before completing, so it is never marked finished
expect(vm.progress.isDeleteActivityFinished).toBe(false);
});

it("never captures a password", () => {
const PASSWORD = "correct-horse-battery-staple";

document.title = "Facebook";
document.body.innerHTML = `
<div role="dialog" aria-modal="true" aria-label="Confirm password">
<h2><span>Please re-enter your password</span></h2>
<form>
<input type="password" name="pass" aria-label="Password" placeholder="Password" />
<div role="button" aria-label="Continue"><span>Continue</span></div>
</form>
</div>
`;

const input = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
// As a user types it, and as a page could reflect it into the markup
input.value = PASSWORD;
input.setAttribute("value", PASSWORD);

const snapshot = eval(DeleteJobs.BLOCKING_UI_JS);

expect(JSON.stringify(snapshot)).not.toContain(PASSWORD);
// Still captures what we need to write a selector
expect(snapshot.passwordInputs[0]).toMatchObject({
name: "pass",
ariaLabel: "Password",
});
expect(snapshot.dialogs[0].buttons).toContain("Continue");

document.body.innerHTML = "";
});

it("attaches a description of whatever blocked us to the error report", async () => {
const vm = createMockFacebookViewModel({
facebookAccount: createMockFacebookAccount({ deleteWallPosts: true }),
});
mockSafeExecuteJavaScript(vm, {
batchesPerCategory: 1,
clickTrashSuccess: false,
});

await DeleteJobs.runJobDeleteActivity(vm, 3);

const sensitiveContext = vi.mocked(vm.error).mock.calls[0][2];
expect(sensitiveContext.blockingUI).toMatchObject({
passwordInputCount: 1,
dialogs: [
expect.objectContaining({
ariaLabel: "Confirm password",
hasPasswordInput: true,
}),
],
});
});
});
});
66 changes: 62 additions & 4 deletions src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,71 @@ const BATCH_COOLDOWN_MS = 5000;
const PROCESSING_BACKOFF_MS = 60000;
const MAX_PROCESSING_RETRIES = 15;

/**
* Attributes and rendered text only: never `.value` (so we don't capture a password)
* Exported so the tests can run the shipping code against this
*/
export const BLOCKING_UI_JS = `(() => {
const text = (el) => ((el.innerText || el.textContent) || '').replace(/\\s+/g, ' ').trim();
const attr = (el, name) => (el.getAttribute ? el.getAttribute(name) : null);

return {
url: document.location ? document.location.href.split('?')[0] : '',
title: (document.title || '').slice(0, 120),
bodyText: text(document.body).slice(0, 500),
passwordInputCount: document.querySelectorAll('input[type="password"]').length,
passwordInputs: Array.from(document.querySelectorAll('input[type="password"]'))
.slice(0, 3)
.map((input) => ({
name: attr(input, 'name'),
id: attr(input, 'id'),
ariaLabel: attr(input, 'aria-label'),
placeholder: attr(input, 'placeholder'),
autocomplete: attr(input, 'autocomplete'),
})),
dialogs: Array.from(document.querySelectorAll('div[role="dialog"]'))
.slice(0, 5)
.map((dialog) => ({
ariaLabel: attr(dialog, 'aria-label'),
ariaModal: attr(dialog, 'aria-modal'),
hasPasswordInput: dialog.querySelector('input[type="password"]') !== null,
buttons: Array.from(dialog.querySelectorAll('div[role="button"], button'))
.slice(0, 8)
.map((button) => (attr(button, 'aria-label') || text(button)).slice(0, 40))
.filter((label) => label.length > 0),
text: text(dialog).slice(0, 300),
})),
};
})()`;

/**
* Describe whatever Facebook has put on screen, so an error report identifies the modal
* that blocked us such as a password confirmation
*/
async function describeBlockingUI(
vm: FacebookViewModel,
): Promise<Record<string, unknown>> {
const result = await vm.safeExecuteJavaScript<Record<string, unknown>>(
BLOCKING_UI_JS,
"describeBlockingUI",
);
return result.success
? result.value
: { error: `Could not inspect the page: ${result.error}` };
}

async function reportDeleteWallPostsError(
vm: FacebookViewModel,
jobIndex: number,
errorType: AutomationErrorType,
errorReportData: Record<string, unknown>,
) {
const blockingUI = await describeBlockingUI(vm);
vm.log("reportDeleteWallPostsError", { errorType, blockingUI });

await vm.error(errorType, errorReportData, {
currentURL: vm.webview?.getURL(),
blockingUI,
});
await Helpers.errorJob(vm, jobIndex);
}
Expand Down Expand Up @@ -305,10 +362,11 @@ async function deleteCategory(
// Select all currently loaded items
const toggled = await toggleSelectAllCheckbox(vm, true);
if (!toggled) {
vm.log(
"deleteCategory",
`Could not select items for category ${category.setting}`,
);
// Exits without an error report, so log the page
vm.log("deleteCategory", {
message: `Could not select items for category ${category.setting}`,
blockingUI: await describeBlockingUI(vm),
});
break;
}

Expand Down
Loading