diff --git a/packages/database/features/groupAccess.feature b/packages/database/features/groupAccess.feature new file mode 100644 index 000000000..2819cb0c9 --- /dev/null +++ b/packages/database/features/groupAccess.feature @@ -0,0 +1,43 @@ +Feature: Group content access + User story: + * As a user of the Obsidian plugin + * Logged in through a given space's anonymous account + * I want to be able to create a group including another user outside my space + * giving that user access to my private content + + Acceptance criteria: + * The second user should not have access to the content before I publish my content to the group + * The second user should have access after I publish my content to the group + + Background: + Given the database is blank + And the user user1 opens the Roam plugin in space s1 + And the user user2 opens the Roam plugin in space s2 + + Scenario: Creating content + When Document are added to the database: + | $id | source_local_id | created | last_modified | _author_id | _space_id | + | d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 | + | d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 | + And Content are added to the database: + | $id | source_local_id | _document_id | text | created | last_modified | scale | _author_id | _space_id | + | ct1 | lct1 | d1 | Claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + | ct2 | lct2 | d2 | Claim 2 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + Then a user logged in space s1 should see 2 PlatformAccount in the database + And a user logged in space s1 should see 2 Content in the database + And a user logged in space s2 should see 2 PlatformAccount in the database + But a user logged in space s2 should see 0 Content in the database + When user of space s1 creates group my_group + And user of space s1 adds space s2 to group my_group + Then a user logged in space s1 should see 2 Content in the database + But a user logged in space s2 should see 0 Content in the database + And a user logged in space s2 should see 1 Space in the database + And ResourceAccess are added to the database: + | _account_uid | _space_id | source_local_id | + | my_group | s1 | lct1 | + And SpaceAccess are added to the database: + | _account_uid | _space_id | permissions | + | my_group | s1 | partial | + Then a user logged in space s1 should see 2 Content in the database + Then a user logged in space s2 should see 1 Content in the database + And a user logged in space s2 should see 2 Space in the database diff --git a/packages/database/features/step-definitions/stepdefs.ts b/packages/database/features/step-definitions/stepdefs.ts index c7c8bb903..e0636c4c0 100644 --- a/packages/database/features/step-definitions/stepdefs.ts +++ b/packages/database/features/step-definitions/stepdefs.ts @@ -19,6 +19,7 @@ import { type Platform = Enums<"Platform">; type TableName = keyof Database["public"]["Tables"]; +type LocalRefsType = Record; const PLATFORMS: readonly Platform[] = Constants.public.Enums.Platform; if (getVariant() === "production") { @@ -64,6 +65,19 @@ Given("the database is blank", async () => { assert.equal(r.error, null); r = await client.from("AgentIdentifier").delete().neq("account_id", -1); assert.equal(r.error, null); + const r3 = await client.from("group_membership").select("group_id"); + assert.equal(r3.error, null); + const groupIds = new Set((r3.data || []).map(({group_id})=>group_id)); + for (const id of groupIds) { + const ur = await client.auth.admin.deleteUser(id); + assert.equal(ur.error, null); + } + const r2 = await client.from("PlatformAccount").select("dg_account").not('dg_account', 'is', null); + assert.equal(r2.error, null); + for (const {dg_account} of r2.data || []) { + const r = await client.auth.admin.deleteUser(dg_account!); + assert.equal(r.error, null); + } r = await client.from("PlatformAccount").delete().neq("id", -1); assert.equal(r.error, null); r = await client.from("Space").delete().neq("id", -1); @@ -75,7 +89,7 @@ Given("the database is blank", async () => { const substituteLocalReferences = ( obj: any, - localRefs: Record, + localRefs: LocalRefsType, prefixValue: boolean = false, ): any => { const substituteLocalReferencesRec = (v: any): any => { @@ -102,7 +116,7 @@ const substituteLocalReferences = ( const substituteLocalReferencesRow = ( row: Record, - localRefs: Record, + localRefs: LocalRefsType, ): Record => { const processKV = ([k, v]: [string, any]): [string, any] => { const isJson = k.charAt(0) === "@"; @@ -134,7 +148,7 @@ Given( // Columns prefixed with _ are translated back from aliases to db ids. // Columns prefixed with @ are parsed as json values. (Use @ before _) const client = getServiceClient(); - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const rows = table.hashes(); const values: Record[] = rows.map((r) => substituteLocalReferencesRow(r, localRefs), @@ -186,7 +200,7 @@ When( // assumption: turbo dev is running. TODO: Make into hooks if (PLATFORMS.indexOf(platform) < 0) throw new Error(`Platform must be one of ${PLATFORMS.join(", ")}`); - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceResponse = await fetchOrCreateSpaceDirect({ password: SPACE_ANONYMOUS_PASSWORD, url: `https://roamresearch.com/#/app/${spaceName}`, @@ -246,9 +260,9 @@ const getLoggedinDatabase = async (spaceId: number) => { Then( "a user logged in space {word} should see a {word} in the database", async (spaceName: string, tableName: TableName) => { - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; - if (spaceId === undefined) assert.fail("spaceId"); + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const client = await getLoggedinDatabase(spaceId); const response = await client .from(tableName) @@ -261,9 +275,9 @@ Then( Then( "a user logged in space {word} should see {int} {word} in the database", async (spaceName: string, expectedCount: number, tableName: TableName) => { - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; - if (spaceId === undefined) assert.fail("spaceId"); + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const client = await getLoggedinDatabase(spaceId); const response = await client .from(tableName) @@ -277,9 +291,9 @@ Given( "user {word} upserts these accounts to space {word}:", async (userName: string, spaceName: string, accountsString: string) => { const accounts = JSON.parse(accountsString) as Json; - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; - if (spaceId === undefined) assert.fail("spaceId"); + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const client = await getLoggedinDatabase(spaceId); const response = await client.rpc("upsert_accounts_in_space", { space_id_: spaceId, // eslint-disable-line @typescript-eslint/naming-convention @@ -294,9 +308,9 @@ Given( "user {word} upserts these documents to space {word}:", async (userName: string, spaceName: string, docString: string) => { const data = JSON.parse(docString) as Json; - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; - if (spaceId === undefined) assert.fail("spaceId"); + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const client = await getLoggedinDatabase(spaceId); const response = await client.rpc("upsert_documents", { v_space_id: spaceId, // eslint-disable-line @typescript-eslint/naming-convention @@ -311,11 +325,11 @@ Given( "user {word} upserts this content to space {word}:", async (userName: string, spaceName: string, docString: string) => { const data = JSON.parse(docString) as Json; - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; - if (spaceId === undefined) assert.fail("spaceId"); + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const userId = localRefs[userName]; - if (userId === undefined) assert.fail("userId"); + if (typeof userId !== "number") assert.fail("userId not a number"); const client = await getLoggedinDatabase(spaceId); const response = await client.rpc("upsert_content", { v_space_id: spaceId, // eslint-disable-line @typescript-eslint/naming-convention @@ -332,9 +346,9 @@ Given( "user {word} upserts these concepts to space {word}:", async (userName: string, spaceName: string, docString: string) => { const data = JSON.parse(docString) as Json; - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; - if (spaceId === undefined) assert.fail("spaceId"); + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const client = await getLoggedinDatabase(spaceId); const response = await client.rpc("upsert_concepts", { v_space_id: spaceId, // eslint-disable-line @typescript-eslint/naming-convention @@ -348,14 +362,14 @@ Given( "a user logged in space {word} and calling getConcepts with these parameters: {string}", async (spaceName: string, paramsJ: string) => { // params are assumed to be Json. Values prefixed with '@' are interpreted as aliases. - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const params = substituteLocalReferences( JSON.parse(paramsJ), localRefs, true, ) as object; const spaceId = localRefs[spaceName]; - if (spaceId === undefined) assert.fail("spaceId"); + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const supabase = await getLoggedinDatabase(spaceId); // note that we supply spaceId and supabase, they do not need to be part of the incoming Json const nodes = await getConcepts({ ...params, supabase, spaceId }); @@ -367,7 +381,7 @@ Given( type ObjectWithId = object & { id: number }; Then("query results should look like this", (table: DataTable) => { - const localRefs = (world.localRefs || {}) as Record; + const localRefs = (world.localRefs || {}) as LocalRefsType; const rows = table.hashes(); const values = rows.map((r) => substituteLocalReferencesRow(r, localRefs), @@ -389,3 +403,48 @@ Then("query results should look like this", (table: DataTable) => { assert.deepEqual(truncatedResults, values); } }); + +When("user of space {word} creates group {word}", async (spaceName: string, name: string) => { + const localRefs = (world.localRefs || {}) as LocalRefsType; + const spaceId = localRefs[spaceName]; + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); + const client = await getLoggedinDatabase(spaceId); + try{ + // eslint-disable-next-line @typescript-eslint/naming-convention + const response = await client.functions.invoke<{group_id: string}>("create-group", {body:{name}}); + assert.equal(response.error, null); + assert.ok(response.data?.group_id, "create-group response missing group_id"); + localRefs[name] = response.data.group_id; + world.localRefs = localRefs; + } catch (error) { + console.error((error as Record).actual); + throw error; + } +}) + +When("user of space {word} adds space {word} to group {word}", + async (space1Name: string, space2Name:string, groupName: string): Promise =>{ + const localRefs = (world.localRefs || {}) as LocalRefsType; + const space1Id = localRefs[space1Name]; + const space2Id = localRefs[space2Name]; + const groupId = localRefs[groupName]; + if (typeof space1Id !== 'number') assert.fail("space1Id not a number"); + if (typeof space2Id !== 'number') assert.fail("space2Id not a number"); + if (typeof groupId !== 'string') assert.fail("groupId not a string"); + const client2 = await getLoggedinDatabase(space2Id); + const r1 = await client2.from("PlatformAccount") + .select("dg_account") + .eq("account_local_id", spaceAnonUserEmail("Roam", space2Id)) + .maybeSingle(); + assert.equal(r1.error, null); + const memberId = r1.data?.dg_account; + assert.ok(memberId, "memberId not found for space2"); + const client1 = await getLoggedinDatabase(space1Id); + const r2 = await client1.from("group_membership").insert({ + /* eslint-disable @typescript-eslint/naming-convention */ + group_id: groupId, + member_id: memberId + /* eslint-enable @typescript-eslint/naming-convention */ + }); + assert.equal(r2.error, null); +})