From 9ae0ce471bf628af76dcfff8f40096fb10b6b46b Mon Sep 17 00:00:00 2001 From: Rafael Tavares <26308880+Rafatcb@users.noreply.github.com> Date: Tue, 14 May 2024 22:13:05 -0300 Subject: [PATCH] feat(firewall): handle firewall with multiple events involved If the firewall catches some suspicious activity from an IP address and that IP address continues to perform the same action, new events are created for the same firewall side-effect. --- models/content.js | 3 +- models/event.js | 20 - models/firewall/find.js | 18 +- models/firewall/review.js | 40 +- .../api/v1/events/firewall/[id]/get.test.js | 194 +++++ .../review_firewall/[id]/post.test.js | 705 ++++++++++++++++++ tests/orchestrator.js | 10 + 7 files changed, 937 insertions(+), 53 deletions(-) diff --git a/models/content.js b/models/content.js index 84312362b..833489446 100644 --- a/models/content.js +++ b/models/content.js @@ -488,7 +488,8 @@ async function creditOrDebitTabCoins(oldContent, newContent, options = {}) { // or if it was deleted and is catch by firewall: "deleted" -> "firewall". if ( oldContent && - ((!oldContent.published_at && newContent.status !== 'published') || oldContent.status === 'deleted') + ((!oldContent.published_at && newContent.status !== 'published') || + ['deleted', 'firewall'].includes(oldContent.status)) ) { return; } diff --git a/models/event.js b/models/event.js index d8c2480b5..8f8a6a97f 100644 --- a/models/event.js +++ b/models/event.js @@ -70,25 +70,6 @@ async function findAllRelatedEvents(id) { return results.rows; } -async function findOneById(eventId) { - const query = { - text: ` - SELECT - * - FROM - events - WHERE - id = $1 - LIMIT - 1 - ;`, - values: [eventId], - }; - - const results = await database.query(query); - return results.rows[0]; -} - async function findOneByOriginalEventId(originalEventId, where = {}) { const firstWhereArgumentIndex = 2; const whereClause = buildWhereClause(where, firstWhereArgumentIndex); @@ -136,6 +117,5 @@ function buildWhereClause(where, nextArgumentIndex) { export default Object.freeze({ create, findAllRelatedEvents, - findOneById, findOneByOriginalEventId, }); diff --git a/models/firewall/find.js b/models/firewall/find.js index 6caf1fb44..8cdef14c2 100644 --- a/models/firewall/find.js +++ b/models/firewall/find.js @@ -6,9 +6,10 @@ import user from 'models/user'; import eventTypes from './event-types'; async function findByEventId(eventId) { - const foundOriginalEvent = await event.findOneById(eventId); + const relatedEvents = await event.findAllRelatedEvents(eventId); + const foundRequestedEvent = relatedEvents.find((event) => event.id === eventId); - if (!foundOriginalEvent || !eventTypes.firewall.includes(foundOriginalEvent.type)) { + if (!foundRequestedEvent || !eventTypes.firewall.includes(foundRequestedEvent.type)) { throw new NotFoundError({ message: `O id "${eventId}" não foi encontrado no sistema.`, action: 'Verifique se o "id" está digitado corretamente.', @@ -18,20 +19,11 @@ async function findByEventId(eventId) { }); } - const foundReviewedEvent = await event.findOneByOriginalEventId(eventId, { - type: eventTypes.review, - }); - - const events = [foundOriginalEvent]; - - if (foundReviewedEvent) { - events.push(foundReviewedEvent); - } - const affectedData = await getAffectedData(events.at(-1)); + const affectedData = await getAffectedData(relatedEvents.at(-1)); return { affected: affectedData, - events: events, + events: relatedEvents, }; } diff --git a/models/firewall/review.js b/models/firewall/review.js index 44cd50981..9291bc3df 100644 --- a/models/firewall/review.js +++ b/models/firewall/review.js @@ -17,7 +17,9 @@ const reviewFunctions = { }; async function reviewEvent({ action, eventId, originatorIp, originatorUserId }) { - const firewallEvent = await validateAndGetFirewallEventToReview(eventId); + const relatedEvents = await validateAndGetRelatedEventsToReview(eventId); + + const firewallEvent = relatedEvents.find((e) => e.id === eventId); const metadata = { original_event_id: eventId, @@ -44,7 +46,7 @@ async function reviewEvent({ action, eventId, originatorIp, originatorUserId }) }, ); - const events = [firewallEvent, createdEvent]; + const events = [...relatedEvents, createdEvent]; const affected = await reviewFunctions[eventType](firewallEvent, { transaction, @@ -65,33 +67,33 @@ async function reviewEvent({ action, eventId, originatorIp, originatorUserId }) } } -async function validateAndGetFirewallEventToReview(eventId) { - const reviewingEvent = await event.findOneByOriginalEventId(eventId, { - types: eventTypes.review, - }); +async function validateAndGetRelatedEventsToReview(eventId) { + const relatedEvents = await event.findAllRelatedEvents(eventId); - if (reviewingEvent) { - throw new ValidationError({ - message: 'Você está tentando analisar um evento que já foi analisado.', - action: 'Utilize um "id" que aponte para um evento de firewall que ainda não foi analisado.', + if (!relatedEvents.length) { + throw new NotFoundError({ + message: `O id "${eventId}" não foi encontrado no sistema.`, + action: 'Verifique se o "id" está digitado corretamente.', stack: new Error().stack, - errorLocationCode: 'MODEL:FIREWALL:VALIDATE_AND_GET_FIREWALL_EVENT_TO_REVIEW:EVENT_ALREADY_REVIEWED', + errorLocationCode: 'MODEL:FIREWALL:VALIDATE_AND_GET_FIREWALL_EVENT_TO_REVIEW:NOT_FOUND', key: 'id', }); } - const firewallEvent = await event.findOneById(eventId); + const reviewingEvent = relatedEvents.find((e) => eventTypes.review.includes(e.type)); - if (!firewallEvent) { - throw new NotFoundError({ - message: `O id "${eventId}" não foi encontrado no sistema.`, - action: 'Verifique se o "id" está digitado corretamente.', + if (reviewingEvent) { + throw new ValidationError({ + message: 'Você está tentando analisar um evento que já foi analisado.', + action: 'Utilize um "id" que aponte para um evento de firewall que ainda não foi analisado.', stack: new Error().stack, - errorLocationCode: 'MODEL:FIREWALL:VALIDATE_AND_GET_FIREWALL_EVENT_TO_REVIEW:NOT_FOUND', + errorLocationCode: 'MODEL:FIREWALL:VALIDATE_AND_GET_FIREWALL_EVENT_TO_REVIEW:EVENT_ALREADY_REVIEWED', key: 'id', }); } + const firewallEvent = relatedEvents.find((e) => e.id === eventId); + if (!eventTypes.firewall.includes(firewallEvent.type)) { throw new ValidationError({ message: 'Você está tentando analisar um evento inválido.', @@ -102,7 +104,7 @@ async function validateAndGetFirewallEventToReview(eventId) { }); } - return firewallEvent; + return relatedEvents; } async function confirmBlockUsers(firewallEvent, options) { @@ -168,7 +170,7 @@ async function unblockUsers(firewallEvent, options) { const activeUsers = []; for (const userData of users) { - if (userData.features.length === 0) { + if (userData.features.length === 0 || (userData.features.length === 1 && userData.features[0] === 'nuked')) { inactiveUsers.push(userData.id); } else { activeUsers.push(userData.id); diff --git a/tests/integration/api/v1/events/firewall/[id]/get.test.js b/tests/integration/api/v1/events/firewall/[id]/get.test.js index 708aacab2..01be08a50 100644 --- a/tests/integration/api/v1/events/firewall/[id]/get.test.js +++ b/tests/integration/api/v1/events/firewall/[id]/get.test.js @@ -249,6 +249,90 @@ describe('GET /api/v1/events/firewall/[id]', () => { }); }); + test('With two consecutive "firewall:block_users" events from the same IP', async () => { + const usersRequestBuilder = new RequestBuilder('/api/v1/users'); + const firewallRequestBuilder = new RequestBuilder(`/api/v1/events/firewall`); + await firewallRequestBuilder.buildUser({ with: ['read:firewall'] }); + + // Create users + const { responseBody: user1 } = await usersRequestBuilder.post({ + username: 'firstUser', + email: 'first-user@gmail.com', + password: 'password', + }); + const { responseBody: user2 } = await usersRequestBuilder.post({ + username: 'secondUser', + email: 'second-user@gmail.com', + password: 'password', + }); + const { response: user3Response } = await usersRequestBuilder.post({ + username: 'thirdUser', + email: 'third-user@gmail.com', + password: 'password', + }); + + expect(user3Response.status).toBe(429); + + const firstFirewallEvent = await orchestrator.getLastEvent(); + + const { response: user4Response } = await usersRequestBuilder.post({ + username: 'fourthUser', + email: 'fourth-user@gmail.com', + password: 'password', + }); + + expect(user4Response.status).toBe(429); + + const secondFirewallEvent = await orchestrator.getLastEvent(); + + expect(firstFirewallEvent.id).not.toBe(secondFirewallEvent.id); + + // Get firewall side-effects + const { response: firstFirewallResponse, responseBody: firstFirewallResponseBody } = + await firewallRequestBuilder.get(`/${firstFirewallEvent.id}`); + const { response: secondFirewallResponse, responseBody: secondFirewallResponseBody } = + await firewallRequestBuilder.get(`/${secondFirewallEvent.id}`); + + expect.soft(firstFirewallResponse.status).toBe(200); + expect.soft(secondFirewallResponse.status).toBe(200); + + await usersRequestBuilder.setUser(user1); + const { responseBody: user1AfterFirewall } = await usersRequestBuilder.get(`/${user1.username}`); + + await usersRequestBuilder.setUser(user2); + const { responseBody: user2AfterFirewall } = await usersRequestBuilder.get(`/${user2.username}`); + + expect(firstFirewallResponseBody).toStrictEqual({ + affected: { + users: [user1AfterFirewall, user2AfterFirewall], + }, + events: [ + { + created_at: firstFirewallEvent.created_at.toISOString(), + id: firstFirewallEvent.id, + metadata: { + from_rule: 'create:user', + users: [user1AfterFirewall.id, user2AfterFirewall.id], + }, + originator_user_id: null, + type: 'firewall:block_users', + }, + { + created_at: secondFirewallEvent.created_at.toISOString(), + id: secondFirewallEvent.id, + metadata: { + from_rule: 'create:user', + users: [user1AfterFirewall.id, user2AfterFirewall.id], + }, + originator_user_id: null, + type: 'firewall:block_users', + }, + ], + }); + + expect(firstFirewallResponseBody).toStrictEqual(secondFirewallResponseBody); + }); + test.each([ { action: 'undo', @@ -335,6 +419,116 @@ describe('GET /api/v1/events/firewall/[id]', () => { }); }); + test('With two consecutive "firewall:block_users" events from the same IP reviewed', async () => { + const usersRequestBuilder = new RequestBuilder('/api/v1/users'); + const firewallRequestBuilder = new RequestBuilder(`/api/v1/events/firewall`); + const firewallUser = await firewallRequestBuilder.buildUser({ with: ['read:firewall', 'review:firewall'] }); + + // Create users + const { responseBody: user1 } = await usersRequestBuilder.post({ + username: 'firstUser', + email: 'first-user@gmail.com', + password: 'password', + }); + const { responseBody: user2 } = await usersRequestBuilder.post({ + username: 'secondUser', + email: 'second-user@gmail.com', + password: 'password', + }); + const { response: user3Response } = await usersRequestBuilder.post({ + username: 'thirdUser', + email: 'third-user@gmail.com', + password: 'password', + }); + + expect(user3Response.status).toBe(429); + + const firstFirewallEvent = await orchestrator.getLastEvent(); + + const { response: user4Response } = await usersRequestBuilder.post({ + username: 'fourthUser', + email: 'fourth-user@gmail.com', + password: 'password', + }); + + expect(user4Response.status).toBe(429); + + const secondFirewallEvent = await orchestrator.getLastEvent(); + + expect(firstFirewallEvent.id).not.toBe(secondFirewallEvent.id); + + // Check firewall side-effect + expect(firstFirewallEvent.type).toBe('firewall:block_users'); + expect(firstFirewallEvent.metadata.users).toStrictEqual([user1.id, user2.id]); + expect(secondFirewallEvent.type).toBe(firstFirewallEvent.type); + expect(secondFirewallEvent.metadata.users).toStrictEqual(firstFirewallEvent.metadata.users); + + // Review firewall side-effect + const reviewFirewallRequestBuilder = new RequestBuilder( + `/api/v1/moderations/review_firewall/${firstFirewallEvent.id}`, + ); + await reviewFirewallRequestBuilder.setUser(firewallUser); + const { response: reviewResponse } = await reviewFirewallRequestBuilder.post({ action: 'confirm' }); + expect(reviewResponse.status).toBe(200); + + // Get reviewed firewall event from first event id + const reviewEvent = await orchestrator.getLastEvent(); + + const { response: responseFromFirstEvent, responseBody: responseBodyFromFirstEvent } = + await firewallRequestBuilder.get(`/${firstFirewallEvent.id}`); + + expect.soft(responseFromFirstEvent.status).toBe(200); + + const user1AfterReview = await user.findOneById(user1.id, { withBalance: true }); + const user2AfterReview = await user.findOneById(user2.id, { withBalance: true }); + + expect(responseBodyFromFirstEvent).toStrictEqual({ + affected: { + users: [mapUserData(user1AfterReview), mapUserData(user2AfterReview)], + }, + events: [ + { + created_at: firstFirewallEvent.created_at.toISOString(), + id: firstFirewallEvent.id, + metadata: { + from_rule: 'create:user', + users: [user1.id, user2.id], + }, + originator_user_id: null, + type: 'firewall:block_users', + }, + { + created_at: secondFirewallEvent.created_at.toISOString(), + id: secondFirewallEvent.id, + metadata: { + from_rule: 'create:user', + users: [user1.id, user2.id], + }, + originator_user_id: null, + type: 'firewall:block_users', + }, + { + created_at: reviewEvent.created_at.toISOString(), + id: reviewEvent.id, + metadata: { + original_event_id: firstFirewallEvent.id, + users: [user1AfterReview.id, user2AfterReview.id], + }, + originator_user_id: firewallUser.id, + type: 'moderation:block_users', + }, + ], + }); + + // Get reviewed firewall event from second event id + const { response: responseFromSecondEvent, responseBody: responseBodyFromSecondEvent } = + await firewallRequestBuilder.get(`/${secondFirewallEvent.id}`); + + expect(responseFromSecondEvent.status).toBe(200); + + expect(responseBodyFromSecondEvent).toStrictEqual(responseBodyFromFirstEvent); + }); + test('With a "firewall:block_contents:text_root" event', async () => { const usersRequestBuilder = new RequestBuilder(`/api/v1/users`); const firewallRequestBuilder = new RequestBuilder(`/api/v1/events/firewall`); diff --git a/tests/integration/api/v1/moderations/review_firewall/[id]/post.test.js b/tests/integration/api/v1/moderations/review_firewall/[id]/post.test.js index 9c73e1036..907457155 100644 --- a/tests/integration/api/v1/moderations/review_firewall/[id]/post.test.js +++ b/tests/integration/api/v1/moderations/review_firewall/[id]/post.test.js @@ -280,6 +280,79 @@ describe('POST /api/v1/moderations/review_firewall/[id]', () => { }); }); + test('Review related events twice', async () => { + const usersRequestBuilder = new RequestBuilder('/api/v1/users'); + + // Create users + const { responseBody: user1 } = await usersRequestBuilder.post({ + username: 'firstUser', + email: 'first-user@gmail.com', + password: 'password', + }); + const { responseBody: user2 } = await usersRequestBuilder.post({ + username: 'secondUser', + email: 'second-user@gmail.com', + password: 'password', + }); + const { response: user3Response } = await usersRequestBuilder.post({ + username: 'thirdUser', + email: 'third-user@gmail.com', + password: 'password', + }); + const firewallEvent1 = await orchestrator.getLastEvent(); + + const { response: user4Response } = await usersRequestBuilder.post({ + username: 'fourthUser', + email: 'fourth-user@gmail.com', + password: 'password', + }); + const firewallEvent2 = await orchestrator.getLastEvent(); + + expect(user3Response.status).toBe(429); + expect(user4Response.status).toBe(429); + + // Check firewall side-effect + expect(firewallEvent1.type).toBe('firewall:block_users'); + expect(firewallEvent2.type).toBe('firewall:block_users'); + + expect(firewallEvent1.metadata.users).toStrictEqual([user1.id, user2.id]); + + // Review firewall + const reviewFirstEventRequestBuilder = new RequestBuilder( + `/api/v1/moderations/review_firewall/${firewallEvent1.id}`, + ); + const firewallUser = await reviewFirstEventRequestBuilder.buildUser({ with: ['review:firewall'] }); + + const { response } = await reviewFirstEventRequestBuilder.post({ + action: 'confirm', + }); + + expect(response.status).toBe(200); + + // Review related event + const reviewSecondEventRequestBuilder = new RequestBuilder( + `/api/v1/moderations/review_firewall/${firewallEvent2.id}`, + ); + await reviewSecondEventRequestBuilder.setUser(firewallUser); + + const { response: responseAgain, responseBody: responseAgainBody } = await reviewSecondEventRequestBuilder.post({ + action: 'undo', + }); + + expect.soft(responseAgain.status).toBe(400); + + expect(responseAgainBody).toStrictEqual({ + name: 'ValidationError', + message: 'Você está tentando analisar um evento que já foi analisado.', + action: 'Utilize um "id" que aponte para um evento de firewall que ainda não foi analisado.', + status_code: 400, + error_id: responseAgainBody.error_id, + request_id: responseAgainBody.request_id, + error_location_code: 'MODEL:FIREWALL:VALIDATE_AND_GET_FIREWALL_EVENT_TO_REVIEW:EVENT_ALREADY_REVIEWED', + key: 'id', + }); + }); + describe('With action = "undo"', () => { test('With a "firewall:block_users" event', async () => { const usersRequestBuilder = new RequestBuilder('/api/v1/users'); @@ -796,6 +869,183 @@ describe('POST /api/v1/moderations/review_firewall/[id]', () => { expect(user2AfterUndo).toStrictEqual(user2AfterRate); }); + test('With three "firewall:block_contents:text_root" events for the same contents', async () => { + // Create user and contents + const contentsRequestBuilder = new RequestBuilder('/api/v1/contents'); + const defaultUser = await contentsRequestBuilder.buildUser(); + + const { responseBody: content1 } = await createContentViaApi(contentsRequestBuilder); + const { responseBody: content2 } = await createContentViaApi(contentsRequestBuilder); + const { response: responseContent3 } = await createContentViaApi(contentsRequestBuilder); + const firewallEvent1 = await orchestrator.getLastEvent(); + const { response: responseContent4 } = await createContentViaApi(contentsRequestBuilder); + const firewallEvent2 = await orchestrator.getLastEvent(); + const { response: responseContent5 } = await createContentViaApi(contentsRequestBuilder); + const firewallEvent3 = await orchestrator.getLastEvent(); + + expect(responseContent3.status).toBe(429); + expect(responseContent4.status).toBe(429); + expect(responseContent5.status).toBe(429); + + // Check firewall side-effect + const content1AfterSideEffect = await content.findOne({ where: { id: content1.id } }); + const content2AfterSideEffect = await content.findOne({ where: { id: content2.id } }); + + expect(content1AfterSideEffect.status).toBe('firewall'); + expect(content2AfterSideEffect.status).toBe('firewall'); + + expect(firewallEvent1.type).toBe('firewall:block_contents:text_root'); + expect(firewallEvent2.type).toBe('firewall:block_contents:text_root'); + expect(firewallEvent3.type).toBe('firewall:block_contents:text_root'); + + expect(firewallEvent1.metadata.contents).toStrictEqual([content1.id, content2.id]); + + expect(firewallEvent2).toStrictEqual({ + ...firewallEvent1, + id: firewallEvent2.id, + created_at: firewallEvent2.created_at, + }); + + expect(firewallEvent3).toStrictEqual({ + ...firewallEvent1, + id: firewallEvent3.id, + created_at: firewallEvent3.created_at, + }); + + expect(firewallEvent1.id).not.toBe(firewallEvent2.id); + expect(firewallEvent1.id).not.toBe(firewallEvent3.id); + expect(firewallEvent2.id).not.toBe(firewallEvent3.id); + + // Undo firewall side-effect from second event + const reviewFirewallRequestBuilder = new RequestBuilder( + `/api/v1/moderations/review_firewall/${firewallEvent2.id}`, + ); + const firewallUser = await reviewFirewallRequestBuilder.buildUser({ + with: ['read:firewall', 'review:firewall'], + }); + + const { response, responseBody } = await reviewFirewallRequestBuilder.post({ + action: 'undo', + }); + + expect.soft(response.status).toBe(200); + + expect(responseBody).toStrictEqual({ + affected: { + contents: [ + { + body: content1.body, + created_at: content1.created_at, + deleted_at: content1.deleted_at, + id: content1.id, + owner_id: content1.owner_id, + parent_id: content1.parent_id, + published_at: content1.published_at, + slug: content1.slug, + source_url: content1.source_url, + status: content1.status, + title: content1.title, + type: content1.type, + updated_at: content1.updated_at, + }, + { + body: content2.body, + created_at: content2.created_at, + deleted_at: content2.deleted_at, + id: content2.id, + owner_id: content2.owner_id, + parent_id: content2.parent_id, + published_at: content2.published_at, + slug: content2.slug, + source_url: content2.source_url, + status: content2.status, + title: content2.title, + type: content2.type, + updated_at: content2.updated_at, + }, + ], + users: [ + { + created_at: defaultUser.created_at.toISOString(), + description: defaultUser.description, + features: defaultUser.features, + id: defaultUser.id, + tabcash: 0, + tabcoins: 0, + updated_at: defaultUser.updated_at.toISOString(), + username: defaultUser.username, + }, + ], + }, + events: [ + { + created_at: firewallEvent1.created_at.toISOString(), + id: firewallEvent1.id, + metadata: firewallEvent1.metadata, + originator_user_id: firewallEvent1.originator_user_id, + type: firewallEvent1.type, + }, + { + created_at: firewallEvent2.created_at.toISOString(), + id: firewallEvent2.id, + metadata: firewallEvent2.metadata, + originator_user_id: firewallEvent2.originator_user_id, + type: firewallEvent2.type, + }, + { + created_at: firewallEvent3.created_at.toISOString(), + id: firewallEvent3.id, + metadata: firewallEvent3.metadata, + originator_user_id: firewallEvent3.originator_user_id, + type: firewallEvent3.type, + }, + { + created_at: responseBody.events[3].created_at, + id: responseBody.events[3].id, + metadata: { + original_event_id: firewallEvent2.id, + contents: firewallEvent2.metadata.contents, + }, + originator_user_id: firewallUser.id, + type: 'moderation:unblock_contents:text_root', + }, + ], + }); + + const createdEventResponse = responseBody.events[1]; + expect(Date.parse(createdEventResponse.created_at)).not.toBeNaN(); + + // Check "undo" event + const undoEvent = await orchestrator.getLastEvent(); + expect(undoEvent).toStrictEqual({ + created_at: expect.any(Date), + id: undoEvent.id, + metadata: { + original_event_id: firewallEvent2.id, + contents: firewallEvent2.metadata.contents, + }, + originator_ip: '127.0.0.1', + originator_user_id: firewallUser.id, + type: 'moderation:unblock_contents:text_root', + }); + + expect(uuidVersion(undoEvent.id)).toBe(4); + + // Check contents + const content1AfterUndo = await content.findOne({ where: { id: content1.id } }); + const content2AfterUndo = await content.findOne({ where: { id: content2.id } }); + + expect(content1AfterUndo).toStrictEqual({ + ...content1AfterSideEffect, + status: 'published', + }); + + expect(content2AfterUndo).toStrictEqual({ + ...content2AfterSideEffect, + status: 'published', + }); + }); + test('With a "firewall:block_contents:text_child" event', async () => { // Create user and contents const contentsRequestBuilder = new RequestBuilder('/api/v1/contents'); @@ -1705,5 +1955,460 @@ describe('POST /api/v1/moderations/review_firewall/[id]', () => { expect(content2AfterConfirm.status).toBe('deleted'); }); }); + + describe('Different firewall events containing an element in common', () => { + test('With two "firewall:block_users" events, "confirm" and "undo"', async () => { + const usersRequestBuilder = new RequestBuilder('/api/v1/users'); + const { response: response1, responseBody: user1 } = await usersRequestBuilder.post({ + username: 'request1', + email: 'request1@gmail.com', + password: 'validpassword', + }); + + const firstCreateUserEvent = await orchestrator.getLastEvent(); + + await orchestrator.updateEventCreatedAt(firstCreateUserEvent.id, new Date(Date.now() - 4 * 1000)); + + const { response: response2, responseBody: user2 } = await usersRequestBuilder.post({ + username: 'request2', + email: 'request2@gmail.com', + password: 'validpassword', + }); + + const { response: response3 } = await usersRequestBuilder.post({ + username: 'request3', + email: 'request3@gmail.com', + password: 'validpassword', + }); + + const firstFirewallEvent = await orchestrator.getLastEvent(); + + await orchestrator.updateEventCreatedAt(firstCreateUserEvent.id, new Date(Date.now() - 30 * 60 * 1000)); + + const { response: response4, responseBody: user4 } = await usersRequestBuilder.post({ + username: 'request4', + email: 'request4@gmail.com', + password: 'validpassword', + }); + + const { response: response5 } = await usersRequestBuilder.post({ + username: 'request5', + email: 'request5@gmail.com', + password: 'validpassword', + }); + + const secondFirewallEvent = await orchestrator.getLastEvent(); + + expect.soft(response1.status).toBe(201); + expect.soft(response2.status).toBe(201); + expect.soft(response3.status).toBe(429); + expect.soft(response4.status).toBe(201); + expect.soft(response5.status).toBe(429); + + expect(firstFirewallEvent).toStrictEqual({ + id: firstFirewallEvent.id, + type: 'firewall:block_users', + originator_user_id: null, + originator_ip: '127.0.0.1', + metadata: { + from_rule: 'create:user', + users: [user1.id, user2.id], + }, + created_at: firstFirewallEvent.created_at, + }); + expect(uuidVersion(firstFirewallEvent.id)).toBe(4); + expect(Date.parse(firstFirewallEvent.created_at)).not.toBe(NaN); + + expect(secondFirewallEvent).toStrictEqual({ + id: secondFirewallEvent.id, + type: 'firewall:block_users', + originator_user_id: null, + originator_ip: '127.0.0.1', + metadata: { + from_rule: 'create:user', + users: [user2.id, user4.id], + }, + created_at: secondFirewallEvent.created_at, + }); + expect(uuidVersion(secondFirewallEvent.id)).toBe(4); + expect(Date.parse(secondFirewallEvent.created_at)).not.toBe(NaN); + + // Confirm first event + const reviewFirewallRequestBuilder = new RequestBuilder(`/api/v1/moderations/review_firewall`); + const firewallUser = await reviewFirewallRequestBuilder.buildUser({ + with: ['read:firewall', 'review:firewall'], + }); + + const { response: responseConfirm, responseBody: responseBodyConfirm } = + await reviewFirewallRequestBuilder.post(`/${firstFirewallEvent.id}`, { + action: 'confirm', + }); + + expect(responseConfirm.status).toEqual(200); + + expect(responseBodyConfirm).toStrictEqual({ + affected: { + users: [ + { + created_at: user1.created_at, + description: user1.description, + features: ['nuked'], + id: user1.id, + tabcash: user1.tabcash, + tabcoins: user1.tabcoins, + updated_at: user1.updated_at, + username: user1.username, + }, + { + created_at: user2.created_at, + description: user2.description, + features: ['nuked'], + id: user2.id, + tabcash: user2.tabcash, + tabcoins: user2.tabcoins, + updated_at: user2.updated_at, + username: user2.username, + }, + ], + }, + events: [ + { + created_at: firstFirewallEvent.created_at.toISOString(), + id: firstFirewallEvent.id, + metadata: firstFirewallEvent.metadata, + originator_user_id: firstFirewallEvent.originator_user_id, + type: firstFirewallEvent.type, + }, + { + created_at: responseBodyConfirm.events[1].created_at, + id: responseBodyConfirm.events[1].id, + metadata: { + original_event_id: firstFirewallEvent.id, + users: firstFirewallEvent.metadata.users, + }, + originator_user_id: firewallUser.id, + type: 'moderation:block_users', + }, + ], + }); + + const createdConfirmationEventResponse = responseBodyConfirm.events[1]; + expect(Date.parse(createdConfirmationEventResponse.created_at)).not.toEqual(NaN); + + // Check "confirm" event + const confirmationEvent = await orchestrator.getLastEvent(); + expect(confirmationEvent).toStrictEqual({ + created_at: expect.any(Date), + id: confirmationEvent.id, + metadata: { + original_event_id: firstFirewallEvent.id, + users: firstFirewallEvent.metadata.users, + }, + originator_ip: '127.0.0.1', + originator_user_id: firewallUser.id, + type: 'moderation:block_users', + }); + + expect(uuidVersion(confirmationEvent.id)).toEqual(4); + + // Undo second event + const { response: responseUndo, responseBody: responseBodyUndo } = await reviewFirewallRequestBuilder.post( + `/${secondFirewallEvent.id}`, + { action: 'undo' }, + ); + + expect(responseUndo.status).toEqual(200); + + expect(responseBodyUndo).toStrictEqual({ + affected: { + users: [ + { + created_at: user4.created_at, + description: user4.description, + features: responseBodyUndo.affected.users[0].features, + id: user4.id, + tabcash: user4.tabcash, + tabcoins: user4.tabcoins, + updated_at: user4.updated_at, + username: user4.username, + }, + { + created_at: user2.created_at, + description: user2.description, + features: responseBodyUndo.affected.users[1].features, + id: user2.id, + tabcash: user2.tabcash, + tabcoins: user2.tabcoins, + updated_at: user2.updated_at, + username: user2.username, + }, + ], + }, + events: [ + { + created_at: secondFirewallEvent.created_at.toISOString(), + id: secondFirewallEvent.id, + metadata: secondFirewallEvent.metadata, + originator_user_id: secondFirewallEvent.originator_user_id, + type: secondFirewallEvent.type, + }, + { + created_at: responseBodyUndo.events[1].created_at, + id: responseBodyUndo.events[1].id, + metadata: { + original_event_id: secondFirewallEvent.id, + users: secondFirewallEvent.metadata.users, + }, + originator_user_id: firewallUser.id, + type: 'moderation:unblock_users', + }, + ], + }); + + const createdEventResponse = responseBodyUndo.events[1]; + expect(Date.parse(createdEventResponse.created_at)).not.toEqual(NaN); + + const user4AfterUndoResponse = responseBodyUndo.affected.users[0]; + const user2AfterUndoResponse = responseBodyUndo.affected.users[1]; + + // User 2 is nuked because of the previous confirmed firewall event. + expect(user2AfterUndoResponse.features.sort()).toStrictEqual(['nuked', 'read:activation_token']); + expect(user4AfterUndoResponse.features.sort()).toStrictEqual(user4.features.sort()); + + // Check "undo" event + const undoEvent = await orchestrator.getLastEvent(); + expect(undoEvent).toStrictEqual({ + created_at: expect.any(Date), + id: undoEvent.id, + metadata: { + original_event_id: secondFirewallEvent.id, + users: secondFirewallEvent.metadata.users, + }, + originator_ip: '127.0.0.1', + originator_user_id: firewallUser.id, + type: 'moderation:unblock_users', + }); + + expect(uuidVersion(undoEvent.id)).toEqual(4); + }); + + test('With two "firewall:block_contents:text_root" events, "confirm" and "undo"', async () => { + const contentsRequestBuilder = new RequestBuilder('/api/v1/contents'); + const user1 = await contentsRequestBuilder.buildUser(); + + const { response: response1, responseBody: content1 } = await createContentViaApi(contentsRequestBuilder); + const firstCreateContentEvent = await orchestrator.getLastEvent(); + + const { response: response2, responseBody: content2 } = await createContentViaApi(contentsRequestBuilder); + await orchestrator.createRate(content2, 10); + + const { response: response3 } = await createContentViaApi(contentsRequestBuilder); + + const firstFirewallEvent = await orchestrator.getLastEvent(); + + await orchestrator.updateEventCreatedAt(firstCreateContentEvent.id, new Date(Date.now() - 30 * 60 * 1000)); + + const { response: response4, responseBody: content4 } = await createContentViaApi(contentsRequestBuilder); + const { response: response5 } = await createContentViaApi(contentsRequestBuilder); + + const secondFirewallEvent = await orchestrator.getLastEvent(); + + expect.soft(response1.status).toBe(201); + expect.soft(response2.status).toBe(201); + expect.soft(response3.status).toBe(429); + expect.soft(response4.status).toBe(201); + expect.soft(response5.status).toBe(429); + + // Confirm first event + const reviewFirewallRequestBuilder = new RequestBuilder(`/api/v1/moderations/review_firewall`); + const firewallUser = await reviewFirewallRequestBuilder.buildUser({ + with: ['read:firewall', 'review:firewall'], + }); + + const { response: responseConfirm, responseBody: responseBodyConfirm } = + await reviewFirewallRequestBuilder.post(`/${firstFirewallEvent.id}`, { action: 'confirm' }); + + expect(responseConfirm.status).toEqual(200); + + expect(responseBodyConfirm).toStrictEqual({ + affected: { + contents: [ + { + body: content1.body, + created_at: content1.created_at, + deleted_at: responseBodyConfirm.affected.contents[0].deleted_at, + id: content1.id, + owner_id: content1.owner_id, + parent_id: content1.parent_id, + published_at: content1.published_at, + slug: content1.slug, + source_url: content1.source_url, + status: 'deleted', + title: content1.title, + type: content1.type, + updated_at: content1.updated_at, + }, + { + body: content2.body, + created_at: content2.created_at, + deleted_at: responseBodyConfirm.affected.contents[1].deleted_at, + id: content2.id, + owner_id: content2.owner_id, + parent_id: content2.parent_id, + published_at: content2.published_at, + slug: content2.slug, + source_url: content2.source_url, + status: 'deleted', + title: content2.title, + type: content2.type, + updated_at: content2.updated_at, + }, + ], + users: [ + { + created_at: user1.created_at.toISOString(), + description: user1.description, + features: user1.features, + id: user1.id, + tabcash: 0, + tabcoins: 0, + updated_at: user1.updated_at.toISOString(), + username: user1.username, + }, + ], + }, + events: [ + { + created_at: firstFirewallEvent.created_at.toISOString(), + id: firstFirewallEvent.id, + metadata: firstFirewallEvent.metadata, + originator_user_id: firstFirewallEvent.originator_user_id, + type: firstFirewallEvent.type, + }, + { + created_at: responseBodyConfirm.events[1].created_at, + id: responseBodyConfirm.events[1].id, + metadata: { + original_event_id: firstFirewallEvent.id, + contents: firstFirewallEvent.metadata.contents, + }, + originator_user_id: firewallUser.id, + type: 'moderation:block_contents:text_root', + }, + ], + }); + + // Check "confirm" event + const confirmationEvent = await orchestrator.getLastEvent(); + expect(confirmationEvent).toStrictEqual({ + created_at: expect.any(Date), + id: confirmationEvent.id, + metadata: { + original_event_id: firstFirewallEvent.id, + contents: firstFirewallEvent.metadata.contents, + }, + originator_ip: '127.0.0.1', + originator_user_id: firewallUser.id, + type: 'moderation:block_contents:text_root', + }); + + expect(uuidVersion(confirmationEvent.id)).toEqual(4); + + // Undo second event + const { response: responseUndo, responseBody: responseBodyUndo } = await reviewFirewallRequestBuilder.post( + `/${secondFirewallEvent.id}`, + { action: 'undo' }, + ); + + expect(responseUndo.status).toEqual(200); + + expect(responseBodyUndo).toStrictEqual({ + affected: { + contents: [ + { + body: content4.body, + created_at: content4.created_at, + deleted_at: content4.deleted_at, + id: content4.id, + owner_id: content4.owner_id, + parent_id: content4.parent_id, + published_at: content4.published_at, + slug: content4.slug, + source_url: content4.source_url, + status: content4.status, + title: content4.title, + type: content4.type, + updated_at: content4.updated_at, + }, + { + body: content2.body, + created_at: content2.created_at, + deleted_at: responseBodyUndo.affected.contents[1].deleted_at, + id: content2.id, + owner_id: content2.owner_id, + parent_id: content2.parent_id, + published_at: content2.published_at, + slug: content2.slug, + source_url: content2.source_url, + status: 'deleted', + title: content2.title, + type: content2.type, + updated_at: content2.updated_at, + }, + ], + users: [ + { + created_at: user1.created_at.toISOString(), + description: user1.description, + features: user1.features, + id: user1.id, + tabcash: 0, + tabcoins: 0, + updated_at: user1.updated_at.toISOString(), + username: user1.username, + }, + ], + }, + events: [ + { + created_at: secondFirewallEvent.created_at.toISOString(), + id: secondFirewallEvent.id, + metadata: secondFirewallEvent.metadata, + originator_user_id: secondFirewallEvent.originator_user_id, + type: secondFirewallEvent.type, + }, + { + created_at: responseBodyUndo.events[1].created_at, + id: responseBodyUndo.events[1].id, + metadata: { + original_event_id: secondFirewallEvent.id, + contents: secondFirewallEvent.metadata.contents, + }, + originator_user_id: firewallUser.id, + type: 'moderation:unblock_contents:text_root', + }, + ], + }); + + const createdEventResponse = responseBodyUndo.events[1]; + expect(Date.parse(createdEventResponse.created_at)).not.toEqual(NaN); + + // Check "undo" event + const undoEvent = await orchestrator.getLastEvent(); + expect(undoEvent).toStrictEqual({ + created_at: expect.any(Date), + id: undoEvent.id, + metadata: { + original_event_id: secondFirewallEvent.id, + contents: secondFirewallEvent.metadata.contents, + }, + originator_ip: '127.0.0.1', + originator_user_id: firewallUser.id, + type: 'moderation:unblock_contents:text_root', + }); + + expect(uuidVersion(undoEvent.id)).toEqual(4); + }); + }); }); }); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index d1c62eea5..19cf5065b 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -420,6 +420,15 @@ async function getLastEvent() { return results.rows[0]; } +async function updateEventCreatedAt(id, createdAt) { + const query = { + text: 'UPDATE events SET created_at = $1 WHERE id = $2;', + values: [createdAt, id], + }; + const results = await database.query(query); + return results.rows[0]; +} + function parseSetCookies(response) { const setCookieHeaderValues = response.headers.get('set-cookie'); const parsedCookies = setCookieParser.parse(setCookieHeaderValues, { map: true }); @@ -447,6 +456,7 @@ const orchestrator = { removeFeaturesFromUser, runPendingMigrations, updateContent, + updateEventCreatedAt, updateRewardedAt, waitForAllServices, webserverUrl,