diff --git a/e2e/pocketbase-harness.ts b/e2e/pocketbase-harness.ts index 97a6d67..0508c3d 100644 --- a/e2e/pocketbase-harness.ts +++ b/e2e/pocketbase-harness.ts @@ -8,9 +8,11 @@ import * as os from "node:os"; import * as path from "node:path"; import PocketBase from "pocketbase"; import { + addUserFields, createCollection, getExistingCollectionNames, getMissingCollections, + setupApiRules, } from "../scripts/setup-db"; /** @@ -172,77 +174,6 @@ async function createAdminUser( throw lastError; } -/** - * Adds custom fields to the users collection. - */ -async function addUserFields(pb: PocketBase): Promise { - const usersCollection = await pb.collections.getOne("users"); - - // Define the custom user fields - const customFields = [ - { name: "garminConnected", type: "bool" }, - { name: "garminOauth1Token", type: "text" }, - { name: "garminOauth2Token", type: "text" }, - { name: "garminTokenExpiresAt", type: "date" }, - { name: "calendarToken", type: "text" }, - { name: "lastPeriodDate", type: "date" }, - { name: "cycleLength", type: "number" }, - { name: "notificationTime", type: "text" }, - { name: "timezone", type: "text" }, - { name: "activeOverrides", type: "json" }, - ]; - - // Get existing field names - const existingFieldNames = new Set( - (usersCollection.fields || []).map((f: { name: string }) => f.name), - ); - - // Filter to only new fields - const newFields = customFields.filter((f) => !existingFieldNames.has(f.name)); - - if (newFields.length > 0) { - // Combine existing fields with new ones - const allFields = [...(usersCollection.fields || []), ...newFields]; - - await pb.collections.update(usersCollection.id, { - fields: allFields, - }); - } -} - -/** - * Sets up API rules for collections to allow user access. - */ -async function setupApiRules(pb: PocketBase): Promise { - // Allow users to update their own user record - // viewRule allows reading user records by ID (needed for ICS calendar feed) - const usersCollection = await pb.collections.getOne("users"); - await pb.collections.update(usersCollection.id, { - viewRule: "", // Empty string = allow all authenticated & unauthenticated reads - updateRule: "id = @request.auth.id", - }); - - // Allow users to read/write their own period_logs - const periodLogs = await pb.collections.getOne("period_logs"); - await pb.collections.update(periodLogs.id, { - listRule: "user = @request.auth.id", - viewRule: "user = @request.auth.id", - createRule: "user = @request.auth.id", - updateRule: "user = @request.auth.id", - deleteRule: "user = @request.auth.id", - }); - - // Allow users to read/write their own dailyLogs - const dailyLogs = await pb.collections.getOne("dailyLogs"); - await pb.collections.update(dailyLogs.id, { - listRule: "user = @request.auth.id", - viewRule: "user = @request.auth.id", - createRule: "user = @request.auth.id", - updateRule: "user = @request.auth.id", - deleteRule: "user = @request.auth.id", - }); -} - /** * Sets up the database collections using the SDK. */ diff --git a/scripts/setup-db.test.ts b/scripts/setup-db.test.ts index 8776bed..c21f808 100644 --- a/scripts/setup-db.test.ts +++ b/scripts/setup-db.test.ts @@ -181,3 +181,51 @@ describe("USER_CUSTOM_FIELDS garmin token max lengths", () => { expect(oauth1Field?.max).toBeGreaterThanOrEqual(10000); }); }); + +describe("setupApiRules", () => { + it("configures user-owned record rules for period_logs and dailyLogs", async () => { + const { setupApiRules } = await import("./setup-db"); + + const updateMock = vi.fn().mockResolvedValue({}); + const mockPb = { + collections: { + getOne: vi.fn().mockImplementation((name: string) => { + return Promise.resolve({ id: `${name}-id`, name }); + }), + update: updateMock, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + await setupApiRules(mockPb as any); + + // Should have called getOne for users, period_logs, and dailyLogs + expect(mockPb.collections.getOne).toHaveBeenCalledWith("users"); + expect(mockPb.collections.getOne).toHaveBeenCalledWith("period_logs"); + expect(mockPb.collections.getOne).toHaveBeenCalledWith("dailyLogs"); + + // Check users collection rules + expect(updateMock).toHaveBeenCalledWith("users-id", { + viewRule: "", + updateRule: "id = @request.auth.id", + }); + + // Check period_logs collection rules + expect(updateMock).toHaveBeenCalledWith("period_logs-id", { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }); + + // Check dailyLogs collection rules + expect(updateMock).toHaveBeenCalledWith("dailyLogs-id", { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }); + }); +}); diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts index 2b3cd48..c933579 100644 --- a/scripts/setup-db.ts +++ b/scripts/setup-db.ts @@ -285,6 +285,40 @@ export async function createCollection( }); } +/** + * Sets up API rules for collections to allow user access. + * Configures row-level security so users can only access their own records. + */ +export async function setupApiRules(pb: PocketBase): Promise { + // Allow users to view any user record (needed for ICS calendar feed) + // and update only their own record + const usersCollection = await pb.collections.getOne("users"); + await pb.collections.update(usersCollection.id, { + viewRule: "", + updateRule: "id = @request.auth.id", + }); + + // Allow users to read/write their own period_logs + const periodLogs = await pb.collections.getOne("period_logs"); + await pb.collections.update(periodLogs.id, { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }); + + // Allow users to read/write their own dailyLogs + const dailyLogs = await pb.collections.getOne("dailyLogs"); + await pb.collections.update(dailyLogs.id, { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }); +} + /** * Main setup function - creates missing collections. */ @@ -337,25 +371,29 @@ async function main(): Promise { const missing = getMissingCollections(existingNames); if (missing.length === 0) { - console.log("All required collections already exist. Nothing to do."); - return; - } + console.log("All required collections already exist."); + } else { + console.log( + `Creating ${missing.length} missing collection(s):`, + missing.map((c) => c.name), + ); - console.log( - `Creating ${missing.length} missing collection(s):`, - missing.map((c) => c.name), - ); - - for (const collection of missing) { - try { - await createCollection(pb, collection); - console.log(` Created: ${collection.name}`); - } catch (error) { - console.error(` Failed to create ${collection.name}:`, error); - process.exit(1); + for (const collection of missing) { + try { + await createCollection(pb, collection); + console.log(` Created: ${collection.name}`); + } catch (error) { + console.error(` Failed to create ${collection.name}:`, error); + process.exit(1); + } } } + // Set up API rules for all collections + console.log("Configuring API rules..."); + await setupApiRules(pb); + console.log(" API rules configured."); + console.log("Database setup complete!"); }