mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(sync): surface Dropbox OAuth service failures (#8988)
This commit is contained in:
parent
a7a26588e6
commit
44df8d1675
6 changed files with 135 additions and 6 deletions
|
|
@ -528,10 +528,7 @@ export class DropboxApi {
|
|||
|
||||
if (!response.ok) {
|
||||
const bodyStr = await response.text();
|
||||
throw new HttpNotOkAPIError(
|
||||
new Response(bodyStr, { status: response.status }),
|
||||
bodyStr,
|
||||
);
|
||||
throw new HttpNotOkAPIError(response, bodyStr);
|
||||
}
|
||||
|
||||
data = (await response.json()) as TokenResponse;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AuthFailSPError,
|
||||
HttpNotOkAPIError,
|
||||
RemoteFileNotFoundAPIError,
|
||||
TooManyRequestsAPIError,
|
||||
UploadRevToMatchMismatchAPIError,
|
||||
|
|
@ -571,6 +572,37 @@ describe('DropboxApi', () => {
|
|||
expect(body).toContain('grant_type=authorization_code');
|
||||
});
|
||||
|
||||
it('preserves response metadata when the token exchange fails', async () => {
|
||||
const headers = new Headers();
|
||||
headers.set('X-Dropbox-Request-Id', 'dbx-request-123');
|
||||
const response = new Response('gateway timeout', {
|
||||
status: 504,
|
||||
statusText: 'Gateway Timeout',
|
||||
headers,
|
||||
});
|
||||
fetchSpy.mockResolvedValue(response);
|
||||
|
||||
let thrown: HttpNotOkAPIError | undefined;
|
||||
try {
|
||||
await dropboxApi.getTokensFromAuthCode(
|
||||
'test-auth-code',
|
||||
'test-code-verifier',
|
||||
null,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpNotOkAPIError) {
|
||||
thrown = error;
|
||||
}
|
||||
}
|
||||
|
||||
expect(thrown).toBeDefined();
|
||||
expect(thrown?.response).toBe(response);
|
||||
expect(thrown?.message).toBe('HTTP 504 Gateway Timeout');
|
||||
expect(thrown?.response.headers.get('X-Dropbox-Request-Id')).toBe(
|
||||
'dbx-request-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid token response', async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -40,10 +40,14 @@ import {
|
|||
EncryptNoPasswordError,
|
||||
ForceUploadFailedError,
|
||||
ForceUploadPendingOpsError,
|
||||
HttpNotOkAPIError,
|
||||
IncompleteRemoteOperationsError,
|
||||
} from '../../op-log/core/errors/sync-errors';
|
||||
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
|
||||
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
|
||||
import type { SyncProviderBase } from '../../op-log/sync-providers/provider.interface';
|
||||
import type { MatDialogRef } from '@angular/material/dialog';
|
||||
import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
|
||||
|
||||
describe('SyncWrapperService', () => {
|
||||
let service: SyncWrapperService;
|
||||
|
|
@ -426,6 +430,79 @@ describe('SyncWrapperService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('configuredAuthForSyncProviderIfNecessary()', () => {
|
||||
it('shows a temporary service error with the Dropbox request ID for HTTP 504', async () => {
|
||||
const headers = new Headers();
|
||||
headers.set('X-Dropbox-Request-Id', '<b>dbx-request-123</b>&"');
|
||||
const response = new Response('gateway timeout', {
|
||||
status: 504,
|
||||
statusText: 'Gateway Timeout',
|
||||
headers,
|
||||
});
|
||||
const provider = {
|
||||
id: SyncProviderId.Dropbox,
|
||||
isReady: jasmine.createSpy('isReady').and.resolveTo(false),
|
||||
getAuthHelper: jasmine.createSpy('getAuthHelper').and.resolveTo({
|
||||
authUrl: 'https://www.dropbox.com/oauth2/authorize',
|
||||
codeVerifier: 'code-verifier',
|
||||
verifyCodeChallenge: jasmine
|
||||
.createSpy('verifyCodeChallenge')
|
||||
.and.rejectWith(new HttpNotOkAPIError(response, 'gateway timeout')),
|
||||
}),
|
||||
} as unknown as SyncProviderBase<SyncProviderId.Dropbox>;
|
||||
mockProviderManager.getProviderById.and.resolveTo(provider);
|
||||
mockMatDialog.open.and.returnValue({
|
||||
afterClosed: () => of('auth-code'),
|
||||
} as unknown as MatDialogRef<DialogGetAndEnterAuthCodeComponent>);
|
||||
|
||||
const result = await service.configuredAuthForSyncProviderIfNecessary(
|
||||
SyncProviderId.Dropbox,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ wasConfigured: false });
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith({
|
||||
msg: T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE,
|
||||
translateParams: {
|
||||
status: '504',
|
||||
reference: '<b>dbx-request-123</b>&"',
|
||||
},
|
||||
type: 'ERROR',
|
||||
config: { duration: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a temporary service error without a reference when none is returned', async () => {
|
||||
const response = new Response('internal server error', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
const provider = {
|
||||
id: SyncProviderId.Dropbox,
|
||||
isReady: jasmine.createSpy('isReady').and.resolveTo(false),
|
||||
getAuthHelper: jasmine.createSpy('getAuthHelper').and.resolveTo({
|
||||
authUrl: 'https://www.dropbox.com/oauth2/authorize',
|
||||
codeVerifier: 'code-verifier',
|
||||
verifyCodeChallenge: jasmine
|
||||
.createSpy('verifyCodeChallenge')
|
||||
.and.rejectWith(new HttpNotOkAPIError(response, 'internal server error')),
|
||||
}),
|
||||
} as unknown as SyncProviderBase<SyncProviderId.Dropbox>;
|
||||
mockProviderManager.getProviderById.and.resolveTo(provider);
|
||||
mockMatDialog.open.and.returnValue({
|
||||
afterClosed: () => of('auth-code'),
|
||||
} as unknown as MatDialogRef<DialogGetAndEnterAuthCodeComponent>);
|
||||
|
||||
await service.configuredAuthForSyncProviderIfNecessary(SyncProviderId.Dropbox);
|
||||
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith({
|
||||
msg: T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE,
|
||||
translateParams: { status: '500' },
|
||||
type: 'ERROR',
|
||||
config: { duration: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('websocket integration', () => {
|
||||
it('should disconnect websocket when provider changes away from SuperSync', async () => {
|
||||
configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV));
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
|
|||
import { T } from '../../t.const';
|
||||
import { getSyncErrorStr } from './get-sync-error-str';
|
||||
import { getErrorTxt } from '../../util/get-error-text';
|
||||
import { escapeHtml } from '../../util/escape-html';
|
||||
import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
|
||||
import { DialogConflictResolutionResult } from './sync.model';
|
||||
import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component';
|
||||
|
|
@ -1151,7 +1152,16 @@ export class SyncWrapperService {
|
|||
} catch (error) {
|
||||
SyncLog.err(`Failed to configure auth for provider ${providerId}:`, error);
|
||||
const httpErr = error instanceof HttpNotOkAPIError ? error : null;
|
||||
const isTokenExchangeError = httpErr?.response?.status === 400;
|
||||
const httpStatus = httpErr?.response.status;
|
||||
const isTokenExchangeError = httpStatus === 400;
|
||||
const isAuthServiceUnavailable =
|
||||
httpStatus !== undefined && httpStatus >= 500 && httpStatus < 600;
|
||||
// Dropbox recommends retaining this response ID for support investigations.
|
||||
// https://developers.dropbox.com/error-handling-guide#logging
|
||||
const providerRequestId =
|
||||
providerId === SyncProviderId.Dropbox
|
||||
? httpErr?.response.headers.get('X-Dropbox-Request-Id')
|
||||
: null;
|
||||
// A OneDrive token-exchange 400 is almost always a misconfigured
|
||||
// Microsoft Entra app registration (typically "Allow public client
|
||||
// flows" disabled), not a mistyped/expired code — the authorize step
|
||||
|
|
@ -1165,6 +1175,14 @@ export class SyncWrapperService {
|
|||
translateParams = { error: httpErr?.detail || getErrorTxt(error) };
|
||||
} else if (isTokenExchangeError) {
|
||||
msg = T.F.SYNC.S.INVALID_AUTH_CODE;
|
||||
} else if (isAuthServiceUnavailable) {
|
||||
translateParams = { status: String(httpStatus) };
|
||||
if (providerRequestId) {
|
||||
msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE;
|
||||
translateParams.reference = escapeHtml(providerRequestId);
|
||||
} else {
|
||||
msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE;
|
||||
}
|
||||
} else {
|
||||
msg = T.F.SYNC.S.AUTH_SETUP_FAILED;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,9 @@ const T = {
|
|||
S: {
|
||||
ALREADY_IN_SYNC: 'F.SYNC.S.ALREADY_IN_SYNC',
|
||||
ARCHIVE_OPERATION_FAILED: 'F.SYNC.S.ARCHIVE_OPERATION_FAILED',
|
||||
AUTH_SERVICE_UNAVAILABLE: 'F.SYNC.S.AUTH_SERVICE_UNAVAILABLE',
|
||||
AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE:
|
||||
'F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE',
|
||||
AUTH_SETUP_FAILED: 'F.SYNC.S.AUTH_SETUP_FAILED',
|
||||
AUTH_TOKEN_REJECTED: 'F.SYNC.S.AUTH_TOKEN_REJECTED',
|
||||
BTN_CONFIGURE: 'F.SYNC.S.BTN_CONFIGURE',
|
||||
|
|
|
|||
|
|
@ -1589,7 +1589,9 @@
|
|||
"S": {
|
||||
"ALREADY_IN_SYNC": "Already in sync",
|
||||
"ARCHIVE_OPERATION_FAILED": "Archive operation failed. Some cleanup may not have completed.",
|
||||
"AUTH_SETUP_FAILED": "Failed to set up authentication. Please try again. If you're using the web app, make sure you're accessing it over HTTPS. Otherwise, please report this as a bug if it keeps happening.",
|
||||
"AUTH_SERVICE_UNAVAILABLE": "The authentication service is temporarily unavailable (HTTP {{status}}). Please try again.",
|
||||
"AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE": "The authentication service is temporarily unavailable (HTTP {{status}}). Please try again. Reference: {{reference}}.",
|
||||
"AUTH_SETUP_FAILED": "Failed to set up authentication. Please try again. If it keeps happening, check your connection and report the error details.",
|
||||
"AUTH_TOKEN_REJECTED": "Sync token was rejected by the server: {{reason}}. Please get a new token.",
|
||||
"BTN_CONFIGURE": "Configure",
|
||||
"BTN_FORCE_OVERWRITE": "Force Overwrite",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue