From 136f126296af65f50da598d084d1485c0e40437a Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 27 Apr 2025 00:02:20 +0100 Subject: feat: Implement generic rule engine (#1318) * Add schema for the new rule engine * Add rule engine backend logic * Implement the worker logic and event firing * Implement the UI changesfor the rule engine * Ensure that when a referenced list or tag are deleted, the corresponding event/action is * Dont show smart lists in rule engine events * Add privacy validations for attached tag and list ids * Move the rules logic into a models --- packages/trpc/routers/rules.test.ts | 379 ++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 packages/trpc/routers/rules.test.ts (limited to 'packages/trpc/routers/rules.test.ts') diff --git a/packages/trpc/routers/rules.test.ts b/packages/trpc/routers/rules.test.ts new file mode 100644 index 00000000..6bbbcd84 --- /dev/null +++ b/packages/trpc/routers/rules.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { RuleEngineRule } from "@karakeep/shared/types/rules"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +describe("Rules Routes", () => { + let tagId1: string; + let tagId2: string; + let otherUserTagId: string; + + let listId: string; + let otherUserListId: string; + + beforeEach(async (ctx) => { + await defaultBeforeEach(true)(ctx); + + tagId1 = ( + await ctx.apiCallers[0].tags.create({ + name: "Tag 1", + }) + ).id; + + tagId2 = ( + await ctx.apiCallers[0].tags.create({ + name: "Tag 2", + }) + ).id; + + otherUserTagId = ( + await ctx.apiCallers[1].tags.create({ + name: "Tag 1", + }) + ).id; + + listId = ( + await ctx.apiCallers[0].lists.create({ + name: "List 1", + icon: "😘", + }) + ).id; + + otherUserListId = ( + await ctx.apiCallers[1].lists.create({ + name: "List 1", + icon: "😘", + }) + ).id; + }); + + test("create rule with valid data", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const validRuleInput: Omit = { + name: "Valid Rule", + description: "A test rule", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [ + { type: "addTag", tagId: tagId1 }, + { type: "addToList", listId: listId }, + ], + }; + + const createdRule = await api.create(validRuleInput); + expect(createdRule).toMatchObject({ + name: "Valid Rule", + description: "A test rule", + enabled: true, + event: validRuleInput.event, + condition: validRuleInput.condition, + actions: validRuleInput.actions, + }); + }); + + test("create rule fails with invalid data (no actions)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Missing actions", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [], // Empty actions array - should fail validation + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify at least one action/, + ); + }); + + test("create rule fails with invalid event (empty tagId)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Invalid event", + enabled: true, + event: { type: "tagAdded", tagId: "" }, // Empty tagId - should fail + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify a tag for this event type/, + ); + }); + + test("create rule fails with invalid condition (empty tagId in hasTag)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Invalid condition", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "hasTag", tagId: "" }, // Empty tagId - should fail + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify a tag for this condition type/, + ); + }); + + test("update rule with valid data", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + // First, create a rule + const createdRule = await api.create({ + name: "Original Rule", + description: "Original desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + const validUpdateInput: RuleEngineRule = { + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: false, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "removeTag", tagId: tagId2 }], + }; + + const updatedRule = await api.update(validUpdateInput); + expect(updatedRule).toMatchObject({ + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: false, + event: validUpdateInput.event, + condition: validUpdateInput.condition, + actions: validUpdateInput.actions, + }); + }); + + test("update rule fails with invalid data (empty action tagId)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + // First, create a rule + const createdRule = await api.create({ + name: "Original Rule", + description: "Original desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + const invalidUpdateInput: RuleEngineRule = { + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "removeTag", tagId: "" }], // Empty tagId - should fail + }; + + await expect(() => api.update(invalidUpdateInput)).rejects.toThrow( + /You must specify a tag for this action type/, + ); + }); + + test("delete rule", async ({ apiCallers }) => { + const api = apiCallers[0].rules; + + const createdRule = await api.create({ + name: "Rule to Delete", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + await api.delete({ id: createdRule.id }); + + // Attempt to fetch the rule should fail + await expect(() => + api.update({ ...createdRule, name: "Updated" }), + ).rejects.toThrow(/Rule not found/); + }); + + test("list rules", async ({ apiCallers }) => { + const api = apiCallers[0].rules; + + await api.create({ + name: "Rule 1", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + await api.create({ + name: "Rule 2", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId2 }], + }); + + const rulesList = await api.list(); + expect(rulesList.rules.length).toBeGreaterThanOrEqual(2); + expect(rulesList.rules.some((rule) => rule.name === "Rule 1")).toBeTruthy(); + expect(rulesList.rules.some((rule) => rule.name === "Rule 2")).toBeTruthy(); + }); + + describe("privacy checks", () => { + test("cannot access or manipulate another user's rule", async ({ + apiCallers, + }) => { + const apiUserA = apiCallers[0].rules; // First user + const apiUserB = apiCallers[1].rules; // Second user + + // User A creates a rule + const createdRule = await apiUserA.create({ + name: "User A's Rule", + description: "A rule for User A", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + // User B tries to update User A's rule + const updateInput: RuleEngineRule = { + id: createdRule.id, + name: "Trying to Update", + description: "Unauthorized update", + enabled: true, + event: createdRule.event, + condition: createdRule.condition, + actions: createdRule.actions, + }; + + await expect(() => apiUserB.update(updateInput)).rejects.toThrow( + /Rule not found/, + ); + }); + + test("cannot create rule with event on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Event with other user's tag", + enabled: true, + event: { type: "tagAdded", tagId: otherUserTagId }, // Other user's tag + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, // Expect an error indicating lack of ownership + ); + }); + + test("cannot create rule with condition on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Condition with other user's tag", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "hasTag", tagId: otherUserTagId }, // Other user's tag + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, + ); + }); + + test("cannot create rule with action on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Action with other user's tag", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: otherUserTagId }], // Other user's tag + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, + ); + }); + + test("cannot create rule with event on another user's list", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's list + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Event with other user's list", + enabled: true, + event: { type: "addedToList", listId: otherUserListId }, // Other user's list + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /List not found/, + ); + }); + + test("cannot create rule with action on another user's list", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's list + + const invalidRuleInput: Omit = { + name: "Invalid Rule", + description: "Action with other user's list", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addToList", listId: otherUserListId }], // Other user's list + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /List not found/, + ); + }); + }); +}); -- cgit v1.2.3-70-g09d2