diff --git a/.env.example b/.env.example index 8cb680f..b0373c9 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,10 @@ DISALLOWED_METHODS= # bytes. Should be a string like 1kb, 1mb, 500b MAX_CONTENT_LENGTH_BYTES=100kb +# Maximum number of parameters that can be passed to endpoints that accept +# multiple slash parameters. +MAX_PARAMS_PER_REQUEST=100 + # Maximum allowed size of a tags array in a search query. MAX_SEARCHABLE_TAGS=10 diff --git a/jest.config.js b/jest.config.js index 00d2d66..4e03b08 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,7 @@ module.exports = { testEnvironment: 'node', testRunner: 'jest-circus/runner', // ? 1 hour so MMS and other tools don't choke during debugging - testTimeout: 5000, + testTimeout: 1000 * 60 * 60, verbose: false, testPathIgnorePatterns: ['/node_modules/'], // ! If changed, also update these aliases in tsconfig.json, diff --git a/lib/mongo-item/index.ts b/lib/mongo-item/index.ts index e9583c0..670f31a 100644 --- a/lib/mongo-item/index.ts +++ b/lib/mongo-item/index.ts @@ -31,8 +31,8 @@ export type ItemExistsOptions = { */ caseInsensitive?: boolean; /** - * When looking for an item matching `{ _id: id }`, where the property is - * `"_id"` is a string, `id` will be optimistically wrapped in a `new + * When looking for an item matching `{ _id: id }`, where the descriptor key + * is the string `"_id"`, `id` will be optimistically wrapped in a `new * ObjectId(id)` call. Set this to `false` to prevent this. * * @default true diff --git a/package-lock.json b/package-lock.json index 84b1ef6..433811b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "find-up": "^5.0.0", "is-server-side": "^1.0.2", "mongodb": "^4.6.0", - "named-app-errors": "^3.1.0", + "named-app-errors": "^3.2.0", "next": "^12.1.6", "node-fetch": "cjs", "react": "^18.1.0", @@ -15210,9 +15210,9 @@ "dev": true }, "node_modules/named-app-errors": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/named-app-errors/-/named-app-errors-3.1.0.tgz", - "integrity": "sha512-gFF1uTrciLWEozvLiFqnfasTK47Wr3VqZr38uXMeLgd9UnQg50wh8Sy5DJNurVKGnouFqKPT8Abbhan4NWzw4A==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/named-app-errors/-/named-app-errors-3.2.0.tgz", + "integrity": "sha512-Hpx5vt2CzwIdLuLIrQto7M3hXZfUW5FgX4+MrLwW+3EdiqZ/cOf2WocXN6l29tWmnqT15tJeCHxjzor2pA8UGw==" }, "node_modules/nan": { "version": "2.16.0", @@ -34060,9 +34060,9 @@ "dev": true }, "named-app-errors": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/named-app-errors/-/named-app-errors-3.1.0.tgz", - "integrity": "sha512-gFF1uTrciLWEozvLiFqnfasTK47Wr3VqZr38uXMeLgd9UnQg50wh8Sy5DJNurVKGnouFqKPT8Abbhan4NWzw4A==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/named-app-errors/-/named-app-errors-3.2.0.tgz", + "integrity": "sha512-Hpx5vt2CzwIdLuLIrQto7M3hXZfUW5FgX4+MrLwW+3EdiqZ/cOf2WocXN6l29tWmnqT15tJeCHxjzor2pA8UGw==" }, "nan": { "version": "2.16.0", diff --git a/package.json b/package.json index 8237d08..2eacaf7 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "find-up": "^5.0.0", "is-server-side": "^1.0.2", "mongodb": "^4.6.0", - "named-app-errors": "^3.1.0", + "named-app-errors": "^3.2.0", "next": "^12.1.6", "node-fetch": "cjs", "react": "^18.1.0", diff --git a/src/backend/db.ts b/src/backend/db.ts index dcf1931..ee9c9e4 100644 --- a/src/backend/db.ts +++ b/src/backend/db.ts @@ -20,6 +20,7 @@ export function getSchemaConfig(): DbSchema { // ? https://stackoverflow.com/a/40914924/1367414 createOptions: { collation: { locale: 'en', strength: 2 } }, indices: [ + { spec: 'key' }, { spec: 'username', options: { unique: true } @@ -46,6 +47,7 @@ export function getSchemaConfig(): DbSchema { indices: [ { spec: 'owner' }, { spec: 'name-lowercase' }, + { spec: 'contents' }, // ? Wildcard indices to index permissions object keys // ? https://www.mongodb.com/docs/manual/core/index-wildcard { spec: 'permissions.$**' } @@ -223,7 +225,6 @@ export function toPublicNode(internalNode: InternalNode): PublicNode { owner: internalNode.owner, createdAt: internalNode.createdAt, name: internalNode.name, - 'name-lowercase': internalNode['name-lowercase'], permissions: internalNode.permissions } as { [key in keyof PublicFileNode & keyof PublicMetaNode]: @@ -261,3 +262,37 @@ export function toPublicUser(internalUser: InternalUser): PublicUser { salt: internalUser.salt }; } + +export const publicUserProjection = { + _id: false, + user_id: { $toString: '$_id' }, + username: true, + salt: true, + email: true +} as const; + +export const publicFileNodeProjection = { + _id: false, + node_id: { $toString: '$_id' }, + type: true, + owner: true, + createdAt: true, + modifiedAt: true, + name: true, + size: true, + text: true, + tags: true, + lock: true, + permissions: true +} as const; + +export const publicMetaNodeProjection = { + _id: false, + node_id: { $toString: '$_id' }, + type: true, + owner: true, + createdAt: true, + name: true, + contents: { $map: { input: '$contents', as: 'id', in: { $toString: '$$id' } } }, + permissions: true +} as const; diff --git a/src/backend/env.ts b/src/backend/env.ts index 0e19ded..e05d82d 100644 --- a/src/backend/env.ts +++ b/src/backend/env.ts @@ -10,6 +10,7 @@ import type { Environment } from 'multiverse/next-env'; // eslint-disable-next-line @typescript-eslint/ban-types export function getEnv() { const env = getDefaultEnv({ + MAX_PARAMS_PER_REQUEST: Number(process.env.MAX_PARAMS_PER_REQUEST) || 0, MAX_SEARCHABLE_TAGS: Number(process.env.MAX_SEARCHABLE_TAGS) || 0, MAX_NODE_NAME_LENGTH: Number(process.env.MAX_NODE_NAME_LENGTH) || 0, MAX_USER_NAME_LENGTH: Number(process.env.MAX_USER_NAME_LENGTH) || 0, @@ -31,6 +32,7 @@ export function getEnv() { ( [ + 'MAX_PARAMS_PER_REQUEST', 'MAX_SEARCHABLE_TAGS', 'MAX_NODE_NAME_LENGTH', 'MAX_USER_NAME_LENGTH', diff --git a/src/backend/error.ts b/src/backend/error.ts index 5ac1e22..c5f8d07 100644 --- a/src/backend/error.ts +++ b/src/backend/error.ts @@ -1,21 +1,23 @@ +import { ErrorMessage as NamedErrorMessage } from 'named-app-errors'; + /** * A collection of possible error and warning messages. */ export const ErrorMessage = { - ItemNotFound: (itemName: string) => `${itemName} could not be found`, - ItemOrItemsNotFound: (itemsName: string) => - `one or more ${itemsName} could not be found`, + ...NamedErrorMessage, TooManyItemsRequested: (itemsName: string) => `too many ${itemsName} requested`, ForbiddenAction: () => 'you are not authorized to take this action', DuplicateFieldValue: (prop: string) => `an item with that "${prop}" already exists`, - DuplicateSetMember: (prop: string) => - `duplicate elements in \`${prop}\` are not allowed`, InvalidField: (prop: string) => `the \`${prop}\` field is not allowed with this type of item`, - InvalidFieldValue: (prop: string) => - `\`${prop}\` field has a missing, invalid, or illegal value`, - InvalidArrayValue: (prop: string) => - `a \`${prop}\` array element has an invalid or illegal value`, + InvalidFieldValue: (prop: string, value?: string) => + value + ? `the \`${prop}\` field value "${value}" is invalid or illegal` + : `\`${prop}\` field has a missing, invalid, or illegal value`, + InvalidArrayValue: (prop: string, value?: string) => + value + ? `the \`${prop}\` array element "${value}" is invalid or illegal` + : `a \`${prop}\` array element has an invalid or illegal value`, InvalidObjectKeyValue: (prop: string) => `a \`${prop}\` object key has an invalid or illegal value`, IllegalUsername: () => 'a user with that username cannot be created', @@ -49,5 +51,5 @@ export const ErrorMessage = { InvalidRegexString: (prop: string) => `\`${prop}\`: invalid regex value`, InvalidMatcher: (prop: string) => `invalid \`${prop}\`: must be an object`, InvalidSpecifierCombination: () => `invalid combination of specifiers`, - InvalidObjectId: (id: string) => `invalid ${id}` + InvalidObjectId: (id: string) => `invalid id "${id}"` }; diff --git a/src/backend/index.ts b/src/backend/index.ts index a18a7bf..4b06419 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,4 +1,20 @@ +import { MongoServerError, ObjectId } from 'mongodb'; +import { ItemNotFoundError, ItemsNotFoundError, ValidationError } from 'named-app-errors'; +import { isPlainObject } from 'is-plain-object'; +import { getDb } from 'multiverse/mongo-schema'; +import { itemExists } from 'multiverse/mongo-item'; +import { getEnv } from 'universe/backend/env'; +import { ErrorMessage } from 'universe/backend/error'; +import { toss } from 'toss-expression'; + import { + NodeId, + publicFileNodeProjection, + publicMetaNodeProjection, + publicUserProjection +} from 'universe/backend/db'; + +import type { PublicNode, NewNode, PatchNode, @@ -6,26 +22,456 @@ import { NewUser, PatchUser, Username, - NodePermission + NodePermission, + UserId, + InternalUser, + InternalNode, + InternalFileNode, + InternalMetaNode, + NewFileNode, + NewMetaNode, + PatchFileNode, + PatchMetaNode } from 'universe/backend/db'; +// TODO: switch to using itemToObjectId from mongo-item library + +const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; +const usernameRegex = /^[a-zA-Z0-9_-]+$/; +const hexadecimalRegex = /^[a-fA-F0-9]+$/; +const nodeNameRegex = /^[a-zA-Z0-9 _-]+$/; + +/** + * Node properties that can be matched against with `searchNodes()`. + */ +const matchableStrings = [ + 'owner', + 'receiver', + 'createdAt', + 'expiredAt', + 'description', + 'totalLikes', + 'private', + 'replyTo' +] as const; + +/** + * Whitelisted MongoDB sub-matchers that can be used with `searchNodes()`, not + * including the special "$or" sub-matcher. + */ +const matchableSubStrings = ['$gt', '$lt', '$gte', '$lte'] as const; + +/** + * Whitelisted MongoDB-esque sub-specifiers that can be used with + * `searchNodes()` via the "$or" sub-matcher. + */ +type SubSpecifierObject = { + [subspecifier in '$gt' | '$lt' | '$gte' | '$lte']?: number; +}; + +/** + * Convert an array of node_id strings into a set of node_id ObjectIds. + */ +const normalizeNodeIds = (ids: string[]) => { + let node_id = ''; + try { + return Array.from(new Set(ids)).map((id) => { + node_id = id; + return new ObjectId(id); + }); + } catch { + throw new ValidationError(ErrorMessage.InvalidObjectId(node_id)); + } +}; + +/** + * Convert an array of strings into a set of proper node tags (still strings). + */ +const normalizedTags = (tags: string[]) => { + return Array.from(new Set(tags.map((tag) => tag.toLowerCase()))); +}; + +/** + * Validate a username string for correctness. + */ +const validateUsername = (username: unknown) => { + return ( + typeof username == 'string' && + usernameRegex.test(username) && + username.length >= getEnv().MIN_USER_NAME_LENGTH && + username.length <= getEnv().MAX_USER_NAME_LENGTH + ); +}; + +/** + * Validate a new or patch user data object. + */ +const validateUserData = ( + data: NewUser | PatchUser, + { required } = { required: false } +) => { + if (!isPlainObject(data)) { + throw new ValidationError(ErrorMessage.InvalidJSON()); + } + + const { + USER_KEY_LENGTH, + USER_SALT_LENGTH, + MIN_USER_EMAIL_LENGTH, + MAX_USER_EMAIL_LENGTH + } = getEnv(); + + if ( + (required || (!required && data.email !== undefined)) && + (typeof data.email != 'string' || + !emailRegex.test(data.email) || + data.email.length < MIN_USER_EMAIL_LENGTH || + data.email.length > MAX_USER_EMAIL_LENGTH) + ) { + throw new ValidationError( + ErrorMessage.InvalidStringLength( + 'email', + MIN_USER_EMAIL_LENGTH, + MAX_USER_EMAIL_LENGTH, + 'string' + ) + ); + } + + if ( + (required || (!required && data.salt !== undefined)) && + (typeof data.salt != 'string' || + !hexadecimalRegex.test(data.salt) || + data.salt.length != USER_SALT_LENGTH) + ) { + throw new ValidationError( + ErrorMessage.InvalidStringLength('salt', USER_SALT_LENGTH, null, 'hexadecimal') + ); + } + + if ( + (required || (!required && data.key !== undefined)) && + (typeof data.key != 'string' || + !hexadecimalRegex.test(data.key) || + data.key.length != USER_KEY_LENGTH) + ) { + throw new ValidationError( + ErrorMessage.InvalidStringLength('key', USER_KEY_LENGTH, null, 'hexadecimal') + ); + } +}; + +/** + * Validate a new or patch file or meta node data object. If no `type` is + * explicitly provided, a data must be a valid NewNode instance with all + * required fields. If `type` is provided, data must be a valid PatchNode + * instance where all fields are optional. + */ +const validateNodeData = async ( + data: NewNode | PatchNode, + { type }: { type: NonNullable | null } +) => { + if (!isPlainObject(data)) { + throw new ValidationError(ErrorMessage.InvalidJSON()); + } + + const isNewNode = (_obj: typeof data): _obj is NewNode => { + return type === null; + }; + + const isNewFileNode = (obj: NewNode): obj is NewFileNode => { + return isNewNode(obj) && obj.type == 'file'; + }; + + const isNewMetaNode = (obj: NewNode): obj is NewMetaNode => { + return isNewNode(obj) && obj.type != 'file'; + }; + + const isPatchNode = (_obj: typeof data): _obj is PatchNode => { + return type !== null; + }; + + const isPatchFileNode = (obj: typeof data): obj is PatchFileNode => { + return isPatchNode(obj) && type == 'file'; + }; + + const isPatchMetaNode = (obj: typeof data): obj is PatchMetaNode => { + return isPatchNode(obj) && type != 'file'; + }; + + const { + MAX_USER_NAME_LENGTH, + MIN_USER_NAME_LENGTH, + MAX_LOCK_CLIENT_LENGTH, + MAX_NODE_NAME_LENGTH, + MAX_NODE_TAGS, + MAX_NODE_TAG_LENGTH, + MAX_NODE_PERMISSIONS, + MAX_NODE_CONTENTS, + MAX_NODE_TEXT_LENGTH_BYTES + } = getEnv(); + + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + + if (isNewNode(data)) { + if ( + typeof data.type != 'string' || + !['file', 'directory', 'symlink'].includes(data.type) + ) { + throw new ValidationError(ErrorMessage.InvalidFieldValue('type')); + } + } + + const typeActual = (isNewNode(data) ? data.type : type) as NonNullable; + + if (isNewNode(data) || data.name !== undefined) { + if ( + typeof data.name != 'string' || + !data.name.length || + data.name.length > MAX_NODE_NAME_LENGTH + ) { + throw new ValidationError( + ErrorMessage.InvalidStringLength('name', 1, MAX_NODE_NAME_LENGTH, 'string') + ); + } + } + + if (isNewNode(data) || data.permissions !== undefined) { + if (!data.permissions || !isPlainObject(data.permissions)) { + throw new ValidationError(ErrorMessage.InvalidFieldValue('permissions')); + } else { + const permsEntries = Object.entries(data.permissions); + + if ( + !permsEntries.every(([k, v]) => { + return ( + typeof k == 'string' && + ['view', 'edit'].includes(v) && + (k != 'public' || (typeActual == 'file' && v == 'view')) + ); + }) + ) { + throw new ValidationError(ErrorMessage.InvalidObjectKeyValue('permissions')); + } else if (permsEntries.length > MAX_NODE_PERMISSIONS) { + throw new ValidationError(ErrorMessage.TooManyItemsRequested('permissions')); + } else { + await Promise.all( + permsEntries.map(async ([username]) => { + if ( + username != 'public' && + !(await itemExists(users, { key: 'username', id: username })) + ) { + throw new ItemNotFoundError(username, 'user (permissions)'); + } + }) + ); + } + } + } + + if (isNewFileNode(data) || (isPatchFileNode(data) && data.text !== undefined)) { + if (typeof data.text != 'string' || data.text.length > MAX_NODE_TEXT_LENGTH_BYTES) { + throw new ValidationError( + ErrorMessage.InvalidStringLength('text', 0, MAX_NODE_TEXT_LENGTH_BYTES, 'bytes') + ); + } + } + + if (isNewFileNode(data) || (isPatchFileNode(data) && data.tags !== undefined)) { + if (!Array.isArray(data.tags)) { + throw new ValidationError(ErrorMessage.InvalidFieldValue('tags')); + } else if (data.tags.length > MAX_NODE_TAGS) { + throw new ValidationError(ErrorMessage.TooManyItemsRequested('tags')); + } else if ( + !data.tags.every( + (tag) => + tag && + typeof tag == 'string' && + tag.length >= 1 && + tag.length <= MAX_NODE_TAG_LENGTH + ) + ) { + throw new ValidationError( + ErrorMessage.InvalidStringLength( + 'tags', + 1, + MAX_NODE_TAG_LENGTH, + 'alphanumeric', + false, + true + ) + ); + } + } + + if (isNewFileNode(data) || (isPatchFileNode(data) && data.lock !== undefined)) { + if (data.lock !== null) { + if (!data.lock || !isPlainObject(data.lock)) { + throw new ValidationError(ErrorMessage.InvalidFieldValue('lock')); + } else if (!validateUsername(data.lock.user)) { + throw new ValidationError( + ErrorMessage.InvalidStringLength( + 'lock.user', + MIN_USER_NAME_LENGTH, + MAX_USER_NAME_LENGTH + ) + ); + } else if ( + typeof data.lock.client != 'string' || + data.lock.client.length < 1 || + data.lock.client.length > MAX_LOCK_CLIENT_LENGTH + ) { + throw new ValidationError( + ErrorMessage.InvalidStringLength( + 'lock.client', + 1, + MAX_LOCK_CLIENT_LENGTH, + 'string' + ) + ); + } else if (typeof data.lock.createdAt != 'number' || data.lock.createdAt <= 0) { + throw new ValidationError(ErrorMessage.InvalidFieldValue('lock.createdAt')); + } else if (Object.keys(data.lock).length != 3) { + throw new ValidationError(ErrorMessage.InvalidObjectKeyValue('lock')); + } + } + } + + if (isNewMetaNode(data) || (isPatchMetaNode(data) && data.contents !== undefined)) { + if (!Array.isArray(data.contents)) { + throw new ValidationError(ErrorMessage.InvalidFieldValue('contents')); + } else if ( + data.contents.length > MAX_NODE_CONTENTS || + (typeActual == 'symlink' && data.contents.length > 1) + ) { + throw new ValidationError(ErrorMessage.TooManyItemsRequested('content node_ids')); + } else { + const fileNodes = db.collection('file-nodes'); + const metaNodes = db.collection('meta-nodes'); + + await Promise.all( + data.contents.map(async (node_id) => { + try { + if ( + !(await itemExists(fileNodes, node_id)) && + !(await itemExists(metaNodes, node_id)) + ) { + throw new ItemNotFoundError(node_id, 'node_id'); + } + } catch (e) { + if (e instanceof ItemNotFoundError) { + throw e; + } else { + throw new ValidationError( + ErrorMessage.InvalidArrayValue('contents', node_id) + ); + } + } + }) + ); + } + } + + if (isPatchNode(data) && data.owner !== undefined) { + if (!(await itemExists(users, { key: 'username', id: data.owner }))) { + throw new ItemNotFoundError(data.owner, 'user'); + } + } +}; + export async function getAllUsers({ after }: { after: string | null; }): Promise { - void after; - return []; + const afterId: UserId | null = (() => { + try { + return after ? new ObjectId(after) : null; + } catch { + throw new ValidationError(ErrorMessage.InvalidObjectId(after as string)); + } + })(); + + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + + if (afterId && !(await itemExists(users, afterId))) { + throw new ItemNotFoundError(after, 'user_id'); + } + + return users + .find(afterId ? { _id: { $lt: afterId } } : {}) + .sort({ _id: -1 }) + .limit(getEnv().RESULTS_PER_PAGE) + .project(publicUserProjection) + .toArray(); } export async function getUser({ username }: { username: Username }): Promise { - void username; - return {} as PublicUser; + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + + return ( + (await users.find({ username }).project(publicUserProjection).next()) || + toss(new ItemNotFoundError(username, 'user')) + ); } export async function createUser({ data }: { data: NewUser }): Promise { - void data; - return {} as PublicUser; + validateUserData(data, { required: true }); + + const { MAX_USER_NAME_LENGTH, MIN_USER_NAME_LENGTH } = getEnv(); + + if (!validateUsername(data.username)) { + throw new ValidationError( + ErrorMessage.InvalidStringLength( + 'username', + MIN_USER_NAME_LENGTH, + MAX_USER_NAME_LENGTH + ) + ); + } + + if (data.username == 'public') { + throw new ValidationError(ErrorMessage.IllegalUsername()); + } + + const { email, username, key, salt, ...rest } = data as Required; + const restKeys = Object.keys(rest); + + if (restKeys.length != 0) { + throw new ValidationError(ErrorMessage.UnknownField(restKeys[0])); + } + + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + + // * At this point, we can finally trust this data is not malicious, but not + // * necessarily valid... + try { + await users.insertOne({ + _id: new ObjectId(), + username, + email, + salt: salt.toLowerCase(), + key: key.toLowerCase() + }); + } catch (e) { + if (e instanceof MongoServerError && e.code == 11000) { + if (e.keyPattern?.username !== undefined) { + throw new ValidationError(ErrorMessage.DuplicateFieldValue('username')); + } + + if (e.keyPattern?.email !== undefined) { + throw new ValidationError(ErrorMessage.DuplicateFieldValue('email')); + } + } + + throw e; + } + + return getUser({ username }); } export async function updateUser({ @@ -35,13 +481,65 @@ export async function updateUser({ username: Username; data: PatchUser; }): Promise { - void username, data; - return; + validateUserData(data, { required: false }); + + const { email, key, salt, ...rest } = data as Required; + const restKeys = Object.keys(rest); + + if (restKeys.length != 0) { + throw new ValidationError(ErrorMessage.UnknownField(restKeys[0])); + } + + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + + // * At this point, we can finally trust this data is not malicious, but not + // * necessarily valid... + try { + const result = await users.updateOne( + { username }, + { + $set: { + ...(email ? { email } : {}), + ...(salt ? { salt: salt.toLowerCase() } : {}), + ...(key ? { key: key.toLowerCase() } : {}) + } + } + ); + + if (!result.modifiedCount) { + throw new ValidationError(ErrorMessage.ItemNotFound(username, 'user')); + } + } catch (e) { + if (e instanceof MongoServerError && e.code == 11000) { + if (e.keyPattern?.email !== undefined) { + throw new ValidationError(ErrorMessage.DuplicateFieldValue('email')); + } + } + + throw e; + } } export async function deleteUser({ username }: { username: Username }): Promise { - void username; - return; + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + const fileNodes = db.collection('file-nodes'); + const metaNodes = db.collection('meta-nodes'); + const result = await users.deleteOne({ username }); + + if (!result.deletedCount) { + throw new ValidationError(ErrorMessage.ItemNotFound(username, 'user')); + } + + await Promise.all( + [fileNodes, metaNodes].map((col) => + col.updateMany( + { [`permissions.${username}`]: { $exists: true } }, + { $unset: { [`permissions.${username}`]: '' } } + ) + ) + ); } export async function authAppUser({ @@ -51,8 +549,9 @@ export async function authAppUser({ username: Username; key: string; }): Promise { - void username, key; - return false; + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + return !!(await users.countDocuments({ username, key })); } export async function getNodes({ @@ -62,11 +561,43 @@ export async function getNodes({ username: Username; node_ids: string[]; }): Promise { - void username, node_ids; - return []; -} + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + + if (node_ids.length > getEnv().MAX_PARAMS_PER_REQUEST) { + throw new ValidationError(ErrorMessage.TooManyItemsRequested('node_ids')); + } + + if (!(await itemExists(users, { key: 'username', id: username }))) { + throw new ItemNotFoundError(username, 'user'); + } -type SubSpecifierObject = { [subspecifier in '$gt' | '$lt' | '$gte' | '$lte']?: number }; + const nodeIds = normalizeNodeIds(node_ids); + + const nodes = await db + .collection('file-nodes') + .aggregate([ + { $match: { _id: { $in: nodeIds }, owner: username } }, + { $project: { ...publicFileNodeProjection, _id: true } }, + { + $unionWith: { + coll: 'meta-nodes', + pipeline: [ + { $match: { _id: { $in: nodeIds }, owner: username } }, + { $project: { ...publicMetaNodeProjection, _id: true } } + ] + } + }, + { $sort: { _id: -1 } }, + { $limit: getEnv().RESULTS_PER_PAGE }, + { $project: { _id: false } } + ]) + .toArray(); + + if (nodes.length != node_ids.length) { + throw new ItemsNotFoundError('node_ids'); + } else return nodes; +} export async function searchNodes({ username, @@ -102,8 +633,64 @@ export async function createNode({ username: Username; data: NewNode; }): Promise { - void username, data; - return {} as PublicNode; + await validateNodeData(data, { type: null }); + + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + const node_id = new ObjectId(); + + if (!(await itemExists(users, { key: 'username', id: username }))) { + throw new ItemNotFoundError(username, 'user'); + } + + if (data.type == 'file') { + const fileNodes = db.collection('file-nodes'); + const { type, name, text, tags, lock, permissions, ...rest } = + data as Required; + const restKeys = Object.keys(rest); + + if (restKeys.length != 0) { + throw new ValidationError(ErrorMessage.UnknownField(restKeys[0])); + } + + // * At this point, we can finally trust this data is not malicious. + await fileNodes.insertOne({ + _id: node_id, + owner: username, + createdAt: Date.now(), + modifiedAt: Date.now(), + type, + name, + 'name-lowercase': name.toLowerCase(), + text, + size: text.length, + tags: normalizedTags(tags), + lock, + permissions + }); + } else { + const metaNodes = db.collection('meta-nodes'); + const { type, name, contents, permissions, ...rest } = data as Required; + const restKeys = Object.keys(rest); + + if (restKeys.length != 0) { + throw new ValidationError(ErrorMessage.UnknownField(restKeys[0])); + } + + // * At this point, we can finally trust this data is not malicious. + await metaNodes.insertOne({ + _id: node_id, + owner: username, + createdAt: Date.now(), + type, + name, + 'name-lowercase': name.toLowerCase(), + contents: normalizeNodeIds(contents), + permissions + }); + } + + return (await getNodes({ username, node_ids: [node_id.toString()] }))[0]; } export async function updateNode({ @@ -115,8 +702,90 @@ export async function updateNode({ node_id: string; data: PatchNode; }): Promise { - void username, node_id, data; - return; + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + const fileNodes = db.collection('file-nodes'); + const metaNodes = db.collection('meta-nodes'); + + const nodeId = (() => { + try { + return new ObjectId(node_id); + } catch { + throw new ValidationError(ErrorMessage.InvalidObjectId(node_id)); + } + })(); + + if (!(await itemExists(users, { key: 'username', id: username }))) { + throw new ItemNotFoundError(username, 'user'); + } + + const node = await db + .collection('file-nodes') + .aggregate([ + { $match: { _id: nodeId, owner: username } }, + { $project: { type: true } }, + { + $unionWith: { + coll: 'meta-nodes', + pipeline: [ + { $match: { _id: nodeId, owner: username } }, + { $project: { type: true } } + ] + } + } + ]) + .next(); + + if (!node) { + throw new ValidationError(ErrorMessage.ItemNotFound(node_id, 'node_id')); + } + + await validateNodeData(data, { type: node.type }); + + if (node.type == 'file') { + const { name, text, tags, lock, permissions, owner, ...rest } = data as PatchFileNode; + const restKeys = Object.keys(rest); + + if (restKeys.length != 0) { + throw new ValidationError(ErrorMessage.UnknownField(restKeys[0])); + } + + // * At this point, we can finally trust this data is not malicious. + await fileNodes.updateOne( + { _id: nodeId }, + { + $set: { + modifiedAt: Date.now(), + ...(owner ? { owner } : {}), + ...(name ? { name, 'name-lowercase': name.toLowerCase() } : {}), + ...(text ? { text, size: text.length } : {}), + ...(tags ? { tags: normalizedTags(tags) } : {}), + ...(lock ? { lock } : {}), + ...(permissions ? { permissions } : {}) + } + } + ); + } else { + const { name, contents, permissions, owner, ...rest } = data as PatchMetaNode; + const restKeys = Object.keys(rest); + + if (restKeys.length != 0) { + throw new ValidationError(ErrorMessage.UnknownField(restKeys[0])); + } + + // * At this point, we can finally trust this data is not malicious. + await metaNodes.updateOne( + { _id: nodeId }, + { + $set: { + ...(owner ? { owner } : {}), + ...(name ? { name, 'name-lowercase': name.toLowerCase() } : {}), + ...(contents ? { contents: normalizeNodeIds(contents) } : {}), + ...(permissions ? { permissions } : {}) + } + } + ); + } } export async function deleteNodes({ @@ -126,6 +795,28 @@ export async function deleteNodes({ username: Username; node_ids: string[]; }): Promise { - void username, node_ids; - return; + if (node_ids.length > getEnv().MAX_PARAMS_PER_REQUEST) { + throw new ValidationError(ErrorMessage.TooManyItemsRequested('node_ids')); + } + + const db = await getDb({ name: 'hscc-api-drive' }); + const users = db.collection('users'); + const fileNodes = db.collection('file-nodes'); + const metaNodes = db.collection('meta-nodes'); + const nodeIds = normalizeNodeIds(node_ids); + + if (!(await itemExists(users, { key: 'username', id: username }))) { + throw new ItemNotFoundError(username, 'user'); + } + + await Promise.all([ + fileNodes.deleteMany({ _id: { $in: nodeIds }, owner: username }), + metaNodes.deleteMany({ _id: { $in: nodeIds }, owner: username }) + ]); + + await metaNodes.updateMany( + // * Is this more optimal than a full scan? + { contents: { $in: nodeIds } }, + { $pull: { contents: { $in: nodeIds } } } + ); } diff --git a/test/api/unit-backend.test.ts b/test/api/unit-backend.test.ts index 3ea1ee5..fcf2699 100644 --- a/test/api/unit-backend.test.ts +++ b/test/api/unit-backend.test.ts @@ -8,8 +8,6 @@ import { withMockedEnv } from 'testverse/setup'; import { toPublicNode, toPublicUser } from 'universe/backend/db'; import { getEnv } from 'universe/backend/env'; import { ErrorMessage } from 'universe/backend/error'; -import { toss } from 'toss-expression'; -import { TrialError } from 'named-app-errors'; import * as Backend from 'universe/backend'; @@ -33,6 +31,24 @@ import type { setupMemoryServerOverride(); useMockDateNow(); +// ? A primitive attempt to replicate MongoDB's sort by { _id: -1 } +const sortNodes = (nodes: InternalNode[]) => { + return nodes + .slice() + .sort( + (a, b) => + parseInt(b._id.toString().slice(-5), 16) - + parseInt(a._id.toString().slice(-5), 16) + ); +}; + +const sortedNodes = sortNodes([ + ...dummyAppData['file-nodes'], + ...dummyAppData['meta-nodes'] +]); + +const sortedUsers = dummyAppData.users.slice().reverse(); + describe('::getAllUsers', () => { it('does not crash when database is empty', async () => { expect.hasAssertions(); @@ -45,8 +61,8 @@ describe('::getAllUsers', () => { it('returns all users', async () => { expect.hasAssertions(); - await expect(Backend.getAllUsers({ after: null })).resolves.toIncludeSameMembers( - dummyAppData.users.map(toPublicUser) + await expect(Backend.getAllUsers({ after: null })).resolves.toStrictEqual( + sortedUsers.map(toPublicUser) ); }); @@ -58,23 +74,33 @@ describe('::getAllUsers', () => { expect([ await Backend.getAllUsers({ after: null }), await Backend.getAllUsers({ - after: dummyAppData.users.at(-1)?._id.toString() || toss(new TrialError()) + after: sortedUsers[0]._id.toString() }), await Backend.getAllUsers({ - after: dummyAppData.users.at(-2)?._id.toString() || toss(new TrialError()) + after: sortedUsers[1]._id.toString() }) - ]).toStrictEqual( - dummyAppData.users - .slice(-3) - .reverse() - .map((user) => [user]) - ); + ]).toStrictEqual(sortedUsers.slice(-3).map((user) => [toPublicUser(user)])); }, - { RESULTS_PER_PAGE: '1' } + { RESULTS_PER_PAGE: '1' }, + { replace: false } ); + }); + + it('rejects if after user_id is not a valid ObjectId', async () => { + expect.hasAssertions(); await expect(Backend.getAllUsers({ after: 'fake-oid' })).rejects.toMatchObject({ - message: ErrorMessage.InvalidObjectId('after') + message: ErrorMessage.InvalidObjectId('fake-oid') + }); + }); + + it('rejects if after user_id not found', async () => { + expect.hasAssertions(); + + const after = new ObjectId().toString(); + + await expect(Backend.getAllUsers({ after })).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound(after, 'user_id') }); }); }); @@ -90,9 +116,10 @@ describe('::getUser', () => { it('rejects if username not found', async () => { expect.hasAssertions(); + const username = 'does-not-exist'; - await expect(Backend.getUser({ username: 'does-not-exist' })).rejects.toMatchObject({ - message: ErrorMessage.ItemNotFound('username') + await expect(Backend.getUser({ username })).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound(username, 'user') }); }); }); @@ -139,77 +166,61 @@ describe('::createUser', () => { const newUsers: [NewUser, string][] = [ [undefined as unknown as NewUser, ErrorMessage.InvalidJSON()], ['string data' as NewUser, ErrorMessage.InvalidJSON()], - [{ data: 1 } as NewUser, ErrorMessage.UnknownField('data')], - [{ name: 'username' } as NewUser, ErrorMessage.UnknownField('name')], - [{} as NewUser, ErrorMessage.InvalidStringLength('username', minULen, maxULen)], [ - { username: 'must be alphanumeric' }, - ErrorMessage.InvalidStringLength('username', minULen, maxULen) - ], - [ - { username: '#&*@^(#@(^$&*#' }, - ErrorMessage.InvalidStringLength('username', minULen, maxULen) - ], - [ - { username: null } as unknown as NewUser, - ErrorMessage.InvalidStringLength('username', minULen, maxULen) - ], - [ - { username: 'x'.repeat(minULen - 1) }, - ErrorMessage.InvalidStringLength('username', minULen, maxULen) - ], - [ - { username: 'x'.repeat(maxULen + 1) }, - ErrorMessage.InvalidStringLength('username', minULen, maxULen) + {} as NewUser, + ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') ], [ - { username: 'x'.repeat(maxULen - 1) }, + { email: null } as unknown as NewUser, ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') ], [ - { username: 'x'.repeat(maxULen - 1), email: null } as unknown as NewUser, + { email: 'x'.repeat(minELen - 1) }, ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') ], [ - { username: 'x'.repeat(maxULen - 1), email: 'x'.repeat(minELen - 1) }, + { email: 'x'.repeat(maxELen + 1) }, ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') ], [ - { username: 'x'.repeat(maxULen - 1), email: 'x'.repeat(maxELen + 1) }, + { email: 'x'.repeat(maxELen) }, ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') ], [ - { username: 'x'.repeat(maxULen - 1), email: 'x'.repeat(maxELen - 1) }, + { email: 'valid@email.address' }, ErrorMessage.InvalidStringLength('salt', saltLen, null, 'hexadecimal') ], [ { - username: 'x'.repeat(maxULen - 1), - email: 'x'.repeat(maxELen - 1), + email: 'valid@email.address', salt: '0'.repeat(saltLen - 1) }, ErrorMessage.InvalidStringLength('salt', saltLen, null, 'hexadecimal') ], [ { - username: 'x'.repeat(maxULen - 1), - email: 'x'.repeat(maxELen - 1), + email: 'valid@email.address', salt: null } as unknown as NewUser, ErrorMessage.InvalidStringLength('salt', saltLen, null, 'hexadecimal') ], [ { - username: 'x'.repeat(maxULen - 1), - email: 'x'.repeat(maxELen - 1), + email: 'valid@email.address', + salt: 'x'.repeat(saltLen) + }, + ErrorMessage.InvalidStringLength('salt', saltLen, null, 'hexadecimal') + ], + [ + { + email: 'valid@email.address', salt: '0'.repeat(saltLen) }, ErrorMessage.InvalidStringLength('key', keyLen, null, 'hexadecimal') ], [ { - username: 'x'.repeat(maxULen - 1), - email: 'x'.repeat(maxELen - 1), + email: 'valid@email.address', salt: '0'.repeat(saltLen), key: '0'.repeat(keyLen - 1) }, @@ -217,17 +228,69 @@ describe('::createUser', () => { ], [ { - username: 'x'.repeat(maxULen - 1), - email: 'x'.repeat(maxELen - 1), + email: 'valid@email.address', + salt: '0'.repeat(saltLen), + key: 'x'.repeat(keyLen) + }, + ErrorMessage.InvalidStringLength('key', keyLen, null, 'hexadecimal') + ], + [ + { + email: 'valid@email.address', salt: '0'.repeat(saltLen), key: null } as unknown as NewUser, ErrorMessage.InvalidStringLength('key', keyLen, null, 'hexadecimal') ], + [ + { + username: 'must be alphanumeric', + email: 'valid@email.address', + salt: '0'.repeat(saltLen), + key: '0'.repeat(keyLen) + }, + ErrorMessage.InvalidStringLength('username', minULen, maxULen) + ], + [ + { + username: '#&*@^(#@(^$&*#', + email: 'valid@email.address', + salt: '0'.repeat(saltLen), + key: '0'.repeat(keyLen) + }, + ErrorMessage.InvalidStringLength('username', minULen, maxULen) + ], + [ + { + username: null, + email: 'valid@email.address', + salt: '0'.repeat(saltLen), + key: '0'.repeat(keyLen) + } as unknown as NewUser, + ErrorMessage.InvalidStringLength('username', minULen, maxULen) + ], + [ + { + username: 'x'.repeat(minULen - 1), + email: 'valid@email.address', + salt: '0'.repeat(saltLen), + key: '0'.repeat(keyLen) + }, + ErrorMessage.InvalidStringLength('username', minULen, maxULen) + ], + [ + { + username: 'x'.repeat(maxULen + 1), + email: 'valid@email.address', + salt: '0'.repeat(saltLen), + key: '0'.repeat(keyLen) + }, + ErrorMessage.InvalidStringLength('username', minULen, maxULen) + ], [ { username: 'x'.repeat(maxULen - 1), - email: 'x'.repeat(maxELen - 1), + email: 'valid@email.address', salt: '0'.repeat(saltLen), key: '0'.repeat(keyLen), user_id: 1 @@ -329,7 +392,20 @@ describe('::updateUser', () => { salt: '0'.repeat(getEnv().USER_SALT_LENGTH) } }) - ).rejects.toMatchObject({ message: ErrorMessage.ItemNotFound('username') }); + ).rejects.toMatchObject({ message: ErrorMessage.ItemNotFound('fake-user', 'user') }); + }); + + it('rejects when attempting to update a user to a duplicate email', async () => { + expect.hasAssertions(); + + await expect( + Backend.updateUser({ + username: dummyAppData.users[1].username, + data: { + email: dummyAppData.users[0].email + } + }) + ).rejects.toMatchObject({ message: ErrorMessage.DuplicateFieldValue('email') }); }); it('rejects if request body is invalid or contains properties that violates limits', async () => { @@ -345,7 +421,6 @@ describe('::updateUser', () => { const patchUsers: [PatchUser, string][] = [ [undefined as unknown as PatchUser, ErrorMessage.InvalidJSON()], ['string data' as PatchUser, ErrorMessage.InvalidJSON()], - [{ data: 1 } as PatchUser, ErrorMessage.UnknownField('data')], [ { email: '' }, ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') @@ -358,6 +433,10 @@ describe('::updateUser', () => { { email: 'x'.repeat(maxELen + 1) }, ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') ], + [ + { email: 'x'.repeat(maxELen) }, + ErrorMessage.InvalidStringLength('email', minELen, maxELen, 'string') + ], [ { salt: '' }, ErrorMessage.InvalidStringLength('salt', saltLen, null, 'hexadecimal') @@ -366,14 +445,24 @@ describe('::updateUser', () => { { salt: '0'.repeat(saltLen - 1) }, ErrorMessage.InvalidStringLength('salt', saltLen, null, 'hexadecimal') ], + [ + { salt: 'x'.repeat(saltLen) }, + ErrorMessage.InvalidStringLength('salt', saltLen, null, 'hexadecimal') + ], [{ key: '' }, ErrorMessage.InvalidStringLength('key', keyLen, null, 'hexadecimal')], [ { key: '0'.repeat(keyLen - 1) }, ErrorMessage.InvalidStringLength('key', keyLen, null, 'hexadecimal') ], + [ + { key: 'x'.repeat(keyLen) }, + ErrorMessage.InvalidStringLength('key', keyLen, null, 'hexadecimal') + ], + [{ data: 1 } as NewUser, ErrorMessage.UnknownField('data')], + [{ name: 'username' } as NewUser, ErrorMessage.UnknownField('name')], [ { - email: 'x'.repeat(maxELen - 1), + email: 'valid@email.address', salt: '0'.repeat(saltLen), key: '0'.repeat(keyLen), username: 'new-username' @@ -399,7 +488,7 @@ describe('::deleteUser', () => { const usersDb = (await getDb({ name: 'hscc-api-drive' })).collection('users'); await expect( - usersDb.countDocuments({ _id: dummyAppData.users[0]._id.toString() }) + usersDb.countDocuments({ _id: dummyAppData.users[0]._id }) ).resolves.toBe(1); await expect( @@ -407,7 +496,7 @@ describe('::deleteUser', () => { ).resolves.toBeUndefined(); await expect( - usersDb.countDocuments({ _id: dummyAppData.users[0]._id.toString() }) + usersDb.countDocuments({ _id: dummyAppData.users[0]._id }) ).resolves.toBe(0); }); @@ -416,7 +505,9 @@ describe('::deleteUser', () => { await expect( Backend.deleteUser({ username: 'does-not-exist' }) - ).rejects.toMatchObject({ message: ErrorMessage.ItemNotFound('username') }); + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound('does-not-exist', 'user') + }); }); it('deleted users are removed from all permissions objects', async () => { @@ -491,7 +582,7 @@ describe('::getNodes', () => { testNodes.map(([username, nodes]) => expect( Backend.getNodes({ username, node_ids: nodes.map((n) => n._id.toString()) }) - ).resolves.toIncludeSameMembers(nodes.map(toPublicNode)) + ).resolves.toStrictEqual(sortNodes(nodes).map(toPublicNode)) ) ); }); @@ -504,14 +595,14 @@ describe('::getNodes', () => { username: dummyAppData['file-nodes'][0].owner, node_ids: ['bad'] }) - ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('node_id') }); + ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('bad') }); await expect( Backend.getNodes({ username: dummyAppData['file-nodes'][0].owner, node_ids: [dummyAppData['file-nodes'][0]._id.toString(), 'bad'] }) - ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('node_id') }); + ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('bad') }); }); it('rejects if one or more node_ids not found', async () => { @@ -535,6 +626,19 @@ describe('::getNodes', () => { ).rejects.toMatchObject({ message: ErrorMessage.ItemOrItemsNotFound('node_ids') }); }); + it('rejects if the username is not found', async () => { + expect.hasAssertions(); + + await expect( + Backend.getNodes({ + username: 'does-not-exist', + node_ids: [dummyAppData['file-nodes'][0]._id.toString()] + }) + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound('does-not-exist', 'user') + }); + }); + it('rejects if node_id not owned by username', async () => { expect.hasAssertions(); @@ -546,6 +650,24 @@ describe('::getNodes', () => { ).rejects.toMatchObject({ message: ErrorMessage.ItemOrItemsNotFound('node_ids') }); }); + it('does not reject if node_id not owned when user has view/edit permission', async () => { + expect.hasAssertions(); + + await expect( + Backend.getNodes({ + username: 'User2', + node_ids: [dummyAppData['file-nodes'][3]._id.toString()] + }) + ).resolves.toStrictEqual([toPublicNode(dummyAppData['file-nodes'][3])]); + + await expect( + Backend.getNodes({ + username: 'User2', + node_ids: [dummyAppData['meta-nodes'][1]._id.toString()] + }) + ).resolves.toStrictEqual([toPublicNode(dummyAppData['meta-nodes'][1])]); + }); + it('does not crash when database is empty', async () => { expect.hasAssertions(); @@ -553,7 +675,6 @@ describe('::getNodes', () => { await db.collection('file-nodes').deleteMany({}); await db.collection('meta-nodes').deleteMany({}); - await db.collection('users').deleteMany({}); await expect( Backend.getNodes({ @@ -561,6 +682,17 @@ describe('::getNodes', () => { node_ids: [dummyAppData['file-nodes'][0]._id.toString()] }) ).rejects.toMatchObject({ message: ErrorMessage.ItemOrItemsNotFound('node_ids') }); + + await db.collection('users').deleteMany({}); + + await expect( + Backend.getNodes({ + username: dummyAppData['file-nodes'][0].owner, + node_ids: [dummyAppData['file-nodes'][0]._id.toString()] + }) + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound(dummyAppData['file-nodes'][0].owner, 'user') + }); }); it('rejects if too many node_ids requested', async () => { @@ -571,23 +703,21 @@ describe('::getNodes', () => { await expect( Backend.getNodes({ username: 'User1', - node_ids: [new ObjectId().toString(), new ObjectId().toString()] + node_ids: Array.from({ length: getEnv().MAX_PARAMS_PER_REQUEST + 1 }).map( + () => new ObjectId().toString() + ) }) ).rejects.toMatchObject({ message: ErrorMessage.TooManyItemsRequested('node_ids') }); }, - { RESULTS_PER_PAGE: '1' } + { RESULTS_PER_PAGE: '1' }, + { replace: false } ); }); }); describe('::searchNodes', () => { - const sortedNodes = [ - ...dummyAppData['file-nodes'].sort((a, b) => b.modifiedAt - a.modifiedAt), - ...dummyAppData['meta-nodes'].sort((a, b) => b.createdAt - a.createdAt) - ]; - const getNodesOwnedBy = (username: Username) => { return sortedNodes.filter((n) => n.owner == username); }; @@ -608,29 +738,42 @@ describe('::searchNodes', () => { getNodesOwnedBy(dummyAppData.users[2].username).slice(0, 4).map(toPublicNode) ); }, - { RESULTS_PER_PAGE: '4' } + { RESULTS_PER_PAGE: '4' }, + { replace: false } ); }); it('only returns nodes owned by the user', async () => { expect.hasAssertions(); - await withMockedEnv( - async () => { - await expect( - Backend.searchNodes({ - username: dummyAppData.users[1].username, - after: null, - match: { tags: ['music'] }, - regexMatch: {} - }) - ).resolves.toStrictEqual( - getNodesOwnedBy(dummyAppData.users[1].username) - .filter((n) => n.type == 'file' && n.tags.includes('music')) - .map(toPublicNode) - ); - }, - { RESULTS_PER_PAGE: '4' } + await expect( + Backend.searchNodes({ + username: dummyAppData.users[1].username, + after: null, + match: { tags: ['music'] }, + regexMatch: {} + }) + ).resolves.toStrictEqual( + getNodesOwnedBy(dummyAppData.users[1].username) + .filter((n) => n.type == 'file' && n.tags.includes('music')) + .map(toPublicNode) + ); + }); + + it('also returns nodes not owned when user has view/edit permission', async () => { + expect.hasAssertions(); + + await expect( + Backend.searchNodes({ + username: dummyAppData.users[1].username, + after: null, + match: { owner: dummyAppData.users[2].username }, + regexMatch: { [`permissions.${dummyAppData.users[1].username}`]: 'view|edit' } + }) + ).resolves.toStrictEqual( + getNodesOwnedBy(dummyAppData.users[2].username) + .filter((n) => !!n.permissions[dummyAppData.users[1].username]) + .map(toPublicNode) ); }); @@ -688,8 +831,13 @@ describe('::searchNodes', () => { }) ).resolves.toStrictEqual([]); }, - { RESULTS_PER_PAGE: '1' } + { RESULTS_PER_PAGE: '1' }, + { replace: false } ); + }); + + it('rejects if after node_id is not a valid ObjectId', async () => { + expect.hasAssertions(); await expect( Backend.searchNodes({ @@ -699,7 +847,37 @@ describe('::searchNodes', () => { regexMatch: {} }) ).rejects.toMatchObject({ - message: ErrorMessage.InvalidObjectId('after') + message: ErrorMessage.InvalidObjectId('fake-oid') + }); + }); + + it('rejects if after node_id not found', async () => { + expect.hasAssertions(); + + const after = new ObjectId().toString(); + + await expect( + Backend.searchNodes({ + username: dummyAppData.users[0].username, + after, + match: {}, + regexMatch: {} + }) + ).rejects.toMatchObject({ message: ErrorMessage.ItemNotFound(after, 'node_id') }); + }); + + it('rejects if the username is not found', async () => { + expect.hasAssertions(); + + await expect( + Backend.searchNodes({ + username: 'does-not-exist', + after: null, + match: {}, + regexMatch: {} + }) + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound('does-not-exist', 'user') }); }); @@ -724,7 +902,8 @@ describe('::searchNodes', () => { }) ).resolves.toStrictEqual([]); }, - { RESULTS_PER_PAGE: '4' } + { RESULTS_PER_PAGE: '4' }, + { replace: false } ); }); @@ -1151,7 +1330,7 @@ describe('::createNode', () => { type: 'file', name: 'My New File', text: "You'll take only seconds to draw me in.", - tags: ['muse', 'darkshines', 'origin', 'symmetry', 'music'], + tags: ['muse', 'darkshines', 'ORIGIN', 'origin', 'music'], lock: null, permissions: {} }; @@ -1160,20 +1339,31 @@ describe('::createNode', () => { await getDb({ name: 'hscc-api-drive' }) ).collection('file-nodes'); - await expect(metaNodesDb.countDocuments({ name: newNode.name })).resolves.toBe(0); + await expect( + metaNodesDb.countDocuments({ + name: newNode.name, + 'name-lowercase': newNode.name.toLowerCase() + }) + ).resolves.toBe(0); await expect( Backend.createNode({ username: dummyAppData.users[0].username, data: newNode }) ).resolves.toStrictEqual({ node_id: expect.any(String), ...newNode, + tags: Array.from(new Set(newNode.tags.map((tag) => tag.toLowerCase()))), owner: dummyAppData.users[0].username, createdAt: Date.now(), modifiedAt: Date.now(), size: newNode.text.length }); - await expect(metaNodesDb.countDocuments({ name: newNode.name })).resolves.toBe(1); + await expect( + metaNodesDb.countDocuments({ + name: newNode.name, + 'name-lowercase': newNode.name.toLowerCase() + }) + ).resolves.toBe(1); }); it('creates and returns a new symlink node', async () => { @@ -1210,7 +1400,10 @@ describe('::createNode', () => { const newNode: Required = { type: 'directory', name: 'New Directory', - contents: [], + contents: [ + dummyAppData['file-nodes'][0]._id.toString(), + dummyAppData['file-nodes'][0]._id.toString() + ], permissions: {} }; @@ -1225,6 +1418,7 @@ describe('::createNode', () => { ).resolves.toStrictEqual({ node_id: expect.any(String), ...newNode, + contents: [dummyAppData['file-nodes'][0]._id.toString()], owner: dummyAppData.users[0].username, createdAt: Date.now() }); @@ -1232,6 +1426,24 @@ describe('::createNode', () => { await expect(metaNodesDb.countDocuments({ name: newNode.name })).resolves.toBe(1); }); + it('rejects if the username is not found', async () => { + expect.hasAssertions(); + + await expect( + Backend.createNode({ + username: 'does-not-exist', + data: { + type: 'directory', + name: 'New Directory', + contents: [], + permissions: {} + } + }) + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound('does-not-exist', 'user') + }); + }); + it('rejects if request body is invalid or contains properties that violates limits', async () => { expect.hasAssertions(); @@ -1247,10 +1459,11 @@ describe('::createNode', () => { MAX_NODE_TEXT_LENGTH_BYTES: maxNodeTextBytes } = getEnv(); + const knownNewId = new ObjectId().toString(); + const newNodes: [NewNode, string][] = [ [undefined as unknown as NewNode, ErrorMessage.InvalidJSON()], ['string data' as NewNode, ErrorMessage.InvalidJSON()], - [{ data: 1 } as NewNode, ErrorMessage.UnknownField('data')], [{ type: null } as unknown as NewNode, ErrorMessage.InvalidFieldValue('type')], [ { type: 'bad-type' } as unknown as NewNode, @@ -1265,47 +1478,175 @@ describe('::createNode', () => { ErrorMessage.InvalidStringLength('name', 1, maxNodeNameLen, 'string') ], [ - { type: 'file', name: 'x', text: null } as unknown as NewNode, - ErrorMessage.InvalidStringLength('text', 1, maxNodeTextBytes, 'bytes') + { + type: 'symlink', + name: 'x', + permissions: null + } as unknown as NewNode, + ErrorMessage.InvalidFieldValue('permissions') ], [ - { type: 'file', name: 'x', text: 'x'.repeat(maxNodeTextBytes + 1) }, - ErrorMessage.InvalidStringLength('text', 1, maxNodeTextBytes, 'bytes') + { + type: 'directory', + name: 'x', + permissions: ['yes'] + } as unknown as NewNode, + ErrorMessage.InvalidFieldValue('permissions') ], [ - { type: 'file', name: 'x', text: 'x', tags: null } as unknown as NewNode, - ErrorMessage.InvalidFieldValue('tags') + { + type: 'symlink', + name: 'x', + permissions: { 'user-does-not-exist': 'edit' } + }, + ErrorMessage.ItemNotFound('user-does-not-exist', 'user (permissions)') ], [ - { type: 'file', name: 'x', text: 'x', tags: [1] } as unknown as NewNode, - ErrorMessage.InvalidFieldValue('tags') + { + type: 'directory', + name: 'x', + permissions: { [dummyAppData.users[0].username]: 'bad-perm' } + } as unknown as NewNode, + ErrorMessage.InvalidObjectKeyValue('permissions') ], [ - { type: 'file', name: 'x', text: 'x', tags: ['grandson', 'grandson'] }, - ErrorMessage.DuplicateSetMember('tags') + { + type: 'symlink', + name: 'x', + permissions: Array.from({ length: maxNodePerms + 1 }).reduce< + NonNullable + >((o) => { + o[Math.random().toString(32).slice(2, 7) as keyof typeof o] = 'view'; + return o; + }, {}) + }, + ErrorMessage.TooManyItemsRequested('permissions') ], [ - { type: 'file', name: 'x', text: 'x', tags: ['grandson', 'GRANDSON'] }, - ErrorMessage.DuplicateSetMember('tags') + { + type: 'file', + name: 'x', + permissions: ['yes'] + } as unknown as NewNode, + ErrorMessage.InvalidFieldValue('permissions') ], [ - { type: 'file', name: 'x', text: 'x', tags: [''] }, - ErrorMessage.InvalidStringLength( + { + type: 'file', + name: 'x', + permissions: { 'user-does-not-exist': 'edit' } + }, + ErrorMessage.ItemNotFound('user-does-not-exist', 'user (permissions)') + ], + [ + { + type: 'file', + name: 'x', + permissions: { [dummyAppData.users[0].username]: 'bad-perm' } + } as unknown as NewNode, + ErrorMessage.InvalidObjectKeyValue('permissions') + ], + [ + { + type: 'file', + name: 'x', + permissions: Array.from({ length: maxNodePerms + 1 }).reduce< + NonNullable + >((o) => { + o[Math.random().toString(32).slice(2, 7) as keyof typeof o] = 'view'; + return o; + }, {}) + }, + ErrorMessage.TooManyItemsRequested('permissions') + ], + [ + { + type: 'file', + name: 'x', + permissions: { public: 'edit' } + }, + ErrorMessage.InvalidObjectKeyValue('permissions') + ], + [ + { + type: 'symlink', + name: 'x', + permissions: { public: 'view' } + }, + ErrorMessage.InvalidObjectKeyValue('permissions') + ], + [ + { + type: 'directory', + name: 'x', + permissions: { public: 'view' } + }, + ErrorMessage.InvalidObjectKeyValue('permissions') + ], + [ + { type: 'file', name: 'x', permissions: {}, text: null } as unknown as NewNode, + ErrorMessage.InvalidStringLength('text', 0, maxNodeTextBytes, 'bytes') + ], + [ + { + type: 'file', + name: 'x', + permissions: { public: 'view' }, + text: 'x'.repeat(maxNodeTextBytes + 1) + }, + ErrorMessage.InvalidStringLength('text', 0, maxNodeTextBytes, 'bytes') + ], + [ + { + type: 'file', + name: 'x', + permissions: {}, + text: 'x', + tags: null + } as unknown as NewNode, + ErrorMessage.InvalidFieldValue('tags') + ], + [ + { + type: 'file', + name: 'x', + permissions: {}, + text: 'x', + tags: [1] + } as unknown as NewNode, + ErrorMessage.InvalidStringLength( + 'tags', + 1, + maxNodeTagLen, + 'alphanumeric', + false, + true + ) + ], + [ + { type: 'file', name: 'x', permissions: {}, text: 'x', tags: [''] }, + ErrorMessage.InvalidStringLength( 'tags', 1, - maxNodeTextBytes, - 'bytes', + maxNodeTagLen, + 'alphanumeric', false, true ) ], [ - { type: 'file', name: 'x', text: 'x', tags: ['x'.repeat(maxNodeTagLen + 1)] }, + { + type: 'file', + name: 'x', + permissions: {}, + text: 'x', + tags: ['x'.repeat(maxNodeTagLen + 1)] + }, ErrorMessage.InvalidStringLength( 'tags', 1, - maxNodeTextBytes, - 'bytes', + maxNodeTagLen, + 'alphanumeric', false, true ) @@ -1314,6 +1655,7 @@ describe('::createNode', () => { { type: 'file', name: 'x', + permissions: {}, text: 'x', tags: Array.from({ length: maxNodeTags + 1 }).map(() => Math.random().toString(32).slice(2, 7) @@ -1325,9 +1667,10 @@ describe('::createNode', () => { { type: 'file', name: 'x', + permissions: {}, text: 'x', tags: [], - lock: { bad: 1 } + lock: 1 } as unknown as NewNode, ErrorMessage.InvalidFieldValue('lock') ], @@ -1335,6 +1678,7 @@ describe('::createNode', () => { { type: 'file', name: 'x', + permissions: {}, text: 'x', tags: [], lock: { @@ -1349,6 +1693,7 @@ describe('::createNode', () => { { type: 'file', name: 'x', + permissions: {}, text: 'x', tags: [], lock: { @@ -1363,25 +1708,27 @@ describe('::createNode', () => { { type: 'file', name: 'x', + permissions: {}, text: 'x', tags: [], lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: '', + user: null as unknown as string, + client: 'y'.repeat(maxLockClientLen - 1), createdAt: Date.now() } }, - ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string') + ErrorMessage.InvalidStringLength('lock.user', minUsernameLen, maxUsernameLen) ], [ { type: 'file', name: 'x', - text: 'x', + permissions: {}, + text: '', tags: [], lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: 'y'.repeat(maxLockClientLen + 1), + user: 'x'.repeat(maxUsernameLen - 1), + client: '', createdAt: Date.now() } }, @@ -1391,177 +1738,129 @@ describe('::createNode', () => { { type: 'file', name: 'x', - text: 'x', + permissions: {}, + text: '', tags: [], lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: 'y'.repeat(maxLockClientLen - 1) - } as NodeLock + user: 'x'.repeat(maxUsernameLen - 1), + client: null as unknown as string, + createdAt: Date.now() + } }, - ErrorMessage.InvalidFieldValue('lock') + ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string') ], [ { type: 'file', name: 'x', - text: 'x', + permissions: {}, + text: '', tags: [], lock: { - user: null, - client: 'y'.repeat(maxLockClientLen - 1), + user: 'x'.repeat(maxUsernameLen - 1), + client: 'y'.repeat(maxLockClientLen + 1), createdAt: Date.now() - } as unknown as NodeLock + } }, - ErrorMessage.InvalidObjectKeyValue('lock') + ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string') ], [ { type: 'file', name: 'x', - text: 'x', + permissions: {}, + text: '', tags: [], lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: null, - createdAt: Date.now() - } as unknown as NodeLock + user: 'x'.repeat(maxUsernameLen - 1), + client: 'y'.repeat(maxLockClientLen - 1) + } as NodeLock }, - ErrorMessage.InvalidObjectKeyValue('lock') + ErrorMessage.InvalidFieldValue('lock.createdAt') ], [ { type: 'file', name: 'x', + permissions: {}, text: 'x', tags: [], lock: { - user: 'x'.repeat(maxUsernameLen + 1), + user: 'x'.repeat(maxUsernameLen - 1), client: 'y'.repeat(maxLockClientLen - 1), createdAt: null } as unknown as NodeLock }, - ErrorMessage.InvalidObjectKeyValue('lock') - ], - [ - { - type: 'file', - name: 'x', - text: 'x', - tags: [], - lock: null, - permissions: ['yes'] - } as unknown as NewNode, - ErrorMessage.InvalidFieldValue('permissions') + ErrorMessage.InvalidFieldValue('lock.createdAt') ], [ { type: 'file', name: 'x', + permissions: {}, text: 'x', tags: [], - lock: null, - permissions: { 'user-does-not-exist': 'edit' } + lock: { + user: dummyAppData.users[0].username, + client: 'y'.repeat(maxLockClientLen - 1), + createdAt: Date.now(), + bad: 1 + } as unknown as NodeLock }, - ErrorMessage.ItemNotFound('permissions username') + ErrorMessage.InvalidObjectKeyValue('lock') ], [ { - type: 'file', + type: 'symlink', name: 'x', - text: 'x', - tags: [], - lock: null, - permissions: { [dummyAppData.users[0].username]: 'bad-perm' } + permissions: {}, + contents: null } as unknown as NewNode, - ErrorMessage.InvalidObjectKeyValue('permissions') - ], - [ - { - type: 'file', - name: 'x', - text: 'x', - tags: [], - lock: null, - permissions: Array.from({ length: maxNodePerms + 1 }).reduce< - NonNullable - >((o) => { - o[Math.random().toString(32).slice(2, 7) as keyof typeof o] = 'view'; - return o; - }, {}) - }, - ErrorMessage.TooManyItemsRequested('permissions') - ], - [ - { type: 'symlink', name: 'x', contents: null } as unknown as NewNode, ErrorMessage.InvalidFieldValue('contents') ], - [ - { type: 'directory', name: 'x', contents: [1] } as unknown as NewNode, - ErrorMessage.InvalidArrayValue('contents') - ], - [ - { type: 'symlink', name: 'x', contents: ['bad'] }, - ErrorMessage.InvalidArrayValue('contents') - ], [ { type: 'directory', name: 'x', - contents: Array.from({ length: maxNodeContents + 1 }).map(() => - new ObjectId().toString() - ) + permissions: {}, + contents: [1] } as unknown as NewNode, - ErrorMessage.TooManyItemsRequested('contents') + ErrorMessage.InvalidArrayValue('contents', '1') ], [ - { - type: 'symlink', - name: 'x', - contents: [], - permissions: null - } as unknown as NewNode, - ErrorMessage.InvalidFieldValue('permissions') + { type: 'symlink', name: 'x', permissions: {}, contents: ['bad'] }, + ErrorMessage.InvalidArrayValue('contents', 'bad') ], [ - { - type: 'directory', - name: 'x', - contents: [], - permissions: ['yes'] - } as unknown as NewNode, - ErrorMessage.InvalidFieldValue('permissions') + { type: 'directory', name: 'x', permissions: {}, contents: [knownNewId] }, + ErrorMessage.ItemNotFound(knownNewId, 'node_id') ], [ - { - type: 'symlink', - name: 'x', - contents: [], - permissions: { 'user-does-not-exist': 'edit' } - }, - ErrorMessage.ItemNotFound('permissions username') + { type: 'symlink', name: 'x', permissions: {}, contents: [knownNewId] }, + ErrorMessage.ItemNotFound(knownNewId, 'node_id') ], [ { type: 'directory', name: 'x', - contents: [], - permissions: { [dummyAppData.users[0].username]: 'bad-perm' } - } as unknown as NewNode, - ErrorMessage.InvalidObjectKeyValue('permissions') + permissions: {}, + contents: Array.from({ length: maxNodeContents + 1 }).map(() => + dummyAppData['file-nodes'][0]._id.toString() + ) + }, + ErrorMessage.TooManyItemsRequested('content node_ids') ], [ { type: 'symlink', name: 'x', - contents: [], - permissions: Array.from({ length: maxNodePerms + 1 }).reduce< - NonNullable - >((o) => { - o[Math.random().toString(32).slice(2, 7) as keyof typeof o] = 'view'; - return o; - }, {}) + contents: [ + dummyAppData['file-nodes'][0]._id.toString(), + dummyAppData['file-nodes'][0]._id.toString() + ], + permissions: {} }, - ErrorMessage.TooManyItemsRequested('permissions') + ErrorMessage.TooManyItemsRequested('content node_ids') ], [ { @@ -1593,7 +1892,7 @@ describe('::createNode', () => { name: 'user1-file1', text: 'Tell me how did we get here?', permissions: {}, - contents: [new ObjectId().toString()] + contents: [] } as NewNode, ErrorMessage.UnknownField('text') ], @@ -1603,7 +1902,7 @@ describe('::createNode', () => { name: 'user1-file1', tags: ['grandson', 'music'], permissions: {}, - contents: [new ObjectId().toString()] + contents: [] } as NewNode, ErrorMessage.UnknownField('tags') ], @@ -1613,9 +1912,19 @@ describe('::createNode', () => { name: 'user1-file1', lock: null, permissions: {}, - contents: [new ObjectId().toString()] + contents: [] } as NewNode, ErrorMessage.UnknownField('lock') + ], + [ + { + type: 'symlink', + name: 'user1-file1', + permissions: {}, + contents: [], + data: 1 + } as NewNode, + ErrorMessage.UnknownField('data') ] ]; @@ -1642,21 +1951,23 @@ describe('::updateNode', () => { await expect( fileNodeDb.countDocuments({ - owner: 'new-user' + _id: dummyAppData['file-nodes'][0]._id, + owner: dummyAppData['file-nodes'][0].owner }) - ).resolves.toBe(0); + ).resolves.toBe(1); await expect( metaNodeDb.countDocuments({ - owner: 'new-user' + _id: dummyAppData['meta-nodes'][0]._id, + owner: dummyAppData['meta-nodes'][0].owner }) - ).resolves.toBe(0); + ).resolves.toBe(1); await expect( Backend.updateNode({ username: dummyAppData['file-nodes'][0].owner, node_id: dummyAppData['file-nodes'][0]._id.toString(), - data: { owner: 'new-user' } + data: { owner: dummyAppData.users[2].username } }) ).resolves.toBeUndefined(); @@ -1664,45 +1975,176 @@ describe('::updateNode', () => { Backend.updateNode({ username: dummyAppData['meta-nodes'][0].owner, node_id: dummyAppData['meta-nodes'][0]._id.toString(), - data: { owner: 'new-user' } + data: { owner: dummyAppData.users[0].username } }) ).resolves.toBeUndefined(); await expect( fileNodeDb.countDocuments({ - owner: 'new-user' + _id: dummyAppData['file-nodes'][0]._id, + owner: dummyAppData['file-nodes'][0].owner }) - ).resolves.toBe(1); + ).resolves.toBe(0); await expect( metaNodeDb.countDocuments({ - owner: 'new-user' + _id: dummyAppData['meta-nodes'][0]._id, + owner: dummyAppData['meta-nodes'][0].owner }) - ).resolves.toBe(1); + ).resolves.toBe(0); }); - it('rejects if the node_id is not a valid ObjectId', async () => { + it('updates modifiedAt', async () => { expect.hasAssertions(); + const db = await getDb({ name: 'hscc-api-drive' }); + const fileNodeDb = db.collection('file-nodes'); + await expect( - Backend.updateNode({ - username: dummyAppData['file-nodes'][0].owner, - node_id: 'bad', - data: { owner: 'new-user' } + fileNodeDb.countDocuments({ + _id: dummyAppData['file-nodes'][0]._id, + modifiedAt: Date.now() }) - ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('node_id') }); - }); - - it('rejects if the node_id is not found', async () => { - expect.hasAssertions(); + ).resolves.toBe(0); await expect( Backend.updateNode({ username: dummyAppData['file-nodes'][0].owner, - node_id: new ObjectId().toString(), - data: { owner: 'new-user' } + node_id: dummyAppData['file-nodes'][0]._id.toString(), + data: { owner: dummyAppData.users[2].username } }) - ).rejects.toMatchObject({ message: ErrorMessage.ItemNotFound('node_id') }); + ).resolves.toBeUndefined(); + + await expect( + fileNodeDb.countDocuments({ + _id: dummyAppData['file-nodes'][0]._id, + modifiedAt: Date.now() + }) + ).resolves.toBe(1); + }); + + it('treats tags as lowercase set', async () => { + expect.hasAssertions(); + + const db = await getDb({ name: 'hscc-api-drive' }); + const fileNodeDb = db.collection('file-nodes'); + const tags = ['TAG-1', 'tag-1', 'tag-2']; + + await expect( + Backend.updateNode({ + username: dummyAppData['file-nodes'][0].owner, + node_id: dummyAppData['file-nodes'][0]._id.toString(), + data: { tags } + }) + ).resolves.toBeUndefined(); + + await expect( + fileNodeDb.countDocuments({ + _id: dummyAppData['file-nodes'][0]._id, + tags: Array.from(new Set(tags.map((tag) => tag.toLowerCase()))) + }) + ).resolves.toBe(1); + }); + + it('updates size', async () => { + expect.hasAssertions(); + + const db = await getDb({ name: 'hscc-api-drive' }); + const fileNodeDb = db.collection('file-nodes'); + const size = 4096; + const text = 'x'.repeat(size); + + await expect( + fileNodeDb.countDocuments({ + _id: dummyAppData['file-nodes'][0]._id, + size + }) + ).resolves.toBe(0); + + await expect( + Backend.updateNode({ + username: dummyAppData['file-nodes'][0].owner, + node_id: dummyAppData['file-nodes'][0]._id.toString(), + data: { text } + }) + ).resolves.toBeUndefined(); + + await expect( + fileNodeDb.countDocuments({ + _id: dummyAppData['file-nodes'][0]._id, + size + }) + ).resolves.toBe(1); + }); + + it('updates name-lowercase', async () => { + expect.hasAssertions(); + + const db = await getDb({ name: 'hscc-api-drive' }); + const fileNodeDb = db.collection('file-nodes'); + const name = 'TEST-NAME'; + + await expect( + fileNodeDb.countDocuments({ + _id: dummyAppData['file-nodes'][0]._id, + 'name-lowercase': name.toLowerCase() + }) + ).resolves.toBe(0); + + await expect( + Backend.updateNode({ + username: dummyAppData['file-nodes'][0].owner, + node_id: dummyAppData['file-nodes'][0]._id.toString(), + data: { name } + }) + ).resolves.toBeUndefined(); + + await expect( + fileNodeDb.countDocuments({ + _id: dummyAppData['file-nodes'][0]._id, + 'name-lowercase': name.toLowerCase() + }) + ).resolves.toBe(1); + }); + + it('rejects if the node_id is not a valid ObjectId', async () => { + expect.hasAssertions(); + + await expect( + Backend.updateNode({ + username: dummyAppData['file-nodes'][0].owner, + node_id: 'bad', + data: { owner: 'new-user' } + }) + ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('bad') }); + }); + + it('rejects if the node_id is not found', async () => { + expect.hasAssertions(); + + const node_id = new ObjectId().toString(); + + await expect( + Backend.updateNode({ + username: dummyAppData['file-nodes'][0].owner, + node_id, + data: { owner: 'new-user' } + }) + ).rejects.toMatchObject({ message: ErrorMessage.ItemNotFound(node_id, 'node_id') }); + }); + + it('rejects if the username is not found', async () => { + expect.hasAssertions(); + + await expect( + Backend.updateNode({ + username: 'does-not-exist', + node_id: dummyAppData['file-nodes'][0]._id.toString(), + data: { owner: 'new-user' } + }) + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound('does-not-exist', 'user') + }); }); it('rejects if node_id not owned by username', async () => { @@ -1710,11 +2152,43 @@ describe('::updateNode', () => { await expect( Backend.updateNode({ - username: 'fake-user', + username: dummyAppData.users[2].username, node_id: dummyAppData['file-nodes'][0]._id.toString(), data: { owner: 'new-user' } }) - ).rejects.toMatchObject({ message: ErrorMessage.ForbiddenAction() }); + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound( + dummyAppData['file-nodes'][0]._id.toString(), + 'node_id' + ) + }); + }); + + it('does not reject if node_id not owned when user has edit (not view) permission', async () => { + expect.hasAssertions(); + + // ? Has edit perms + await expect( + Backend.updateNode({ + username: 'User2', + node_id: dummyAppData['file-nodes'][3]._id.toString(), + data: { text: 'new text' } + }) + ).resolves.toBeUndefined(); + + // ? Only has view perms + await expect( + Backend.updateNode({ + username: 'User2', + node_id: dummyAppData['meta-nodes'][1]._id.toString(), + data: { name: 'new name' } + }) + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound( + dummyAppData['meta-nodes'][1]._id.toString(), + 'node_id' + ) + }); }); it('rejects if request body is invalid or contains properties that violates limits', async () => { @@ -1732,52 +2206,177 @@ describe('::updateNode', () => { MAX_NODE_TEXT_LENGTH_BYTES: maxNodeTextBytes } = getEnv(); - const patchNodes: [PatchNode, string][] = [ - [undefined as unknown as PatchNode, ErrorMessage.InvalidJSON()], - ['string data' as PatchNode, ErrorMessage.InvalidJSON()], - [{ data: 1 } as PatchNode, ErrorMessage.UnknownField('data')], - [{ owner: 'does-not-exist' }, ErrorMessage.ItemNotFound('owner')], + const knownNewId = new ObjectId().toString(); + const knownFileNode = dummyAppData['file-nodes'][4]; + const knownDirNode = dummyAppData['meta-nodes'][1]; + const knownLinkNode = dummyAppData['meta-nodes'][2]; + + const patchNodes: [patch: PatchNode, error: string, node: InternalNode][] = [ + [undefined as unknown as PatchNode, ErrorMessage.InvalidJSON(), knownFileNode], + ['string data' as PatchNode, ErrorMessage.InvalidJSON(), knownFileNode], [ { name: '' }, - ErrorMessage.InvalidStringLength('name', 1, maxNodeNameLen, 'string') + ErrorMessage.InvalidStringLength('name', 1, maxNodeNameLen, 'string'), + knownDirNode ], [ { name: 'x'.repeat(maxNodeNameLen + 1) }, - ErrorMessage.InvalidStringLength('name', 1, maxNodeNameLen, 'string') + ErrorMessage.InvalidStringLength('name', 1, maxNodeNameLen, 'string'), + knownLinkNode + ], + [ + { + permissions: null + } as unknown as PatchNode, + ErrorMessage.InvalidFieldValue('permissions'), + knownLinkNode + ], + [ + { + permissions: ['yes'] + } as unknown as PatchNode, + ErrorMessage.InvalidFieldValue('permissions'), + knownDirNode + ], + [ + { + permissions: { 'user-does-not-exist': 'edit' } + }, + ErrorMessage.ItemNotFound('user-does-not-exist', 'user (permissions)'), + knownLinkNode + ], + [ + { + permissions: { [dummyAppData.users[0].username]: 'bad-perm' } + } as unknown as PatchNode, + ErrorMessage.InvalidObjectKeyValue('permissions'), + knownDirNode + ], + [ + { + permissions: Array.from({ length: maxNodePerms + 1 }).reduce< + NonNullable + >((o) => { + o[Math.random().toString(32).slice(2, 7) as keyof typeof o] = 'view'; + return o; + }, {}) + }, + ErrorMessage.TooManyItemsRequested('permissions'), + knownLinkNode + ], + [ + { + permissions: ['yes'] + } as unknown as PatchNode, + ErrorMessage.InvalidFieldValue('permissions'), + knownFileNode + ], + [ + { + permissions: { 'user-does-not-exist': 'edit' } + }, + ErrorMessage.ItemNotFound('user-does-not-exist', 'user (permissions)'), + knownFileNode + ], + [ + { + permissions: { [dummyAppData.users[0].username]: 'bad-perm' } + } as unknown as PatchNode, + ErrorMessage.InvalidObjectKeyValue('permissions'), + knownFileNode + ], + [ + { + permissions: Array.from({ length: maxNodePerms + 1 }).reduce< + NonNullable + >((o) => { + o[Math.random().toString(32).slice(2, 7) as keyof typeof o] = 'view'; + return o; + }, {}) + }, + ErrorMessage.TooManyItemsRequested('permissions'), + knownFileNode + ], + [ + { + permissions: { public: 'edit' } + }, + ErrorMessage.InvalidObjectKeyValue('permissions'), + knownFileNode + ], + [ + { + permissions: { public: 'view' } + }, + ErrorMessage.InvalidObjectKeyValue('permissions'), + knownLinkNode + ], + [ + { + permissions: { public: 'view' } + }, + ErrorMessage.InvalidObjectKeyValue('permissions'), + knownDirNode ], [ { text: null } as unknown as PatchNode, - ErrorMessage.InvalidStringLength('text', 1, maxNodeTextBytes, 'bytes') + ErrorMessage.InvalidStringLength('text', 0, maxNodeTextBytes, 'bytes'), + knownFileNode + ], + [ + { + permissions: { public: 'view' }, + text: 'x'.repeat(maxNodeTextBytes + 1) + }, + ErrorMessage.InvalidStringLength('text', 0, maxNodeTextBytes, 'bytes'), + knownFileNode + ], + [ + { + tags: null + } as unknown as PatchNode, + ErrorMessage.InvalidFieldValue('tags'), + knownFileNode ], [ - { text: 'x'.repeat(maxNodeTextBytes + 1) }, - ErrorMessage.InvalidStringLength('text', 1, maxNodeTextBytes, 'bytes') + { + tags: [1] + } as unknown as PatchNode, + ErrorMessage.InvalidStringLength( + 'tags', + 1, + maxNodeTagLen, + 'alphanumeric', + false, + true + ), + knownFileNode ], - [{ tags: null } as PatchNode, ErrorMessage.InvalidFieldValue('tags')], - [{ tags: [1] } as PatchNode, ErrorMessage.InvalidFieldValue('tags')], - [{ tags: ['grandson', 'grandson'] }, ErrorMessage.DuplicateSetMember('tags')], - [{ tags: ['grandson', 'GRANDSON'] }, ErrorMessage.DuplicateSetMember('tags')], [ { tags: [''] }, ErrorMessage.InvalidStringLength( 'tags', 1, - maxNodeTextBytes, - 'bytes', + maxNodeTagLen, + 'alphanumeric', false, true - ) + ), + knownFileNode ], [ - { tags: ['x'.repeat(maxNodeTagLen + 1)] }, + { + tags: ['x'.repeat(maxNodeTagLen + 1)] + }, ErrorMessage.InvalidStringLength( 'tags', 1, - maxNodeTextBytes, - 'bytes', + maxNodeTagLen, + 'alphanumeric', false, true - ) + ), + knownFileNode ], [ { @@ -1785,9 +2384,16 @@ describe('::updateNode', () => { Math.random().toString(32).slice(2, 7) ) }, - ErrorMessage.TooManyItemsRequested('tags') + ErrorMessage.TooManyItemsRequested('tags'), + knownFileNode + ], + [ + { + lock: 1 + } as unknown as PatchNode, + ErrorMessage.InvalidFieldValue('lock'), + knownFileNode ], - [{ lock: { bad: 1 } } as PatchNode, ErrorMessage.InvalidFieldValue('lock')], [ { lock: { @@ -1796,7 +2402,8 @@ describe('::updateNode', () => { createdAt: Date.now() } }, - ErrorMessage.InvalidStringLength('lock.user', minUsernameLen, maxUsernameLen) + ErrorMessage.InvalidStringLength('lock.user', minUsernameLen, maxUsernameLen), + knownFileNode ], [ { @@ -1806,165 +2413,190 @@ describe('::updateNode', () => { createdAt: Date.now() } }, - ErrorMessage.InvalidStringLength('lock.user', minUsernameLen, maxUsernameLen) + ErrorMessage.InvalidStringLength('lock.user', minUsernameLen, maxUsernameLen), + knownFileNode ], [ { lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: '', + user: null as unknown as string, + client: 'y'.repeat(maxLockClientLen - 1), createdAt: Date.now() } }, - ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string') + ErrorMessage.InvalidStringLength('lock.user', minUsernameLen, maxUsernameLen), + knownFileNode ], [ { + text: '', lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: 'y'.repeat(maxLockClientLen + 1), + user: 'x'.repeat(maxUsernameLen - 1), + client: '', createdAt: Date.now() } }, - ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string') + ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string'), + knownFileNode ], [ { lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: 'y'.repeat(maxLockClientLen - 1) - } as NodeLock + user: 'x'.repeat(maxUsernameLen - 1), + client: null as unknown as string, + createdAt: Date.now() + } }, - ErrorMessage.InvalidFieldValue('lock') + ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string'), + knownFileNode ], [ { lock: { - user: null, - client: 'y'.repeat(maxLockClientLen - 1), + user: 'x'.repeat(maxUsernameLen - 1), + client: 'y'.repeat(maxLockClientLen + 1), createdAt: Date.now() - } as unknown as NodeLock + } }, - ErrorMessage.InvalidObjectKeyValue('lock') + ErrorMessage.InvalidStringLength('lock.client', 1, maxLockClientLen, 'string'), + knownFileNode ], [ { lock: { - user: 'x'.repeat(maxUsernameLen + 1), - client: null, - createdAt: Date.now() - } as unknown as NodeLock + user: 'x'.repeat(maxUsernameLen - 1), + client: 'y'.repeat(maxLockClientLen - 1) + } as NodeLock }, - ErrorMessage.InvalidObjectKeyValue('lock') + ErrorMessage.InvalidFieldValue('lock.createdAt'), + knownFileNode ], [ { lock: { - user: 'x'.repeat(maxUsernameLen + 1), + user: 'x'.repeat(maxUsernameLen - 1), client: 'y'.repeat(maxLockClientLen - 1), createdAt: null } as unknown as NodeLock }, - ErrorMessage.InvalidObjectKeyValue('lock') - ], - [ - { permissions: ['yes'] } as unknown as PatchNode, - ErrorMessage.InvalidFieldValue('permissions') + ErrorMessage.InvalidFieldValue('lock.createdAt'), + knownFileNode ], [ - { permissions: { 'user-does-not-exist': 'edit' } }, - ErrorMessage.ItemNotFound('permissions username') + { + lock: { + user: dummyAppData.users[0].username, + client: 'y'.repeat(maxLockClientLen - 1), + createdAt: Date.now(), + bad: 1 + } as unknown as NodeLock + }, + ErrorMessage.InvalidObjectKeyValue('lock'), + knownFileNode ], [ { - permissions: { [dummyAppData.users[0].username]: 'bad-perm' } + contents: null } as unknown as PatchNode, - ErrorMessage.InvalidObjectKeyValue('permissions') + ErrorMessage.InvalidFieldValue('contents'), + knownLinkNode ], [ { - permissions: Array.from({ length: maxNodePerms + 1 }).reduce< - NonNullable - >((o) => { - o[Math.random().toString(32).slice(2, 7) as keyof typeof o] = 'view'; - return o; - }, {}) - }, - ErrorMessage.TooManyItemsRequested('permissions') + contents: [1] + } as unknown as PatchNode, + ErrorMessage.InvalidArrayValue('contents', '1'), + knownDirNode ], [ - { contents: null } as unknown as PatchNode, - ErrorMessage.InvalidFieldValue('contents') + { contents: ['bad'] }, + ErrorMessage.InvalidArrayValue('contents', 'bad'), + knownLinkNode ], [ - { contents: [1] } as unknown as PatchNode, - ErrorMessage.InvalidArrayValue('contents') + { contents: [knownNewId] }, + ErrorMessage.ItemNotFound(knownNewId, 'node_id'), + knownDirNode ], [ - { contents: ['bad'] } as unknown as PatchNode, - ErrorMessage.InvalidArrayValue('contents') + { contents: [knownNewId] }, + ErrorMessage.ItemNotFound(knownNewId, 'node_id'), + knownLinkNode ], [ { contents: Array.from({ length: maxNodeContents + 1 }).map(() => - new ObjectId().toString() + dummyAppData['file-nodes'][0]._id.toString() ) - } as unknown as PatchNode, - ErrorMessage.TooManyItemsRequested('contents') + }, + ErrorMessage.TooManyItemsRequested('content node_ids'), + knownDirNode ], [ { - owner: 'User1', - name: 'user1-file1', - text: 'Tell me how did we get here?', - tags: ['grandson', 'music'], - lock: null, - permissions: {}, - type: 'symlink' + contents: [ + dummyAppData['meta-nodes'][0]._id.toString(), + dummyAppData['meta-nodes'][1]._id.toString() + ] + }, + ErrorMessage.TooManyItemsRequested('content node_ids'), + dummyAppData['meta-nodes'][2] + ], + [ + { + owner: 'user-does-not-exist' } as PatchNode, - ErrorMessage.UnknownField('type') + ErrorMessage.ItemNotFound('user-does-not-exist', 'user'), + knownFileNode ], [ { - owner: 'User1', - name: 'user1-file1', - text: 'Tell me how did we get here?', - tags: ['grandson', 'music'], - lock: null, - permissions: {}, contents: [new ObjectId().toString()] } as PatchNode, - ErrorMessage.UnknownField('contents') + ErrorMessage.UnknownField('contents'), + knownFileNode + ], + [ + { + text: 'Tell me how did we get here?' + } as PatchNode, + ErrorMessage.UnknownField('text'), + knownLinkNode + ], + [ + { + tags: ['grandson', 'music'] + } as PatchNode, + ErrorMessage.UnknownField('tags'), + knownDirNode + ], + [ + { + lock: null + } as PatchNode, + ErrorMessage.UnknownField('lock'), + knownLinkNode + ], + [ + { + data: 1 + } as PatchNode, + ErrorMessage.UnknownField('data'), + knownLinkNode ] ]; await Promise.all( - patchNodes.map(([data, message]) => + patchNodes.map(([data, message, node]) => expect( Backend.updateNode({ - username: dummyAppData['file-nodes'][0].owner, - node_id: dummyAppData['file-nodes'][0]._id.toString(), + username: node.owner, + node_id: node._id.toString(), data }) ).rejects.toMatchObject({ message }) ) ); - - await expect( - Backend.updateNode({ - username: dummyAppData['meta-nodes'][0].owner, - node_id: dummyAppData['meta-nodes'][0]._id.toString(), - data: { - owner: 'User1', - name: 'user1-file1', - text: 'Tell me how did we get here?', - tags: ['grandson', 'music'], - lock: null, - permissions: {}, - contents: [new ObjectId().toString()] - } - }) - ).rejects.toMatchObject({ message: ErrorMessage.UnknownField('text') }); }); }); @@ -1979,16 +2611,13 @@ describe('::deleteNodes', () => { await expect( fileNodeDb.countDocuments({ _id: { - $in: [ - dummyAppData['file-nodes'][0]._id.toString(), - dummyAppData['file-nodes'][1]._id.toString() - ] + $in: [dummyAppData['file-nodes'][0]._id, dummyAppData['file-nodes'][1]._id] } }) ).resolves.toBe(2); await expect( - metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][0]._id.toString() }) + metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][0]._id }) ).resolves.toBe(1); await expect( @@ -2011,16 +2640,13 @@ describe('::deleteNodes', () => { await expect( fileNodeDb.countDocuments({ _id: { - $in: [ - dummyAppData['file-nodes'][0]._id.toString(), - dummyAppData['file-nodes'][1]._id.toString() - ] + $in: [dummyAppData['file-nodes'][0]._id, dummyAppData['file-nodes'][1]._id] } }) ).resolves.toBe(0); await expect( - metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][0]._id.toString() }) + metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][0]._id }) ).resolves.toBe(0); }); @@ -2032,14 +2658,42 @@ describe('::deleteNodes', () => { username: dummyAppData['file-nodes'][0].owner, node_ids: ['bad'] }) - ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('node_id') }); + ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('bad') }); await expect( Backend.deleteNodes({ username: dummyAppData['file-nodes'][0].owner, node_ids: [dummyAppData['file-nodes'][0]._id.toString(), 'bad'] }) - ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('node_id') }); + ).rejects.toMatchObject({ message: ErrorMessage.InvalidObjectId('bad') }); + }); + + it('rejects if too many node_ids requested', async () => { + expect.hasAssertions(); + + await expect( + Backend.deleteNodes({ + username: 'User1', + node_ids: Array.from({ length: getEnv().MAX_PARAMS_PER_REQUEST + 1 }).map(() => + new ObjectId().toString() + ) + }) + ).rejects.toMatchObject({ + message: ErrorMessage.TooManyItemsRequested('node_ids') + }); + }); + + it('rejects if the username is not found', async () => { + expect.hasAssertions(); + + await expect( + Backend.deleteNodes({ + username: 'does-not-exist', + node_ids: [] + }) + ).rejects.toMatchObject({ + message: ErrorMessage.ItemNotFound('does-not-exist', 'user') + }); }); it('does not reject if one or more of the node_ids is not found', async () => { @@ -2048,7 +2702,7 @@ describe('::deleteNodes', () => { const fileNodeDb = (await getDb({ name: 'hscc-api-drive' })).collection('file-nodes'); await expect( - fileNodeDb.countDocuments({ _id: dummyAppData['file-nodes'][0]._id.toString() }) + fileNodeDb.countDocuments({ _id: dummyAppData['file-nodes'][0]._id }) ).resolves.toBe(1); await expect( @@ -2069,11 +2723,11 @@ describe('::deleteNodes', () => { ).resolves.toBeUndefined(); await expect( - fileNodeDb.countDocuments({ _id: dummyAppData['file-nodes'][0]._id.toString() }) + fileNodeDb.countDocuments({ _id: dummyAppData['file-nodes'][0]._id }) ).resolves.toBe(0); }); - it('rejects if one or more of the node_ids is not owned by username', async () => { + it('does nothing to any node_ids not owned by username', async () => { expect.hasAssertions(); const db = await getDb({ name: 'hscc-api-drive' }); @@ -2081,31 +2735,56 @@ describe('::deleteNodes', () => { const metaNodeDb = db.collection('meta-nodes'); await expect( - fileNodeDb.countDocuments({ _id: dummyAppData['file-nodes'][2]._id.toString() }) + fileNodeDb.countDocuments({ _id: dummyAppData['file-nodes'][2]._id }) + ).resolves.toBe(1); + + await expect( + metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][0]._id }) ).resolves.toBe(1); + await Backend.deleteNodes({ + username: dummyAppData['file-nodes'][2].owner, + node_ids: [ + dummyAppData['file-nodes'][2]._id.toString(), + dummyAppData['meta-nodes'][0]._id.toString() + ] + }); + + await expect( + fileNodeDb.countDocuments({ _id: dummyAppData['file-nodes'][2]._id }) + ).resolves.toBe(0); + await expect( - metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][0]._id.toString() }) + metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][0]._id }) ).resolves.toBe(1); + }); + + it('does nothing to node_ids even when user has edit permissions', async () => { + expect.hasAssertions(); + + const metaNodeDb = (await getDb({ name: 'hscc-api-drive' })).collection('meta-nodes'); await expect( - Backend.deleteNodes({ - username: dummyAppData['file-nodes'][2].owner, - node_ids: [ - dummyAppData['file-nodes'][2]._id.toString(), - dummyAppData['meta-nodes'][0]._id.toString() - ] - }) - ).rejects.toMatchObject({ message: ErrorMessage.ForbiddenAction() }); + metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][1]._id }) + ).resolves.toBe(1); + + await Backend.deleteNodes({ + username: 'User2', + node_ids: [dummyAppData['meta-nodes'][1]._id.toString()] + }); + + await expect( + metaNodeDb.countDocuments({ _id: dummyAppData['meta-nodes'][1]._id }) + ).resolves.toBe(1); }); it('deleted node_ids are removed from all MetaNode contents arrays', async () => { expect.hasAssertions(); - const node_id = dummyAppData['file-nodes'][4]._id.toString(); + const node_id = dummyAppData['file-nodes'][4]._id; const numInContentArrays = dummyAppData['meta-nodes'].filter(({ contents }) => - contents.includes(new ObjectId(node_id)) + contents.includes(node_id) ).length; expect(numInContentArrays).toBeGreaterThan(0); @@ -2119,7 +2798,7 @@ describe('::deleteNodes', () => { await expect( Backend.deleteNodes({ username: dummyAppData['file-nodes'][4].owner, - node_ids: [node_id] + node_ids: [node_id.toString()] }) ).resolves.toBeUndefined(); diff --git a/test/db.ts b/test/db.ts index 79f8524..fb5b458 100644 --- a/test/db.ts +++ b/test/db.ts @@ -83,7 +83,7 @@ export const dummyAppData: DummyAppData = { modifiedAt: generatedAt - 800, name: 'User1-File1', 'name-lowercase': 'user1-file1', - size: 28, + size: 109, text: "NOW I GOT A FRONT ROW SEAT WATCH THE SYSTEM FALL!\n\nCause look who's in control!\n\nTELL ME HOW DID WE GET HERE?", tags: ['grandson', 'music'], lock: null,