fix(sync): surface Dropbox OAuth service failures (#8988)

This commit is contained in:
Johannes Millan 2026-07-14 11:39:49 +02:00 committed by GitHub
parent a7a26588e6
commit 44df8d1675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 135 additions and 6 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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: '&lt;b&gt;dbx-request-123&lt;/b&gt;&amp;&quot;',
},
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));

View file

@ -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;
}

View file

@ -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',

View file

@ -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",