clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix/175-template-quality-pass
lofi / package / schema / private_table_test.ts
private_table_test.ts 6.1K
1// privateTable contract: every column seals by default with a derived label,2// refs and plain-marked columns stay plaintext, bytes stay plaintext with a3// report, and configurations the seal boundary cannot honor are refused.4import { encryptedColumnsOf, s } from "./mod.ts";5import {6 clearEncryptedColumnKey,7 clearEncryptedColumnRegistry,8 encryptedColumnLabelOf,9 setEncryptedColumnKey,10} from "./encrypted.ts";11import { assert } from "../runtime/test-assert.ts";1213function expectTypeError(run: () => unknown, needle: string, context: string): void {14 try {15 run();16 } catch (error) {17 assert(18 error instanceof TypeError && error.message.includes(needle),19 `${context}: expected a TypeError mentioning "${needle}", received ${String(error)}`,20 );21 return;22 }23 throw new Error(`${context}: expected a refusal, but the call succeeded`);24}2526Deno.test("privateTable seals every column type with derived labels", () => {27 clearEncryptedColumnRegistry();28 try {29 const table = s.privateTable("vault", {30 body: s.string(),31 score: s.int(),32 ratio: s.float(),33 at: s.timestamp(),34 done: s.boolean(),35 kind: s.enum("a", "b"),36 meta: s.json(),37 tags: s.array(s.string()),38 workspaceId: s.ref("workspaces"),39 title: s.plain(s.string()),40 });41 const columns = table.columns as Record<string, unknown>;42 for (const sealedName of ["body", "score", "ratio", "at", "done", "kind", "meta", "tags"]) {43 assert(44 encryptedColumnLabelOf(columns[sealedName]) === `vault.${sealedName}`,45 `column "${sealedName}" did not seal with the derived label`,46 );47 }48 assert(49 encryptedColumnLabelOf(columns.workspaceId) === undefined,50 "a ref column must stay plaintext",51 );52 assert(53 encryptedColumnLabelOf(columns.title) === undefined,54 "a plain-marked column must stay plaintext",55 );5657 // Defining an app over the table registers the sealed columns.58 s.defineApp({ vault: table } as never);59 const registered = encryptedColumnsOf("vault");60 assert(61 registered !== undefined && registered.size === 8 &&62 registered.get("body") === "vault.body",63 "privateTable columns did not register through defineApp",64 );65 } finally {66 clearEncryptedColumnRegistry();67 }68});6970Deno.test("privateTable sealed values round-trip through the transforms", () => {71 setEncryptedColumnKey(new Uint8Array(32).map((_, index) => index + 1));72 clearEncryptedColumnRegistry();73 try {74 const table = s.privateTable("roundtrip", {75 done: s.boolean(),76 kind: s.enum("draft", "final"),77 tags: s.array(s.string()),78 });79 const columns = table.columns as Record<string, {80 _transform?: { to(value: unknown): string; from(value: string): unknown };81 }>;82 const cases: ReadonlyArray<readonly [string, unknown]> = [83 ["done", true],84 ["kind", "final"],85 ["tags", ["a", "b"]],86 ];87 for (const [name, value] of cases) {88 const transform = columns[name]._transform;89 assert(transform !== undefined, `sealed column "${name}" carries no transform`);90 const stored = transform.to(value);91 assert(stored.startsWith("enc2."), `sealed column "${name}" stored an unsealed value`);92 assert(93 JSON.stringify(transform.from(stored)) === JSON.stringify(value),94 `sealed column "${name}" did not round-trip`,95 );96 }97 } finally {98 clearEncryptedColumnKey();99 clearEncryptedColumnRegistry();100 }101});102103Deno.test("privateTable refuses configurations the seal boundary cannot honor", () => {104 clearEncryptedColumnRegistry();105 try {106 expectTypeError(107 () => s.privateTable("bad", { note: s.string().optional() }),108 "optional",109 "optional column",110 );111 expectTypeError(112 () => s.privateTable("bad", { note: s.string().default("x") }),113 "default",114 "defaulted column",115 );116 // The alpha.53 legacy signatures degrade .merge()/.transform() to the117 // untyped builder, hence the casts (see the modifier note in mod.ts).118 expectTypeError(119 () => s.privateTable("bad", { tags: s.array(s.string()).merge("g-set") as never }),120 "merge",121 "non-lww merge",122 );123 expectTypeError(124 () =>125 s.privateTable("bad", {126 csv: s.string().transform({127 from: (value: string) => value.split(","),128 to: (value: string[]) => value.join(","),129 }) as never,130 }),131 "transform",132 "transformed column",133 );134 expectTypeError(() => s.privateTable(" ", { note: s.string() }), "prefix", "empty prefix");135 } finally {136 clearEncryptedColumnRegistry();137 }138});139140Deno.test("privateTable passes through author-sealed and byte columns", () => {141 clearEncryptedColumnRegistry();142 try {143 const explicit = s.encryptedText("custom.label");144 const table = s.privateTable("mixed", {145 note: explicit,146 raw: s.bytes(),147 });148 const columns = table.columns as Record<string, unknown>;149 assert(150 encryptedColumnLabelOf(columns.note) === "custom.label",151 "an author-sealed column must keep its own label",152 );153 assert(154 encryptedColumnLabelOf(columns.raw) === undefined,155 "a bytes column must stay plaintext (reported, not sealed)",156 );157 } finally {158 clearEncryptedColumnRegistry();159 }160});161162// Compile-time pins for the column mapping.163const typed = s.privateTable("typedvault", {164 body: s.string(),165 workspaceId: s.ref("workspaces"),166 title: s.plain(s.string()),167});168clearEncryptedColumnRegistry();169const typedApp = s.defineApp({ workspaces: s.table({ name: s.string() }), typedvault: typed });170clearEncryptedColumnRegistry();171172Deno.test("privateTable type mapping: sealed columns lose where, plain and refs keep it", () => {173 // Plain-marked and ref columns remain filterable.174 const byTitle = typedApp.typedvault.where({ title: "x" });175 const byRef = typedApp.typedvault.where({ workspaceId: "some-id" });176 assert(byTitle !== undefined && byRef !== undefined, "plaintext filters must stay expressible");177 // @ts-expect-error — a sealed column is excluded from where178 typedApp.typedvault.where({ body: "x" });179});