Skip to content
Draft
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
38 changes: 35 additions & 3 deletions packages/gatekeeper-google/__tests__/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import {
BIGQUERY_RESOURCE, GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DOC_RESOURCE,
GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE,
GOOGLE_SHEETS_RESOURCE, IDENTITY_SCOPES, LEGACY_GRANTED_RESOURCE_URL_PATTERNS, RESOURCE_BY_KIND,
RESOURCE_SCOPES, SCOPE_DERIVED_RESOURCE_URL_PATTERNS, SUPPORTED_RESOURCES,
grantedResourceUrlPatterns, hasDriveResourceGrant, parseResourceUrl,
GOOGLE_SHEETS_RESOURCE, IDENTITY_SCOPES, LEGACY_GRANTED_RESOURCE_URL_PATTERNS,
PROVISIONAL_DOC_ID_PREFIX, RESOURCE_BY_KIND, RESOURCE_SCOPES,
SCOPE_DERIVED_RESOURCE_URL_PATTERNS, SUPPORTED_RESOURCES,
assertNoCreationOptions, grantedResourceUrlPatterns, hasDriveResourceGrant, isProvisionalDocId,
parseResourceUrl,
recordedResourceUrlPatterns, resourceUrlPatternsToOAuthScopes, resourcesCoveredByScopes,
validateResourceUrlPatterns,
} from "../src/resources";
Expand Down Expand Up @@ -57,6 +59,27 @@ describe("resource declarations", () => {
expect(new Set(patterns).size).toBe(patterns.length);
});

// The vendor half of the ResourceCreationOptions contract. A dropped option is a placement the
// user asked for and silently did not get, so every key is refused while none is accepted.
describe("creation options", () => {
it("accepts an absent or empty map", () => {
expect(() => assertNoCreationOptions(GOOGLE_DOC_RESOURCE)).not.toThrow();
expect(() => assertNoCreationOptions(GOOGLE_DOC_RESOURCE, {})).not.toThrow();
});

it("refuses every key, naming them and what creation does instead", () => {
expect(() => assertNoCreationOptions(GOOGLE_DOC_RESOURCE, {parentId: "abc", pinned: true}))
.toThrow(/accepts no options, but received: parentId, pinned\./);
expect(() => assertNoCreationOptions(GOOGLE_DOC_RESOURCE, {parentId: "abc"}))
.toThrow(/in the account's My Drive/);
});

it("refuses a key that would otherwise reach vendor code as a prototype write", () => {
expect(() => assertNoCreationOptions(GOOGLE_DOC_RESOURCE, JSON.parse('{"__proto__": "x"}')))
.toThrow(/accepts no options/);
});
});

// Adding an entry short-circuits ensureResources, so a legacy account would be treated as
// already holding a grant it never made and would never be re-prompted for consent.
it("keeps the legacy granted set frozen at Gmail, Doc and BigQuery", () => {
Expand Down Expand Up @@ -379,6 +402,15 @@ describe("parseResourceUrl", () => {
.toEqual({ kind: "doc", documentId: "DOC123" });
});

// getGatekeeperClassFor refuses to bind such a URL: the document was never created. The
// placeholder must stay recognisable through the URL grammar for that guard to fire.
it("keeps a provisional document ID recognisable", () => {
let documentId = `${PROVISIONAL_DOC_ID_PREFIX}abc`;
let target = parseResourceUrl(`https://docs.google.com/document/d/${documentId}/edit`);
expect(target).toEqual({ kind: "doc", documentId });
expect(target.kind === "doc" && isProvisionalDocId(target.documentId)).toBe(true);
});

it("extracts a spreadsheet ID", () => {
expect(parseResourceUrl("https://docs.google.com/spreadsheets/d/SHEET123/edit#gid=0"))
.toEqual({ kind: "sheets", spreadsheetId: "SHEET123" });
Expand Down
126 changes: 107 additions & 19 deletions packages/gatekeeper-google/__tests__/worker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DurableObject, RpcStub, RpcTarget } from "cloudflare:workers";
import { DurableObject, RpcStub, RpcTarget, WorkerEntrypoint } from "cloudflare:workers";
import type {
ActionDescription, ApprovalQueue, GitCache, HookController, HookDescription,
ObservationDescription,
ActionDescription, ApprovalQueue, GatekeeperUserVerifier, GitCache, HookController,
HookDescription, ObservationDescription, ResourceDescription,
} from "@gadgets/workshop-shared/gatekeeper";
import { TestGitCache } from "./test-git-cache";
import type { GoogleAccessToken } from "../src/google-api";
Expand All @@ -16,12 +16,27 @@ export class UserAccount extends DurableObject<Env> {
}
}

type GatekeeperProps = { userObjectId: string; documentId: string };
/**
* Stands in for the Google verifier an observer is checked against; props fix its verdict.
*
* An entrypoint rather than a local RpcTarget because the tracker persists the stub it is given,
* and only a persistent stub can be stored.
*/
export class TestVerifier extends WorkerEntrypoint<Env, { allowed: boolean }> {
async hasDocAccess(_documentId: string): Promise<boolean> {
return this.ctx.props.allowed;
}
}

type GatekeeperProps = { userObjectId: string; documentId: string; creation?: { title: string } };

class TestApprovalQueue extends RpcTarget implements ApprovalQueue {
actionId?: number;
readonly observations: ObservationDescription[] = [];

async authorizeObservation(_description: ObservationDescription): Promise<void> {}
async authorizeObservation(description: ObservationDescription): Promise<void> {
this.observations.push(description);
}

async getGitCache(): Promise<GitCache> {
throw new Error("Unexpected git cache access");
Expand All @@ -41,20 +56,56 @@ class TestApprovalQueue extends RpcTarget implements ApprovalQueue {
}

export class TestHooks extends DurableObject<Env> {
#gatekeeper(facetName: string) {
// `creation` makes the facet a createResource-minted provisional doc gatekeeper. Facets are
// cached by name, so every call addressing one provisional facet must pass the same creation
// (the callback re-runs after a Durable Object restart).
#gatekeeper(facetName: string, creation?: { title: string }) {
let userObjectId = this.ctx.exports.UserAccount.idFromName("test-user").toString();
let props: GatekeeperProps = creation
? { userObjectId, documentId: `provisional-${facetName}`, creation }
: { userObjectId, documentId: "doc-1" };
return this.ctx.facets.get<GoogleDocGatekeeper>(facetName, () => ({
class: this.ctx.exports.GoogleDocGatekeeperImpl({
props: { userObjectId, documentId: "doc-1" } satisfies GatekeeperProps,
}),
class: this.ctx.exports.GoogleDocGatekeeperImpl({ props }),
}));
}

async submitAppend(facetName: string, markdown: string): Promise<number> {
/** The error admitting an observer fails with, or null when they are admitted. */
async addObserver(
facetName: string, id: string, allowed: boolean, creation?: { title: string },
): Promise<string | null> {
// ctx.exports is typed from the production main module, which has no test-only entrypoint.
let testExports = this.ctx.exports as unknown as {
TestVerifier(options: { props: { allowed: boolean } }): Fetcher<GatekeeperUserVerifier>;
};
let verifier = testExports.TestVerifier({ props: { allowed } });
try {
await this.#gatekeeper(facetName, creation).addObserver(id, verifier);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}

/** Observers a content read withholds the disclosure from. */
async readContentExclusions(
facetName: string, creation?: { title: string }): Promise<string[]> {
let queue = new TestApprovalQueue();
{
using approvalQueue = new RpcStub<ApprovalQueue>(queue);
using session = await this.#gatekeeper(facetName, creation).startSession(
approvalQueue as unknown as ApprovalQueue,
) as GoogleDocSession & Disposable;
await session.getContent();
}
return queue.observations.flatMap(observation => observation.excludeObservers ?? []);
}

async submitAppend(
facetName: string, markdown: string, creation?: { title: string }): Promise<number> {
let queue = new TestApprovalQueue();
{
using approvalQueue = new RpcStub<ApprovalQueue>(queue);
using session = await this.#gatekeeper(facetName).startSession(
using session = await this.#gatekeeper(facetName, creation).startSession(
approvalQueue as unknown as ApprovalQueue,
) as GoogleDocSession & Disposable;
await session.appendText(markdown);
Expand All @@ -63,39 +114,76 @@ export class TestHooks extends DurableObject<Env> {
return queue.actionId;
}

/** Queue the creation action; null when the call was an idempotent no-op. */
async submitCreation(facetName: string, title: string): Promise<number | null> {
let queue = new TestApprovalQueue();
{
using approvalQueue = new RpcStub<ApprovalQueue>(queue);
await this.#gatekeeper(facetName, { title }).submitCreationAction(
approvalQueue as unknown as ApprovalQueue);
}
return queue.actionId ?? null;
}

/** The `lastModified` a metadata read reports, as epoch milliseconds. */
async readMetadata(facetName: string): Promise<number> {
async readMetadata(facetName: string, creation?: { title: string }): Promise<number> {
using approvalQueue = new RpcStub<ApprovalQueue>(new TestApprovalQueue());
using session = await this.#gatekeeper(facetName).startSession(
using session = await this.#gatekeeper(facetName, creation).startSession(
approvalQueue as unknown as ApprovalQueue,
) as GoogleDocSession & Disposable;
let metadata = await session.getMetadata();
return metadata.lastModified.valueOf();
}

/** The simulated document content a read reports. */
async readContent(facetName: string): Promise<string> {
async readContent(facetName: string, creation?: { title: string }): Promise<string> {
using approvalQueue = new RpcStub<ApprovalQueue>(new TestApprovalQueue());
using session = await this.#gatekeeper(facetName).startSession(
using session = await this.#gatekeeper(facetName, creation).startSession(
approvalQueue as unknown as ApprovalQueue,
) as GoogleDocSession & Disposable;
let content = await session.getContent();
return content;
}

async applyAction(facetName: string, actionId: number): Promise<string | null> {
/** The error message a content read fails with, or null if it succeeds. */
async readContentError(
facetName: string, creation?: { title: string }): Promise<string | null> {
try {
await this.readContent(facetName, creation);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}

async describeDoc(
facetName: string, creation?: { title: string }): Promise<ResourceDescription> {
return this.#gatekeeper(facetName, creation).describe();
}

async autoApprovableActions(facetName: string, creation?: { title: string }) {
return this.#gatekeeper(facetName, creation).getAutoApprovableActions();
}

async applyAction(
facetName: string, actionId: number, creation?: { title: string },
): Promise<string | null> {
try {
// The overseer always passes an action-scoped git cache with the apply call, and the
// validator (sharpened by the `Gatekeeper` interface) requires it, so the test passes a
// stand-in the same way.
await this.#gatekeeper(facetName).applyAction(actionId, new RpcStub(new TestGitCache()));
await this.#gatekeeper(facetName, creation)
.applyAction(actionId, new RpcStub(new TestGitCache()));
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}

async rejectAction(facetName: string, actionId: number): Promise<void> {
await this.#gatekeeper(facetName).rejectAction(actionId);
/** Whether the gatekeeper asked for a session restart. */
async rejectAction(
facetName: string, actionId: number, creation?: { title: string }): Promise<boolean> {
let result = await this.#gatekeeper(facetName, creation).rejectAction(actionId);
return !!(result && result.restart);
}
}
Loading
Loading