Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/database/features/groupAccess.feature
Original file line number Diff line number Diff line change
@@ -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
99 changes: 79 additions & 20 deletions packages/database/features/step-definitions/stepdefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {

type Platform = Enums<"Platform">;
type TableName = keyof Database["public"]["Tables"];
type LocalRefsType = Record<string, number | string>;
const PLATFORMS: readonly Platform[] = Constants.public.Enums.Platform;

if (getVariant() === "production") {
Expand Down Expand Up @@ -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);
Expand All @@ -75,7 +89,7 @@ Given("the database is blank", async () => {

const substituteLocalReferences = (
obj: any,
localRefs: Record<string, number>,
localRefs: LocalRefsType,
prefixValue: boolean = false,
): any => {
const substituteLocalReferencesRec = (v: any): any => {
Expand All @@ -102,7 +116,7 @@ const substituteLocalReferences = (

const substituteLocalReferencesRow = (
row: Record<string, string>,
localRefs: Record<string, number>,
localRefs: LocalRefsType,
): Record<string, any> => {
const processKV = ([k, v]: [string, any]): [string, any] => {
const isJson = k.charAt(0) === "@";
Expand Down Expand Up @@ -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<string, number>;
const localRefs = (world.localRefs || {}) as LocalRefsType;
const rows = table.hashes();
const values: Record<string, any>[] = rows.map((r) =>
substituteLocalReferencesRow(r, localRefs),
Expand Down Expand Up @@ -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<string, number>;
const localRefs = (world.localRefs || {}) as LocalRefsType;
const spaceResponse = await fetchOrCreateSpaceDirect({
password: SPACE_ANONYMOUS_PASSWORD,
url: `https://roamresearch.com/#/app/${spaceName}`,
Expand Down Expand Up @@ -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<string, number>;
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)
Expand All @@ -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<string, number>;
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)
Expand All @@ -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<string, number>;
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
Expand All @@ -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<string, number>;
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
Expand All @@ -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<string, number>;
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
Expand All @@ -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<string, number>;
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
Expand All @@ -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<string, number>;
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 });
Expand All @@ -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<string, number>;
const localRefs = (world.localRefs || {}) as LocalRefsType;
const rows = table.hashes();
const values = rows.map((r) =>
substituteLocalReferencesRow(r, localRefs),
Expand All @@ -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<string, any>).actual);
throw error;
}
})

When("user of space {word} adds space {word} to group {word}",
async (space1Name: string, space2Name:string, groupName: string): Promise<void> =>{
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);
})