Skip to content

Commit

Permalink
Error handling for card saves, including WAF rejections
Browse files Browse the repository at this point in the history
  • Loading branch information
lukemelia committed Nov 27, 2024
1 parent d2e8804 commit 0de7b6f
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 30 deletions.
52 changes: 36 additions & 16 deletions packages/host/app/components/operator-mode/stack-item.gts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export default class OperatorModeStackItem extends Component<Signature> {
@tracked private hasUnsavedChanges = false;
@tracked private lastSaved: number | undefined;
@tracked private lastSavedMsg: string | undefined;
@tracked private lastSaveError: Error | undefined;
private refreshSaveMsg: number | undefined;
private subscribedCard: CardDef | undefined;
private contentEl: HTMLElement | undefined;
Expand Down Expand Up @@ -350,28 +351,47 @@ export default class OperatorModeStackItem extends Component<Signature> {
private initiateAutoSaveTask = restartableTask(async () => {
this.hasUnsavedChanges = true;
await timeout(this.environmentService.autoSaveDelayMs);
this.isSaving = true;
await this.args.saveCard(this.card);
this.hasUnsavedChanges = false;
this.isSaving = false;
this.lastSaved = Date.now();
this.calculateLastSavedMsg();
try {
this.isSaving = true;
this.lastSaveError = undefined;
await timeout(25);
await this.args.saveCard(this.card);
this.hasUnsavedChanges = false;
this.lastSaved = Date.now();
} catch (error) {
// error will already be logged in CardService
this.lastSaveError = error as Error;
} finally {
this.isSaving = false;
this.calculateLastSavedMsg();
}
});

private calculateLastSavedMsg() {
// runs frequently, so only change a tracked property if the value has changed
if (this.lastSaved == null) {
if (this.lastSavedMsg) {
this.lastSavedMsg = undefined;
}
} else {
let savedMessage = `Saved ${formatDistanceToNow(this.lastSaved, {
let savedMessage: string | undefined;
if (this.lastSaveError) {
savedMessage = `Failed to save: ${this.getErrorMessage(
this.lastSaveError,
)}`;
} else if (this.lastSaved) {
savedMessage = `Saved ${formatDistanceToNow(this.lastSaved, {
addSuffix: true,
})}`;
if (this.lastSavedMsg != savedMessage) {
this.lastSavedMsg = savedMessage;
}
}
// runs frequently, so only change a tracked property if the value has changed
if (this.lastSavedMsg != savedMessage) {
this.lastSavedMsg = savedMessage;
}
}

private getErrorMessage(error: Error) {
if ((error as any).responseHeaders?.get('x-blocked-by-waf-rule')) {
return 'Rejected by firewall';
}
if (error.message) {
return error.message;
}
return 'Unknown error';
}

private doneEditing = restartableTask(async () => {
Expand Down
5 changes: 1 addition & 4 deletions packages/host/app/services/card-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export default class CardService extends Service {

err.status = response.status;
err.responseText = responseText;
err.responseHeaders = response.headers;

throw err;
}
Expand Down Expand Up @@ -255,12 +256,8 @@ export default class CardService extends Service {
}
return result;
} catch (err) {
// TODO for CS-6268 we'll need to show a visual indicator that the auto
// save has failed. Until that ticket is implemented, the only indication
// of a failed auto-save will be from the console.
console.error(`Failed to save ${card.id}: `, err);
throw err;
return;
} finally {
api.unsubscribeFromChanges(card, onCardChange);
}
Expand Down
78 changes: 68 additions & 10 deletions packages/host/tests/integration/components/operator-mode-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
fillIn,
focus,
blur,
setupOnerror,
triggerEvent,
triggerKeyEvent,
typeIn,
Expand Down Expand Up @@ -41,6 +40,7 @@ import { TestRealmAdapter } from '../../helpers/adapter';
import { setupMockMatrix } from '../../helpers/mock-matrix';
import { renderComponent } from '../../helpers/render-component';
import { setupRenderingTest } from '../../helpers/setup';
import NetworkService from '@cardstack/host/services/network';

module('Integration | operator-mode', function (hooks) {
setupRenderingTest(hooks);
Expand Down Expand Up @@ -548,15 +548,7 @@ module('Integration | operator-mode', function (hooks) {
await click('[data-test-edit-button]');
});

// TODO CS-6268 visual indicator for failed auto-save should build off of this test
test('an error in auto-save is handled gracefully', async function (assert) {
let done = assert.async();

setupOnerror(function (error) {
assert.ok(error, 'expected a global error');
done();
});

await setCardInOperatorModeState(`${testRealmURL}BoomPet/paper`);

await renderComponent(
Expand All @@ -570,7 +562,19 @@ module('Integration | operator-mode', function (hooks) {
await waitFor('[data-test-pet]');
await waitFor('[data-test-edit-button]');
await click('[data-test-edit-button]');
await fillIn('[data-test-field="boom"] input', 'Bad cat!');
fillIn('[data-test-field="boom"] input', 'Bad cat!');
await waitUntil(
() =>
document
.querySelector('[data-test-auto-save-indicator]')
?.textContent?.trim() == 'Saving…',
);
await waitUntil(
() =>
document
.querySelector('[data-test-auto-save-indicator]')
?.textContent?.trim() == 'Failed to save: Boom!',
);
await setCardInOperatorModeState(`${testRealmURL}BoomPet/paper`);

await waitFor('[data-test-pet]');
Expand All @@ -579,6 +583,60 @@ module('Integration | operator-mode', function (hooks) {
assert.dom('[data-test-pet]').includesText('Paper Bad cat!');
});

test('a 403 from Web Appliction Firewall is handled gracefully when auto-saving', async function (assert) {
// TODO: setup virtual network to return WAF 403 when saving
let networkService = this.owner.lookup('service:network') as NetworkService;
networkService.virtualNetwork.mount(
async (req: Request) => {
if (req.method === 'PATCH' && req.url.includes('test/Pet/buzz')) {
return new Response(
'{ message: "Request blocked by Web Application Firewall. See x-blocked-by-waf-rule response header for detail." }',
{
status: 403,
headers: {
'Content-Type': 'application/json',
'X-Blocked-By-WAF-Rule': 'CrossSiteScripting_BODY',
},
},
);
}
return null;
},
{ prepend: true },
);
await setCardInOperatorModeState(`${testRealmURL}Pet/buzz`);

await renderComponent(
class TestDriver extends GlimmerComponent {
<template>
<OperatorMode @onClose={{noop}} />
<CardPrerender />
</template>
},
);
await waitFor('[data-test-field="name"]');
await waitFor('[data-test-edit-button]');
await click('[data-test-edit-button]');
fillIn('[data-test-field="name"] input', 'Fuzz');
await waitUntil(
() =>
document
.querySelector('[data-test-auto-save-indicator]')
?.textContent?.trim() == 'Saving…',
{ timeoutMessage: 'Waitng for Saving... to appear' },
);
await waitUntil(
() =>
document
.querySelector('[data-test-auto-save-indicator]')
?.textContent?.trim() == 'Failed to save: Rejected by firewall',
{ timeoutMessage: 'Waitng for "Failed to save" to appear' },
);
assert
.dom('[data-test-auto-save-indicator]')
.containsText('Failed to save: Rejected by firewall');
});

test('opens workspace chooser after closing the only remainingcard on the stack', async function (assert) {
await setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`);

Expand Down

0 comments on commit 0de7b6f

Please sign in to comment.