Openstatus www.openstatus.dev

add limit check api (#1806)

authored by

Thibault Le Ouay and committed by
GitHub
f979b98d 3ba6cb7f

+162 -2
+129
apps/server/src/routes/rpc/services/status-page/__tests__/status-page.test.ts
··· 671 671 .delete(pageComponent) 672 672 .where(eq(pageComponent.id, Number(data.component.id))); 673 673 }); 674 + 675 + test("returns 403 when page component limit is exceeded", async () => { 676 + // Workspace 2 is on free plan with page-components limit of 3 677 + // Create a page for workspace 2 678 + const limitTestPage = await db 679 + .insert(page) 680 + .values({ 681 + workspaceId: 2, 682 + title: `${TEST_PREFIX}-component-limit-test`, 683 + slug: `${TEST_PREFIX}-component-limit-test-slug`, 684 + description: "Page for component limit test", 685 + customDomain: "", 686 + }) 687 + .returning() 688 + .get(); 689 + 690 + // Create a monitor for workspace 2 691 + const limitTestMonitor = await db 692 + .insert(monitor) 693 + .values({ 694 + workspaceId: 2, 695 + name: `${TEST_PREFIX}-limit-monitor`, 696 + url: "https://example.com", 697 + periodicity: "1m", 698 + active: true, 699 + jobType: "http", 700 + }) 701 + .returning() 702 + .get(); 703 + 704 + // Create 3 components to hit the limit 705 + const createdComponentIds: number[] = []; 706 + for (let i = 0; i < 3; i++) { 707 + const component = await db 708 + .insert(pageComponent) 709 + .values({ 710 + workspaceId: 2, 711 + pageId: limitTestPage.id, 712 + type: "static", 713 + name: `${TEST_PREFIX}-limit-component-${i}`, 714 + order: i, 715 + }) 716 + .returning() 717 + .get(); 718 + createdComponentIds.push(component.id); 719 + } 720 + 721 + try { 722 + // Try to add a 4th component - should fail with PermissionDenied 723 + const res = await connectRequest( 724 + "AddMonitorComponent", 725 + { 726 + pageId: String(limitTestPage.id), 727 + monitorId: String(limitTestMonitor.id), 728 + name: `${TEST_PREFIX}-limit-exceeded-component`, 729 + }, 730 + { "x-openstatus-key": "2" }, 731 + ); 732 + 733 + expect(res.status).toBe(403); // PermissionDenied 734 + 735 + const data = await res.json(); 736 + expect(data.message).toContain("Upgrade for more page components"); 737 + } finally { 738 + // Clean up 739 + for (const id of createdComponentIds) { 740 + await db.delete(pageComponent).where(eq(pageComponent.id, id)); 741 + } 742 + await db.delete(monitor).where(eq(monitor.id, limitTestMonitor.id)); 743 + await db.delete(page).where(eq(page.id, limitTestPage.id)); 744 + } 745 + }); 674 746 }); 675 747 676 748 describe("StatusPageService.AddStaticComponent", () => { ··· 720 792 ); 721 793 722 794 expect(res.status).toBe(404); 795 + }); 796 + 797 + test("returns 403 when page component limit is exceeded", async () => { 798 + // Workspace 2 is on free plan with page-components limit of 3 799 + // Create a page for workspace 2 800 + const limitTestPage = await db 801 + .insert(page) 802 + .values({ 803 + workspaceId: 2, 804 + title: `${TEST_PREFIX}-static-limit-test`, 805 + slug: `${TEST_PREFIX}-static-limit-test-slug`, 806 + description: "Page for static component limit test", 807 + customDomain: "", 808 + }) 809 + .returning() 810 + .get(); 811 + 812 + // Create 3 components to hit the limit 813 + const createdComponentIds: number[] = []; 814 + for (let i = 0; i < 3; i++) { 815 + const component = await db 816 + .insert(pageComponent) 817 + .values({ 818 + workspaceId: 2, 819 + pageId: limitTestPage.id, 820 + type: "static", 821 + name: `${TEST_PREFIX}-static-limit-component-${i}`, 822 + order: i, 823 + }) 824 + .returning() 825 + .get(); 826 + createdComponentIds.push(component.id); 827 + } 828 + 829 + try { 830 + // Try to add a 4th component - should fail with PermissionDenied 831 + const res = await connectRequest( 832 + "AddStaticComponent", 833 + { 834 + pageId: String(limitTestPage.id), 835 + name: `${TEST_PREFIX}-static-limit-exceeded`, 836 + description: "Should fail due to limit", 837 + }, 838 + { "x-openstatus-key": "2" }, 839 + ); 840 + 841 + expect(res.status).toBe(403); // PermissionDenied 842 + 843 + const data = await res.json(); 844 + expect(data.message).toContain("Upgrade for more page components"); 845 + } finally { 846 + // Clean up 847 + for (const id of createdComponentIds) { 848 + await db.delete(pageComponent).where(eq(pageComponent.id, id)); 849 + } 850 + await db.delete(page).where(eq(page.id, limitTestPage.id)); 851 + } 723 852 }); 724 853 }); 725 854
+9 -1
apps/server/src/routes/rpc/services/status-page/index.ts
··· 53 53 subscriberCreateFailedError, 54 54 subscriberNotFoundError, 55 55 } from "./errors"; 56 - import { checkStatusPageLimits } from "./limits"; 56 + import { checkPageComponentLimits, checkStatusPageLimits } from "./limits"; 57 57 58 58 /** 59 59 * Helper to get a status page by ID with workspace scope. ··· 319 319 async addMonitorComponent(req, ctx) { 320 320 const rpcCtx = getRpcContext(ctx); 321 321 const workspaceId = rpcCtx.workspace.id; 322 + const limits = rpcCtx.workspace.limits; 322 323 323 324 // Verify page exists and belongs to workspace 324 325 const pageData = await getPageById(Number(req.pageId), workspaceId); 325 326 if (!pageData) { 326 327 throw statusPageNotFoundError(req.pageId); 327 328 } 329 + 330 + // Check workspace limits for page components 331 + await checkPageComponentLimits(pageData.id, limits); 328 332 329 333 // Verify monitor exists and belongs to workspace 330 334 const monitorData = await getMonitorById( ··· 371 375 async addStaticComponent(req, ctx) { 372 376 const rpcCtx = getRpcContext(ctx); 373 377 const workspaceId = rpcCtx.workspace.id; 378 + const limits = rpcCtx.workspace.limits; 374 379 375 380 // Verify page exists and belongs to workspace 376 381 const pageData = await getPageById(Number(req.pageId), workspaceId); 377 382 if (!pageData) { 378 383 throw statusPageNotFoundError(req.pageId); 379 384 } 385 + 386 + // Check workspace limits for page components 387 + await checkPageComponentLimits(pageData.id, limits); 380 388 381 389 // Validate group exists if provided 382 390 if (req.groupId) {
+24 -1
apps/server/src/routes/rpc/services/status-page/limits.ts
··· 1 1 import { Code, ConnectError } from "@connectrpc/connect"; 2 2 import { count, db, eq } from "@openstatus/db"; 3 - import { page } from "@openstatus/db/src/schema"; 3 + import { page, pageComponent } from "@openstatus/db/src/schema"; 4 4 import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 5 5 6 6 /** ··· 62 62 ); 63 63 } 64 64 } 65 + 66 + /** 67 + * Check workspace limits for creating a new page component. 68 + * Throws ConnectError with PermissionDenied if limit is exceeded. 69 + */ 70 + export async function checkPageComponentLimits( 71 + pageId: number, 72 + limits: Limits, 73 + ): Promise<void> { 74 + const countResult = await db 75 + .select({ count: count() }) 76 + .from(pageComponent) 77 + .where(eq(pageComponent.pageId, pageId)) 78 + .get(); 79 + 80 + const currentCount = countResult?.count ?? 0; 81 + if (currentCount >= limits["page-components"]) { 82 + throw new ConnectError( 83 + "Upgrade for more page components", 84 + Code.PermissionDenied, 85 + ); 86 + } 87 + }