mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
fix(supersync): plug residual gaps surfaced by post-slice review
Two post-merge findings from the careful review pass on the
SuperSync slice (PR 7b):
1. **Storage NaN bug.** The app-side `localStorage`-backed
`SuperSyncStorage.getLastServerSeq` returned `NaN` for an empty-
string value (`parseInt("", 10) === NaN`), which the package's
`stored ?? 0` fallback could not catch (nullish coalescing only
triggers on `null`/`undefined`). The original implementation
used `stored ? parseInt(stored, 10) : 0` and returned `0` for
any falsy string. Add an explicit `Number.isFinite` check in
the adapter so the port contract (returns `number | null`) is
actually honored. Doesn't happen in practice today — `setItem`
always writes a stringified int — but a hypothetical regression
that wrote `""` would silently break sync replay.
2. **`_handleNativeRequestError` residual hostname leak.** The
non-retryable native path re-threw the original error
unchanged. Foreign errors from the native HTTP executor (e.g.
iOS TLS cert errors like "SSL certificate problem: hostname
mismatch for sync.example.com") can embed the resolved
hostname in `.message`, which surfaces in user-visible snack
notifications via `super-sync-restore.service.ts` and
similar. Scrub to a name-only form
(`SuperSync native request failed: <Error.name>`) while
preserving identity for our own thrown errors
(`AuthFailSPError`, `MissingCredentialsSPError`,
`HTTP <status>` form). The full message is still captured in
the structured log meta for diagnostics.
Two new Vitest specs cover the scrub path and the
`AuthFailSPError` identity preservation through the non-retryable
native branch. Package spec count: 275 (was 273).
This commit is contained in:
parent
c40feef6d2
commit
8d7e05cceb
3 changed files with 68 additions and 1 deletions
|
|
@ -538,6 +538,23 @@ export class SuperSyncProvider
|
|||
'Unable to connect to SuperSync server. Check your internet connection.',
|
||||
);
|
||||
}
|
||||
// Our own thrown errors (`AuthFailSPError`, `MissingCredentialsSPError`,
|
||||
// `HTTP <status>` from the non-2xx branch) carry only scrubbed content
|
||||
// and propagate unchanged. Foreign errors from the native HTTP executor
|
||||
// (e.g. iOS TLS-cert errors like "Hostname mismatch for example.com")
|
||||
// can embed the resolved hostname in `.message`. Replace those with a
|
||||
// name-only surface — the full message is captured above via the
|
||||
// structured logger.
|
||||
if (
|
||||
error instanceof AuthFailSPError ||
|
||||
error instanceof MissingCredentialsSPError ||
|
||||
(error instanceof Error && error.message.startsWith('HTTP '))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`SuperSync native request failed: ${error.name}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1339,6 +1339,51 @@ describe('SuperSyncProvider', () => {
|
|||
// Privacy: the URL/hostname must NOT be in the user-facing message.
|
||||
expect(caught!.message).not.toContain('internal-host.invalid');
|
||||
});
|
||||
|
||||
/**
|
||||
* Non-retryable foreign native errors (e.g. iOS TLS-cert errors) can
|
||||
* embed the resolved hostname in `.message`. Verify the user-facing
|
||||
* surface scrubs to a name-only form so dialogs / snacks don't leak.
|
||||
*/
|
||||
it('scrubs non-retryable foreign native errors (e.g. TLS cert) to name-only surface', async () => {
|
||||
const ctx = buildProvider({ isNativePlatform: true });
|
||||
ctx.cfgStore.load.mockResolvedValue(testConfig);
|
||||
const tlsError = new Error(
|
||||
'SSL certificate problem: hostname mismatch for sync.example.com',
|
||||
);
|
||||
tlsError.name = 'TlsError';
|
||||
ctx.nativeHttpExecutor.mockRejectedValue(tlsError);
|
||||
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
await ctx.provider.uploadOps([createMockOperation()], 'client-1');
|
||||
} catch (e) {
|
||||
caught = e as Error;
|
||||
}
|
||||
expect(caught?.message).toBe('SuperSync native request failed: TlsError');
|
||||
// Privacy: the hostname from the cert error must NOT leak.
|
||||
expect(caught!.message).not.toContain('sync.example.com');
|
||||
expect(caught!.message).not.toContain('hostname mismatch');
|
||||
});
|
||||
|
||||
/**
|
||||
* Our own thrown errors carry only scrubbed content (HTTP <status>,
|
||||
* AuthFailSPError, MissingCredentialsSPError) and must propagate
|
||||
* unchanged on the non-retryable native path.
|
||||
*/
|
||||
it('preserves AuthFailSPError identity through non-retryable native path', async () => {
|
||||
const ctx = buildProvider({ isNativePlatform: true });
|
||||
ctx.cfgStore.load.mockResolvedValue(testConfig);
|
||||
ctx.nativeHttpExecutor.mockResolvedValue({
|
||||
status: 401,
|
||||
headers: {},
|
||||
data: { error: 'token expired' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.provider.uploadOps([createMockOperation()], 'client-1'),
|
||||
).rejects.toBeInstanceOf(AuthFailSPError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request timeout handling', () => {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ export const createSuperSyncProvider = (): PackageSuperSyncProvider => {
|
|||
const localStoragePort: SuperSyncDeps['storage'] = {
|
||||
getLastServerSeq: (key) => {
|
||||
const v = localStorage.getItem(key);
|
||||
return v == null ? null : Number.parseInt(v, 10);
|
||||
if (v == null || v === '') return null;
|
||||
// `parseInt` returns NaN for non-numeric strings (e.g. a future
|
||||
// regression that writes ""). Bridge to `null` so the package's
|
||||
// `stored ?? 0` fallback fires correctly.
|
||||
const n = Number.parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
},
|
||||
setLastServerSeq: (key, value) => localStorage.setItem(key, String(value)),
|
||||
removeLastServerSeq: (key) => localStorage.removeItem(key),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue