Skip to content

Commit

Permalink
Merge pull request #102 from PermanentOrg/feature/PER-8805-allow-remo…
Browse files Browse the repository at this point in the history
…ve-self-from-archive

PER-8805 Allow remove self from archive
  • Loading branch information
valentinvsp authored Jul 29, 2024
2 parents fbaf83d + b1e17a6 commit bd15442
Show file tree
Hide file tree
Showing 15 changed files with 635 additions and 22 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}
20 changes: 16 additions & 4 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ Queues a thumbnail regeneration task for the record given by `recordId`
}
```

### DELETE `/account/archive/:archiveId`

Used by accounts that want to leave an archive of which they are a member.

- Headers: Authorization: Bearer \<JWT from FusionAuth>

- Response: 204 if successful

## Archives

### GET `/archive/:archiveId/tags/public`
Expand Down Expand Up @@ -437,10 +445,10 @@ for valid values of `action` and `entity`.

```
{
entity: string (see database for valid values),
action: string (see database for valid values),
entity: string (see database for valid values),
action: string (see database for valid values),
version: number (this should be 1 for now and can increment if we
change the body of the event),
change the body of the event),
entityId: string (should be the ID of the database object the event
happened to; e.g. 1 if this pertains to accountId 1),
userAgent: string (optional),
Expand Down Expand Up @@ -468,6 +476,7 @@ MixPanel](https://permanent.atlassian.net/wiki/spaces/EN/pages/2086862849/Mixpan

- Headers: Authorization: Bearer \<JWT from FusionAuth>
- Response

```
[
{
Expand Down Expand Up @@ -500,7 +509,6 @@ MixPanel](https://permanent.atlassian.net/wiki/spaces/EN/pages/2086862849/Mixpan
{}
```


## FusionAuth Users and Two-Factor Methods

(Note: this section is not yet implemented)
Expand Down Expand Up @@ -529,6 +537,7 @@ JWT.

- Headers: Authorization: Bearer \<JWT from FusionAuth>
- Request Body

```
{
method: string // either 'sms' or 'email'
Expand All @@ -540,6 +549,7 @@ JWT.

- Headers: Authorization: Bearer \<JWT from FusionAuth>
- Request Body

```
{
method: string // the same method from `idpuser/send-enable-code`
Expand All @@ -552,6 +562,7 @@ JWT.

- Headers: Authorization: Bearer \<JWT from FusionAuth>
- Request Body

```
{
methodId: 4-digit string // this was returned in the GET
Expand All @@ -562,6 +573,7 @@ JWT.

- Headers: Authorization: Bearer \<JWT from FusionAuth>
- Request Body

```
{
code: string // provided by the user (they will have received it from FusionAuth)
Expand Down
126 changes: 126 additions & 0 deletions packages/api/src/account/controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import request from "supertest";
import type { Request, NextFunction } from "express";

import { db } from "../database";
import { EVENT_ACTION, EVENT_ACTOR, EVENT_ENTITY } from "../constants";
import { verifyUserAuthentication, extractIp } from "../middleware";
import { app } from "../app";

jest.mock("../database");
jest.mock("@stela/logger");
jest.mock("../middleware");

const loadFixtures = async (): Promise<void> => {
await db.sql("fixtures.create_test_accounts");
await db.sql("fixtures.create_test_archives");
await db.sql("fixtures.create_test_account_archives");
};

const clearDatabase = async (): Promise<void> => {
await db.query("TRUNCATE event, account_archive, account, archive CASCADE");
};

describe("leaveArchive", () => {
const agent = request(app);

const ip = "127.0.0.1";
const selectEventRow = `
SELECT * FROM event e
WHERE e.entity = '${EVENT_ENTITY.AccountArchive}'
AND e.version = 1
AND e.entity_id = '5'
AND e.action = '${EVENT_ACTION.Delete}'
AND e.ip = '${ip}'
AND e.actor_type = '${EVENT_ACTOR.User}'
AND e.actor_id = 'b5461dc2-1eb0-450e-b710-fef7b2cafe1e'`;

const selectAccountArchiveRow = `SELECT * FROM account_archive WHERE
accountid = 3 AND archiveid = 1`;

beforeEach(async () => {
(verifyUserAuthentication as jest.Mock).mockImplementation(
async (
req: Request<
unknown,
unknown,
{ userSubjectFromAuthToken?: string; emailFromAuthToken?: string }
>,
__,
next: NextFunction
) => {
req.body.emailFromAuthToken = "test+1@permanent.org";
req.body.userSubjectFromAuthToken =
"b5461dc2-1eb0-450e-b710-fef7b2cafe1e";

next();
}
);

(extractIp as jest.Mock).mockImplementation(
async (
req: Request<unknown, unknown, { ip?: string }>,
__,
next: NextFunction
) => {
req.body.ip = ip;

next();
}
);

await loadFixtures();
jest.clearAllMocks();
});

afterEach(async () => {
await clearDatabase();
});

test("should successfully leave an archive", async () => {
const accountArchiveBeforeLeaveResult = await db.query(
selectAccountArchiveRow
);

expect(accountArchiveBeforeLeaveResult.rows.length).toBe(1);

await agent.delete("/api/v2/account/archive/1").expect(204);

const accountArchiveAfterLeaveResult = await db.query(
selectAccountArchiveRow
);

expect(accountArchiveAfterLeaveResult.rows.length).toBe(0);
});

test("should throw 404 error if account archive relationship is not found", async () => {
await agent.delete("/api/v2/account/archive/2022").expect(404);
});

test("should throw 400 error if the account owns the archive", async () => {
await agent.delete("/api/v2/account/archive/2").expect(400);
});

test("should log an action in the database events table", async () => {
const eventsBeforeLeave = await db.query(selectEventRow);
expect(eventsBeforeLeave.rows.length).toBe(0);

await agent.delete("/api/v2/account/archive/1").expect(204);

const eventsAfterLeave = await db.query(selectEventRow);
expect(eventsAfterLeave.rows.length).toBe(1);
});

test("logged event contains expected body values", async () => {
await agent.delete("/api/v2/account/archive/1").expect(204);

const eventResult = await db.query<{ body: object }>(selectEventRow);
expect(eventResult.rows.length).toBe(1);

const eventBody = eventResult.rows[0]?.body;
expect(eventBody).toEqual({
archiveId: "1",
accountId: "3",
accountPrimaryEmail: "test+1@permanent.org",
});
});
});
36 changes: 34 additions & 2 deletions packages/api/src/account/controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import { verifyUserAuthentication } from "../middleware";

import { extractIp, verifyUserAuthentication } from "../middleware";
import { isValidationError } from "../validators/validator_util";

import {
validateUpdateTagsRequest,
validateBodyFromAuthentication,
validateLeaveArchiveParams,
validateLeaveArchiveRequest,
} from "./validators";
import { isValidationError } from "../validators/validator_util";
import { accountService } from "./service";
import type { LeaveArchiveRequest } from "./models";

export const accountController = Router();
accountController.put(
Expand Down Expand Up @@ -45,3 +50,30 @@ accountController.get(
}
}
);
accountController.delete(
"/archive/:archiveId",
verifyUserAuthentication,
extractIp,
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
validateLeaveArchiveRequest(req.body);
validateLeaveArchiveParams(req.params);

const data: LeaveArchiveRequest = {
...req.params,
...req.body,
};

await accountService.leaveArchive(data);

res.send(204);
} catch (err) {
if (isValidationError(err)) {
res.status(400).json({ error: err });
return;
}

next(err);
}
}
);
14 changes: 14 additions & 0 deletions packages/api/src/account/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ export interface UpdateTagsRequest {
export interface SignupDetails {
token: string;
}

export interface GetAccountArchiveResult {
accountArchiveId: string;
accountId: string;
accessRole: string;
type: string;
status: string;
}
export interface LeaveArchiveRequest {
emailFromAuthToken: string;
userSubjectFromAuthToken: string;
archiveId: string;
ip: string;
}
7 changes: 7 additions & 0 deletions packages/api/src/account/queries/delete_account_archive.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DELETE FROM account_archive
WHERE
archiveid = :archiveId
AND accessrole != 'access.role.owner'
AND accountid IN (SELECT accountid FROM account WHERE primaryemail = :email)
RETURNING
account_archiveid AS "accountArchiveId";
15 changes: 15 additions & 0 deletions packages/api/src/account/queries/get_account_archive.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
SELECT
account_archive.account_archiveid AS "accountArchiveId",
account_archive.accountid AS "accountId",
account_archive.accessrole AS "accessRole",
account_archive.type,
account_archive.status
FROM
account_archive
INNER JOIN
account
ON account_archive.accountid = account.accountid
WHERE
account_archive.archiveid = :archiveId
AND account.primaryemail = :email
AND account_archive.status = 'status.generic.ok';
80 changes: 78 additions & 2 deletions packages/api/src/account/service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { Md5 } from "ts-md5";
import createError from "http-errors";
import { logger } from "@stela/logger";

import { db } from "../database";
import { MailchimpMarketing } from "../mailchimp";
import type { UpdateTagsRequest, SignupDetails } from "./models";
import { ACCESS_ROLE, EVENT_ACTION, EVENT_ENTITY } from "../constants";
import { createEvent } from "../event/service";
import type { CreateEventRequest } from "../event/models";

import type {
UpdateTagsRequest,
SignupDetails,
GetAccountArchiveResult,
LeaveArchiveRequest,
} from "./models";

const updateTags = async (requestBody: UpdateTagsRequest): Promise<void> => {
const tags = (requestBody.addTags ?? [])
Expand Down Expand Up @@ -50,7 +60,73 @@ const getSignupDetails = async (
return signupDetailResult.rows[0];
};

const leaveArchive = async ({
emailFromAuthToken,
userSubjectFromAuthToken,
archiveId,
ip,
}: LeaveArchiveRequest): Promise<{
accountArchiveId: string;
}> =>
db.transaction(async (transactionDb) => {
const accountArchiveResult =
await transactionDb.sql<GetAccountArchiveResult>(
"account.queries.get_account_archive",
{
archiveId,
email: emailFromAuthToken,
}
);

const accountArchive = accountArchiveResult.rows[0];

if (!accountArchive) {
throw new createError.NotFound(
`Unable to find relationship with archiveId ${archiveId}`
);
}

if (accountArchive.accessRole === ACCESS_ROLE.Owner) {
throw new createError.BadRequest(
"Cannot leave archive while owning it. Either pass ownership to another account or delete archive."
);
}

const deleteResult = await db.sql<{ accountArchiveId: string }>(
"account.queries.delete_account_archive",
{
archiveId,
email: emailFromAuthToken,
}
);

if (!deleteResult.rows[0]) {
throw new createError.InternalServerError(
"Unexpected result while performing DELETE on account archive relationship."
);
}

const eventData: CreateEventRequest = {
action: EVENT_ACTION.Delete,
entity: EVENT_ENTITY.AccountArchive,
entityId: accountArchive.accountArchiveId,
ip,
version: 1,
body: {
archiveId,
accountId: accountArchive.accountId,
accountPrimaryEmail: emailFromAuthToken,
},
userSubjectFromAuthToken,
};

await createEvent(eventData);

return deleteResult.rows[0];
});

export const accountService = {
updateTags,
getSignupDetails,
leaveArchive,
updateTags,
};
Loading

0 comments on commit bd15442

Please sign in to comment.