aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers/rules.test.ts
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-04-27 00:02:20 +0100
committerGitHub <noreply@github.com>2025-04-27 00:02:20 +0100
commit136f126296af65f50da598d084d1485c0e40437a (patch)
tree2725c7932ebbcb9b48b5af98eb9b72329a400260 /packages/trpc/routers/rules.test.ts
parentca47be7fe7be128f459c37614a04902a873fe289 (diff)
downloadkarakeep-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.ts379
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/,
+ );
+ });
+ });
+});