|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | | -import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 3 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
4 | 4 | import { render, screen, waitFor, within } from '@testing-library/react'; |
5 | 5 | import userEvent from '@testing-library/user-event'; |
6 | 6 | import { RecordAttachmentsPanel } from '../RecordAttachmentsPanel'; |
@@ -712,3 +712,137 @@ describe('RecordAttachmentsPanel — authenticated signed-URL download (#2970)', |
712 | 712 | expect(screen.queryByRole('alert')).not.toBeInTheDocument(); |
713 | 713 | }); |
714 | 714 | }); |
| 715 | + |
| 716 | +/** |
| 717 | + * Inside the ObjectStack mobile shell, when it announces `openFile` |
| 718 | + * (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files"): the row tap |
| 719 | + * fetches the signed URL with the Console's session as before, then fetches |
| 720 | + * the BYTES and hands them to the shell's `openFile`. Over the bridge's |
| 721 | + * 8 MiB, a failed byte fetch or a `bad_args` refusal keeps the browser path. |
| 722 | + * ⛔ The signed URL is a credential, so it never crosses the bridge. |
| 723 | + */ |
| 724 | +describe('RecordAttachmentsPanel — mobile shell openFile', () => { |
| 725 | + const GLOBAL = 'ObjectStackNative'; |
| 726 | + const SIGNED = '/api/v1/storage/_local/raw/tok123'; |
| 727 | + |
| 728 | + function installShell(opts: { capabilities?: string[]; refuse?: boolean } = {}) { |
| 729 | + const capabilities = opts.capabilities ?? ['openFile', 'share']; |
| 730 | + const openFile = vi.fn(async (_file: { base64: string; name: string; mime: string }) => { |
| 731 | + if (opts.refuse) throw Object.assign(new Error('Files cross the bridge up to 8 MB'), { code: 'bad_args' }); |
| 732 | + return null; |
| 733 | + }); |
| 734 | + const host = { |
| 735 | + version: 1, |
| 736 | + platform: 'ios', |
| 737 | + appVersion: '2.2.0', |
| 738 | + capabilities, |
| 739 | + safeArea: { top: 0, bottom: 0, left: 0, right: 0 }, |
| 740 | + has: (c: string) => capabilities.includes(c), |
| 741 | + openFile, |
| 742 | + openExternal: vi.fn(async () => null), |
| 743 | + share: vi.fn(async () => null), |
| 744 | + ready: vi.fn(), |
| 745 | + routeChanged: vi.fn(), |
| 746 | + }; |
| 747 | + (window as unknown as Record<string, unknown>)[GLOBAL] = host; |
| 748 | + return host; |
| 749 | + } |
| 750 | + |
| 751 | + /** `/files/:id/url` answers the signed URL; the signed URL answers `bytes`. */ |
| 752 | + function mockStorage(bytes: BlobPart = 'hello', type = 'application/pdf', bytesStatus = 200) { |
| 753 | + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { |
| 754 | + const url = String(input instanceof Request ? input.url : input); |
| 755 | + if (url.includes('/api/v1/storage/files/f1/url')) { |
| 756 | + return new Response(JSON.stringify({ success: true, data: { url: SIGNED } }), { |
| 757 | + status: 200, |
| 758 | + headers: { 'Content-Type': 'application/json' }, |
| 759 | + }); |
| 760 | + } |
| 761 | + if (url.endsWith(SIGNED)) return new Response(new Blob([bytes], { type }), { status: bytesStatus }); |
| 762 | + throw new Error(`unexpected fetch ${url}`); |
| 763 | + }); |
| 764 | + } |
| 765 | + |
| 766 | + async function tapDownload(rows: unknown[] = [ROW]) { |
| 767 | + setup(makeDataSource({ find: vi.fn(async () => rows) })); |
| 768 | + await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument()); |
| 769 | + await userEvent.setup().click(screen.getByRole('button', { name: 'Download' })); |
| 770 | + } |
| 771 | + |
| 772 | + afterEach(() => { |
| 773 | + delete (window as unknown as Record<string, unknown>)[GLOBAL]; |
| 774 | + }); |
| 775 | + |
| 776 | + it('hands the fetched bytes to openFile, never the URL, and opens no tab', async () => { |
| 777 | + const host = installShell(); |
| 778 | + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); |
| 779 | + const fetchSpy = mockStorage(); |
| 780 | + await tapDownload(); |
| 781 | + |
| 782 | + await waitFor(() => expect(host.openFile).toHaveBeenCalledTimes(1)); |
| 783 | + // "hello" in standard padded base64; the bridge file has exactly these keys. |
| 784 | + expect(host.openFile.mock.calls[0][0]).toEqual({ base64: 'aGVsbG8=', name: 'report.pdf', mime: 'application/pdf' }); |
| 785 | + expect(fetchSpy.mock.calls.map((c) => String(c[0]))).toEqual([ |
| 786 | + expect.stringContaining('/api/v1/storage/files/f1/url'), |
| 787 | + expect.stringContaining(SIGNED), |
| 788 | + ]); |
| 789 | + expect(JSON.stringify(host.openFile.mock.calls)).not.toContain('tok123'); |
| 790 | + expect(host.openExternal).not.toHaveBeenCalled(); |
| 791 | + expect(host.share).not.toHaveBeenCalled(); |
| 792 | + expect(openSpy).not.toHaveBeenCalled(); |
| 793 | + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); |
| 794 | + }); |
| 795 | + |
| 796 | + it('a row over 8 MiB keeps the browser path without fetching the bytes', async () => { |
| 797 | + const host = installShell(); |
| 798 | + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); |
| 799 | + const fetchSpy = mockStorage(); |
| 800 | + await tapDownload([{ ...ROW, size: 8 * 1024 * 1024 + 1 }]); |
| 801 | + |
| 802 | + await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer')); |
| 803 | + expect(fetchSpy).toHaveBeenCalledTimes(1); |
| 804 | + expect(host.openFile).not.toHaveBeenCalled(); |
| 805 | + }); |
| 806 | + |
| 807 | + it('fetched bytes over 8 MiB keep the browser path', async () => { |
| 808 | + const host = installShell(); |
| 809 | + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); |
| 810 | + mockStorage(new Uint8Array(8 * 1024 * 1024 + 1)); |
| 811 | + await tapDownload([{ ...ROW, size: null }]); |
| 812 | + |
| 813 | + await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer')); |
| 814 | + expect(host.openFile).not.toHaveBeenCalled(); |
| 815 | + }); |
| 816 | + |
| 817 | + it('a bad_args refusal keeps the browser path', async () => { |
| 818 | + const host = installShell({ refuse: true }); |
| 819 | + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); |
| 820 | + mockStorage(); |
| 821 | + await tapDownload(); |
| 822 | + |
| 823 | + await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer')); |
| 824 | + expect(host.openFile).toHaveBeenCalledTimes(1); |
| 825 | + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); |
| 826 | + }); |
| 827 | + |
| 828 | + it('a failed byte fetch keeps the browser path', async () => { |
| 829 | + const host = installShell(); |
| 830 | + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); |
| 831 | + mockStorage('nope', 'text/plain', 403); |
| 832 | + await tapDownload(); |
| 833 | + |
| 834 | + await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer')); |
| 835 | + expect(host.openFile).not.toHaveBeenCalled(); |
| 836 | + }); |
| 837 | + |
| 838 | + it('CONTROL: an older shell without openFile fetches no bytes and opens the tab as today', async () => { |
| 839 | + const host = installShell({ capabilities: ['share'] }); |
| 840 | + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); |
| 841 | + const fetchSpy = mockStorage(); |
| 842 | + await tapDownload(); |
| 843 | + |
| 844 | + await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer')); |
| 845 | + expect(fetchSpy).toHaveBeenCalledTimes(1); |
| 846 | + expect(host.openFile).not.toHaveBeenCalled(); |
| 847 | + }); |
| 848 | +}); |
0 commit comments