Browse Source

feat: signed attachments

pull/6572/head
mertmit 1 year ago
parent
commit
4f54084f8c
  1. 2
      packages/nc-gui/components/cell/attachment/utils.ts
  2. 2
      packages/nc-gui/composables/useAttachment.ts
  3. 71
      packages/nocodb/src/controllers/attachments-secure.controller.ts
  4. 16
      packages/nocodb/src/controllers/attachments.controller.ts
  5. 70
      packages/nocodb/src/controllers/public-datas-export.controller.ts
  6. 94
      packages/nocodb/src/db/BaseModelSqlv2.ts
  7. 152
      packages/nocodb/src/models/TemporaryUrl.ts
  8. 1
      packages/nocodb/src/models/index.ts
  9. 4
      packages/nocodb/src/modules/datas/helpers.ts
  10. 5
      packages/nocodb/src/modules/metas/metas.module.ts
  11. 83
      packages/nocodb/src/plugins/s3/S3.ts
  12. 2
      packages/nocodb/src/plugins/storage/Local.ts
  13. 53
      packages/nocodb/src/services/attachments.service.ts
  14. 1
      packages/nocodb/src/utils/globals.ts
  15. 2
      tests/playwright/tests/db/columns/columnAttachments.spec.ts

2
packages/nc-gui/components/cell/attachment/utils.ts

@ -191,7 +191,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
try {
const data = await api.storage.upload(
{
path: [NOCO, base.value.title, meta.value?.title, column.value?.title].join('/'),
path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'),
},
{
files,

2
packages/nc-gui/composables/useAttachment.ts

@ -6,6 +6,8 @@ const useAttachment = () => {
const getPossibleAttachmentSrc = (item: Record<string, any>) => {
const res: string[] = []
if (item?.data) res.push(item.data)
if (item?.signedPath) res.push(`${appInfo.value.ncSiteUrl}/${item.signedPath}`)
if (item?.signedUrl) res.push(item.signedUrl)
if (item?.path) res.push(`${appInfo.value.ncSiteUrl}/${item.path}`)
if (item?.url) res.push(item.url)
return res

71
packages/nocodb/src/controllers/attachments-secure.controller.ts

@ -0,0 +1,71 @@
import path from 'path';
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Request,
Response,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import hash from 'object-hash';
import moment from 'moment';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { GlobalGuard } from '~/guards/global/global.guard';
import { AttachmentsService } from '~/services/attachments.service';
import { TemporaryUrl } from '~/models';
import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor';
@Controller()
export class AttachmentsSecureController {
constructor(private readonly attachmentsService: AttachmentsService) {}
@UseGuards(GlobalGuard)
@Post(['/api/v1/db/storage/upload', '/api/v1/storage/upload'])
@HttpCode(200)
@UseInterceptors(UploadAllowedInterceptor, AnyFilesInterceptor())
async upload(@UploadedFiles() files: Array<any>, @Request() req) {
const path = `${moment().format('YYYY/MM/DD')}/${hash(req.user.id)}`;
const attachments = await this.attachmentsService.upload({
files: files,
path: path,
});
return attachments;
}
@Post(['/api/v1/db/storage/upload-by-url', '/api/v1/storage/upload-by-url'])
@HttpCode(200)
@UseInterceptors(UploadAllowedInterceptor)
@UseGuards(GlobalGuard)
async uploadViaURL(@Body() body: any, @Request() req) {
const path = `${moment().format('YYYY/MM/DD')}/${hash(req.user.id)}`;
const attachments = await this.attachmentsService.uploadViaURL({
urls: body,
path,
});
return attachments;
}
@Get('/dltemp/:param(*)')
async fileReadv3(@Param('param') param: string, @Response() res) {
try {
const fpath = await TemporaryUrl.getPath(`dltemp/${param}`);
const { img } = await this.attachmentsService.fileRead({
path: path.join('nc', 'uploads', fpath),
});
res.sendFile(img);
} catch (e) {
res.status(404).send('Not found');
}
}
}

16
packages/nocodb/src/controllers/attachments.controller.ts

@ -17,6 +17,7 @@ import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor';
import { GlobalGuard } from '~/guards/global/global.guard';
import { AttachmentsService } from '~/services/attachments.service';
import { TemporaryUrl } from '~/models';
@Controller()
export class AttachmentsController {
@ -96,4 +97,19 @@ export class AttachmentsController {
res.status(404).send('Not found');
}
}
@Get('/dltemp/:param(*)')
async fileReadv3(@Param('param') param: string, @Response() res) {
try {
const fpath = await TemporaryUrl.getPath(`dltemp/${param}`);
const { img } = await this.attachmentsService.fileRead({
path: path.join('nc', 'uploads', fpath),
});
res.sendFile(img);
} catch (e) {
res.status(404).send('Not found');
}
}
}

70
packages/nocodb/src/controllers/public-datas-export.controller.ts

@ -1,9 +1,8 @@
import { Controller, Get, Param, Request, Response } from '@nestjs/common';
import { ErrorMessages, isSystemColumn, UITypes, ViewTypes } from 'nocodb-sdk';
import { ErrorMessages, isSystemColumn, ViewTypes } from 'nocodb-sdk';
import * as XLSX from 'xlsx';
import { nocoExecute } from 'nc-help';
import papaparse from 'papaparse';
import type { LinkToAnotherRecordColumn, LookupColumn } from '~/models';
import { NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst';
import { serializeCellValue } from '~/modules/datas/helpers';
@ -210,71 +209,4 @@ export class PublicDatasExportController {
}
return { offset, dbRows, elapsed };
}
async serializeCellValue({
value,
column,
ncSiteUrl,
}: {
column?: Column;
value: any;
ncSiteUrl?: string;
}) {
if (!column) {
return value;
}
if (!value) return value;
switch (column?.uidt) {
case UITypes.Attachment: {
let data = value;
try {
if (typeof value === 'string') {
data = JSON.parse(value);
}
} catch {}
return (data || []).map(
(attachment) =>
`${encodeURI(attachment.title)}(${encodeURI(attachment.url)})`,
);
}
case UITypes.Lookup:
{
const colOptions = await column.getColOptions<LookupColumn>();
const lookupColumn = await colOptions.getLookupColumn();
return (
await Promise.all(
[...(Array.isArray(value) ? value : [value])].map(async (v) =>
serializeCellValue({
value: v,
column: lookupColumn,
siteUrl: ncSiteUrl,
}),
),
)
).join(', ');
}
break;
case UITypes.LinkToAnotherRecord:
{
const colOptions =
await column.getColOptions<LinkToAnotherRecordColumn>();
const relatedModel = await colOptions.getRelatedTable();
await relatedModel.getColumns();
return [...(Array.isArray(value) ? value : [value])]
.map((v) => {
return v[relatedModel.displayValue?.title];
})
.join(', ');
}
break;
default:
if (value && typeof value === 'object') {
return JSON.stringify(value);
}
return value;
}
}
}

94
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -45,7 +45,16 @@ import { customValidators } from '~/db/util/customValidators';
import { extractLimitAndOffset } from '~/helpers';
import { NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst';
import { Audit, Column, Filter, Model, Sort, Source, View } from '~/models';
import {
Audit,
Column,
Filter,
Model,
Sort,
Source,
TemporaryUrl,
View,
} from '~/models';
import { sanitize, unsanitize } from '~/helpers/sqlSanitize';
import Noco from '~/Noco';
import { HANDLE_WEBHOOK } from '~/services/hook-handler.service';
@ -54,6 +63,7 @@ import {
COMPARISON_SUB_OPS,
IS_WITHIN_COMPARISON_SUB_OPS,
} from '~/utils/globals';
import { extractProps } from '~/helpers/extractProps';
dayjs.extend(utc);
@ -2162,6 +2172,9 @@ class BaseModelSqlv2 {
}
await this.model.getColumns();
await this.prepareAttachmentData(insertObj);
let response;
// const driver = trx ? trx : this.dbDriver;
@ -2387,6 +2400,8 @@ class BaseModelSqlv2 {
await this.beforeUpdate(data, trx, cookie);
await this.prepareAttachmentData(updateObj);
const prevData = await this.readByPk(
id,
false,
@ -2783,6 +2798,8 @@ class BaseModelSqlv2 {
}
}
await this.prepareAttachmentData(insertObj);
insertDatas.push(insertObj);
}
}
@ -2897,6 +2914,8 @@ class BaseModelSqlv2 {
continue;
}
if (!raw) {
await this.prepareAttachmentData(d);
const oldRecord = await this.readByPk(pkValues);
if (!oldRecord) {
// throw or skip if no record found
@ -4032,17 +4051,42 @@ class BaseModelSqlv2 {
return data;
}
protected _convertAttachmentType(
protected async _convertAttachmentType(
attachmentColumns: Record<string, any>[],
d: Record<string, any>,
) {
try {
if (d) {
attachmentColumns.forEach((col) => {
const promises = [];
for (const col of attachmentColumns) {
if (d[col.title] && typeof d[col.title] === 'string') {
d[col.title] = JSON.parse(d[col.title]);
}
});
if (d[col.title]?.length) {
for (const attachment of d[col.title]) {
if (attachment?.path) {
promises.push(
TemporaryUrl.getTemporaryUrl({
path: attachment.path.replace(/^download\//, ''),
}).then((r) => (attachment.signedPath = r)),
);
} else if (attachment?.url) {
if (attachment.url.includes('.amazonaws.com/')) {
const relativePath =
attachment.url.split('.amazonaws.com/')[1];
promises.push(
TemporaryUrl.getTemporaryUrl({
path: relativePath,
s3: true,
}).then((r) => (attachment.signedUrl = r)),
);
}
}
}
}
}
await Promise.all(promises);
}
} catch {}
return d;
@ -4052,25 +4096,25 @@ class BaseModelSqlv2 {
data: Record<string, any>,
childTable?: Model,
) {
if (childTable && !childTable?.columns) {
await childTable.getColumns();
} else if (!this.model?.columns) {
await this.model.getColumns();
}
// attachment is stored in text and parse in UI
// convertAttachmentType is used to convert the response in string to array of object in API response
if (data) {
if (childTable && !childTable?.columns) {
await childTable.getColumns();
} else if (!this.model?.columns) {
await this.model.getColumns();
}
const attachmentColumns = (
childTable ? childTable.columns : this.model.columns
).filter((c) => c.uidt === UITypes.Attachment);
if (attachmentColumns.length) {
if (Array.isArray(data)) {
data = data.map((d) =>
this._convertAttachmentType(attachmentColumns, d),
data = await Promise.all(
data.map((d) => this._convertAttachmentType(attachmentColumns, d)),
);
} else {
data = this._convertAttachmentType(attachmentColumns, data);
data = await this._convertAttachmentType(attachmentColumns, data);
}
}
}
@ -4680,6 +4724,29 @@ class BaseModelSqlv2 {
throw e;
}
}
prepareAttachmentData(data) {
if (this.model.columns.some((c) => c.uidt === UITypes.Attachment)) {
for (const column of this.model.columns) {
if (column.uidt === UITypes.Attachment) {
if (data[column.column_name]) {
if (Array.isArray(data[column.column_name])) {
for (let attachment of data[column.column_name]) {
attachment = extractProps(attachment, [
'url',
'path',
'title',
'mimetype',
'size',
'icon',
]);
}
}
}
}
}
}
}
}
export function extractSortsObject(
@ -4868,7 +4935,6 @@ export function _wherePk(primaryKeys: Column[], id: unknown | unknown[]) {
[primaryKeys[i].column_name, ids[i]],
);
};
continue;
}
// Cast the id to string.

152
packages/nocodb/src/models/TemporaryUrl.ts

@ -0,0 +1,152 @@
import NcPluginMgrv2 from 'src/helpers/NcPluginMgrv2';
import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache';
import { CacheGetType, CacheScope } from '~/utils/globals';
function roundExpiry(date) {
const msInHour = 10 * 60 * 1000;
return new Date(Math.ceil(date.getTime() / msInHour) * msInHour);
}
const DEFAULT_EXPIRE_SECONDS = isNaN(
parseInt(process.env.NC_ATTACHMENT_EXPIRE_SECONDS),
)
? 2 * 60 * 60
: parseInt(process.env.NC_ATTACHMENT_EXPIRE_SECONDS);
export default class TemporaryUrl {
path: string;
url: string;
expires_at: string;
constructor(data: Partial<TemporaryUrl>) {
Object.assign(this, data);
}
private static async add(param: {
path: string;
url: string;
expires_at: Date;
expiresInSeconds?: number;
}) {
const {
path,
url,
expires_at,
expiresInSeconds = DEFAULT_EXPIRE_SECONDS,
} = param;
await NocoCache.setExpiring(
`${CacheScope.TEMPORARY_URL}:path:${path}`,
{
path,
url,
expires_at,
},
expiresInSeconds,
);
await NocoCache.setExpiring(
`${CacheScope.TEMPORARY_URL}:url:${decodeURIComponent(url)}`,
{
path,
url,
expires_at,
},
expiresInSeconds,
);
}
private static async delete(param: { path: string; url: string }) {
const { path, url } = param;
await NocoCache.del(`${CacheScope.TEMPORARY_URL}:path:${path}`);
await NocoCache.del(`${CacheScope.TEMPORARY_URL}:url:${url}`);
}
public static async getPath(url: string, _ncMeta = Noco.ncMeta) {
const urlData =
url &&
(await NocoCache.get(
`${CacheScope.TEMPORARY_URL}:url:${url}`,
CacheGetType.TYPE_OBJECT,
));
if (!urlData) {
return null;
}
// if present, check if the expiry date is greater than now
if (
urlData &&
new Date(urlData.expires_at).getTime() < new Date().getTime()
) {
// if not, delete the url
await this.delete({ path: urlData.path, url: urlData.url });
return null;
}
return urlData?.path;
}
public static async getTemporaryUrl(
param: {
path: string;
expireSeconds?: number;
s3?: boolean;
},
_ncMeta = Noco.ncMeta,
) {
const { path, expireSeconds = DEFAULT_EXPIRE_SECONDS, s3 = false } = param;
const expireAt = roundExpiry(
new Date(new Date().getTime() + expireSeconds * 1000),
); // at least expireSeconds from now
// calculate the expiry time in seconds considering rounding
const expiresInSeconds = Math.ceil(
(expireAt.getTime() - new Date().getTime()) / 1000,
);
let tempUrl;
const url = await NocoCache.get(
`${CacheScope.TEMPORARY_URL}:path:${path}`,
CacheGetType.TYPE_OBJECT,
);
if (url) {
// if present, check if the expiry date is greater than now
if (new Date(url.expires_at).getTime() > new Date().getTime()) {
// if greater, return the url
return url.url;
} else {
// if not, delete the url
await this.delete({ path: url.path, url: url.url });
}
}
if (s3) {
// if not present, create a new url
const storageAdapter = await NcPluginMgrv2.storageAdapter();
tempUrl = await (storageAdapter as any).getSignedUrl(
path,
expiresInSeconds,
);
await this.add({
path: path,
url: tempUrl,
expires_at: expireAt,
expiresInSeconds,
});
} else {
// if not present, create a new url
tempUrl = `dltemp/${expireAt.getTime()}/${path}`;
await this.add({
path: path,
url: tempUrl,
expires_at: expireAt,
expiresInSeconds,
});
}
// return the url
return tempUrl;
}
}

1
packages/nocodb/src/models/index.ts

@ -36,3 +36,4 @@ export { default as User } from './User';
export { default as View } from './View';
export { default as LinksColumn } from './LinksColumn';
export { default as Notification } from './Notification';
export { default as TemporaryUrl } from './TemporaryUrl';

4
packages/nocodb/src/modules/datas/helpers.ts

@ -163,7 +163,9 @@ export async function serializeCellValue({
return (data || []).map(
(attachment) =>
`${encodeURI(attachment.title)}(${encodeURI(
attachment.path ? `${siteUrl}/${attachment.path}` : attachment.url,
attachment.signedPath
? `${siteUrl}/${attachment.signedPath}`
: attachment.signedUrl,
)})`,
);
}

5
packages/nocodb/src/modules/metas/metas.module.ts

@ -6,6 +6,7 @@ import { NC_ATTACHMENT_FIELD_SIZE } from '~/constants';
import { ApiDocsController } from '~/controllers/api-docs/api-docs.controller';
import { ApiTokensController } from '~/controllers/api-tokens.controller';
import { AttachmentsController } from '~/controllers/attachments.controller';
import { AttachmentsSecureController } from '~/controllers/attachments-secure.controller';
import { AuditsController } from '~/controllers/audits.controller';
import { SourcesController } from '~/controllers/sources.controller';
import { CachesController } from '~/controllers/caches.controller';
@ -88,7 +89,9 @@ export const metaModuleMetadata = {
? [
ApiDocsController,
ApiTokensController,
AttachmentsController,
...(process.env.NC_SECURE_ATTACHMENTS === 'true'
? [AttachmentsSecureController]
: [AttachmentsController]),
AuditsController,
SourcesController,
CachesController,

83
packages/nocodb/src/plugins/s3/S3.ts

@ -1,23 +1,31 @@
import fs from 'fs';
import { promisify } from 'util';
import AWS from 'aws-sdk';
import { GetObjectCommand, S3 as S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import axios from 'axios';
import type { IStorageAdapterV2, XcFile } from 'nc-plugin';
import type { Readable } from 'stream';
import { generateTempFilePath, waitForStreamClose } from '~/utils/pluginUtils';
export default class S3 implements IStorageAdapterV2 {
private s3Client: AWS.S3;
private s3Client: S3Client;
private input: any;
constructor(input: any) {
this.input = input;
}
get defaultParams() {
return {
ACL: 'private',
Bucket: this.input.bucket,
};
}
async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = {
ACL: 'public-read',
ContentType: file.mimetype,
...this.defaultParams,
// ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
@ -31,20 +39,20 @@ export default class S3 implements IStorageAdapterV2 {
uploadParams.Key = key;
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
// call S3 to retrieve upload file to specified bucket
this.upload(uploadParams)
.then((data) => {
resolve(data);
})
.catch((err) => {
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
});
}
async fileCreateByUrl(key: string, url: string): Promise<any> {
const uploadParams: any = {
ACL: 'public-read',
...this.defaultParams,
};
return new Promise((resolve, reject) => {
axios
@ -55,14 +63,8 @@ export default class S3 implements IStorageAdapterV2 {
uploadParams.ContentType = response.headers['content-type'];
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err1, data) => {
if (err1) {
console.log('Error', err1);
reject(err1);
}
if (data) {
resolve(data.Location);
}
this.upload(uploadParams).then((data) => {
resolve(data);
});
})
.catch((error) => {
@ -104,6 +106,14 @@ export default class S3 implements IStorageAdapterV2 {
});
}
public async getSignedUrl(key, expires = 7200) {
const command = new GetObjectCommand({
Key: key,
Bucket: this.input.bucket,
});
return getSignedUrl(this.s3Client, command, { expiresIn: expires });
}
public async init(): Promise<any> {
// const s3Options: any = {
// params: {Bucket: process.env.NC_S3_BUCKET},
@ -113,15 +123,15 @@ export default class S3 implements IStorageAdapterV2 {
// s3Options.accessKeyId = process.env.NC_S3_KEY;
// s3Options.secretAccessKey = process.env.NC_S3_SECRET;
const s3Options: any = {
params: { Bucket: this.input.bucket },
const s3Options = {
region: this.input.region,
credentials: {
accessKeyId: this.input.access_key,
secretAccessKey: this.input.access_secret,
},
};
s3Options.accessKeyId = this.input.access_key;
s3Options.secretAccessKey = this.input.access_secret;
this.s3Client = new AWS.S3(s3Options);
this.s3Client = new S3Client(s3Options);
}
public async test(): Promise<boolean> {
@ -141,4 +151,23 @@ export default class S3 implements IStorageAdapterV2 {
throw e;
}
}
private async upload(uploadParams): Promise<any> {
return new Promise((resolve, reject) => {
// call S3 to retrieve upload file to specified bucket
this.s3Client
.putObject({ ...this.defaultParams, ...uploadParams })
.then((data) => {
if (data) {
resolve(
`https://${this.input.bucket}.s3.${this.input.region}.amazonaws.com/${uploadParams.Key}`,
);
}
})
.catch((err) => {
console.error(err);
reject(err);
});
});
}
}

2
packages/nocodb/src/plugins/storage/Local.ts

@ -121,7 +121,7 @@ export default class Local implements IStorageAdapterV2 {
}
// method for validate/normalise the path for avoid path traversal attack
protected validateAndNormalisePath(
public validateAndNormalisePath(
fileOrFolderPath: string,
throw404 = false,
): string {

53
packages/nocodb/src/services/attachments.service.ts

@ -7,6 +7,7 @@ import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import Local from '~/plugins/storage/Local';
import mimetypes, { mimeIcons } from '~/utils/mimeTypes';
import { TemporaryUrl } from '~/models';
@Injectable()
export class AttachmentsService {
@ -34,24 +35,49 @@ export class AttachmentsService {
file,
);
let attachmentPath;
// if `url` is null, then it is local attachment
if (!url) {
// then store the attachment path only
// url will be constructed in `useAttachmentCell`
attachmentPath = `download/${filePath.join('/')}/${fileName}`;
}
return {
const attachment: {
url?: string;
path?: string;
title: string;
mimetype: string;
size: number;
icon?: string;
signedPath?: string;
signedUrl?: string;
} = {
...(url ? { url } : {}),
...(attachmentPath ? { path: attachmentPath } : {}),
title: file.originalname,
mimetype: file.mimetype,
size: file.size,
icon:
mimeIcons[path.extname(file.originalname).slice(1)] || undefined,
};
const promises = [];
// if `url` is null, then it is local attachment
if (!url) {
// then store the attachment path only
// url will be constructed in `useAttachmentCell`
attachment.path = `download/${filePath.join('/')}/${fileName}`;
promises.push(
TemporaryUrl.getTemporaryUrl({
path: attachment.path.replace(/^download\//, ''),
}).then((r) => (attachment.signedPath = r)),
);
} else {
if (attachment.url.includes('.amazonaws.com/')) {
const relativePath = attachment.url.split('.amazonaws.com/')[1];
promises.push(
TemporaryUrl.getTemporaryUrl({
path: relativePath,
s3: true,
}).then((r) => (attachment.signedUrl = r)),
);
}
}
return Promise.all(promises).then(() => attachment);
}),
);
@ -123,7 +149,10 @@ export class AttachmentsService {
mimetypes[path.extname(param.path).split('/').pop().slice(1)] ||
'text/plain';
const img = await storageAdapter.fileRead(slash(param.path));
const img = await storageAdapter.validateAndNormalisePath(
slash(param.path),
true,
);
return { img, type };
}

1
packages/nocodb/src/utils/globals.ts

@ -157,6 +157,7 @@ export enum CacheScope {
DASHBOARD_PROJECT_DB_PROJECT_LINKING = 'dashboardProjectDBProjectLinking',
SINGLE_QUERY = 'singleQuery',
JOBS = 'nc_jobs',
TEMPORARY_URL = 'temporaryUrl',
}
export enum CacheGetType {

2
tests/playwright/tests/db/columns/columnAttachments.spec.ts

@ -109,6 +109,6 @@ test.describe('Attachment column', () => {
// PR8504
// await expect(cells[1]).toBe('al-Manama');
expect(cells[1]).toBe('1');
expect(cells[2].includes('5.json(http://localhost:8080/download/')).toBe(true);
expect(cells[2].includes('5.json(http://localhost:8080/dltemp/')).toBe(true);
});
});

Loading…
Cancel
Save