Browse Source
* Zod WIP * Zod form validation * DynamicForm testing * Fix linting * Delete rasterSource.ts --------- Co-authored-by: philon- <[email protected]>pull/648/head
committed by
GitHub
61 changed files with 1780 additions and 1292 deletions
@ -0,0 +1,304 @@ |
|||
import { describe, expect, it, vi } from "vitest"; |
|||
import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx"; |
|||
import { DynamicForm } from "./DynamicForm.tsx"; |
|||
import { z } from "zod/v4"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
|
|||
vi.mock("react-i18next", () => ({ |
|||
useTranslation: () => ({ |
|||
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key), |
|||
}), |
|||
})); |
|||
|
|||
const addErrorMock = vi.fn(); |
|||
const removeErrorMock = vi.fn(); |
|||
|
|||
vi.mock("@core/stores/appStore.ts", () => ({ |
|||
useAppStore: () => ({ |
|||
addError: addErrorMock, |
|||
removeError: removeErrorMock, |
|||
}), |
|||
})); |
|||
|
|||
describe("DynamicForm", () => { |
|||
const schema = z.object({ |
|||
name: z.string().min(3, { message: "Too short" }), |
|||
}); |
|||
|
|||
const fieldGroups = [ |
|||
{ |
|||
label: "Test Group", |
|||
description: "Testing validation", |
|||
fields: [ |
|||
{ |
|||
type: "text", |
|||
id: "name", |
|||
name: "name", |
|||
label: "Name", |
|||
description: "Enter your name", |
|||
properties: {}, |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
|
|||
it("shows validation error when input is too short", async () => { |
|||
render( |
|||
<DynamicForm<z.infer<typeof schema>> |
|||
onSubmit={vi.fn()} |
|||
validationSchema={schema} |
|||
defaultValues={{ name: "" }} |
|||
fieldGroups={fieldGroups} |
|||
/>, |
|||
); |
|||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|||
|
|||
fireEvent.input(input, { target: { value: "ab" } }); |
|||
|
|||
const error = await screen.findByText( |
|||
"formValidation.tooSmall.string", |
|||
); |
|||
expect(error).toBeVisible(); |
|||
}); |
|||
|
|||
it("clears validation error when input becomes valid", async () => { |
|||
render( |
|||
<DynamicForm<z.infer<typeof schema>> |
|||
onSubmit={vi.fn()} |
|||
validationSchema={schema} |
|||
defaultValues={{ name: "" }} |
|||
fieldGroups={fieldGroups} |
|||
/>, |
|||
); |
|||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|||
|
|||
fireEvent.input(input, { target: { value: "ab" } }); |
|||
expect( |
|||
await screen.findByText("formValidation.tooSmall.string"), |
|||
).toBeVisible(); |
|||
|
|||
fireEvent.input(input, { target: { value: "abcd" } }); |
|||
await waitFor(() => |
|||
expect( |
|||
screen.queryByText("formValidation.tooSmall.string"), |
|||
).toBeNull() |
|||
); |
|||
}); |
|||
|
|||
it("calls onSubmit when form is valid onChange", async () => { |
|||
const onSubmit = vi.fn(); |
|||
render( |
|||
<DynamicForm<z.infer<typeof schema>> |
|||
onSubmit={onSubmit} |
|||
validationSchema={schema} |
|||
defaultValues={{ name: "" }} |
|||
fieldGroups={fieldGroups} |
|||
/>, |
|||
); |
|||
|
|||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|||
|
|||
fireEvent.input(input, { target: { value: "ab" } }); |
|||
expect( |
|||
await screen.findByText("formValidation.tooSmall.string"), |
|||
).toBeVisible(); |
|||
|
|||
fireEvent.input(input, { target: { value: "abcd" } }); |
|||
|
|||
await waitFor(() => { |
|||
expect(onSubmit).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
expect(onSubmit).toHaveBeenCalledWith( |
|||
{ name: "abcd" }, |
|||
expect.any(Object), |
|||
); |
|||
}); |
|||
|
|||
it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => { |
|||
const onSubmit = vi.fn(); |
|||
render( |
|||
<DynamicForm<z.infer<typeof schema>> |
|||
onSubmit={onSubmit} |
|||
submitType="onSubmit" |
|||
hasSubmitButton |
|||
validationSchema={schema} |
|||
defaultValues={{ name: "" }} |
|||
fieldGroups={fieldGroups} |
|||
/>, |
|||
); |
|||
|
|||
const btn = screen.getByRole("button", { name: /submit/i }); |
|||
expect(btn).toBeInTheDocument(); |
|||
|
|||
fireEvent.input(screen.getByLabelText("Name"), { target: { value: "ab" } }); |
|||
await screen.findByText("formValidation.tooSmall.string"); |
|||
fireEvent.click(btn); |
|||
expect(onSubmit).not.toHaveBeenCalled(); |
|||
|
|||
fireEvent.input(screen.getByLabelText("Name"), { |
|||
target: { value: "abcd" }, |
|||
}); |
|||
await waitFor(() => |
|||
expect(screen.queryByText("formValidation.tooSmall.string")).toBeNull() |
|||
); |
|||
fireEvent.click(btn); |
|||
|
|||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); |
|||
expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object)); |
|||
}); |
|||
|
|||
it("renders defaultValues correctly", () => { |
|||
render( |
|||
<DynamicForm<{ name: string }> |
|||
onSubmit={vi.fn()} |
|||
// no validationSchema
|
|||
defaultValues={{ name: "Alice" }} |
|||
fieldGroups={[ |
|||
{ |
|||
label: "Group", |
|||
description: "", |
|||
fields: [ |
|||
{ |
|||
type: "text", |
|||
name: "name", |
|||
label: "Name", |
|||
description: "", |
|||
properties: {}, |
|||
}, |
|||
], |
|||
}, |
|||
]} |
|||
/>, |
|||
); |
|||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|||
expect(input.value).toBe("Alice"); |
|||
}); |
|||
|
|||
it("toggles disabled state based on disabledBy rules", async () => { |
|||
const schema = z.object({ |
|||
enable: z.boolean(), |
|||
follow: z.string(), |
|||
}); |
|||
render( |
|||
<DynamicForm<z.infer<typeof schema>> |
|||
onSubmit={vi.fn()} |
|||
validationSchema={schema} |
|||
defaultValues={{ enable: false, follow: "" }} |
|||
fieldGroups={[ |
|||
{ |
|||
label: "Group", |
|||
description: "", |
|||
fields: [ |
|||
{ |
|||
type: "toggle", |
|||
name: "enable", |
|||
label: "enable", |
|||
description: "", |
|||
}, |
|||
{ |
|||
type: "text", |
|||
name: "follow", |
|||
label: "follow", |
|||
description: "", |
|||
disabledBy: [{ fieldName: "enable" }], |
|||
properties: {}, |
|||
}, |
|||
], |
|||
}, |
|||
]} |
|||
/>, |
|||
); |
|||
const enable = screen.getByRole("switch", { |
|||
name: "enable", |
|||
}) as HTMLInputElement; |
|||
|
|||
const follow = screen.getByLabelText("follow") as HTMLInputElement; |
|||
await waitFor(() => { |
|||
expect(enable.getAttribute("aria-checked")).toBe("false"); |
|||
expect(follow).toBeDisabled(); |
|||
}); |
|||
|
|||
fireEvent.click(enable); |
|||
await waitFor(() => { |
|||
expect(enable.getAttribute("aria-checked")).toBe("true"); |
|||
expect(follow).not.toBeDisabled(); |
|||
}); |
|||
}); |
|||
|
|||
it("always calls onSubmit onChange when no validationSchema is provided", async () => { |
|||
const onSubmit = vi.fn(); |
|||
render( |
|||
<DynamicForm<{ foo: string }> |
|||
onSubmit={onSubmit} |
|||
// no validationSchema
|
|||
defaultValues={{ foo: "" }} |
|||
fieldGroups={[ |
|||
{ |
|||
label: "G", |
|||
description: "", |
|||
fields: [ |
|||
{ |
|||
type: "text", |
|||
name: "foo", |
|||
label: "Foo", |
|||
description: "", |
|||
properties: {}, |
|||
}, |
|||
], |
|||
}, |
|||
]} |
|||
/>, |
|||
); |
|||
const input = screen.getByLabelText("Foo") as HTMLInputElement; |
|||
fireEvent.input(input, { target: { value: "bar" } }); |
|||
|
|||
await waitFor(() => { |
|||
expect(onSubmit).toHaveBeenCalledTimes(1); |
|||
expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object)); |
|||
}); |
|||
}); |
|||
|
|||
it("syncs errors to appStore when formId is set", async () => { |
|||
const { addError, removeError } = useAppStore(); |
|||
const schema = z.object({ foo: z.string().min(2) }); |
|||
const groups = [ |
|||
{ |
|||
label: "G", |
|||
description: "", |
|||
fields: [ |
|||
{ |
|||
type: "text", |
|||
name: "foo", |
|||
label: "Foo", |
|||
description: "", |
|||
properties: {}, |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
|
|||
render( |
|||
<DynamicForm<z.infer<typeof schema>> |
|||
onSubmit={vi.fn()} |
|||
formId="myForm" |
|||
validationSchema={schema} |
|||
defaultValues={{ foo: "" }} |
|||
fieldGroups={groups} |
|||
/>, |
|||
); |
|||
const input = screen.getByLabelText("Foo") as HTMLInputElement; |
|||
|
|||
fireEvent.input(input, { target: { value: "a" } }); |
|||
await screen.findByText(/tooSmall/i); |
|||
|
|||
expect(addError).toHaveBeenCalledWith("foo", ""); |
|||
expect(addError).toHaveBeenCalledWith("myForm", ""); |
|||
|
|||
fireEvent.input(input, { target: { value: "abc" } }); |
|||
await waitFor(() => { |
|||
expect(removeError).toHaveBeenCalledWith("foo"); |
|||
expect(removeError).toHaveBeenCalledWith("myForm"); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,64 @@ |
|||
import type { ZodType } from "zod/v4"; |
|||
import type { |
|||
FieldError, |
|||
FieldValues, |
|||
Resolver, |
|||
ResolverOptions, |
|||
ResolverResult, |
|||
} from "react-hook-form"; |
|||
|
|||
export function createZodResolver<T extends FieldValues>( |
|||
schema: ZodType<T, unknown>, |
|||
): Resolver<T, unknown> { |
|||
return ( |
|||
values: T, |
|||
_context: unknown, |
|||
_options?: ResolverOptions<T>, |
|||
): ResolverResult<T> => { |
|||
const result = schema.safeParse(values); |
|||
if (result.success) { |
|||
return { |
|||
values: result.data, |
|||
errors: {}, |
|||
}; |
|||
} |
|||
|
|||
const errors: Record< |
|||
string, |
|||
FieldError & { params?: Record<string, unknown> } |
|||
> = {}; |
|||
|
|||
for (const issue of result.error.issues) { |
|||
const { path, code, message, ...params } = issue; |
|||
const key = path.join("."); |
|||
|
|||
const suffix = "format" in params |
|||
? params.format |
|||
: "origin" in params |
|||
? params.origin |
|||
: "expected" in params |
|||
? params.expected |
|||
: ""; |
|||
|
|||
const newCode = code.replace( |
|||
/_([a-z])/g, |
|||
(_, char) => char.toUpperCase(), |
|||
) + (suffix ? `.${suffix}` : ""); |
|||
|
|||
const fieldError: FieldError & { params?: Record<string, unknown> } = { |
|||
type: newCode, |
|||
message: message, |
|||
...(Object.keys(params).length ? { params } : {}), |
|||
}; |
|||
|
|||
if (!errors[key]) { |
|||
errors[key] = fieldError; |
|||
} |
|||
} |
|||
|
|||
return { |
|||
values: {} as T, |
|||
errors, |
|||
}; |
|||
}; |
|||
} |
|||
@ -1,30 +0,0 @@ |
|||
import type { SecurityAction, SecurityState } from "./types.ts"; |
|||
|
|||
export function securityReducer( |
|||
state: SecurityState, |
|||
action: SecurityAction, |
|||
): SecurityState { |
|||
switch (action.type) { |
|||
case "SET_PRIVATE_KEY": |
|||
return { ...state, privateKey: action.payload }; |
|||
case "SET_PRIVATE_KEY_BIT_COUNT": |
|||
return { ...state, privateKeyBitCount: action.payload }; |
|||
case "SET_PUBLIC_KEY": |
|||
return { ...state, publicKey: action.payload }; |
|||
case "SET_ADMIN_KEY": |
|||
return { ...state, adminKey: action.payload }; |
|||
case "SHOW_PRIVATE_KEY_DIALOG": |
|||
return { ...state, privateKeyDialogOpen: action.payload }; |
|||
case "REGENERATE_PRIV_PUB_KEY": |
|||
return { |
|||
...state, |
|||
privateKey: action.payload.privateKey, |
|||
publicKey: action.payload.publicKey, |
|||
privateKeyDialogOpen: false, |
|||
}; |
|||
case "SET_TOGGLE": |
|||
return { ...state, [action.field]: action.payload }; |
|||
default: |
|||
return state; |
|||
} |
|||
} |
|||
@ -1,35 +0,0 @@ |
|||
import { type MessageInitShape } from "@bufbuild/protobuf"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
|
|||
export interface SecurityState { |
|||
privateKey: string; |
|||
privateKeyVisible: boolean; |
|||
adminKeyVisible: [boolean, boolean, boolean]; |
|||
privateKeyBitCount: number; |
|||
publicKey: string; |
|||
adminKey: [string, string, string]; |
|||
privateKeyDialogOpen: boolean; |
|||
isManaged: boolean; |
|||
adminChannelEnabled: boolean; |
|||
debugLogApiEnabled: boolean; |
|||
serialEnabled: boolean; |
|||
} |
|||
|
|||
export type SecurityAction = |
|||
| { type: "SET_PRIVATE_KEY"; payload: string } |
|||
| { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number } |
|||
| { type: "SET_PUBLIC_KEY"; payload: string } |
|||
| { type: "SET_ADMIN_KEY"; payload: [string, string, string] } |
|||
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean } |
|||
| { type: "SET_TOGGLE"; payload: boolean; field: string } |
|||
| { |
|||
type: "REGENERATE_PRIV_PUB_KEY"; |
|||
payload: { privateKey: string; publicKey: string }; |
|||
}; |
|||
|
|||
export type SecurityConfigInit = Extract< |
|||
MessageInitShape< |
|||
typeof Protobuf.Config.ConfigSchema |
|||
>["payloadVariant"], |
|||
{ case: "security" } |
|||
>["value"]; |
|||
@ -0,0 +1,66 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
import { dotPaths } from "./dotPath.ts"; |
|||
|
|||
describe("dotPaths", () => { |
|||
it("returns flat keys for a simple object", () => { |
|||
const obj = { a: 1, b: 2, c: 3 }; |
|||
expect(dotPaths(obj)).toEqual(["a", "b", "c"]); |
|||
}); |
|||
|
|||
it("returns dot notation keys for nested objects", () => { |
|||
const obj = { a: { b: { c: 1 } }, d: 2 }; |
|||
expect(dotPaths(obj)).toEqual(["a.b.c", "d"]); |
|||
}); |
|||
|
|||
it("handles arrays at the root", () => { |
|||
const arr = [{ x: 1 }, { y: 2 }]; |
|||
expect(dotPaths(arr)).toEqual(["0.x", "1.y"]); |
|||
}); |
|||
|
|||
it("handles arrays nested in objects", () => { |
|||
const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 }; |
|||
expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]); |
|||
}); |
|||
|
|||
it("handles objects nested in arrays", () => { |
|||
const arr = [{ a: { b: 1 } }, { c: 2 }]; |
|||
expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]); |
|||
}); |
|||
|
|||
it("handles primitive values in arrays", () => { |
|||
const arr = [1, { a: 2 }, 3]; |
|||
expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]); |
|||
}); |
|||
|
|||
it("handles empty objects and arrays", () => { |
|||
expect(dotPaths({})).toEqual([]); |
|||
expect(dotPaths([])).toEqual([]); |
|||
}); |
|||
|
|||
it("handles mixed nested structures", () => { |
|||
const obj = { |
|||
a: [ |
|||
{ b: 1, c: [2, 3] }, |
|||
{ d: { e: 4 } }, |
|||
], |
|||
f: 5, |
|||
}; |
|||
expect(dotPaths(obj)).toEqual([ |
|||
"a.0.b", |
|||
"a.0.c.0", |
|||
"a.0.c.1", |
|||
"a.1.d.e", |
|||
"f", |
|||
]); |
|||
}); |
|||
|
|||
it("handles prefix argument", () => { |
|||
const obj = { a: { b: 1 } }; |
|||
expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]); |
|||
}); |
|||
|
|||
it("skips null and undefined values", () => { |
|||
const obj = { a: null, b: undefined, c: { d: 1 } }; |
|||
expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,19 @@ |
|||
export type DotPath = { [key: string]: unknown } | unknown[]; |
|||
|
|||
export const dotPaths = <T extends DotPath>( |
|||
obj: T, |
|||
prefix = "", |
|||
): string[] => { |
|||
if (Array.isArray(obj)) { |
|||
return obj.flatMap((v, i) => |
|||
v && typeof v === "object" |
|||
? dotPaths(v as DotPath, `${prefix}${i}.`) |
|||
: [`${prefix}${i}`] |
|||
); |
|||
} |
|||
return Object.entries(obj).flatMap(([k, v]) => |
|||
v && typeof v === "object" |
|||
? dotPaths(v as DotPath, `${prefix}${k}.`) |
|||
: [`${prefix}${k}`] |
|||
); |
|||
}; |
|||
@ -0,0 +1,141 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
import { makeChannelSchema } from "./channel.ts"; |
|||
import { fromByteArray } from "base64-js"; |
|||
|
|||
const mockRole = 0; |
|||
|
|||
function makeBase64OfLength(len: number): string { |
|||
return fromByteArray(new Uint8Array(len)); |
|||
} |
|||
|
|||
describe("makeChannelSchema", () => { |
|||
const allowedBytes = 16; |
|||
const schema = makeChannelSchema(allowedBytes); |
|||
|
|||
const validBase64 = makeBase64OfLength(allowedBytes); |
|||
|
|||
const validSettings = { |
|||
channelNum: 3, |
|||
psk: validBase64, |
|||
name: "TestName", |
|||
id: 3, |
|||
uplinkEnabled: true, |
|||
downlinkEnabled: false, |
|||
moduleSettings: { positionPrecision: 10 }, |
|||
}; |
|||
|
|||
it("accepts valid channel object", () => { |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: validSettings, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("rejects invalid base64 psk", () => { |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: { ...validSettings, psk: "not_base64!" }, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect( |
|||
result.error.issues.some((i) => |
|||
i.path.includes("settings") && i.path.includes("psk") |
|||
), |
|||
).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("rejects psk of wrong length", () => { |
|||
const wrongLength = makeBase64OfLength(8); |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: { ...validSettings, psk: wrongLength }, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect( |
|||
result.error.issues.some((i) => |
|||
i.path.includes("settings") && i.path.includes("psk") |
|||
), |
|||
).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("rejects name longer than 12 bytes", () => { |
|||
const longName = "a".repeat(13); |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: { ...validSettings, name: longName }, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect( |
|||
result.error.issues.some((i) => |
|||
i.path.includes("settings") && i.path.includes("name") |
|||
), |
|||
).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("rejects channelNum out of range", () => { |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: { ...validSettings, channelNum: 10 }, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect( |
|||
result.error.issues.some((i) => |
|||
i.path.includes("settings") && i.path.includes("channelNum") |
|||
), |
|||
).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("rejects missing required fields", () => { |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: {}, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues.length).toBeGreaterThan(0); |
|||
} |
|||
}); |
|||
|
|||
it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => { |
|||
for (const val of [0, 10, 15, 19, 32]) { |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: { |
|||
...validSettings, |
|||
moduleSettings: { positionPrecision: val }, |
|||
}, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("rejects moduleSettings.positionPrecision out of range", () => { |
|||
for (const val of [9, 20, 31, 33]) { |
|||
const result = schema.safeParse({ |
|||
index: 0, |
|||
settings: { |
|||
...validSettings, |
|||
moduleSettings: { positionPrecision: val }, |
|||
}, |
|||
role: mockRole, |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
} |
|||
}); |
|||
}); |
|||
@ -1,51 +1,38 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { |
|||
IsBoolean, |
|||
IsEnum, |
|||
IsInt, |
|||
IsNumber, |
|||
IsString, |
|||
Length, |
|||
} from "class-validator"; |
|||
|
|||
export class ChannelValidation |
|||
implements Omit<Protobuf.Channel.Channel, keyof Message | "settings"> { |
|||
@IsNumber() |
|||
index: number; |
|||
|
|||
settings: Channel_SettingsValidation; |
|||
|
|||
@IsEnum(Protobuf.Channel.Channel_Role) |
|||
role: Protobuf.Channel.Channel_Role; |
|||
} |
|||
|
|||
export class Channel_SettingsValidation |
|||
implements Omit<Protobuf.Channel.ChannelSettings, keyof Message | "psk"> { |
|||
@IsNumber() |
|||
channelNum: number; |
|||
|
|||
@IsString() |
|||
psk: string; |
|||
|
|||
@Length(0, 11) |
|||
name: string; |
|||
|
|||
@IsInt() |
|||
id: number; |
|||
|
|||
@IsBoolean() |
|||
uplinkEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
downlinkEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
positionEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
preciseLocation: boolean; |
|||
|
|||
@IsInt() |
|||
positionPrecision: number; |
|||
import { makePskHelpers } from "./pskSchema.ts"; |
|||
import { validateMaxByteLength } from "@core/utils/string.ts"; |
|||
|
|||
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role); |
|||
|
|||
const moduleSettingsSchema = z.object({ |
|||
positionPrecision: z.union([ |
|||
z.literal(0), |
|||
z.coerce.number().int().min(10).max(19), |
|||
z.literal(32), |
|||
]), |
|||
}); |
|||
|
|||
export function makeChannelSchema(allowedBytes: number) { |
|||
const { stringSchema } = makePskHelpers([allowedBytes]); |
|||
|
|||
const ChannelSettingsSchema = z.object({ |
|||
channelNum: z.coerce.number().int().min(0).max(7), |
|||
psk: stringSchema(false), |
|||
name: z.string() |
|||
.refine( |
|||
(s) => validateMaxByteLength(s, 12).isValid, |
|||
{ message: "formValidation.tooBig.bytes", params: { maximum: 12 } }, |
|||
), |
|||
id: z.coerce.number().int(), |
|||
uplinkEnabled: z.boolean(), |
|||
downlinkEnabled: z.boolean(), |
|||
moduleSettings: moduleSettingsSchema, |
|||
}); |
|||
|
|||
return z.object({ |
|||
index: z.coerce.number(), |
|||
settings: ChannelSettingsSchema, |
|||
role: RoleEnum, |
|||
}); |
|||
} |
|||
|
|||
@ -1,18 +1,14 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|||
|
|||
export class BluetoothValidation implements |
|||
Omit< |
|||
Protobuf.Config.Config_BluetoothConfig, |
|||
keyof Message | "deviceLoggingEnabled" |
|||
> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
const PairingModeEnum = z.enum( |
|||
Protobuf.Config.Config_BluetoothConfig_PairingMode, |
|||
); |
|||
|
|||
@IsEnum(Protobuf.Config.Config_BluetoothConfig_PairingMode) |
|||
mode: Protobuf.Config.Config_BluetoothConfig_PairingMode; |
|||
export const BluetoothValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
mode: PairingModeEnum, |
|||
fixedPin: z.coerce.number().int().min(100000).max(999999), |
|||
}); |
|||
|
|||
@IsInt() |
|||
fixedPin: number; |
|||
} |
|||
export type BluetoothValidation = z.infer<typeof BluetoothValidationSchema>; |
|||
|
|||
@ -1,42 +1,25 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator"; |
|||
|
|||
export class DeviceValidation |
|||
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message> { |
|||
@IsEnum(Protobuf.Config.Config_DeviceConfig_Role) |
|||
role: Protobuf.Config.Config_DeviceConfig_Role; |
|||
|
|||
@IsBoolean() |
|||
serialEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
debugLogEnabled: boolean; |
|||
|
|||
@IsInt() |
|||
buttonGpio: number; |
|||
|
|||
@IsInt() |
|||
buzzerGpio: number; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode) |
|||
rebroadcastMode: Protobuf.Config.Config_DeviceConfig_RebroadcastMode; |
|||
|
|||
@IsInt() |
|||
nodeInfoBroadcastSecs: number; |
|||
|
|||
@IsBoolean() |
|||
doubleTapAsButtonPress: boolean; |
|||
|
|||
@IsBoolean() |
|||
isManaged: boolean; |
|||
|
|||
@IsBoolean() |
|||
disableTripleClick: boolean; |
|||
|
|||
@IsBoolean() |
|||
ledHeartbeatDisabled: boolean; |
|||
|
|||
@IsString() |
|||
tzdef: string; |
|||
} |
|||
const RoleEnum = z.enum( |
|||
Protobuf.Config.Config_DeviceConfig_Role, |
|||
); |
|||
const RebroadcastModeEnum = z.enum( |
|||
Protobuf.Config.Config_DeviceConfig_RebroadcastMode, |
|||
); |
|||
|
|||
export const DeviceValidationSchema = z.object({ |
|||
role: RoleEnum, |
|||
serialEnabled: z.boolean(), |
|||
buttonGpio: z.coerce.number().int().min(0), |
|||
buzzerGpio: z.coerce.number().int().min(0), |
|||
rebroadcastMode: RebroadcastModeEnum, |
|||
nodeInfoBroadcastSecs: z.coerce.number().int().min(0), |
|||
doubleTapAsButtonPress: z.boolean(), |
|||
isManaged: z.boolean(), |
|||
disableTripleClick: z.boolean(), |
|||
ledHeartbeatDisabled: z.boolean(), |
|||
tzdef: z.string().max(65), |
|||
}); |
|||
|
|||
export type DeviceValidation = z.infer<typeof DeviceValidationSchema>; |
|||
|
|||
@ -1,39 +1,35 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|||
|
|||
export class DisplayValidation |
|||
implements Omit<Protobuf.Config.Config_DisplayConfig, keyof Message> { |
|||
@IsInt() |
|||
screenOnSecs: number; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat) |
|||
gpsFormat: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat; |
|||
|
|||
@IsInt() |
|||
autoScreenCarouselSecs: number; |
|||
|
|||
@IsBoolean() |
|||
compassNorthTop: boolean; |
|||
|
|||
@IsBoolean() |
|||
flipScreen: boolean; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayUnits) |
|||
units: Protobuf.Config.Config_DisplayConfig_DisplayUnits; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_DisplayConfig_OledType) |
|||
oled: Protobuf.Config.Config_DisplayConfig_OledType; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayMode) |
|||
displaymode: Protobuf.Config.Config_DisplayConfig_DisplayMode; |
|||
|
|||
@IsBoolean() |
|||
headingBold: boolean; |
|||
|
|||
@IsBoolean() |
|||
wakeOnTapOrMotion: boolean; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation) |
|||
compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation; |
|||
} |
|||
const GpsCoordinateEnum = z.enum( |
|||
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat, |
|||
); |
|||
const DisplayUnitsEnum = z.enum( |
|||
Protobuf.Config.Config_DisplayConfig_DisplayUnits, |
|||
); |
|||
const OledTypeEnum = z.enum( |
|||
Protobuf.Config.Config_DisplayConfig_OledType, |
|||
); |
|||
const DisplayModeEnum = z.enum( |
|||
Protobuf.Config.Config_DisplayConfig_DisplayMode, |
|||
); |
|||
const CompassOrientationEnum = z.enum( |
|||
Protobuf.Config.Config_DisplayConfig_CompassOrientation, |
|||
); |
|||
|
|||
export const DisplayValidationSchema = z.object({ |
|||
screenOnSecs: z.coerce.number().int().min(0), |
|||
gpsFormat: GpsCoordinateEnum, |
|||
autoScreenCarouselSecs: z.coerce.number().int().min(0), |
|||
compassNorthTop: z.boolean(), |
|||
flipScreen: z.boolean(), |
|||
units: DisplayUnitsEnum, |
|||
oled: OledTypeEnum, |
|||
displaymode: DisplayModeEnum, |
|||
headingBold: z.boolean(), |
|||
wakeOnTapOrMotion: z.boolean(), |
|||
compassOrientation: CompassOrientationEnum, |
|||
use12hClock: z.boolean(), |
|||
}); |
|||
|
|||
export type DisplayValidation = z.infer<typeof DisplayValidationSchema>; |
|||
|
|||
@ -1,65 +1,31 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator"; |
|||
|
|||
export class LoRaValidation |
|||
implements |
|||
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled"> { |
|||
@IsBoolean() |
|||
usePreset: boolean; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_LoRaConfig_ModemPreset) |
|||
modemPreset: Protobuf.Config.Config_LoRaConfig_ModemPreset; |
|||
|
|||
@IsInt() |
|||
bandwidth: number; |
|||
|
|||
@IsInt() |
|||
// @Min(7)
|
|||
@Max(12) |
|||
spreadFactor: number; |
|||
|
|||
@IsInt() |
|||
@Min(0) |
|||
@Max(10) |
|||
codingRate: number; |
|||
|
|||
@IsInt() |
|||
frequencyOffset: number; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_LoRaConfig_RegionCode) |
|||
region: Protobuf.Config.Config_LoRaConfig_RegionCode; |
|||
|
|||
@IsInt() |
|||
@Min(1) |
|||
@Max(7) |
|||
hopLimit: number; |
|||
|
|||
@IsBoolean() |
|||
txEnabled: boolean; |
|||
|
|||
@IsInt() |
|||
@Min(0) |
|||
txPower: number; |
|||
|
|||
@IsInt() |
|||
channelNum: number; |
|||
|
|||
@IsBoolean() |
|||
overrideDutyCycle: boolean; |
|||
|
|||
@IsBoolean() |
|||
sx126xRxBoostedGain: boolean; |
|||
|
|||
@IsInt() |
|||
overrideFrequency: number; |
|||
|
|||
@IsArray() |
|||
ignoreIncoming: number[]; |
|||
|
|||
@IsBoolean() |
|||
ignoreMqtt: boolean; |
|||
|
|||
@IsBoolean() |
|||
configOkToMqtt: boolean; |
|||
} |
|||
const ModemPresetEnum = z.enum( |
|||
Protobuf.Config.Config_LoRaConfig_ModemPreset, |
|||
); |
|||
const RegionCodeEnum = z.enum( |
|||
Protobuf.Config.Config_LoRaConfig_RegionCode, |
|||
); |
|||
|
|||
export const LoRaValidationSchema = z.object({ |
|||
usePreset: z.boolean(), |
|||
modemPreset: ModemPresetEnum, |
|||
bandwidth: z.coerce.number().int(), |
|||
spreadFactor: z.coerce.number().int().max(12), |
|||
codingRate: z.coerce.number().int().min(0).max(10), |
|||
frequencyOffset: z.coerce.number().int(), |
|||
region: RegionCodeEnum, |
|||
hopLimit: z.coerce.number().int().min(0).max(7), |
|||
txEnabled: z.boolean(), |
|||
txPower: z.coerce.number().int().min(0), |
|||
channelNum: z.coerce.number().int(), |
|||
overrideDutyCycle: z.boolean(), |
|||
sx126xRxBoostedGain: z.boolean(), |
|||
overrideFrequency: z.coerce.number().int(), |
|||
ignoreIncoming: z.coerce.number().array(), |
|||
ignoreMqtt: z.boolean(), |
|||
configOkToMqtt: z.boolean(), |
|||
}); |
|||
|
|||
export type LoRaValidation = z.infer<typeof LoRaValidationSchema>; |
|||
|
|||
@ -1,30 +1,30 @@ |
|||
import { z } from "zod"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
|
|||
const AddressModeEnum = z.nativeEnum( |
|||
const AddressModeEnum = z.enum( |
|||
Protobuf.Config.Config_NetworkConfig_AddressMode, |
|||
); |
|||
const ProtocolFlagsEnum = z.nativeEnum( |
|||
const ProtocolFlagsEnum = z.enum( |
|||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags, |
|||
); |
|||
|
|||
export const NetworkValidationIpV4ConfigSchema = z.object({ |
|||
ip: z.string().ip(), |
|||
gateway: z.string().ip(), |
|||
subnet: z.string().ip(), |
|||
dns: z.string().ip(), |
|||
ip: z.ipv4(), |
|||
gateway: z.ipv4(), |
|||
subnet: z.ipv4(), |
|||
dns: z.ipv4(), |
|||
}); |
|||
|
|||
export const NetworkValidationSchema = z.object({ |
|||
wifiEnabled: z.boolean(), |
|||
wifiSsid: z.string().min(0).max(33).optional(), |
|||
wifiPsk: z.string().min(0).max(64).optional(), |
|||
ntpServer: z.string().min(2).max(30), |
|||
wifiSsid: z.string().max(33), |
|||
wifiPsk: z.string().max(64), |
|||
ntpServer: z.string().min(2).max(33), |
|||
ethEnabled: z.boolean(), |
|||
addressMode: AddressModeEnum, |
|||
ipv4Config: NetworkValidationIpV4ConfigSchema.optional(), |
|||
ipv4Config: NetworkValidationIpV4ConfigSchema, |
|||
enabledProtocols: ProtocolFlagsEnum, |
|||
rsyslogServer: z.string(), |
|||
rsyslogServer: z.string().max(33), |
|||
}); |
|||
|
|||
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>; |
|||
|
|||
@ -1,44 +1,22 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|||
|
|||
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"]; |
|||
|
|||
export class PositionValidation implements |
|||
Omit< |
|||
Protobuf.Config.Config_PositionConfig, |
|||
keyof Message | (typeof DeprecatedPositionValidationFields)[number] |
|||
> { |
|||
@IsInt() |
|||
positionBroadcastSecs: number; |
|||
|
|||
@IsBoolean() |
|||
positionBroadcastSmartEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
fixedPosition: boolean; |
|||
|
|||
@IsInt() |
|||
gpsUpdateInterval: number; |
|||
|
|||
@IsInt() |
|||
positionFlags: number; |
|||
|
|||
@IsInt() |
|||
rxGpio: number; |
|||
|
|||
@IsInt() |
|||
txGpio: number; |
|||
|
|||
@IsInt() |
|||
broadcastSmartMinimumDistance: number; |
|||
|
|||
@IsInt() |
|||
broadcastSmartMinimumIntervalSecs: number; |
|||
|
|||
@IsInt() |
|||
gpsEnGpio: number; |
|||
|
|||
@IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode) |
|||
gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode; |
|||
} |
|||
const GpsModeEnum = z.enum( |
|||
Protobuf.Config.Config_PositionConfig_GpsMode, |
|||
); |
|||
|
|||
export const PositionValidationSchema = z.object({ |
|||
positionBroadcastSecs: z.coerce.number().int().min(0), |
|||
positionBroadcastSmartEnabled: z.boolean(), |
|||
fixedPosition: z.boolean(), |
|||
gpsUpdateInterval: z.coerce.number().int().min(0), |
|||
positionFlags: z.coerce.number().int().min(0), |
|||
rxGpio: z.coerce.number().int().min(0), |
|||
txGpio: z.coerce.number().int().min(0), |
|||
broadcastSmartMinimumDistance: z.coerce.number().int().min(0), |
|||
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0), |
|||
gpsEnGpio: z.coerce.number().int().min(0), |
|||
gpsMode: GpsModeEnum, |
|||
}); |
|||
|
|||
export type PositionValidation = z.infer<typeof PositionValidationSchema>; |
|||
|
|||
@ -1,35 +1,14 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator"; |
|||
|
|||
export class PowerValidation implements |
|||
Omit< |
|||
Protobuf.Config.Config_PowerConfig, |
|||
keyof Message | "powermonEnables" |
|||
> { |
|||
@IsBoolean() |
|||
isPowerSaving: boolean; |
|||
|
|||
@IsInt() |
|||
onBatteryShutdownAfterSecs: number; |
|||
|
|||
@IsNumber() |
|||
@Min(2) |
|||
@Max(4) |
|||
adcMultiplierOverride: number; |
|||
|
|||
@IsInt() |
|||
waitBluetoothSecs: number; |
|||
|
|||
@IsInt() |
|||
sdsSecs: number; |
|||
|
|||
@IsInt() |
|||
lsSecs: number; |
|||
|
|||
@IsInt() |
|||
minWakeSecs: number; |
|||
|
|||
@IsInt() |
|||
deviceBatteryInaAddress: number; |
|||
} |
|||
import { z } from "zod/v4"; |
|||
|
|||
export const PowerValidationSchema = z.object({ |
|||
isPowerSaving: z.boolean(), |
|||
onBatteryShutdownAfterSecs: z.coerce.number().int().min(0), |
|||
adcMultiplierOverride: z.coerce.number().min(0).max(4), |
|||
waitBluetoothSecs: z.coerce.number().int().min(0), |
|||
sdsSecs: z.coerce.number().int().min(0), |
|||
lsSecs: z.coerce.number().int().min(0), |
|||
minWakeSecs: z.coerce.number().int().min(0), |
|||
deviceBatteryInaAddress: z.coerce.number().int().min(0), |
|||
}); |
|||
|
|||
export type PowerValidation = z.infer<typeof PowerValidationSchema>; |
|||
|
|||
@ -0,0 +1,113 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
import { fromByteArray } from "base64-js"; |
|||
import { ParsedSecuritySchema, RawSecuritySchema } from "./security.ts"; |
|||
|
|||
function makeBase64OfLength(len: number): string { |
|||
return fromByteArray(new Uint8Array(len)); |
|||
} |
|||
|
|||
describe("RawSecuritySchema", () => { |
|||
const validKey = makeBase64OfLength(32); |
|||
|
|||
it("accepts valid security config", () => { |
|||
const result = RawSecuritySchema.safeParse({ |
|||
isManaged: false, |
|||
adminChannelEnabled: true, |
|||
debugLogApiEnabled: false, |
|||
serialEnabled: true, |
|||
privateKey: validKey, |
|||
publicKey: validKey, |
|||
adminKey: [validKey, "", ""], |
|||
}); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("rejects if privateKey is invalid", () => { |
|||
const result = RawSecuritySchema.safeParse({ |
|||
isManaged: false, |
|||
adminChannelEnabled: true, |
|||
debugLogApiEnabled: false, |
|||
serialEnabled: true, |
|||
privateKey: "badkey", |
|||
publicKey: validKey, |
|||
adminKey: [validKey, "", ""], |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues.some((i) => i.path.includes("privateKey"))) |
|||
.toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("requires at least one adminKey if isManaged", () => { |
|||
const result = RawSecuritySchema.safeParse({ |
|||
isManaged: true, |
|||
adminChannelEnabled: true, |
|||
debugLogApiEnabled: false, |
|||
serialEnabled: true, |
|||
privateKey: validKey, |
|||
publicKey: validKey, |
|||
adminKey: ["", "", ""], |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect( |
|||
result.error.issues.some((i) => |
|||
i.message === "formValidation.adminKeyRequiredWhenManaged" |
|||
), |
|||
).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("accepts if at least one adminKey is valid when isManaged", () => { |
|||
const result = RawSecuritySchema.safeParse({ |
|||
isManaged: true, |
|||
adminChannelEnabled: true, |
|||
debugLogApiEnabled: false, |
|||
serialEnabled: true, |
|||
privateKey: validKey, |
|||
publicKey: validKey, |
|||
adminKey: [validKey, "", ""], |
|||
}); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
}); |
|||
|
|||
describe("ParsedSecuritySchema", () => { |
|||
const validKey = new Uint8Array(32); |
|||
|
|||
it("accepts valid parsed security config", () => { |
|||
const result = ParsedSecuritySchema.safeParse({ |
|||
isManaged: false, |
|||
adminChannelEnabled: true, |
|||
debugLogApiEnabled: false, |
|||
serialEnabled: true, |
|||
privateKey: validKey, |
|||
publicKey: validKey, |
|||
adminKey: [validKey, new Uint8Array(), new Uint8Array()], |
|||
}); |
|||
console.log(result); |
|||
|
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("requires at least one adminKey if isManaged", () => { |
|||
const result = ParsedSecuritySchema.safeParse({ |
|||
isManaged: true, |
|||
adminChannelEnabled: true, |
|||
debugLogApiEnabled: false, |
|||
serialEnabled: true, |
|||
privateKey: validKey, |
|||
publicKey: validKey, |
|||
adminKey: [new Uint8Array(), new Uint8Array(), new Uint8Array()], |
|||
}); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect( |
|||
result.error.issues.some((i) => |
|||
i.message === "formValidation.adminKeyRequiredWhenManaged" |
|||
), |
|||
).toBe(true); |
|||
} |
|||
}); |
|||
}); |
|||
@ -1,36 +1,47 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsString } from "class-validator"; |
|||
|
|||
export class SecurityValidation implements |
|||
Omit< |
|||
Protobuf.Config.Config_SecurityConfig, |
|||
| keyof Message |
|||
| "adminKey" |
|||
| "privateKey" |
|||
| "publicKey" |
|||
> { |
|||
@IsBoolean() |
|||
adminChannelEnabled: boolean; |
|||
|
|||
@IsString() |
|||
adminKey: [string, string, string]; |
|||
|
|||
@IsBoolean() |
|||
bluetoothLoggingEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
debugLogApiEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
isManaged: boolean; |
|||
|
|||
@IsString() |
|||
privateKey: string; |
|||
import { z, ZodType } from "zod/v4"; |
|||
import { makePskHelpers } from "./../pskSchema.ts"; |
|||
|
|||
const { |
|||
stringSchema, |
|||
bytesSchema, |
|||
isValidKey, |
|||
} = makePskHelpers([32]); // 256-bit
|
|||
|
|||
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged"; |
|||
|
|||
function makeSecuritySchema<KeyT>( |
|||
keyMaker: (optional: boolean) => ZodType<KeyT>, |
|||
) { |
|||
return z |
|||
.object({ |
|||
isManaged: z.boolean(), |
|||
adminChannelEnabled: z.boolean(), |
|||
debugLogApiEnabled: z.boolean(), |
|||
serialEnabled: z.boolean(), |
|||
|
|||
privateKey: keyMaker(false), |
|||
publicKey: keyMaker(false), |
|||
adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]), |
|||
}) |
|||
.check((ctx) => { |
|||
if (ctx.value.isManaged) { |
|||
const hasAdmin = ctx.value.adminKey.some(isValidKey); |
|||
if (!hasAdmin) { |
|||
for (const path of [["isManaged"], ["adminKey", 0]] as const) { |
|||
ctx.issues.push({ |
|||
code: "custom", |
|||
message: isManagedRequiredMsg, |
|||
path: [...path], |
|||
input: ctx.value, |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@IsString() |
|||
publicKey: string; |
|||
export const RawSecuritySchema = makeSecuritySchema(stringSchema); |
|||
export type RawSecurity = z.infer<typeof RawSecuritySchema>; |
|||
|
|||
@IsBoolean() |
|||
serialEnabled: boolean; |
|||
} |
|||
export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema); |
|||
export type ParsedSecurity = z.infer<typeof ParsedSecuritySchema>; |
|||
|
|||
@ -1,24 +1,13 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt } from "class-validator"; |
|||
|
|||
export class AmbientLightingValidation implements |
|||
Omit< |
|||
Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig, |
|||
keyof Message |
|||
> { |
|||
@IsBoolean() |
|||
ledState: boolean; |
|||
|
|||
@IsInt() |
|||
current: number; |
|||
|
|||
@IsInt() |
|||
red: number; |
|||
|
|||
@IsInt() |
|||
green: number; |
|||
|
|||
@IsInt() |
|||
blue: number; |
|||
} |
|||
import { z } from "zod/v4"; |
|||
|
|||
export const AmbientLightingValidationSchema = z.object({ |
|||
ledState: z.boolean(), |
|||
current: z.coerce.number().int().min(0), |
|||
red: z.coerce.number().int().min(0).max(255), |
|||
green: z.coerce.number().int().min(0).max(255), |
|||
blue: z.coerce.number().int().min(0).max(255), |
|||
}); |
|||
|
|||
export type AmbientLightingValidation = z.infer< |
|||
typeof AmbientLightingValidationSchema |
|||
>; |
|||
|
|||
@ -1,28 +1,18 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|||
|
|||
export class AudioValidation |
|||
implements |
|||
Omit<Protobuf.ModuleConfig.ModuleConfig_AudioConfig, keyof Message> { |
|||
@IsBoolean() |
|||
codec2Enabled: boolean; |
|||
const Audio_BaudEnum = z.enum( |
|||
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud, |
|||
); |
|||
|
|||
@IsInt() |
|||
pttPin: number; |
|||
export const AudioValidationSchema = z.object({ |
|||
codec2Enabled: z.boolean(), |
|||
pttPin: z.coerce.number().int().min(0), |
|||
bitrate: Audio_BaudEnum, |
|||
i2sWs: z.coerce.number().int().min(0), |
|||
i2sSd: z.coerce.number().int().min(0), |
|||
i2sDin: z.coerce.number().int().min(0), |
|||
i2sSck: z.coerce.number().int().min(0), |
|||
}); |
|||
|
|||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud) |
|||
bitrate: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud; |
|||
|
|||
@IsInt() |
|||
i2sWs: number; |
|||
|
|||
@IsInt() |
|||
i2sSd: number; |
|||
|
|||
@IsInt() |
|||
i2sDin: number; |
|||
|
|||
@IsInt() |
|||
i2sSck: number; |
|||
} |
|||
export type AudioValidation = z.infer<typeof AudioValidationSchema>; |
|||
|
|||
@ -1,45 +1,24 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsEnum, IsInt, Length } from "class-validator"; |
|||
|
|||
export class CannedMessageValidation implements |
|||
Omit< |
|||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig, |
|||
keyof Message |
|||
> { |
|||
@IsBoolean() |
|||
rotary1Enabled: boolean; |
|||
|
|||
@IsInt() |
|||
inputbrokerPinA: number; |
|||
|
|||
@IsInt() |
|||
inputbrokerPinB: number; |
|||
|
|||
@IsInt() |
|||
inputbrokerPinPress: number; |
|||
|
|||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) |
|||
inputbrokerEventCw: |
|||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; |
|||
|
|||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) |
|||
inputbrokerEventCcw: |
|||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; |
|||
|
|||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) |
|||
inputbrokerEventPress: |
|||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; |
|||
|
|||
@IsBoolean() |
|||
updown1Enabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@Length(2, 30) |
|||
allowInputSource: string; |
|||
|
|||
@IsBoolean() |
|||
sendBell: boolean; |
|||
} |
|||
const InputEventCharEnum = z.enum( |
|||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar, |
|||
); |
|||
|
|||
export const CannedMessageValidationSchema = z.object({ |
|||
rotary1Enabled: z.boolean(), |
|||
inputbrokerPinA: z.coerce.number().int().min(0), |
|||
inputbrokerPinB: z.coerce.number().int().min(0), |
|||
inputbrokerPinPress: z.coerce.number().int().min(0), |
|||
inputbrokerEventCw: InputEventCharEnum, |
|||
inputbrokerEventCcw: InputEventCharEnum, |
|||
inputbrokerEventPress: InputEventCharEnum, |
|||
updown1Enabled: z.boolean(), |
|||
enabled: z.boolean(), |
|||
allowInputSource: z.string().max(30), |
|||
sendBell: z.boolean(), |
|||
}); |
|||
|
|||
export type CannedMessageValidation = z.infer< |
|||
typeof CannedMessageValidationSchema |
|||
>; |
|||
|
|||
@ -1,33 +1,21 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt, Length } from "class-validator"; |
|||
|
|||
export class DetectionSensorValidation implements |
|||
Omit< |
|||
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig, |
|||
keyof Message |
|||
> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@IsInt() |
|||
minimumBroadcastSecs: number; |
|||
|
|||
@IsInt() |
|||
stateBroadcastSecs: number; |
|||
|
|||
@IsBoolean() |
|||
sendBell: boolean; |
|||
|
|||
@Length(0, 20) |
|||
name: string; |
|||
|
|||
@IsInt() |
|||
monitorPin: number; |
|||
|
|||
@IsBoolean() |
|||
detectionTriggeredHigh: boolean; |
|||
|
|||
@IsBoolean() |
|||
usePullup: boolean; |
|||
} |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
|
|||
const detectionTriggerTypeEnum = z.enum( |
|||
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType, |
|||
); |
|||
|
|||
export const DetectionSensorValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
minimumBroadcastSecs: z.coerce.number().int().min(0), |
|||
stateBroadcastSecs: z.coerce.number().int().min(0), |
|||
sendBell: z.boolean(), |
|||
name: z.string().min(0).max(20), |
|||
monitorPin: z.coerce.number().int().min(0), |
|||
detectionTriggerType: detectionTriggerTypeEnum, |
|||
usePullup: z.boolean(), |
|||
}); |
|||
|
|||
export type DetectionSensorValidation = z.infer< |
|||
typeof DetectionSensorValidationSchema |
|||
>; |
|||
|
|||
@ -1,54 +1,23 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt } from "class-validator"; |
|||
|
|||
export class ExternalNotificationValidation implements |
|||
Omit< |
|||
Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig, |
|||
keyof Message |
|||
> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@IsInt() |
|||
outputMs: number; |
|||
|
|||
@IsInt() |
|||
output: number; |
|||
|
|||
@IsInt() |
|||
outputVibra: number; |
|||
|
|||
@IsInt() |
|||
outputBuzzer: number; |
|||
|
|||
@IsBoolean() |
|||
active: boolean; |
|||
|
|||
@IsBoolean() |
|||
alertMessage: boolean; |
|||
|
|||
@IsBoolean() |
|||
alertMessageVibra: boolean; |
|||
|
|||
@IsBoolean() |
|||
alertMessageBuzzer: boolean; |
|||
|
|||
@IsBoolean() |
|||
alertBell: boolean; |
|||
|
|||
@IsBoolean() |
|||
alertBellVibra: boolean; |
|||
|
|||
@IsBoolean() |
|||
alertBellBuzzer: boolean; |
|||
|
|||
@IsBoolean() |
|||
usePwm: boolean; |
|||
|
|||
@IsInt() |
|||
nagTimeout: number; |
|||
|
|||
@IsBoolean() |
|||
useI2sAsBuzzer: boolean; |
|||
} |
|||
import { z } from "zod/v4"; |
|||
|
|||
export const ExternalNotificationValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
outputMs: z.coerce.number().int().min(0), |
|||
output: z.coerce.number().int().min(0), |
|||
outputVibra: z.coerce.number().int().min(0), |
|||
outputBuzzer: z.coerce.number().int().min(0), |
|||
active: z.boolean(), |
|||
alertMessage: z.boolean(), |
|||
alertMessageVibra: z.boolean(), |
|||
alertMessageBuzzer: z.boolean(), |
|||
alertBell: z.boolean(), |
|||
alertBellVibra: z.boolean(), |
|||
alertBellBuzzer: z.boolean(), |
|||
usePwm: z.boolean(), |
|||
nagTimeout: z.coerce.number().int().min(0), |
|||
useI2sAsBuzzer: z.boolean(), |
|||
}); |
|||
|
|||
export type ExternalNotificationValidation = z.infer< |
|||
typeof ExternalNotificationValidationSchema |
|||
>; |
|||
|
|||
@ -1,59 +1,22 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { |
|||
IsBoolean, |
|||
IsNumber, |
|||
IsOptional, |
|||
IsString, |
|||
Length, |
|||
} from "class-validator"; |
|||
|
|||
export class MqttValidation implements |
|||
Omit< |
|||
Protobuf.ModuleConfig.ModuleConfig_MQTTConfig, |
|||
keyof Message | "mapReportSettings" |
|||
> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@Length(0, 30) |
|||
address: string; |
|||
|
|||
@Length(0, 30) |
|||
username: string; |
|||
|
|||
@Length(0, 30) |
|||
password: string; |
|||
|
|||
@IsBoolean() |
|||
encryptionEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
jsonEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
tlsEnabled: boolean; |
|||
|
|||
@IsString() |
|||
root: string; |
|||
|
|||
@IsBoolean() |
|||
proxyToClientEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
mapReportingEnabled: boolean; |
|||
|
|||
mapReportSettings: MqttValidationMapReportSettings; |
|||
} |
|||
|
|||
export class MqttValidationMapReportSettings |
|||
implements |
|||
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> { |
|||
@IsNumber() |
|||
@IsOptional() |
|||
publishIntervalSecs: number; |
|||
|
|||
@IsNumber() |
|||
@IsOptional() |
|||
positionPrecision: number; |
|||
} |
|||
import { z } from "zod/v4"; |
|||
|
|||
export const MqttValidationMapReportSettingsSchema = z.object({ |
|||
publishIntervalSecs: z.number().optional(), |
|||
positionPrecision: z.number().optional(), |
|||
}); |
|||
|
|||
export const MqttValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
address: z.string().min(0).max(30), |
|||
username: z.string().min(0).max(30), |
|||
password: z.string().min(0).max(30), |
|||
encryptionEnabled: z.boolean(), |
|||
jsonEnabled: z.boolean(), |
|||
tlsEnabled: z.boolean(), |
|||
root: z.string(), |
|||
proxyToClientEnabled: z.boolean(), |
|||
mapReportingEnabled: z.boolean(), |
|||
mapReportSettings: MqttValidationMapReportSettingsSchema, |
|||
}); |
|||
|
|||
export type MqttValidation = z.infer<typeof MqttValidationSchema>; |
|||
|
|||
@ -1,13 +1,10 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt } from "class-validator"; |
|||
import { z } from "zod/v4"; |
|||
|
|||
export class NeighborInfoValidation |
|||
implements |
|||
Omit<Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig, keyof Message> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
export const NeighborInfoValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
updateInterval: z.coerce.number().int().min(0), |
|||
}); |
|||
|
|||
@IsInt() |
|||
updateInterval: number; |
|||
} |
|||
export type NeighborInfoValidation = z.infer< |
|||
typeof NeighborInfoValidationSchema |
|||
>; |
|||
|
|||
@ -1,19 +1,10 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt } from "class-validator"; |
|||
import { z } from "zod/v4"; |
|||
|
|||
export class PaxcounterValidation |
|||
implements |
|||
Omit<Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig, keyof Message> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
export const PaxcounterValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
paxcounterUpdateInterval: z.coerce.number().int().min(0), |
|||
bleThreshold: z.coerce.number().int(), |
|||
wifiThreshold: z.coerce.number().int(), |
|||
}); |
|||
|
|||
@IsInt() |
|||
paxcounterUpdateInterval: number; |
|||
|
|||
@IsInt() |
|||
bleThreshold: number; |
|||
|
|||
@IsInt() |
|||
wifiThreshold: number; |
|||
} |
|||
export type PaxcounterValidation = z.infer<typeof PaxcounterValidationSchema>; |
|||
|
|||
@ -1,16 +1,9 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt } from "class-validator"; |
|||
import { z } from "zod/v4"; |
|||
|
|||
export class RangeTestValidation |
|||
implements |
|||
Omit<Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig, keyof Message> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
export const RangeTestValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
sender: z.coerce.number().int().min(0), |
|||
save: z.boolean(), |
|||
}); |
|||
|
|||
@IsInt() |
|||
sender: number; |
|||
|
|||
@IsBoolean() |
|||
save: boolean; |
|||
} |
|||
export type RangeTestValidation = z.infer<typeof RangeTestValidationSchema>; |
|||
|
|||
@ -1,31 +1,22 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import { z } from "zod/v4"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|||
|
|||
export class SerialValidation |
|||
implements |
|||
Omit<Protobuf.ModuleConfig.ModuleConfig_SerialConfig, keyof Message> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
echo: boolean; |
|||
|
|||
@IsInt() |
|||
rxd: number; |
|||
|
|||
@IsInt() |
|||
txd: number; |
|||
|
|||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud) |
|||
baud: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud; |
|||
|
|||
@IsInt() |
|||
timeout: number; |
|||
|
|||
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode) |
|||
mode: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode; |
|||
|
|||
@IsBoolean() |
|||
overrideConsoleSerialPort: boolean; |
|||
} |
|||
const Serial_BaudEnum = z.enum( |
|||
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud, |
|||
); |
|||
const Serial_ModeEnum = z.enum( |
|||
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode, |
|||
); |
|||
|
|||
export const SerialValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
echo: z.boolean(), |
|||
rxd: z.coerce.number().int().min(0), |
|||
txd: z.coerce.number().int().min(0), |
|||
baud: Serial_BaudEnum, |
|||
timeout: z.coerce.number().int().min(0), |
|||
mode: Serial_ModeEnum, |
|||
overrideConsoleSerialPort: z.boolean(), |
|||
}); |
|||
|
|||
export type SerialValidation = z.infer<typeof SerialValidationSchema>; |
|||
|
|||
@ -1,24 +1,13 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt } from "class-validator"; |
|||
|
|||
export class StoreForwardValidation implements |
|||
Omit< |
|||
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, |
|||
keyof Message | "isServer" |
|||
> { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
heartbeat: boolean; |
|||
|
|||
@IsInt() |
|||
records: number; |
|||
|
|||
@IsInt() |
|||
historyReturnMax: number; |
|||
|
|||
@IsInt() |
|||
historyReturnWindow: number; |
|||
} |
|||
import { z } from "zod/v4"; |
|||
|
|||
export const StoreForwardValidationSchema = z.object({ |
|||
enabled: z.boolean(), |
|||
heartbeat: z.boolean(), |
|||
records: z.coerce.number().int().min(0), |
|||
historyReturnMax: z.coerce.number().int().min(0), |
|||
historyReturnWindow: z.coerce.number().int().min(0), |
|||
}); |
|||
|
|||
export type StoreForwardValidation = z.infer< |
|||
typeof StoreForwardValidationSchema |
|||
>; |
|||
|
|||
@ -1,37 +1,18 @@ |
|||
import type { Message } from "@bufbuild/protobuf"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { IsBoolean, IsInt } from "class-validator"; |
|||
|
|||
export class TelemetryValidation |
|||
implements |
|||
Omit<Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig, keyof Message> { |
|||
@IsInt() |
|||
deviceUpdateInterval: number; |
|||
|
|||
@IsInt() |
|||
environmentUpdateInterval: number; |
|||
|
|||
@IsBoolean() |
|||
environmentMeasurementEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
environmentScreenEnabled: boolean; |
|||
|
|||
@IsBoolean() |
|||
environmentDisplayFahrenheit: boolean; |
|||
|
|||
@IsBoolean() |
|||
airQualityEnabled: boolean; |
|||
|
|||
@IsInt() |
|||
airQualityInterval: number; |
|||
|
|||
@IsBoolean() |
|||
powerMeasurementEnabled: boolean; |
|||
|
|||
@IsInt() |
|||
powerUpdateInterval: number; |
|||
|
|||
@IsBoolean() |
|||
powerScreenEnabled: boolean; |
|||
} |
|||
import { z } from "zod/v4"; |
|||
|
|||
export const TelemetryValidationSchema = z.object({ |
|||
deviceUpdateInterval: z.coerce.number().int().min(0), |
|||
environmentUpdateInterval: z.coerce.number().int().min(0), |
|||
environmentMeasurementEnabled: z.boolean(), |
|||
environmentScreenEnabled: z.boolean(), |
|||
environmentDisplayFahrenheit: z.boolean(), |
|||
airQualityEnabled: z.boolean(), |
|||
airQualityInterval: z.coerce.number().int().min(0), |
|||
powerMeasurementEnabled: z.boolean(), |
|||
powerUpdateInterval: z.coerce.number().int().min(0), |
|||
powerScreenEnabled: z.boolean(), |
|||
}); |
|||
|
|||
export type TelemetryValidation = z.infer< |
|||
typeof TelemetryValidationSchema |
|||
>; |
|||
|
|||
@ -0,0 +1,174 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
import { makePskHelpers } from "./pskSchema.ts"; |
|||
import { fromByteArray } from "base64-js"; |
|||
|
|||
function makeBase64OfLength(len: number): string { |
|||
return fromByteArray(new Uint8Array(len)); |
|||
} |
|||
|
|||
describe("stringSchema", () => { |
|||
it("accepts valid base64 string of allowed length", () => { |
|||
const { stringSchema } = makePskHelpers([16]); |
|||
const valid = makeBase64OfLength(16); |
|||
expect(() => stringSchema().parse(valid)).not.toThrow(); |
|||
}); |
|||
|
|||
it("rejects base64 string of disallowed length", () => { |
|||
const { stringSchema, msgs } = makePskHelpers([16]); |
|||
const invalid = makeBase64OfLength(8); |
|||
const result = stringSchema().safeParse(invalid); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.length); |
|||
} |
|||
}); |
|||
|
|||
it("rejects invalid base64 string", () => { |
|||
const { stringSchema, msgs } = makePskHelpers([16]); |
|||
const result = stringSchema().safeParse("not_base64!"); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.format); |
|||
} |
|||
}); |
|||
|
|||
it("rejects empty string if not optional and 0 not allowed", () => { |
|||
const { stringSchema, msgs } = makePskHelpers([16]); |
|||
const result = stringSchema().safeParse(""); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.required); |
|||
} |
|||
}); |
|||
|
|||
it("accepts empty string if 0 is allowed", () => { |
|||
const { stringSchema } = makePskHelpers([0]); |
|||
const result = stringSchema().safeParse(""); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("accepts empty string if optional=true", () => { |
|||
const { stringSchema } = makePskHelpers([16]); |
|||
const result = stringSchema(true).safeParse(""); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("accepts all allowed lengths", () => { |
|||
const { stringSchema } = makePskHelpers([8, 16, 32]); |
|||
for (const len of [8, 16, 32]) { |
|||
const valid = makeBase64OfLength(len); |
|||
const result = stringSchema().safeParse(valid); |
|||
expect(result.success).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("accepts valid base64 string as optional when optional=true", () => { |
|||
const { stringSchema } = makePskHelpers([16]); |
|||
const valid = makeBase64OfLength(16); |
|||
const result = stringSchema(true).safeParse(valid); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("rejects base64 string with correct length but extra padding", () => { |
|||
const { stringSchema, msgs } = makePskHelpers([16]); |
|||
const valid = makeBase64OfLength(16) + "=="; |
|||
const result = stringSchema().safeParse(valid); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.format); |
|||
} |
|||
}); |
|||
|
|||
it("accepts empty string if allowedByteLengths includes 0 and optional=false", () => { |
|||
const { stringSchema } = makePskHelpers([0, 16]); |
|||
const result = stringSchema(false).safeParse(""); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("rejects base64 string with valid format but not in allowedByteLengths", () => { |
|||
const { stringSchema, msgs } = makePskHelpers([8, 32]); |
|||
const invalid = makeBase64OfLength(16); |
|||
const result = stringSchema().safeParse(invalid); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.length); |
|||
} |
|||
}); |
|||
|
|||
describe("bytesSchema", () => { |
|||
it("accepts valid byte array of allowed length", () => { |
|||
const { bytesSchema } = makePskHelpers([16]); |
|||
const valid = new Uint8Array(16); |
|||
expect(() => bytesSchema().parse(valid)).not.toThrow(); |
|||
}); |
|||
|
|||
it("rejects byte array of disallowed length", () => { |
|||
const { bytesSchema, msgs } = makePskHelpers([16]); |
|||
const invalid = new Uint8Array(8); |
|||
const result = bytesSchema().safeParse(invalid); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.length); |
|||
} |
|||
}); |
|||
|
|||
it("rejects non-Uint8Array input", () => { |
|||
const { bytesSchema } = makePskHelpers([16]); |
|||
const result = bytesSchema().safeParse([1, 2, 3]); |
|||
expect(result.success).toBe(false); |
|||
}); |
|||
|
|||
it("rejects empty array if not optional and 0 not allowed", () => { |
|||
const { bytesSchema, msgs } = makePskHelpers([16]); |
|||
const result = bytesSchema().safeParse(new Uint8Array(0)); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.required); |
|||
} |
|||
}); |
|||
|
|||
it("accepts empty array if 0 is allowed", () => { |
|||
const { bytesSchema } = makePskHelpers([0]); |
|||
const result = bytesSchema().safeParse(new Uint8Array(0)); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("accepts empty array if optional=true", () => { |
|||
const { bytesSchema } = makePskHelpers([16]); |
|||
const result = bytesSchema(true).safeParse(new Uint8Array(0)); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("accepts all allowed lengths", () => { |
|||
const { bytesSchema } = makePskHelpers([8, 16, 32]); |
|||
for (const len of [8, 16, 32]) { |
|||
const valid = new Uint8Array(len); |
|||
const result = bytesSchema().safeParse(valid); |
|||
expect(result.success).toBe(true); |
|||
} |
|||
}); |
|||
|
|||
it("accepts valid byte array as optional when optional=true", () => { |
|||
const { bytesSchema } = makePskHelpers([16]); |
|||
const valid = new Uint8Array(16); |
|||
const result = bytesSchema(true).safeParse(valid); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("accepts empty array if allowedByteLengths includes 0 and optional=false", () => { |
|||
const { bytesSchema } = makePskHelpers([0, 16]); |
|||
const result = bytesSchema(false).safeParse(new Uint8Array(0)); |
|||
expect(result.success).toBe(true); |
|||
}); |
|||
|
|||
it("rejects byte array with valid format but not in allowedByteLengths", () => { |
|||
const { bytesSchema, msgs } = makePskHelpers([8, 32]); |
|||
const invalid = new Uint8Array(16); |
|||
const result = bytesSchema().safeParse(invalid); |
|||
expect(result.success).toBe(false); |
|||
if (!result.success) { |
|||
expect(result.error.issues[0].message).toBe(msgs.length); |
|||
} |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,70 @@ |
|||
import { z, ZodType } from "zod/v4"; |
|||
import { toByteArray } from "base64-js"; |
|||
|
|||
export function makePskHelpers( |
|||
allowedByteLengths: readonly number[], |
|||
) { |
|||
const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | "); |
|||
const msgs = { |
|||
format: "formValidation.invalidFormat.key", |
|||
required: "formValidation.required.key", |
|||
length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`, |
|||
} as const; |
|||
|
|||
function tryParse(str: string): Uint8Array | null { |
|||
try { |
|||
return toByteArray(str); |
|||
} catch { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
function isValidString(str: string): boolean { |
|||
const arr = tryParse(str); |
|||
return arr !== null && |
|||
allowedByteLengths.includes(arr.byteLength); |
|||
} |
|||
|
|||
function isValidKey(v: unknown): boolean { |
|||
if (typeof v === "string") return isValidString(v); |
|||
if (v instanceof Uint8Array) { |
|||
return allowedByteLengths.includes(v.byteLength); |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
const stringSchema = (optional = false) => |
|||
z.string() |
|||
.refine((s) => |
|||
optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), { |
|||
message: msgs.required, |
|||
}) |
|||
.refine((s) => |
|||
s === "" || tryParse(s) !== null, { message: msgs.format }) |
|||
.refine((s) => |
|||
s === "" || isValidString(s), { |
|||
message: msgs.length, |
|||
params: { bits: bitsLabel }, |
|||
}); |
|||
|
|||
const bytesSchema = (optional = false): ZodType<Uint8Array> => |
|||
z.instanceof(Uint8Array) |
|||
.refine( |
|||
(arr) => |
|||
optional || arr.byteLength !== 0 || allowedByteLengths.includes(0), |
|||
{ message: msgs.required }, |
|||
) |
|||
.refine( |
|||
(arr) => optional || allowedByteLengths.includes(arr.byteLength), |
|||
{ message: msgs.length, params: { bits: bitsLabel } }, |
|||
); |
|||
|
|||
return { |
|||
allowedByteLengths, |
|||
msgs, |
|||
tryParseStringKey: tryParse, |
|||
isValidKey, |
|||
stringSchema, |
|||
bytesSchema, |
|||
}; |
|||
} |
|||
@ -1,22 +0,0 @@ |
|||
import { IsArray, IsBoolean, IsNumber, IsString, IsUrl } from "class-validator"; |
|||
|
|||
import type { RasterSource } from "@core/stores/appStore.ts"; |
|||
|
|||
export class MapValidation { |
|||
@IsArray() |
|||
rasterSources: MapValidation_RasterSources[]; |
|||
} |
|||
|
|||
export class MapValidation_RasterSources implements RasterSource { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@IsString() |
|||
title: string; |
|||
|
|||
@IsUrl() |
|||
tiles: string; |
|||
|
|||
@IsNumber() |
|||
tileSize: number; |
|||
} |
|||
Loading…
Reference in new issue