import { afterAll, beforeAll, describe, expect, it } from "@jest/globals"; import { init, TestPlatform } from "../setup"; import UserApi from "../common/user-api"; import { DriveFile, TYPE as DriveFileType } from "../../../src/services/documents/entities/drive-file"; import { File } from "../../../src/services/files/entities/file"; import { FileVersion, TYPE as FileVersionType } from "../../../src/services/documents/entities/file-version"; import type User from "../../../src/services/user/entities/user"; import { getFilePath } from "../../../src/services/files/services"; import { buildUserDeletionRepositories } from "../../../src/core/platform/services/admin/utils"; import { getConfig } from "../../../src/core/platform/framework/api/admin"; const loadRepositories = async (platform: TestPlatform) => buildUserDeletionRepositories(platform!.database, platform!.search); describe("The users deletion API", () => { let platform: TestPlatform | undefined; let adminUser: UserApi; let currentUser: UserApi; let userToTestPending: UserApi; let myUserId: string; let myDriveId: string; let myCompanyId: string; let repos: Awaited>; const adminConfig = getConfig(); const listByUserIn = { driveFileByCreator: async (userId: string) => repos.driveFile.find({ creator: userId }), driveFileByParent: async (userId: string) => repos.driveFile.find({ parent_id: "user_" + userId }), fileVersion: async (userId: string) => repos.fileVersion.find({ creator_id: userId }), file: async (userId: string) => repos.file.find({ user_id: userId }), missedDriveFile: async (userId: string) => repos.missedDriveFile.find({ creator: userId }), // user: async (userId: string) => repos.user.find({ id: userId }), // ignored because it stays with deleted=true companyUser: async (userId: string) => repos.companyUser.find({ user_id: userId }), externalUser: async (userId: string) => repos.externalUser.find({ user_id: userId }), }; interface UserTreeEntry { driveFile: DriveFile; driveFileFromSearch?: DriveFile; versions?: { version: FileVersion, file?: File, storagePaths?: string[], }[]; children?: UserTreeEntry[]; } async function hydrateDriveFile(context: UserTreeEntry) { const { driveFile } = context; context.driveFileFromSearch = (await repos.search.driveFile.search({}, { $text: { $search: "d" } })).getEntities()[0]; const versions = (await repos.fileVersion.find({ drive_item_id: driveFile.id })).getEntities(); context.versions = await Promise.all(versions.map(async version => { const file = await repos.file.findOne({id: version.file_metadata.external_id}); const fileStoragePath = file && getFilePath(file); return { version, file, storagePaths: fileStoragePath ? (await platform!.storage.getConnector().enumeratePathsForFile(fileStoragePath)) : undefined, }; })); } async function loadDriveFiles(parent_id: string, parents: UserTreeEntry[] = []): Promise { const driveFiles = (await repos.driveFile.find({ parent_id })).getEntities(); const tree = driveFiles.map(driveFile => ({ driveFile }) as UserTreeEntry); for (const entry of tree) { const { driveFile } = entry; if (driveFile.is_directory) entry.children = await loadDriveFiles(driveFile.id, parents.concat([entry])); else await hydrateDriveFile(entry); } return tree; } async function userTreeDescend(tree: UserTreeEntry[], map: (entry: UserTreeEntry, parents: UserTreeEntry[]) => Promise, parents: UserTreeEntry[] = [], output: string[] = []) { for (const entry of tree) { await map(entry, parents); if (entry.children) await userTreeDescend(entry.children, map, parents.concat([entry]), output); } } async function userTreeToString(tree: UserTreeEntry[]) { const oneIndent = " "; const output: string[] = []; await userTreeDescend(tree, async (entry, parents) => { const indent = parents.map(() => oneIndent).join(""); output.push(indent + (entry.driveFile.is_directory ? "📂" : "📄") + " " + entry.driveFile.name); output.push(indent + oneIndent + " search: " + entry.driveFileFromSearch); for (const version of entry.versions ?? []) { output.push(indent + oneIndent + "version: " + JSON.stringify(version.version?.id)); output.push(indent + oneIndent + " file: " + JSON.stringify(version.file?.id)); for (const path of version.storagePaths ?? []) output.push(indent + oneIndent + " path: " + JSON.stringify(path)); } }); return output.join("\n"); } async function createFakeFiles() { const rootFolder = await currentUser.createDirectory(myDriveId, { name: "root_folder" }); const subRootFolder = await currentUser.createDirectory(rootFolder.id, { name: "sub_root_folder" }); return { rootFolder, subRootFolder, fileAtRoot: await currentUser.uploadRandomFileAndCreateDocument(myDriveId), fileInRootFolder: await currentUser.uploadRandomFileAndCreateDocument(rootFolder.id), fileInSubRoot: await currentUser.uploadRandomFileAndCreateDocument(subRootFolder.id), }; } function findEntriesInUserTree(tree: UserTreeEntry[]): { [EntryKey in keyof Awaited>]: UserTreeEntry | undefined } { const rootFolder = tree.find(({driveFile}) => driveFile.is_directory); const subRootFolder = rootFolder?.children!.find(({driveFile}) => driveFile.is_directory); return { rootFolder, subRootFolder, fileAtRoot: tree.find(({driveFile}) => !driveFile.is_directory), fileInRootFolder: rootFolder?.children?.find(({driveFile}) => !driveFile.is_directory), fileInSubRoot: subRootFolder?.children?.find(({driveFile}) => !driveFile.is_directory), } } async function getSearchEntriesFromFoundEntries(items: ReturnType) { const searchDoc = async ({id}) => (await repos.search.driveFile.search({ id }, { pagination: {limitStr: "100"} })).getEntities()[0]; return { fileAtRoot: await searchDoc(items.fileAtRoot!.driveFile), fileInRootFolder: await searchDoc(items.fileInRootFolder!.driveFile), fileInSubRoot: await searchDoc(items.fileInSubRoot!.driveFile), } } beforeAll(async () => { platform = await init({ services: [ "admin", "database", "search", "message-queue", "applications", "webserver", "user", "auth", "storage", "counter", "console", "workspaces", "statistics", "platform-services", ], }); adminUser = await UserApi.getInstance(platform, true, { companyRole: "admin" }); currentUser = await UserApi.getInstance(platform); userToTestPending = await UserApi.getInstance(platform, true); myUserId = currentUser.user.id; myDriveId = "user_" + currentUser.user.id; myCompanyId = currentUser.workspace.company_id; repos = await loadRepositories(platform); }); afterAll(async () => { platform && await platform.tearDown(); platform = undefined; }); async function sendDeleteUser(secret: string, userId: string, expected: number = 200, deleteData: boolean = true) { const response = await platform!.app.inject({ method: "POST", url: `/admin/api/user/delete`, body: { secret, userId, deleteData }, }); expect(response.statusCode).toBe(expected); if (expected === 200) return JSON.parse(response.body); return response; } async function requestPendingUserDeletions(secret: string, expected: number = 200) { const response = await platform!.app.inject({ method: "POST", url: `/admin/api/user/delete/pending`, body: { secret }, }); expect(response.statusCode).toBe(expected); if (expected === 200) return JSON.parse(response.body); return response; } describe("admin/api/user/delete routes", () => { it("should have a secret setup or none of this test will work and we should know", () => { expect(adminConfig.endpointSecret).toBeTruthy(); }); it("should start with a non deleted user with some files", async () => { const user = await currentUser.getUser(); expect(user).toMatchObject({ id: myUserId, deleted: false, delete_process_started_epoch: 0, }); const initialFakeFiles = await createFakeFiles(); expect(initialFakeFiles.rootFolder.id).toBeTruthy(); expect(initialFakeFiles.subRootFolder.id).toBeTruthy(); expect(initialFakeFiles.fileAtRoot.id).toBeTruthy(); expect(initialFakeFiles.fileInRootFolder.id).toBeTruthy(); expect(initialFakeFiles.fileInSubRoot.id).toBeTruthy(); // Wait for indexing, inspired by ../documents/documents-search.spec.ts await new Promise(resolve => setTimeout(resolve, 5000)); const tree = await loadDriveFiles(myDriveId); const foundEntries = findEntriesInUserTree(tree); expect(foundEntries.rootFolder?.driveFile).toMatchObject({ id: initialFakeFiles.rootFolder.id, parent_id: myDriveId, name: "root_folder" }); expect(foundEntries.subRootFolder?.driveFile).toMatchObject({ id: initialFakeFiles.subRootFolder.id, parent_id: initialFakeFiles.rootFolder.id, name: "sub_root_folder" }); expect(foundEntries.fileAtRoot?.driveFile).toMatchObject({ id: initialFakeFiles.fileAtRoot.id }); expect(foundEntries.fileInRootFolder?.driveFile).toMatchObject({ id: initialFakeFiles.fileInRootFolder.id }); expect(foundEntries.fileInSubRoot?.driveFile).toMatchObject({ id: initialFakeFiles.fileInSubRoot.id }); const searchDocs = await getSearchEntriesFromFoundEntries(foundEntries); expect(searchDocs.fileAtRoot).toMatchObject({ id: initialFakeFiles.fileAtRoot.id }); expect(searchDocs.fileInRootFolder).toMatchObject({ id: initialFakeFiles.fileInRootFolder.id }); expect(searchDocs.fileInSubRoot).toMatchObject({ id: initialFakeFiles.fileInSubRoot.id }); }); it("should reject queries without valid secret", async () => { await sendDeleteUser(adminConfig.endpointSecret + "x", myUserId, 403); await requestPendingUserDeletions(adminConfig.endpointSecret + "x", 403); }); it("should reject queries without valid user ID", async () => { await sendDeleteUser(adminConfig.endpointSecret!, "", 400); }); it("should start with an empty pending list", async () => { const responseBody = await requestPendingUserDeletions(adminConfig.endpointSecret!, 200); expect(responseBody).toStrictEqual([]); }); it("should timeout with temp test user ID", async () => { let isResolved = false; const waitable = sendDeleteUser(adminConfig.endpointSecret!, "e2e_simulate_timeout", 400); waitable.then(() => isResolved = true, () => isResolved = true); expect(new Promise(resolve => setTimeout(() => resolve(isResolved), 3000))).resolves.toBeFalsy(); }); it("should be immediately listed after deletion", async () => { const deleteResult = await sendDeleteUser(adminConfig.endpointSecret!, userToTestPending.user.id, 200, false); expect(deleteResult).toMatchObject({ status: "deleting" }); const pendingDeletes = await requestPendingUserDeletions(adminConfig.endpointSecret!, 200); expect(pendingDeletes).toHaveLength(1); expect(pendingDeletes[0]).toHaveLength(2); expect(pendingDeletes[0][0]).toBe(userToTestPending.user.id); expect(pendingDeletes[0][1]).toBeGreaterThan(0); const userBefore = await adminUser.getUser(userToTestPending.user.id); expect(userBefore).toMatchObject({ id: userToTestPending.user.id, deleted: true, }); expect((userBefore as unknown as User).delete_process_started_epoch).toBeGreaterThan(0); const actualDeleteResult = await sendDeleteUser(adminConfig.endpointSecret!, userToTestPending.user.id, 200, true); expect(actualDeleteResult).toMatchObject({ status: "done" }); expect(await requestPendingUserDeletions(adminConfig.endpointSecret!, 200)).toStrictEqual([ ]); const userAfter = await adminUser.getUser(userToTestPending.user.id); expect(userAfter).toMatchObject({ id: userToTestPending.user.id, deleted: true, }); expect((userAfter as unknown as User).delete_process_started_epoch).toBe(0); }); it("the user deletion should complete inline in e2e", async () => { const deleteResult = await sendDeleteUser(adminConfig.endpointSecret!, myUserId, 200, true); expect(deleteResult).toMatchObject({ status: "done" }); }); it("the user should no longer have any documents", async () => { const tree = await loadDriveFiles(myDriveId); const foundEntries = findEntriesInUserTree(tree); expect(foundEntries.rootFolder?.driveFile).toBeUndefined(); expect(foundEntries.subRootFolder?.driveFile).toBeUndefined(); expect(foundEntries.fileAtRoot?.driveFile).toBeUndefined(); expect(foundEntries.fileInRootFolder?.driveFile).toBeUndefined(); expect(foundEntries.fileInSubRoot?.driveFile).toBeUndefined(); }); it("the user should be marked deleted", async () => { const user = await adminUser.getUser(currentUser.user.id); expect(user).toMatchObject({ id: myUserId, deleted: true, }); expect((user as unknown as User).delete_process_started_epoch).toBe(0); }); it("the user should not be able to continue with API", async () => { expect(await currentUser.browseDocuments(myDriveId)).toMatchObject({ "error": "Unauthorized", }); }); it("should have no objects left other than user (and untestable files)", async () => { expect((await Promise.all( Object.entries(listByUserIn) .flatMap(async ([_, fn]) => (await fn(myUserId)).getEntities().map(e => ({"": e.constructor.name, ...e}))) )).flat()).toHaveLength(0); }); it("'existing user with to_delete flag' should return 404 if user is not found", async () => { const secret = adminConfig.endpointSecret; const email = "notexistingemail@test.com"; const response = await platform!.app.inject({ method: "POST", url: `/admin/api/user/delete/mark`, body: { secret, email }, }); expect(response.statusCode).toBe(404); }); it("'existing user with to_delete flag' should return 200", async () => { const user = await UserApi.getInstance(platform); const response = await platform!.app.inject({ method: "POST", url: `/admin/api/user/delete/mark`, body: { secret: adminConfig.endpointSecret, email: (await user.getUser()).email }, }); expect(response.statusCode).toBe(200); }); }); });