Changes
10 changed files (+268/-57)
-
-
@@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-onlyimport { describe, expect, test } from "bun:test"; import { create, type MessageInitShape } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { isSameBytes, maskMessage } from "./helpers";
-
@@ -38,50 +38,58 @@ });}); describe("maskMessage", () => { test("Should return empty message if mask is empty", () => { expect( maskMessage(DateSchema, { fields: [] }, { test("Should return every field on empty mask", () => { const masked = maskMessage( DateSchema, { fields: [] }, create(DateSchema, { year: 2000, month: 1, day: 1, } as MessageInitShape<typeof DateSchema>), ).toEqual({}); }), ); expect(masked.year).toBe(2000); expect(masked.month).toBe(1); expect(masked.day).toBe(1); }); test("Should mask field", () => { expect( maskMessage(DateSchema, { fields: [DateSchema.field.month.number] }, { const masked = maskMessage( DateSchema, { fields: [DateSchema.field.month.number] }, create(DateSchema, { year: 2000, month: 1, day: 1, } as MessageInitShape<typeof DateSchema>), ).toEqual({ month: 1, }); }), ); expect(masked.year).toBeEmpty(); expect(masked.month).toBe(1); expect(masked.day).toBeEmpty(); }); test("Should return every field on full mask", () => { expect( maskMessage( DateSchema, { fields: [ DateSchema.field.year.number, DateSchema.field.month.number, DateSchema.field.day.number, ], }, { year: 2000, month: 1, day: 1, } as MessageInitShape<typeof DateSchema>, ), ).toEqual({ year: 2000, month: 1, day: 1, }); const masked = maskMessage( DateSchema, { fields: [ DateSchema.field.year.number, DateSchema.field.month.number, DateSchema.field.day.number, ], }, create(DateSchema, { year: 2000, month: 1, day: 1, }), ); expect(masked.year).toBe(2000); expect(masked.month).toBe(1); expect(masked.day).toBe(1); }); test("Should keep $typeName", () => {
-
-
-
@@ -1,7 +1,13 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type DescMessage, type MessageInitShape } from "@bufbuild/protobuf"; import { create, clearField, type DescMessage, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; export function isSameBytes(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) {
-
@@ -19,15 +25,26 @@ }export function maskMessage< Message extends DescMessage, Data extends Readonly<MessageInitShape<Message>>, >(schema: Message, mask: { fields: readonly number[] }, message: Data): Data { const init: Data = { ...message }; Data extends MessageInitShape<Message>, >( schema: Message, mask: { fields: readonly number[] }, message: Data, ): MessageShape<Message> { if (!mask.fields.length) { return create(schema, message); } const masked = create(schema, { ...message }); for (const field of schema.fields) { if (!mask.fields.includes(field.number)) { delete init[field.localName as keyof Data]; // `create` されたオブジェクトの repeated フィールドを `undefined` にすると // ランタイムでエラーが発生するといった、 Buf が型に見た幻想のツケが回って // くるため `delete` 文や `undefined` 代入は使わずに API を使う。 clearField(masked, field); } } return init; return masked; }
-
-
-
@@ -10,6 +10,7 @@ import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js";import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { KeySchema } from "@yamori/proto/yamori/idempotency/v1/key_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb";
-
@@ -174,3 +175,29 @@ expect(listResp.result.value.workspaces).toHaveLength(2);expect(listResp.result.value.workspaces[0]!.displayName).toBe("Foo"); expect(listResp.result.value.workspaces[1]!.displayName).toBe("Foo"); }); test("Should mask output", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [WorkspaceSchema.field.displayName.number] }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.displayName).toBe("Foo"); expect(resp.result.value.workspace?.id).toBeEmpty(); expect(resp.result.value.workspace?.updateKey).toBeEmpty(); });
-
-
-
@@ -13,6 +13,7 @@ WorkspaceSchema,} from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type Context } from "../../../../types"; import { maskWorkspace } from "../workspace"; export async function create(data: Uint8Array, { db }: Context): Promise<Uint8Array> { let req: CreateRequest;
-
@@ -140,7 +141,7 @@ createMessage(CreateResponseSchema, {result: { case: "ok", value: { workspace: added, workspace: maskWorkspace(req.readMask, added), }, }, }),
-
-
-
@@ -19,6 +19,7 @@ import { DeleteRequestSchema } from "@yamori/proto/yamori/workspace/v1/delete_request_pb.js";import { DeleteResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb";
-
@@ -269,6 +270,7 @@ const deleted = await deleteWorkspace({ id: foo.result.value.workspace.id, deletionKey: foo.result.value.workspace.deletionKey, readMask: { fields: [WorkspaceSchema.field.id.number] }, }, ctx, );
-
@@ -278,6 +280,7 @@ expect.unreachable(`Expected "ok", found "${deleted.result.case}"`);} expect(deleted.result.value.workspace?.id).toEqual(foo.result.value.workspace.id); expect(deleted.result.value.workspace?.displayName).toBeEmpty(); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") {
-
-
-
@@ -10,6 +10,7 @@ import { DeleteResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_response_pb.js";import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import { maskWorkspace } from "../workspace"; function respond(payload: MessageInitShape<typeof DeleteResponseSchema>): Uint8Array { return toBinary(DeleteResponseSchema, create(DeleteResponseSchema, payload));
-
@@ -100,12 +101,15 @@ return respond({result: { case: "ok", value: { workspace: { workspace: maskWorkspace(req.readMask, { id: { value: found.id, }, displayName: found.displayName, }, updateKey: { key: found.capabilities.updateKey }, deletionKey: { key: found.capabilities.deletionKey }, workerAddKey: { key: found.capabilities.workerAddKey }, }), }, }, });
-
-
-
@@ -10,6 +10,7 @@ import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js";import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb";
-
@@ -114,3 +115,76 @@ expect(resp.result.value.workspaces[0]!.displayName).toBe("Baz");expect(resp.result.value.workspaces[1]!.displayName).toBe("Bar"); expect(resp.result.value.workspaces[2]!.displayName).toBe("Foo"); }); test("Should mask output fields", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const add = async (displayName: string) => { return fromBinary( CreateResponseSchema, await createHandler( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName, }), ), ctx, ), ); }; const foo = await add("Foo"); if (foo.result.case !== "ok") { expect.unreachable(); } await sleep(); const bar = await add("Bar"); if (bar.result.case !== "ok") { expect.unreachable(); } await sleep(); const baz = await add("Baz"); if (baz.result.case !== "ok") { expect.unreachable(); } const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.displayName.number, ], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspaces).toHaveLength(3); expect(resp.result.value.workspaces[0]!.id).not.toBeEmpty(); expect(resp.result.value.workspaces[0]!.displayName).toBe("Baz"); expect(resp.result.value.workspaces[0]!.updateKey).toBeEmpty(); expect(resp.result.value.workspaces[1]!.id).not.toBeEmpty(); expect(resp.result.value.workspaces[1]!.displayName).toBe("Bar"); expect(resp.result.value.workspaces[1]!.deletionKey).toBeEmpty(); expect(resp.result.value.workspaces[2]!.id).not.toBeEmpty(); expect(resp.result.value.workspaces[2]!.displayName).toBe("Foo"); expect(resp.result.value.workspaces[2]!.leaveDefinitions).toBeEmpty(); });
-
-
-
@@ -1,16 +1,39 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, toBinary } from "@bufbuild/protobuf"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { WorkspaceSchema, type Workspace, } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; ListRequestSchema, type ListRequest, } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type Context } from "../../../../types"; import { maskWorkspace } from "../workspace"; export async function list(_data: Uint8Array, { db }: Context): Promise<Uint8Array> { export async function list(data: Uint8Array, { db }: Context): Promise<Uint8Array> { let req: ListRequest; try { req = fromBinary(ListRequestSchema, data); } catch (error) { return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }, }), ); } try { const tx = db.transaction("workspaces", "readonly"); const index = tx.store.index("updatedAt");
-
@@ -18,7 +41,7 @@const workspaces: Workspace[] = []; for await (const cursor of index.iterate(null, "prev")) { workspaces.push( create(WorkspaceSchema, { maskWorkspace(req.readMask, { id: { value: cursor.value.id, },
-
-
-
@@ -19,6 +19,7 @@ import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js";import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { UpdateRequestSchema } from "@yamori/proto/yamori/workspace/v1/update_request_pb.js"; import { UpdateResponseSchema } from "@yamori/proto/yamori/workspace/v1/update_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb";
-
@@ -421,3 +422,47 @@ expect(workspaces.result.value.workspaces[1]?.id?.value).toBe(baz.result.value.workspace.id.value, ); }); test("Should respect read_mask", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace( { displayName: "Foo", }, ctx, ); if (foo.result.case !== "ok") { expect.unreachable(); } if (!foo.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!foo.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const updated = await updateWorkspace( { id: { value: foo.result.value.workspace.id.value, }, updateKey: foo.result.value.workspace.updateKey, displayName: "Bar", fieldMask: { paths: ["display_name"], }, readMask: { fields: [WorkspaceSchema.field.displayName.number], }, }, ctx, ); if (updated.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${updated.result.case}"`); } expect(updated.result.value.workspace?.displayName).toBe("Bar"); expect(updated.result.value.workspace?.id).toBeEmpty(); });
-
-
-
@@ -9,7 +9,8 @@ } from "@yamori/proto/yamori/workspace/v1/update_request_pb.js";import { UpdateResponseSchema } from "@yamori/proto/yamori/workspace/v1/update_response_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import { type Context, type YamoriDB } from "../../../../types"; import { maskWorkspace } from "../workspace"; function respond(payload: MessageInitShape<typeof UpdateResponseSchema>): Uint8Array { return toBinary(UpdateResponseSchema, create(UpdateResponseSchema, payload));
-
@@ -81,10 +82,10 @@ break;} } let found: any; let found: YamoriDB["workspaces"]["value"]; try { found = await db.get("workspaces", req.id.value); if (!found) { const entry = await db.get("workspaces", req.id.value); if (!entry) { return respond({ result: { case: "notFound",
-
@@ -94,6 +95,8 @@ },}, }); } found = entry; } catch (error) { return respond({ result: {
-
@@ -128,12 +131,15 @@ return respond({result: { case: "ok", value: { workspace: { workspace: maskWorkspace(req.readMask, { id: { value: found.id, }, displayName: found.displayName, }, updateKey: { key: found.capabilities.updateKey }, deletionKey: { key: found.capabilities.deletionKey }, workerAddKey: { key: found.capabilities.workerAddKey }, }), }, }, });
-
@@ -160,12 +166,15 @@ return respond({result: { case: "ok", value: { workspace: { workspace: maskWorkspace(req.readMask, { id: { value: updated.id, }, displayName: updated.displayName, }, updateKey: { key: updated.capabilities.updateKey }, deletionKey: { key: updated.capabilities.deletionKey }, workerAddKey: { key: updated.capabilities.workerAddKey }, }), }, }, });
-