File size: 9,706 Bytes
79ed62f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { downloadFile, openFile } from '../../../src/utils/fileDownload'
import { getCachedBlob } from '../../../src/db/offlineDb'

// Mock the offline DB so these tests never touch Dexie/IndexedDB.
vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() }))

function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) {
  return vi.fn().mockResolvedValue({
    status,
    ok: status >= 200 && status < 300,
    blob: () => Promise.resolve(blob),
  })
}

beforeEach(() => {
  vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url')
  vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
  vi.spyOn(document.body, 'appendChild').mockImplementation((el) => el)
  vi.spyOn(document.body, 'removeChild').mockImplementation((el) => el)
  vi.useFakeTimers()
})

afterEach(() => {
  vi.restoreAllMocks()
  vi.useRealTimers()
})

describe('assertRelativeUrl (URL guard)', () => {
  it('rejects absolute http URLs', async () => {
    await expect(downloadFile('https://evil.com/x')).rejects.toThrow('Refusing to fetch non-relative URL')
  })
  it('rejects protocol-relative URLs', async () => {
    await expect(downloadFile('//evil.com/x')).rejects.toThrow('Refusing to fetch non-relative URL')
  })
  it('allows relative paths', async () => {
    vi.stubGlobal('fetch', makeFetchMock(200))
    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
    await expect(downloadFile('/trips/1/files/2/download')).resolves.toBeUndefined()
  })
})

describe('downloadFile', () => {
  it('fetches with credentials:include and triggers anchor download', async () => {
    const fetchMock = makeFetchMock(200)
    vi.stubGlobal('fetch', fetchMock)

    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await downloadFile('/uploads/files/test.pdf', 'test.pdf')

    expect(fetchMock).toHaveBeenCalledWith('/uploads/files/test.pdf', { credentials: 'include' })
    expect(URL.createObjectURL).toHaveBeenCalled()
    expect(clickSpy).toHaveBeenCalled()

    // Revoke happens after setTimeout(100)
    vi.runAllTimers()
    expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
  })

  it('sets download attribute to filename when provided', async () => {
    vi.stubGlobal('fetch', makeFetchMock(200))
    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await downloadFile('/uploads/files/report.pdf', 'report.pdf')

    // Check anchor was created with download attribute
    const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
    const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
    expect(anchor.download).toBe('report.pdf')
  })

  it('throws on 401 response', async () => {
    vi.stubGlobal('fetch', makeFetchMock(401))
    await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
    expect(URL.createObjectURL).not.toHaveBeenCalled()
  })
})

describe('openFile', () => {
  it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
    vi.stubGlobal('fetch', makeFetchMock(200))
    const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await openFile('/uploads/files/doc.pdf')

    expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
    expect(URL.createObjectURL).toHaveBeenCalled()
    // Must NOT call window.open — that path returns null when noreferrer is
    // set, which previously caused the file to also open in the current tab.
    expect(openSpy).not.toHaveBeenCalled()
    expect(clickSpy).toHaveBeenCalledTimes(1)

    // The anchor used to open the new tab must be target=_blank, must NOT
    // carry a `download` attribute (otherwise it would download in-page
    // instead of opening), and must use rel=noopener noreferrer.
    const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
    const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
    expect(anchor.target).toBe('_blank')
    expect(anchor.rel).toBe('noopener noreferrer')
    expect(anchor.hasAttribute('download')).toBe(false)

    // Revoke happens after 30s timeout
    vi.runAllTimers()
    expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
  })

  it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
    vi.stubGlobal('fetch', makeFetchMock(200))
    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await openFile('/uploads/files/doc.pdf', 'doc.pdf')

    // Exactly ONE anchor click — opening the new tab. No fallback download.
    expect(clickSpy).toHaveBeenCalledTimes(1)
  })

  it('throws on 401 response', async () => {
    vi.stubGlobal('fetch', makeFetchMock(401, new Blob([], { type: 'application/pdf' })))
    await expect(openFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
    expect(URL.createObjectURL).not.toHaveBeenCalled()
  })

  it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
    const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
    vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
    const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await openFile('/uploads/files/malicious.html', 'malicious.html')

    // Must NOT open inline — download anchor clicked instead
    expect(openSpy).not.toHaveBeenCalled()
    expect(clickSpy).toHaveBeenCalledTimes(1)

    const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
    const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
    expect(anchor.download).toBe('malicious.html')
  })

  it('forces download for SVG MIME type', async () => {
    const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
    vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
    const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await openFile('/uploads/files/malicious.svg')

    expect(openSpy).not.toHaveBeenCalled()
    expect(clickSpy).toHaveBeenCalledTimes(1)
  })

  it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
    vi.stubGlobal('fetch', makeFetchMock(200))
    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
    // Simulate iOS PWA (Add-to-Home-Screen) context
    Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })

    try {
      await openFile('/uploads/files/doc.pdf', 'doc.pdf')

      // Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
      // because target="_blank" in iOS PWA would hand off to Safari which cannot
      // read the in-WebView blob URL.
      expect(clickSpy).toHaveBeenCalledTimes(1)
      const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
      const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
      expect(anchor.target).toBe('')
      expect(anchor.download).toBe('doc.pdf')
    } finally {
      // Clean up the non-standard iOS-only property we forced above.
      delete (navigator as any).standalone
    }
  })
})

describe('offline fallback (#1046)', () => {
  function setOnline(value: boolean) {
    Object.defineProperty(navigator, 'onLine', { value, configurable: true })
  }
  beforeEach(() => vi.mocked(getCachedBlob).mockReset())
  afterEach(() => setOnline(true))

  it('serves the cached blob without a network call when offline', async () => {
    setOnline(false)
    const blob = new Blob(['x'], { type: 'application/pdf' })
    vi.mocked(getCachedBlob).mockResolvedValue(blob)
    const fetchSpy = vi.fn()
    vi.stubGlobal('fetch', fetchSpy)
    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await downloadFile('/uploads/files/cached.pdf')

    expect(fetchSpy).not.toHaveBeenCalled()
    expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
    expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
  })

  it('falls back to the cache when a live fetch rejects (network error) while online', async () => {
    setOnline(true)
    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
    const blob = new Blob(['x'], { type: 'application/pdf' })
    vi.mocked(getCachedBlob).mockResolvedValue(blob)
    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})

    await downloadFile('/uploads/files/cached.pdf')

    expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
    expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
  })

  it('throws when offline and the file was never cached', async () => {
    setOnline(false)
    vi.mocked(getCachedBlob).mockResolvedValue(null)
    await expect(downloadFile('/uploads/files/missing.pdf')).rejects.toThrow(/offline/i)
  })

  it('does not consult the cache on an HTTP error — a 401 still surfaces', async () => {
    setOnline(true)
    vi.stubGlobal('fetch', makeFetchMock(401))
    await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
    expect(getCachedBlob).not.toHaveBeenCalled()
  })
})