diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-04-27 00:02:20 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-27 00:02:20 +0100 |
| commit | 136f126296af65f50da598d084d1485c0e40437a (patch) | |
| tree | 2725c7932ebbcb9b48b5af98eb9b72329a400260 /packages/trpc/routers/rules.test.ts | |
| parent | ca47be7fe7be128f459c37614a04902a873fe289 (diff) | |
| download | karakeep-136f126296af65f50da598d084d1485c0e40437a.tar.zst | |
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
Diffstat (limited to 'packages/trpc/routers/rules.test.ts')
| -rw-r--r-- | packages/trpc/routers/rules.test.ts | 379 |
1 files changed, 379 insertions, 0 deletions
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<CustomTestContext>(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<CustomTestContext>("create rule with valid data", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const validRuleInput: Omit<RuleEngineRule, "id"> = { + 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<CustomTestContext>("create rule fails with invalid data (no actions)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + 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<CustomTestContext>("create rule fails with invalid event (empty tagId)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + 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<CustomTestContext>("create rule fails with invalid condition (empty tagId in hasTag)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + 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<CustomTestContext>("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<CustomTestContext>("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<CustomTestContext>("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<CustomTestContext>("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<CustomTestContext>("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<CustomTestContext>("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<RuleEngineRule, "id"> = { + 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<CustomTestContext>("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<RuleEngineRule, "id"> = { + 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<CustomTestContext>("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<RuleEngineRule, "id"> = { + 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<CustomTestContext>("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<RuleEngineRule, "id"> = { + 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<CustomTestContext>("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<RuleEngineRule, "id"> = { + 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/, + ); + }); + }); +}); |
