mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
d4f3d9d6a5
revert unnecessary change update mocks add structured-headers to e2e deps
718 lines
28 KiB
TypeScript
718 lines
28 KiB
TypeScript
import { getMyUser, LoginResponseDto } from '@immich/sdk';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { createUserDto } from 'src/fixtures';
|
|
import { errorDto } from 'src/responses';
|
|
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
|
import { serializeDictionary } from 'structured-headers';
|
|
import request from 'supertest';
|
|
import { beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
describe('/upload', () => {
|
|
let admin: LoginResponseDto;
|
|
let user: LoginResponseDto;
|
|
let quotaUser: LoginResponseDto;
|
|
let cancelQuotaUser: LoginResponseDto;
|
|
|
|
let assetData: string;
|
|
|
|
beforeAll(async () => {
|
|
await utils.resetDatabase();
|
|
admin = await utils.adminSetup({ onboarding: false });
|
|
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
|
cancelQuotaUser = await utils.userSetup(admin.accessToken, createUserDto.user2);
|
|
quotaUser = await utils.userSetup(admin.accessToken, createUserDto.userQuota);
|
|
assetData = serializeDictionary({
|
|
filename: 'test-image.jpg',
|
|
'device-asset-id': 'rufh',
|
|
'device-id': 'test',
|
|
'file-created-at': new Date('2025-01-02T00:00:00Z').toISOString(),
|
|
'file-modified-at': new Date('2025-01-01T00:00:00Z').toISOString(),
|
|
'is-favorite': false,
|
|
'icloud-id': 'example-icloud-id',
|
|
});
|
|
});
|
|
|
|
describe('startUpload', () => {
|
|
it('should require auth', async () => {
|
|
const content = randomBytes(1024);
|
|
|
|
const { status, headers } = await request(app)
|
|
.post('/upload')
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send(content);
|
|
|
|
expect(status).toBe(401);
|
|
expect(headers['location']).toBeUndefined();
|
|
});
|
|
|
|
it('should create a complete upload with Upload-Complete: ?1', async () => {
|
|
const content = randomBytes(1024);
|
|
|
|
const { status, headers } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send(content);
|
|
|
|
expect(status).toBe(200);
|
|
expect(headers['upload-complete']).toBe('?1');
|
|
});
|
|
|
|
it('should create a complete upload with Upload-Incomplete: ?0 if version is 3', async () => {
|
|
const content = randomBytes(1024);
|
|
|
|
const { status, headers } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '3')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Incomplete', '?0')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send(content);
|
|
|
|
expect(status).toBe(200);
|
|
expect(headers['upload-incomplete']).toBe('?0');
|
|
});
|
|
|
|
it('should reject when Upload-Complete: ?1 with mismatching Content-Length and Upload-Length', async () => {
|
|
const content = randomBytes(1000);
|
|
|
|
const { status, headers, body } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Upload-Length', '2000')
|
|
.set('Content-Length', '1000')
|
|
.send(content);
|
|
|
|
expect(status).toBe(400);
|
|
expect(headers['content-type']).toBe('application/problem+json; charset=utf-8');
|
|
expect(body).toEqual({
|
|
type: 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length',
|
|
title: 'inconsistent length values for upload',
|
|
});
|
|
});
|
|
|
|
it('should create an incomplete upload with Upload-Complete: ?0', async () => {
|
|
const partialContent = randomBytes(512);
|
|
|
|
const { status, headers } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Content-Length', '512')
|
|
.set('Upload-Length', '513')
|
|
.send(partialContent);
|
|
|
|
expect(status).toBe(201);
|
|
expect(headers['location']).toMatch(/^\/api\/upload\/[a-zA-Z0-9\-]+$/);
|
|
expect(headers['upload-complete']).toBe('?0');
|
|
});
|
|
|
|
it('should create an incomplete upload with Upload-Incomplete: ?1 if version is 3', async () => {
|
|
const partialContent = randomBytes(512);
|
|
|
|
const { status, headers } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '3')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`)
|
|
.set('Upload-Incomplete', '?1')
|
|
.set('Content-Length', '512')
|
|
.set('Upload-Length', '513')
|
|
.send(partialContent);
|
|
|
|
expect(status).toBe(201);
|
|
expect(headers['location']).toMatch(/^\/api\/upload\/[a-zA-Z0-9\-]+$/);
|
|
expect(headers['upload-incomplete']).toBe('?1');
|
|
});
|
|
|
|
it('should reject attempt to upload completed asset', async () => {
|
|
const content = randomBytes(1024);
|
|
|
|
const firstRequest = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send(content);
|
|
|
|
expect(firstRequest.status).toBe(200);
|
|
expect(firstRequest.headers['upload-complete']).toBe('?1');
|
|
|
|
const secondRequest = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send(content);
|
|
|
|
expect(secondRequest.status).toBe(400);
|
|
expect(secondRequest.headers['content-type']).toBe('application/problem+json; charset=utf-8');
|
|
expect(secondRequest.body).toEqual({
|
|
type: 'https://iana.org/assignments/http-problem-types#completed-upload',
|
|
title: 'upload is already completed',
|
|
});
|
|
});
|
|
|
|
// TODO: find a way to test interim responses
|
|
it('should return 500 if existing incomplete asset', async () => {
|
|
const content = randomBytes(1024);
|
|
|
|
const firstRequest = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send(content);
|
|
|
|
expect(firstRequest.status).toBe(201);
|
|
expect(firstRequest.headers['upload-complete']).toBe('?0');
|
|
expect(firstRequest.headers['location']).toMatch(/^\/api\/upload\/[a-zA-Z0-9\-]+$/);
|
|
|
|
const secondRequest = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send();
|
|
|
|
expect(secondRequest.status).toBe(500);
|
|
expect(secondRequest.text).toEqual('Incomplete asset already exists');
|
|
});
|
|
|
|
it('should reject asset with mismatching checksum', async () => {
|
|
const content = randomBytes(1024);
|
|
|
|
const { status, headers, text } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update('').digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1024')
|
|
.send(content);
|
|
|
|
expect(status).toBe(460);
|
|
expect(headers['location']).toBeUndefined();
|
|
expect(text).toBe('File on server does not match provided checksum');
|
|
});
|
|
|
|
it('should update the used quota', async () => {
|
|
const content = randomBytes(500);
|
|
|
|
const { status, headers } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '500')
|
|
.send(content);
|
|
|
|
expect(status).toBe(200);
|
|
expect(headers['upload-complete']).toBe('?1');
|
|
|
|
const userData = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) });
|
|
|
|
expect(userData).toEqual(expect.objectContaining({ quotaUsageInBytes: 500 }));
|
|
});
|
|
|
|
it('should not upload an asset if it would exceed the quota', async () => {
|
|
const { body, status } = await request(app)
|
|
.post('/assets')
|
|
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
|
.field('deviceAssetId', 'example-image')
|
|
.field('deviceId', 'e2e')
|
|
.field('fileCreatedAt', new Date().toISOString())
|
|
.field('fileModifiedAt', new Date().toISOString())
|
|
.attach('assetData', randomBytes(13), 'example.jpg');
|
|
|
|
expect(status).toBe(400);
|
|
expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!'));
|
|
});
|
|
});
|
|
|
|
describe('resumeUpload', () => {
|
|
let uploadResource: string;
|
|
let chunks: Buffer[];
|
|
|
|
beforeAll(async () => {
|
|
// Create an incomplete upload
|
|
chunks = [randomBytes(750), randomBytes(500), randomBytes(1500)];
|
|
const fullContent = Buffer.concat(chunks);
|
|
const response = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(fullContent).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Upload-Length', '2750')
|
|
.send(chunks[0]);
|
|
|
|
uploadResource = response.headers['location'];
|
|
});
|
|
|
|
it('should require auth', async () => {
|
|
const { status, headers } = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', '1250')
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(chunks[2]);
|
|
|
|
expect(status).toBe(401);
|
|
expect(headers['upload-complete']).toBeUndefined();
|
|
});
|
|
|
|
it("should reject upload to another user's asset", async () => {
|
|
const { status, headers } = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', '1250')
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(chunks[2]);
|
|
|
|
expect(status).toBe(404);
|
|
expect(headers['upload-complete']).toEqual('?0');
|
|
});
|
|
|
|
it('should append data with correct offset', async () => {
|
|
const { status, headers } = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', chunks[0].length.toString())
|
|
.set('Upload-Complete', '?0')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(chunks[1]);
|
|
|
|
expect(status).toBe(204);
|
|
expect(headers['upload-complete']).toBe('?0');
|
|
|
|
const headResponse = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
|
|
expect(headResponse.headers['upload-offset']).toBe('1250');
|
|
});
|
|
|
|
it('should reject append with different upload length than before', async () => {
|
|
const { status, headers, body } = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', '1250')
|
|
.set('Upload-Complete', '?0')
|
|
.set('Upload-Length', '1250')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send();
|
|
|
|
expect(status).toBe(400);
|
|
expect(headers['content-type']).toBe('application/problem+json; charset=utf-8');
|
|
expect(body).toEqual({
|
|
type: 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length',
|
|
title: 'inconsistent length values for upload',
|
|
});
|
|
});
|
|
|
|
it('should reject append with mismatching length', async () => {
|
|
const wrongOffset = 100;
|
|
|
|
const { status, headers, body } = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', wrongOffset.toString())
|
|
.set('Upload-Complete', '?0')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(randomBytes(100));
|
|
|
|
expect(status).toBe(409);
|
|
expect(headers['upload-offset']).toBe('1250');
|
|
expect(headers['content-type']).toBe('application/problem+json; charset=utf-8');
|
|
expect(body).toEqual({
|
|
type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset',
|
|
title: 'offset from request does not match offset of resource',
|
|
'expected-offset': 1250,
|
|
'provided-offset': wrongOffset,
|
|
});
|
|
});
|
|
|
|
it('should complete upload with Upload-Complete: ?1', async () => {
|
|
const headResponse = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
|
|
const offset = parseInt(headResponse.headers['upload-offset']);
|
|
expect(offset).toBe(1250);
|
|
|
|
const { status, headers } = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', offset.toString())
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(chunks[2]);
|
|
|
|
expect(status).toBe(200);
|
|
expect(headers['upload-complete']).toBe('?1');
|
|
});
|
|
|
|
it('should reject append to completed upload', async () => {
|
|
const { status, headers, body } = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', '2750')
|
|
.set('Upload-Complete', '?0')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(randomBytes(100));
|
|
|
|
expect(status).toBe(400);
|
|
expect(headers['content-type']).toBe('application/problem+json; charset=utf-8');
|
|
expect(body).toEqual({
|
|
type: 'https://iana.org/assignments/http-problem-types#completed-upload',
|
|
title: 'upload is already completed',
|
|
});
|
|
});
|
|
|
|
it('should handle interrupted initial upload and resume', async () => {
|
|
const totalContent = randomBytes(5000);
|
|
const firstPart = totalContent.subarray(0, 2000);
|
|
|
|
const initialResponse = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(totalContent).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Upload-Length', '5000')
|
|
.send(firstPart);
|
|
expect(initialResponse.status).toBe(201);
|
|
const uploadResource = initialResponse.headers['location'];
|
|
|
|
const offsetResponse = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
expect(offsetResponse.headers['upload-offset']).toBe('2000');
|
|
|
|
const remainingContent = totalContent.subarray(2000);
|
|
const resumeResponse = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', '2000')
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(remainingContent);
|
|
|
|
expect(resumeResponse.status).toBe(200);
|
|
expect(resumeResponse.headers['upload-complete']).toBe('?1');
|
|
});
|
|
|
|
it('should handle multiple interruptions and resumptions', async () => {
|
|
const chunks = [randomBytes(2000), randomBytes(3000), randomBytes(5000)];
|
|
const hash = createHash('sha1');
|
|
chunks.forEach((chunk) => hash.update(chunk));
|
|
|
|
const createResponse = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${hash.digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Upload-Length', '10000')
|
|
.send(chunks[0]);
|
|
|
|
const uploadResource = createResponse.headers['location'];
|
|
let currentOffset = 2000;
|
|
|
|
let response = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', currentOffset.toString())
|
|
.set('Upload-Complete', '?0')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(chunks[1]);
|
|
|
|
expect(response.status).toBe(204);
|
|
currentOffset += 3000;
|
|
|
|
const offsetCheck = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
|
|
expect(offsetCheck.headers['upload-offset']).toBe('5000');
|
|
|
|
response = await request(baseUrl)
|
|
.patch(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', currentOffset.toString())
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send(chunks[2]);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers['upload-complete']).toBe('?1');
|
|
});
|
|
});
|
|
|
|
describe('cancelUpload', () => {
|
|
let uploadResource: string;
|
|
|
|
beforeAll(async () => {
|
|
const content = randomBytes(200);
|
|
// Create an incomplete upload
|
|
const response = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Upload-Length', '200')
|
|
.send(content);
|
|
|
|
uploadResource = response.headers['location'];
|
|
});
|
|
|
|
it('should require auth', async () => {
|
|
const { status } = await request(baseUrl).delete(uploadResource);
|
|
expect(status).toBe(401);
|
|
|
|
const headResponse = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
expect(headResponse.status).toBe(204);
|
|
});
|
|
|
|
it("should reject attempt to delete another user's asset", async () => {
|
|
const { status } = await request(baseUrl)
|
|
.delete(uploadResource)
|
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
|
|
|
expect(status).toBe(404);
|
|
|
|
const headResponse = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
expect(headResponse.status).toBe(204);
|
|
});
|
|
|
|
it('should reject attempt to delete completed asset', async () => {
|
|
const content = randomBytes(1000);
|
|
const postResponse = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Content-Type', 'image/jpeg')
|
|
.set('Upload-Length', '1000')
|
|
.send(content);
|
|
expect(postResponse.status).toBe(201);
|
|
expect(postResponse.headers['upload-complete']).toBe('?0');
|
|
const location = postResponse.headers['location'];
|
|
expect(location).toBeDefined();
|
|
|
|
const patchResponse = await request(baseUrl)
|
|
.patch(location)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Upload-Offset', '1000')
|
|
.set('Upload-Complete', '?1')
|
|
.set('Content-Type', 'application/partial-upload')
|
|
.send();
|
|
expect(patchResponse.status).toBe(200);
|
|
expect(patchResponse.headers['upload-complete']).toBe('?1');
|
|
|
|
const deleteResponse = await request(baseUrl).delete(location).set('Authorization', `Bearer ${user.accessToken}`);
|
|
expect(deleteResponse.status).toBe(400);
|
|
expect(deleteResponse.headers['content-type']).toBe('application/problem+json; charset=utf-8');
|
|
expect(deleteResponse.body).toEqual({
|
|
type: 'https://iana.org/assignments/http-problem-types#completed-upload',
|
|
title: 'upload is already completed',
|
|
});
|
|
|
|
const headResponse = await request(baseUrl)
|
|
.head(location)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
expect(headResponse.status).toBe(204);
|
|
});
|
|
|
|
it('should cancel upload with DELETE request', async () => {
|
|
const { status } = await request(baseUrl)
|
|
.delete(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`);
|
|
expect(status).toBe(204);
|
|
|
|
const headResponse = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
expect(headResponse.status).toBe(404);
|
|
});
|
|
|
|
it('should update quota usage', async () => {
|
|
const content = randomBytes(200);
|
|
const response = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Upload-Length', '200')
|
|
.send(content);
|
|
const uploadResource = response.headers['location'];
|
|
|
|
const userDataBefore = await getMyUser({ headers: asBearerAuth(cancelQuotaUser.accessToken) });
|
|
expect(userDataBefore).toEqual(expect.objectContaining({ quotaUsageInBytes: 200 }));
|
|
|
|
const { status } = await request(baseUrl)
|
|
.delete(uploadResource)
|
|
.set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`);
|
|
expect(status).toBe(204);
|
|
|
|
const userDataAfter = await getMyUser({ headers: asBearerAuth(cancelQuotaUser.accessToken) });
|
|
expect(userDataAfter).toEqual(expect.objectContaining({ quotaUsageInBytes: 0 }));
|
|
|
|
const headResponse = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
expect(headResponse.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('getUploadStatus', () => {
|
|
let uploadResource: string;
|
|
|
|
beforeAll(async () => {
|
|
const content = randomBytes(512);
|
|
// Create an incomplete upload first
|
|
const { headers } = await request(app)
|
|
.post('/upload')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('X-Immich-Asset-Data', assetData)
|
|
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
|
.set('Upload-Complete', '?0')
|
|
.set('Upload-Length', '512')
|
|
.send(content);
|
|
|
|
expect(headers['location']).toBeDefined();
|
|
uploadResource = headers['location'];
|
|
});
|
|
|
|
it('should require auth', async () => {
|
|
const { status, headers } = await request(baseUrl).head(uploadResource).set('Upload-Draft-Interop-Version', '8');
|
|
|
|
expect(status).toBe(401);
|
|
expect(headers['upload-offset']).toBeUndefined();
|
|
expect(headers['upload-complete']).toBeUndefined();
|
|
expect(headers['upload-limit']).toBeUndefined();
|
|
});
|
|
|
|
it("should disallow fetching another user's asset", async () => {
|
|
const { status, headers } = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Upload-Draft-Interop-Version', '8')
|
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
|
|
|
expect(status).toBe(404);
|
|
expect(headers['upload-offset']).toBeUndefined();
|
|
expect(headers['upload-complete']).toBeUndefined();
|
|
expect(headers['upload-limit']).toBeUndefined();
|
|
});
|
|
|
|
it('should retrieve upload offset with HEAD request', async () => {
|
|
const { status, headers } = await request(baseUrl)
|
|
.head(uploadResource)
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
|
|
expect(status).toBe(204);
|
|
expect(headers['upload-offset']).toBe('512');
|
|
expect(headers['upload-complete']).toBe('?0');
|
|
expect(headers['upload-limit']).toEqual('min-size=0');
|
|
expect(headers['cache-control']).toBe('no-store');
|
|
});
|
|
|
|
it('should return 404 for non-existent upload resource', async () => {
|
|
const { status } = await request(app)
|
|
.head('/upload/4feacf6f-830f-46c8-8140-2b3da67070c0')
|
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
.set('Upload-Draft-Interop-Version', '8');
|
|
|
|
expect(status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('getUploadOptions', () => {
|
|
it('should include upload limits in response', async () => {
|
|
const { status, headers } = await request(app).options('/upload');
|
|
|
|
expect(status).toBe(204);
|
|
expect(headers['upload-limit']).toEqual('min-size=0');
|
|
});
|
|
});
|
|
});
|