|
1 | 1 | /** |
2 | 2 | * @vitest-environment node |
3 | 3 | */ |
4 | | -import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' |
| 4 | +import { link, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' |
5 | 5 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
6 | 6 |
|
| 7 | +/** |
| 8 | + * Spied rather than replaced: every assertion in this file reads the real |
| 9 | + * filesystem, and the only behaviour worth faking is a single `link` answering |
| 10 | + * `EXDEV`, which no temporary directory can be made to produce on its own. |
| 11 | + */ |
| 12 | +vi.mock('node:fs/promises', { spy: true }) |
| 13 | + |
7 | 14 | const { testUploadDirectory, mockS3Presign, mockS3PartUrls } = vi.hoisted(() => ({ |
8 | 15 | testUploadDirectory: `/tmp/sim-upload-session-provider-${process.pid}`, |
9 | 16 | mockS3Presign: vi.fn(), |
@@ -117,6 +124,60 @@ describe('local upload-session provider', () => { |
117 | 124 | }) |
118 | 125 | }) |
119 | 126 |
|
| 127 | + /** |
| 128 | + * Staging moved out of the destination's own directory into one shared |
| 129 | + * `.staging` root, which is what makes this reachable: a volume mounted under |
| 130 | + * part of the uploads tree puts the staged object and its destination on |
| 131 | + * different devices, and a hard link cannot span them. Publication has to |
| 132 | + * survive that without giving up the create-or-fail the link provides. |
| 133 | + */ |
| 134 | + it('publishes across a filesystem boundary a hard link cannot span', async () => { |
| 135 | + vi.mocked(link).mockRejectedValueOnce( |
| 136 | + Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' }) |
| 137 | + ) |
| 138 | + |
| 139 | + await writeLocalPutObject({ |
| 140 | + uploadId: 'upload-1', |
| 141 | + key: 'workspace/workspace-1/file.bin', |
| 142 | + body: byteStream('ab', 'cd'), |
| 143 | + expectedSize: 4, |
| 144 | + contentType: 'application/octet-stream', |
| 145 | + metadata: METADATA, |
| 146 | + }) |
| 147 | + |
| 148 | + await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe( |
| 149 | + 'abcd' |
| 150 | + ) |
| 151 | + await expect( |
| 152 | + headProviderObject({ |
| 153 | + provider: 'local', |
| 154 | + key: 'workspace/workspace-1/file.bin', |
| 155 | + context: CONTEXT, |
| 156 | + }) |
| 157 | + ).resolves.toMatchObject({ size: 4, uploadId: 'upload-1' }) |
| 158 | + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) |
| 159 | + expect(await allEntries('.staging')).toEqual([]) |
| 160 | + }) |
| 161 | + |
| 162 | + it('still refuses to overwrite an existing object when the link cannot span devices', async () => { |
| 163 | + const params = { |
| 164 | + uploadId: 'upload-1', |
| 165 | + key: 'workspace/workspace-1/file.bin', |
| 166 | + expectedSize: 3, |
| 167 | + contentType: 'application/octet-stream', |
| 168 | + metadata: METADATA, |
| 169 | + } |
| 170 | + await writeLocalPutObject({ ...params, body: byteStream('one') }) |
| 171 | + vi.mocked(link).mockRejectedValueOnce( |
| 172 | + Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' }) |
| 173 | + ) |
| 174 | + |
| 175 | + await expect(writeLocalPutObject({ ...params, body: byteStream('two') })).rejects.toThrow() |
| 176 | + |
| 177 | + await expect(readFile(localPath(params.key), 'utf8')).resolves.toBe('one') |
| 178 | + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) |
| 179 | + }) |
| 180 | + |
120 | 181 | it('does not let a replayed PUT overwrite the final object', async () => { |
121 | 182 | const params = { |
122 | 183 | uploadId: 'upload-1', |
|
0 commit comments