huridocs/uwazi

View on GitHub
app/api/files/specs/routes.spec.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { Application, NextFunction, Request, Response } from 'express';
import path from 'path';
import request, { Response as SuperTestResponse } from 'supertest';

import entities from 'api/entities';
import { editorUser } from 'api/entities/specs/entitySavingManagerFixtures';
import { spyOnEmit, toEmitEvent, toEmitEventWith } from 'api/eventsbus/eventTesting';
import { legacyLogger } from 'api/log';
import connections from 'api/relationships';
import { search } from 'api/search';
import * as ocrRecords from 'api/services/ocr/ocrRecords';
import { testingEnvironment } from 'api/utils/testingEnvironment';
import { setUpApp } from 'api/utils/testingRoutes';
import db from 'api/utils/testing_db';
import { FileType } from 'shared/types/fileType';
import { UserSchema } from 'shared/types/userType';
import { FileCreatedEvent } from '../events/FileCreatedEvent';
import { FileUpdatedEvent } from '../events/FileUpdatedEvent';
import { FilesDeletedEvent } from '../events/FilesDeletedEvent';
import { files } from '../files';
import uploadRoutes from '../routes';
import { storage } from '../storage';
import {
  adminUser,
  collabUser,
  customFileId,
  externalUrlFileId,
  fixtures,
  readOnlyUploadId,
  restrictedUploadId2,
  uploadId,
  uploadId2,
  writerUser,
} from './fixtures';

expect.extend({ toEmitEvent, toEmitEventWith });

describe('files routes', () => {
  let requestMockedUser: UserSchema = collabUser;

  const app: Application = setUpApp(
    uploadRoutes,
    (req: Request, _res: Response, next: NextFunction) => {
      (req as any).user = (() => requestMockedUser)();
      next();
    }
  );

  const mockCurrentUser = (user: UserSchema) => {
    requestMockedUser = user;
    testingEnvironment.setPermissions(user);
  };

  beforeEach(async () => {
    jest.spyOn(search, 'indexEntities').mockImplementation(async () => Promise.resolve());
    await testingEnvironment.setUp(fixtures);
    mockCurrentUser(collabUser);
  });

  afterAll(async () => testingEnvironment.tearDown());

  describe('POST/files', () => {
    describe('editor user', () => {
      it('should not have permissions to post files that are of type custom', async () => {
        mockCurrentUser(editorUser);
        const response = await request(app)
          .post('/api/files')
          .send({ _id: uploadId.toString(), originalname: 'custom_file', type: 'custom' });

        expect(response.status).toBe(404);
      });
    });

    describe('collaborator user', () => {
      it('should allow modification if and only if user has write permission for the entity', async () => {
        mockCurrentUser(collabUser);
        let response = await request(app)
          .post('/api/files')
          .send({ _id: restrictedUploadId2.toString(), originalname: 'changed' });

        expect(response.status).toBe(404);

        mockCurrentUser(writerUser);
        response = await request(app)
          .post('/api/files')
          .send({ _id: restrictedUploadId2.toString(), originalname: 'changed2' });

        expect(response.status).toBe(200);

        response = await request(app)
          .post('/api/files')
          .send({ _id: readOnlyUploadId.toString(), originalname: 'changed read only' });

        expect(response.status).toBe(404);
      });

      it('should not have permissions to post files that are of type custom', async () => {
        mockCurrentUser(collabUser);
        await request(app)
          .post('/api/files')
          .send({ _id: uploadId.toString(), originalname: 'custom_file', type: 'custom' })
          .expect(404);
      });
    });

    describe('Basic save', () => {
      beforeEach(async () => {
        mockCurrentUser(adminUser);
        await request(app)
          .post('/api/files')
          .send({ _id: uploadId.toString(), originalname: 'newName', entity: 'sharedId1' })
          .expect(200);
      });

      it('should save file on the body', async () => {
        const uploads = await files.get({ _id: uploadId.toString() });
        expect(uploads[0]).toEqual(
          expect.objectContaining({
            originalname: 'newName',
          })
        );
      });

      it('should reindex all entities that are related to the saved file', async () => {
        expect(search.indexEntities).toHaveBeenCalledWith({ sharedId: 'sharedId1' }, '+fullText');
      });

      it(`should emit a ${FileUpdatedEvent.name} an existing file as been saved`, async () => {
        const emitSpy = spyOnEmit();

        const [original] = await files.get({ _id: uploadId });

        await request(app)
          .post('/api/files')
          .send({
            ...original,
            extractedMetadata: [
              {
                name: 'propertyName',
                selection: {
                  text: 'something',
                  selectionRectangles: [{ top: 0, left: 0, width: 0, height: 0, page: '1' }],
                },
              },
            ],
          });

        const [after] = await files.get({ _id: uploadId });
        emitSpy.expectToEmitEventWith(FileUpdatedEvent, { before: original, after });
        emitSpy.restore();
      });

      it(`should emit a ${FileCreatedEvent.name} if a new file has been saved`, async () => {
        const fileInfo = {
          creationDate: 1,
          entity: 'sharedId1',
          originalname: 'doc.pdf',
          type: 'document',
          language: 'eng',
        };
        const caller = async () => request(app).post('/api/files').send(fileInfo).expect(200);
        await expect(caller).toEmitEventWith(FileCreatedEvent, {
          newFile: { ...fileInfo, _id: expect.anything(), __v: 0 },
        });
        await expect(caller).not.toEmitEvent(FileUpdatedEvent);
      });

      describe('when external url file', () => {
        it('should guess the mimetype', async () => {
          await request(app)
            .post('/api/files')
            .send({ url: 'https://awesomecats.org/ahappycat.png', originalname: 'A Happy Cat' });

          const [file]: FileType[] = await files.get({ originalname: 'A Happy Cat' });
          expect(file.mimetype).toBe('image/png');
        });

        it('should return a validation error for a no secured url', async () => {
          const rest = await request(app)
            .post('/api/files')
            .send({ url: 'http://awesomecats.org/ahappycat.png', originalname: 'A Happy Cat' });

          expect(rest.status).toBe(400);
        });
      });
    });
  });

  describe('GET/files', () => {
    it('should return entity related files only if the collaborator user has permission for the entity', async () => {
      mockCurrentUser(writerUser);
      const response: SuperTestResponse = await request(app)
        .get('/api/files')
        .query({ type: 'document' })
        .expect(200);

      expect(response.body.map((file: FileType) => file.originalname)).toEqual([
        'publicEntityFile',
        'restrictedUpload',
        'restrictedUpload2',
        'readOnlyUpload',
        'upload2',
      ]);
    });

    it.each([adminUser, editorUser])('should return all uploads for an ($role)', async user => {
      mockCurrentUser(user);
      const response: SuperTestResponse = await request(app)
        .get('/api/files')
        .query({ type: 'document' })
        .expect(200);

      expect(response.body.map((file: FileType) => file.originalname)).toEqual([
        'publicEntityFile',
        'upload1',
        'fileNotInDisk',
        'restrictedUpload',
        'restrictedUpload2',
        'readOnlyUpload',
        'upload2',
      ]);
    });

    it('should only allow properly typed id and type parameters in the query', async () => {
      mockCurrentUser(adminUser);

      await request(app).get('/api/files').query({ $where: '1===1' }).expect(400);

      await request(app)
        .get('/api/files')
        .query({ type: { $exists: 1 } })
        .expect(400);

      const response: SuperTestResponse = await request(app)
        .get('/api/files')
        .query({ _id: uploadId.toString(), type: 'document' })
        .expect(200);

      expect(response.body.map((file: FileType) => file.originalname)).toEqual(['upload1']);
    });
  });

  describe('DELETE/api/files', () => {
    beforeEach(() => {
      mockCurrentUser(adminUser);
    });

    it('should properly delete files that are external urls', async () => {
      await request(app)
        .delete('/api/files')
        .query({ _id: externalUrlFileId.toString() })
        .expect(200);

      const [file] = await files.get({ _id: externalUrlFileId.toString() });
      expect(file).toBeUndefined();
    });

    it('should not allow any extra parameters aside from a properly typed id', async () => {
      await request(app)
        .delete('/api/files')
        .query({ _id: 'someid', __property__: 'should_not_be_here' })
        .expect(400);

      await request(app)
        .delete('/api/files')
        .query({ _id: { $eq: 1234 } })
        .expect(400);
    });

    it('should delete upload and return the response', async () => {
      await request(app)
        .post('/api/files/upload/document')
        .attach('file', path.join(__dirname, 'test.txt'));

      const [file]: FileType[] = await files.get({ originalname: 'test.txt' });

      await request(app).delete('/api/files').query({ _id: file._id?.toString() });

      expect(await storage.fileExists(file.filename!, 'custom')).toBe(false);
    });

    it('should allow deletion if and only if user has permission for the entity', async () => {
      mockCurrentUser(collabUser);
      let response = await request(app)
        .delete('/api/files')
        .query({ _id: restrictedUploadId2.toString() });
      expect(response.status).toBe(404);

      mockCurrentUser(writerUser);
      response = await request(app)
        .delete('/api/files')
        .query({ _id: restrictedUploadId2.toString() });
      expect(response.status).toBe(200);
    });

    it('should allow deletion of custom files only if the user is an admin', async () => {
      mockCurrentUser(editorUser);
      let response = await request(app)
        .delete('/api/files')
        .query({ _id: customFileId.toString() });
      expect(response.status).toBe(404);

      mockCurrentUser(collabUser);
      response = await request(app).delete('/api/files').query({ _id: customFileId.toString() });
      expect(response.status).toBe(404);

      mockCurrentUser(adminUser);
      response = await request(app).delete('/api/files').query({ _id: customFileId.toString() });
      expect(response.status).toBe(200);
    });

    it('should reindex all entities that are related to the files deleted', async () => {
      await request(app).delete('/api/files').query({ _id: uploadId2.toString() }).expect(200);

      expect(search.indexEntities).toHaveBeenCalledWith(
        { sharedId: { $in: ['sharedId1'] } },
        '+fullText'
      );
    });

    it('should delete all connections related to the file', async () => {
      await request(app).delete('/api/files').query({ _id: uploadId2.toString() });

      const allConnections = await connections.get();
      expect(allConnections.length).toBe(2);
      expect(allConnections[0]).toEqual(expect.objectContaining({ entity: 'entity3' }));
      expect(allConnections[1]).toEqual(expect.objectContaining({ entity: 'sharedId1' }));
    });

    it('should cleanup the ocr records related to the file', async () => {
      const ocrCleanupSpy = jest.spyOn(ocrRecords, 'cleanupRecordsOfFiles');
      await request(app).delete('/api/files').query({ _id: uploadId2.toString() });
      expect(ocrCleanupSpy).toHaveBeenCalledWith([uploadId2]);
    });

    describe('events', () => {
      it(`should emit a ${FilesDeletedEvent.name} when a file is deleted`, async () => {
        const emitSpy = spyOnEmit();

        const file = await db.mongodb?.collection('files').findOne({ _id: uploadId2 });
        await request(app).delete('/api/files').query({ _id: uploadId2.toString() });

        emitSpy.expectToEmitEventWith(FilesDeletedEvent, { files: [file!] });
        emitSpy.restore();
      });
    });

    it('should validate _id as string', async () => {
      const response: SuperTestResponse = await request(app)
        .delete('/api/files')
        .query({ _id: { test: 'test' } });

      expect(response.body.errors[0].message).toBe('must be string');
    });

    describe('api/files/tocReviewed', () => {
      beforeEach(() => {
        // WARNING!!! this sets an editor user in the permissions context.
        // It's inconsistent with the request logged-in user!!
        // This is here to avoid changing the test implementation without research.
        // Fix the inconsistency and remove this.
        testingEnvironment.setPermissions();
      });

      it('should set tocGenerated to false on the file', async () => {
        const response: SuperTestResponse = await request(app)
          .post('/api/files/tocReviewed')
          .set('content-language', 'es')
          .send({ fileId: uploadId.toString() });

        const [file] = await files.get({ _id: uploadId });
        expect(file.generatedToc).toBe(false);
        expect(response.body.entity).toBe('sharedId1');
      });

      it('should set tocGenerated to false on the entity when all associated files are false', async () => {
        await request(app)
          .post('/api/files/tocReviewed')
          .send({ fileId: uploadId.toString() })
          .expect(200);

        let [entity] = await entities.get({ sharedId: 'sharedId1' });
        expect(entity.generatedToc).toBe(true);

        await request(app)
          .post('/api/files/tocReviewed')
          .send({ fileId: uploadId2.toString() })
          .expect(200);

        [entity] = await entities.get({ sharedId: 'sharedId1' });
        expect(entity.generatedToc).toBe(false);
      });
    });
  });

  describe('POST/files/attachment', () => {
    it('should save file on the body', async () => {
      const entityId = db.id();
      await request(app)
        .post('/api/files/upload/attachment')
        .field('entity', entityId.toString())
        .attach('file', Buffer.from('attachment content'), 'Dont bring me down - 1979')
        .expect(200);

      const [attachment] = await files.get({ entity: entityId.toString() });
      expect(attachment).toEqual(
        expect.objectContaining({
          originalname: 'Dont bring me down - 1979',
          type: 'attachment',
        })
      );
    });
  });

  describe('POST/files/upload/document', () => {
    it('should save the attached file', async () => {
      jest.spyOn(legacyLogger, 'debug').mockImplementation(() => ({}));
      const response = await request(app)
        .post('/api/files/upload/document')
        .attach('file', path.join(__dirname, 'test.txt'));
      expect(response.status).toBe(200);
      const [file]: FileType[] = await files.get({ originalname: 'test.txt' });

      expect(await storage.fileExists(file.filename!, 'document')).toBe(true);
      expect(legacyLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Deprecation'));
    });
  });

  describe('POST/files/upload/*', () => {
    describe.each(['document', 'attachment'] as FileType['type'][])('when file is a %s', type => {
      it.each(['Hello, World.pdf', 'Aló mundo.pdf', 'Привет, мир.pdf', '헬로월드.pdf'])(
        'should accept the filename %s in a field',
        async filename => {
          const response = await request(app)
            .post(`/api/files/upload/${type}`)
            .field('originalname', filename)
            .attach('file', path.join(__dirname, filename));
          expect(response.status).toBe(200);
          const [file]: FileType[] = await files.get({ originalname: filename, type });
          expect(file).not.toBe(undefined);
        }
      );
    });
  });
});