Skip to content
Merged
76 changes: 75 additions & 1 deletion packages/experimental/src/attrs.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { describe, expect, it } from "vitest";
import { ValidationError } from "./error";
import { v } from "./index";
import { attrsOf, validate, withAttrs } from "./schema";
import {
asType,
attrsOf,
omitFields,
parseFields,
rejectFields,
validate,
withAttrs,
} from "./schema";

describe("schema $attrs", () => {
it("withAttrs merges within a namespace and isolates namespaces", () => {
Expand Down Expand Up @@ -61,3 +69,69 @@ describe("schema $attrs", () => {
).toBeDefined();
});
});

describe("omitFields / rejectFields / parseFields", () => {
const dropMarked = (schema: unknown) =>
attrsOf(schema, "http")?.readonly === true;
const shape = v.object({
id: v.string(),
role: withAttrs(v.string(), "http", { readonly: true }),
meta: v.object({
note: v.string(),
secret: withAttrs(v.string(), "http", { readonly: true }),
}),
});

it("omitFields projects nested objects", () => {
const projected = asType(omitFields(shape, dropMarked));
expect(Object.keys(projected.shape as object).sort()).toEqual([
"id",
"meta",
]);
const meta = asType((projected.shape as Record<string, unknown>).meta);
expect(Object.keys(meta.shape as object)).toEqual(["note"]);
});

it("rejectFields throws on smuggled keys", () => {
expect(() =>
rejectFields(shape, { id: "1", role: "admin" }, dropMarked),
).toThrow(ValidationError);
expect(() =>
rejectFields(
shape,
{ id: "1", meta: { note: "n", secret: "s" } },
dropMarked,
),
).toThrow(/field is not allowed/);
});

it("parseFields rejects then validates the projection", () => {
expect(
parseFields(
shape,
{ id: "1", meta: { note: "hi" } },
{ reject: dropMarked, omit: dropMarked },
),
).toEqual({ id: "1", meta: { note: "hi" } });
expect(() =>
parseFields(
shape,
{ id: "1", role: "admin" },
{ reject: dropMarked, omit: dropMarked },
),
).toThrow(ValidationError);
});

it("omitFields projects through a var schema", () => {
const user = v.var("parse_user", {
schema: shape,
});
const projected = omitFields(user, dropMarked);
expect(projected.$var).toBe(true);
expect(
Object.keys(
asType((projected as { schema: unknown }).schema).shape as object,
).sort(),
).toEqual(["id", "meta"]);
});
});
5 changes: 3 additions & 2 deletions packages/experimental/src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,8 +565,9 @@ export const serve = async (
const held = await spend(token);
c.capability = { ...held, entry: c.input.call };
}
// Wire edge: reject http.serverOnly fields before the target
// validates (object validate would otherwise strip them quietly).
// Wire edge: reject http.readonly / serverOnly fields before the
// target validates (object validate would otherwise strip them
// quietly).
const inputSchema = (target as FnDefination<any, any>).$schema?.input;
if (inputSchema !== undefined) {
rejectServerOnly(inputSchema, c.input.input, c.input.call);
Expand Down
15 changes: 15 additions & 0 deletions packages/experimental/src/fn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ describe("v.fn call forms", () => {
expect(make({ ok: true })()).toEqual({ ok: true });
expect(() => make({ ok: "nope" })()).toThrow(/fnt\.split\.output/);
});

it("http.returned fields are stripped from output validation", async () => {
const { returned } = await import("./plugins/http/attrs");
const user = v.var("fnt_returned_user", {
schema: v.object({
id: v.string(),
password: returned(v.string()),
}),
});
const f = v.fn("fnt.returned", { output: user }, () => ({
id: "1",
password: "secret",
}));
expect(f()).toEqual({ id: "1" });
});
});

describe("omittable input", () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/experimental/src/fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ import {
} from "./module";
import {
asType,
attrsOf,
type InferArgs,
type InferInput,
isVar,
type OutputSchemaOf,
omitFields,
outputContract,
type TypeDefination,
validate,
Expand Down Expand Up @@ -813,7 +815,17 @@ const defineFn = (

// Only the VALIDATION half of the output contract is checked on exit -
// a `{ def }`-only output is a documented promise, never a check.
const outputValidation = outputContract(options.output).validation;
// Fields marked `http.returned` are projected out so they never leave
// the process through this fn's result (direct in-process data on the
// handler's locals is unaffected).
const rawOutputValidation = outputContract(options.output).validation;
const outputValidation =
rawOutputValidation === undefined
? undefined
: omitFields(
rawOutputValidation,
(field) => attrsOf(field, "http")?.returned === true,
);
const errorTypes = declaredErrors
? Object.fromEntries(
Object.entries(declaredErrors).map(([tag, schema]) => [
Expand Down Expand Up @@ -1196,8 +1208,10 @@ const defineFn = (
);

// Exit contracts run after the body, whether or not it was async.
// Output validation both checks AND projects (so `http.returned`
// fields / undeclared keys leave through the validated shape).
const finish = (result: unknown) => {
const afterOutput = () => {
const afterOutput = (out: unknown) => {
if (bodyRan) {
for (const name of options.provides ?? []) {
if (missing(name)) {
Expand All @@ -1208,9 +1222,9 @@ const defineFn = (
}
}
}
return result;
return out;
};
if (outputValidation === undefined) return afterOutput();
if (outputValidation === undefined) return afterOutput(result);
return thenMaybe(
validate(asType(outputValidation), result, `${key}.output`),
afterOutput,
Expand Down
5 changes: 5 additions & 0 deletions packages/experimental/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,15 @@ export {
export {
type AttrBag,
attrsOf,
type FieldPred,
type InferArgs,
type InferInput,
type InferOutput,
type InferType,
omitFields,
type ParseFieldsOptions,
parseFields,
rejectFields,
type TypeDefination,
withAttrs,
} from "./schema";
Expand Down
93 changes: 71 additions & 22 deletions packages/experimental/src/plugins/http/attrs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,91 +5,140 @@ import { asType, attrsOf, validate } from "../../schema";
import {
clientSchema,
fromJsonBody,
rejectServerOnly,
readonly,
rejectReadonly,
responseSchema,
returned,
serverOnly,
wireInput,
} from "./attrs";

describe("http field attrs", () => {
const shape = {
id: v.string(),
role: serverOnly(v.string()),
role: readonly(v.string()),
password: returned(v.string()),
meta: v.object({
note: v.string(),
secret: serverOnly(v.string()),
secret: readonly(v.string()),
hash: returned(v.string()),
}),
};

it("serverOnly writes $attrs.http", () => {
it("readonly and returned write $attrs.http", () => {
expect(attrsOf(readonly(v.string()), "http")).toEqual({
readonly: true,
});
expect(attrsOf(returned(v.string()), "http")).toEqual({
returned: true,
});
expect(attrsOf(serverOnly(v.string()), "http")).toEqual({
serverOnly: true,
});
});

it("clientSchema drops server-only fields nested", () => {
it("clientSchema drops readonly fields nested", () => {
const projected = asType(clientSchema(v.object(shape)));
expect(Object.keys(projected.shape as object).sort()).toEqual([
"id",
"meta",
"password",
]);
const meta = asType((projected.shape as Record<string, unknown>).meta);
expect(Object.keys(meta.shape as object).sort()).toEqual(["hash", "note"]);
});

it("responseSchema drops returned fields nested", () => {
const projected = asType(responseSchema(v.object(shape)));
expect(Object.keys(projected.shape as object).sort()).toEqual([
"id",
"meta",
"role",
]);
const meta = asType((projected.shape as Record<string, unknown>).meta);
expect(Object.keys(meta.shape as object)).toEqual(["note"]);
expect(Object.keys(meta.shape as object).sort()).toEqual([
"note",
"secret",
]);
});

it("rejectServerOnly fails when a server-only key is present", () => {
it("rejectReadonly fails when a readonly key is present", () => {
expect(() =>
rejectServerOnly(v.object(shape), {
rejectReadonly(v.object(shape), {
id: "1",
role: "admin",
}),
).toThrow(ValidationError);
expect(() =>
rejectServerOnly(v.object(shape), {
rejectReadonly(v.object(shape), {
id: "1",
meta: { note: "hi", secret: "x" },
}),
).toThrow(/server-only/);
).toThrow(/readonly field/);
});

it("wireInput accepts client fields and rejects smuggled server-only", () => {
it("wireInput accepts client fields and rejects smuggled readonly", () => {
expect(
wireInput(v.object(shape), {
id: "1",
meta: { note: "hi" },
password: "secret",
meta: { note: "hi", hash: "h" },
}),
).toEqual({ id: "1", meta: { note: "hi" } });
).toEqual({
id: "1",
password: "secret",
meta: { note: "hi", hash: "h" },
});
expect(() =>
wireInput(v.object(shape), { id: "1", role: "admin" }),
).toThrow(ValidationError);
});

it("in-process validate still accepts server-only fields", () => {
it("serverOnly still gates the wire like readonly", () => {
const schema = v.object({
id: v.string(),
role: serverOnly(v.string()),
});
expect(() => wireInput(schema, { id: "1", role: "admin" })).toThrow(
/readonly field/,
);
expect(wireInput(schema, { id: "1" })).toEqual({ id: "1" });
});

it("in-process validate still accepts readonly fields", () => {
expect(
validate(
asType(v.object(shape)),
{
id: "1",
role: "admin",
meta: { note: "n", secret: "s" },
password: "p",
meta: { note: "n", secret: "s", hash: "h" },
},
"user",
),
).toEqual({
id: "1",
role: "admin",
meta: { note: "n", secret: "s" },
password: "p",
meta: { note: "n", secret: "s", hash: "h" },
});
});

it("fromJsonBody runs wireInput on the request body", async () => {
const request = new Request("https://example.com", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "1", meta: { note: "ok" } }),
body: JSON.stringify({
id: "1",
password: "p",
meta: { note: "ok", hash: "h" },
}),
});
await expect(fromJsonBody(request, v.object(shape))).resolves.toEqual({
id: "1",
meta: { note: "ok" },
password: "p",
meta: { note: "ok", hash: "h" },
});

const smuggle = new Request("https://example.com", {
Expand All @@ -111,12 +160,12 @@ describe("http field attrs", () => {
);
});

it("clientSchema and rejectServerOnly recurse into union arms", () => {
it("clientSchema and rejectReadonly recurse into union arms", () => {
const schema = v.union([
v.object({
kind: v.string({ enum: ["user"] }),
id: v.string(),
role: serverOnly(v.string()),
role: readonly(v.string()),
}),
v.object({
kind: v.string({ enum: ["anon"] }),
Expand All @@ -136,8 +185,8 @@ describe("http field attrs", () => {
]);

expect(() =>
rejectServerOnly(schema, { kind: "user", id: "1", role: "admin" }),
).toThrow(/server-only/);
rejectReadonly(schema, { kind: "user", id: "1", role: "admin" }),
).toThrow(/readonly field/);
expect(wireInput(schema, { kind: "anon", token: "t" })).toEqual({
kind: "anon",
token: "t",
Expand Down
Loading
Loading