import { describe, expect, it } from 'vitest'; import { AdditionalLogErrorBase, AuthFailSPError, EmptyRemoteBodySPError, FileHashCreationAPIError, extractErrorMessage, HttpNotOkAPIError, InvalidDataSPError, MissingCredentialsSPError, MissingRefreshTokenAPIError, NetworkUnavailableSPError, NoRevAPIError, PotentialCorsError, RemoteFileChangedUnexpectedly, RemoteFileNotFoundAPIError, TooManyRequestsAPIError, UploadRevToMatchMismatchAPIError, WebDavNativeRequestError, } from '../src/errors'; describe('AdditionalLogErrorBase', () => { it('does not invoke any logger on construction (Option A privacy guarantee)', () => { // Constructing an error must have NO side effects. The previous // app-side base class called OP_LOG_SYNC_LOGGER.log(...) from the // constructor; the moved version intentionally drops that. Verified // by the absence of any other behavior to assert against — if a // future change adds logging back, this test will need updating // and the privacy contract becomes "only safe keys" again. expect( () => new InvalidDataSPError({ secret: 'user-task-title', status: 400 }), ).not.toThrow(); }); it('preserves additionalLog as the raw payload (for catch-site use)', () => { const payload = { responseName: 'sync-response', status: 400 }; const err = new InvalidDataSPError(payload); expect(err.additionalLog).toEqual([payload]); }); it('extracts a meaningful message from the first additional arg', () => { const err = new AdditionalLogErrorBase('something went wrong'); expect(err.message).toBe('something went wrong'); }); it('extractErrorMessage rewrites Z_* compression codes', () => { expect(extractErrorMessage(new Error('Z_BUF_ERROR'))).toBe( 'Compression error: buf error', ); }); it('extractErrorMessage returns null for empty inputs', () => { expect(extractErrorMessage(undefined)).toBeNull(); }); }); describe('HttpNotOkAPIError', () => { it('keeps message brief (no body excerpt) for privacy-aware logging', () => { const body = 'Reflected query: SECRET_USER_DATA'; const err = new HttpNotOkAPIError( new Response(body, { status: 503, statusText: 'Service Unavailable' }), body, ); expect(err.message).toBe('HTTP 503 Service Unavailable'); expect(err.message).not.toContain('SECRET_USER_DATA'); }); it('exposes the body excerpt on .detail (opt-in surface)', () => { const body = 'Lock token submitted is not enabled in this resource'; const err = new HttpNotOkAPIError( new Response(body, { status: 423, statusText: 'Locked' }), body, ); expect(err.detail).toBe('Lock token submitted is not enabled in this resource'); }); it('falls back to plain text extraction when no structured error present', () => { const body = 'Service Temporarily Down'; const err = new HttpNotOkAPIError( new Response(body, { status: 502, statusText: 'Bad Gateway' }), body, ); expect(err.detail).toContain('Service Temporarily Down'); }); it('parses JSON error fields', () => { const body = '{"error":"invalid_grant","error_description":"code expired"}'; const err = new HttpNotOkAPIError( new Response(body, { status: 400, statusText: 'Bad Request' }), body, ); expect(err.detail).toBe('invalid_grant'); }); it('caps detail at 300 chars', () => { const longTitle = 'X'.repeat(500); const body = `${longTitle}`; const err = new HttpNotOkAPIError( new Response(body, { status: 500, statusText: 'Internal' }), body, ); expect(err.detail?.length).toBe(300); }); it('strips Real error text'; const err = new HttpNotOkAPIError( new Response(body, { status: 500, statusText: 'Internal' }), body, ); expect(err.detail).toContain('Real error text'); expect(err.detail).not.toContain('alert'); }); it('handles nested/crafted pt> injection patterns', () => { const body = 'pt>alert(1)Real content'; const err = new HttpNotOkAPIError( new Response(body, { status: 500, statusText: 'Internal' }), body, ); expect(err.detail).not.toMatch(/alert/); }); }); describe('TooManyRequestsAPIError', () => { it('takes only narrow primitives — never the raw request headers', () => { const err = new TooManyRequestsAPIError({ status: 429, retryAfter: 5, path: '/files/sync', }); expect(err.status).toBe(429); expect(err.retryAfter).toBe(5); expect(err.path).toBe('/files/sync'); }); it('serialized form never contains "Bearer" (regression guard against header leak)', () => { // Defense-in-depth: the *type signature* forbids passing headers, // but if a caller squeezes an `as any` past TypeScript this test // ensures the runtime payload still cannot encode auth material. const err = new TooManyRequestsAPIError({ status: 429, retryAfter: 10, path: '/files/upload', }); expect( JSON.stringify({ name: err.name, additionalLog: err.additionalLog }), ).not.toMatch(/Bearer/i); expect( JSON.stringify({ name: err.name, additionalLog: err.additionalLog }), ).not.toMatch(/authorization/i); }); }); describe('Error class identity (single definition per class)', () => { // App-side `sync-errors.ts` re-exports these classes. This spec lives // package-side and only asserts internal consistency; the cross-realm // identity assertion lives app-side at // `src/app/op-log/core/errors/sync-errors.identity.spec.ts`. const ERROR_CLASSES: ReadonlyArray Error]> = [ ['AuthFailSPError', AuthFailSPError], ['InvalidDataSPError', InvalidDataSPError], ['EmptyRemoteBodySPError', EmptyRemoteBodySPError], ['RemoteFileNotFoundAPIError', RemoteFileNotFoundAPIError], ['NoRevAPIError', NoRevAPIError], ['FileHashCreationAPIError', FileHashCreationAPIError], ['HttpNotOkAPIError', HttpNotOkAPIError], ['MissingCredentialsSPError', MissingCredentialsSPError], ['MissingRefreshTokenAPIError', MissingRefreshTokenAPIError], ['TooManyRequestsAPIError', TooManyRequestsAPIError], ['UploadRevToMatchMismatchAPIError', UploadRevToMatchMismatchAPIError], ['PotentialCorsError', PotentialCorsError], ['RemoteFileChangedUnexpectedly', RemoteFileChangedUnexpectedly], ['NetworkUnavailableSPError', NetworkUnavailableSPError], ['WebDavNativeRequestError', WebDavNativeRequestError], ]; it.each(ERROR_CLASSES)('%s is an Error subclass', (_name, ErrCtor) => { expect(typeof ErrCtor).toBe('function'); expect(Object.create(ErrCtor.prototype) instanceof Error).toBe(true); }); // Regression guard: prior versions shipped seven classes with a leading // space in `name` (' NoRevAPIError') and one truncated to // ' UploadRevToMatchMismatchAP'. Equality with the class identifier keeps // the string identity log-correct and matches Error subclass convention. // Asserting against `ErrCtor.name` (the static class identifier, which TS // cannot synthesize with a leading space) rather than the tuple string // means a future "fix" that co-mutates the test fixture and the source // cannot mask a regression. // `name` is an instance override (not a prototype property), so we must // instantiate. Each class has a different constructor signature; pass a // minimal stub that exercises the field-only branches. it.each(ERROR_CLASSES)('%s instance.name matches the class name', (_name, ErrCtor) => { let instance: Error; if (ErrCtor === HttpNotOkAPIError) { instance = new HttpNotOkAPIError( new Response(null, { status: 500, statusText: 'Internal Server Error' }), ); } else if (ErrCtor === TooManyRequestsAPIError) { instance = new TooManyRequestsAPIError({ status: 429 }); } else if (ErrCtor === PotentialCorsError) { instance = new PotentialCorsError('https://example.test'); } else if (ErrCtor === WebDavNativeRequestError) { instance = new WebDavNativeRequestError('Native request failed'); } else { instance = new (ErrCtor as new (...a: never[]) => Error)(); } expect(instance.name).toBe(ErrCtor.name); }); it('EmptyRemoteBodySPError extends InvalidDataSPError', () => { const err = new EmptyRemoteBodySPError('empty body'); expect(err).toBeInstanceOf(InvalidDataSPError); expect(err).toBeInstanceOf(Error); }); it('WebDavNativeRequestError preserves native error code for catch-site classification', () => { const err = new WebDavNativeRequestError('Network error', 'NETWORK_ERROR'); expect(err.code).toBe('NETWORK_ERROR'); }); });