-
Notifications
You must be signed in to change notification settings - Fork 312
fix: LoRa/Channels/Bluetooth forms silently fail validation #1422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
xtantaudio
wants to merge
1
commit into
meshtastic:main
Choose a base branch
from
xtantaudio:fix/lora-channels-bluetooth-validation-schema
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
238 changes: 238 additions & 0 deletions
238
apps/web/src/components/PageComponents/Channels/Channel.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| import { create, fromBinary } from "@bufbuild/protobuf"; | ||
| import { CurrentDeviceContext, useDeviceStore } from "@core/stores"; | ||
| import { MeshClient, MeshRegistry, Protobuf } from "@meshtastic/sdk"; | ||
| import { createFakeTransport } from "@meshtastic/sdk/testing"; | ||
| import { MeshRegistryProvider } from "@meshtastic/sdk-react"; | ||
| import { render, screen, waitFor } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { Suspense } from "react"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { Channel } from "./Channel.tsx"; | ||
|
|
||
| /** | ||
| * End-to-end cover for the channel save path: the real channel form -> | ||
| * `ConfigEditor` staging -> the AdminMessage bytes that go to the radio. | ||
| * | ||
| * `ChannelSettings.uplink_enabled` is field 5 and `downlink_enabled` is field | ||
| * 6 of a proto3 message, so `false` is simply *absent* from the encoding. The | ||
| * assertions therefore decode the outgoing packet from its wire bytes: if the | ||
| * toggle never really reached the payload, the field is missing on the device | ||
| * exactly as it was in the live regression this covers. | ||
| */ | ||
|
|
||
| let deviceIdSeq = 500; | ||
|
|
||
| const PRIMARY_PSK = new Uint8Array([1]); | ||
|
|
||
| function primaryChannel( | ||
| init: Partial<{ | ||
| uplinkEnabled: boolean; | ||
| downlinkEnabled: boolean; | ||
| channelNum: number; | ||
| isMuted: boolean; | ||
| }> = {}, | ||
| ): Protobuf.Channel.Channel { | ||
| return create(Protobuf.Channel.ChannelSchema, { | ||
| index: 0, | ||
| role: Protobuf.Channel.Channel_Role.PRIMARY, | ||
| settings: create(Protobuf.Channel.ChannelSettingsSchema, { | ||
| channelNum: init.channelNum ?? 0, | ||
| psk: PRIMARY_PSK, | ||
| name: "", | ||
| id: 1234, | ||
| uplinkEnabled: init.uplinkEnabled ?? false, | ||
| downlinkEnabled: init.downlinkEnabled ?? false, | ||
| moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, { | ||
| positionPrecision: 10, | ||
| isMuted: init.isMuted ?? false, | ||
| }), | ||
| }), | ||
| }); | ||
| } | ||
|
|
||
| function setup(channel: Protobuf.Channel.Channel) { | ||
| const { transport } = createFakeTransport(); | ||
| const registry = new MeshRegistry(); | ||
| const client = new MeshClient({ transport }); | ||
| const connectionId = deviceIdSeq++; | ||
| registry.register(connectionId, client); | ||
| registry.setActive(connectionId); | ||
|
|
||
| const sent: Protobuf.Admin.AdminMessage[] = []; | ||
| let release: (() => void) | undefined; | ||
| let gate: Protobuf.Admin.AdminMessage["payloadVariant"]["case"] | undefined; | ||
|
|
||
| client.sendPacket = (async (payload: Uint8Array) => { | ||
| const admin = fromBinary(Protobuf.Admin.AdminMessageSchema, payload); | ||
| sent.push(admin); | ||
| if (gate && admin.payloadVariant.case === gate) { | ||
| gate = undefined; | ||
| await new Promise<void>((resolve) => { | ||
| release = resolve; | ||
| }); | ||
| } | ||
| return 1; | ||
| }) as never; | ||
|
|
||
| const device = useDeviceStore.getState().addDevice(connectionId); | ||
| device.addChannel(channel); | ||
| client.events.onChannelPacket.dispatch(channel); | ||
|
|
||
| render( | ||
| <CurrentDeviceContext.Provider value={{ deviceId: connectionId }}> | ||
| <MeshRegistryProvider registry={registry}> | ||
| <Suspense fallback={<div>loading</div>}> | ||
| <Channel onFormInit={() => {}} channel={channel} /> | ||
| </Suspense> | ||
| </MeshRegistryProvider> | ||
| </CurrentDeviceContext.Provider>, | ||
| ); | ||
|
|
||
| return { | ||
| client, | ||
| editor: client.config.editor, | ||
| sent, | ||
| gateOn: ( | ||
| value: Protobuf.Admin.AdminMessage["payloadVariant"]["case"], | ||
| ): void => { | ||
| gate = value; | ||
| }, | ||
| release: () => release?.(), | ||
| }; | ||
| } | ||
|
|
||
| function channelFromWire( | ||
| sent: Protobuf.Admin.AdminMessage[], | ||
| ): Protobuf.Channel.Channel | undefined { | ||
| for (const admin of sent) { | ||
| if (admin.payloadVariant.case === "setChannel") { | ||
| return admin.payloadVariant.value; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| describe("Channel settings", () => { | ||
| it("puts `uplinkEnabled: true` on the wire when the toggle is switched on", async () => { | ||
| const { editor, sent } = setup(primaryChannel()); | ||
|
|
||
| await userEvent.click(await screen.findByLabelText("Uplink Enabled")); | ||
|
|
||
| await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); | ||
| expect((await editor.commit()).status).toBe("ok"); | ||
|
|
||
| const wire = channelFromWire(sent); | ||
| expect(wire).toBeDefined(); | ||
| expect(wire?.settings?.uplinkEnabled).toBe(true); | ||
| // Untouched fields must still round-trip. | ||
| expect(wire?.index).toBe(0); | ||
| expect(wire?.role).toBe(Protobuf.Channel.Channel_Role.PRIMARY); | ||
| expect(wire?.settings?.id).toBe(1234); | ||
| expect(wire?.settings?.psk).toEqual(PRIMARY_PSK); | ||
| expect(wire?.settings?.downlinkEnabled).toBe(false); | ||
| expect(wire?.settings?.moduleSettings?.positionPrecision).toBe(10); | ||
| }); | ||
|
|
||
| it("puts `downlinkEnabled: true` on the wire when the toggle is switched on", async () => { | ||
| const { editor, sent } = setup(primaryChannel()); | ||
|
|
||
| await userEvent.click(await screen.findByLabelText("Downlink Enabled")); | ||
|
|
||
| await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); | ||
| expect((await editor.commit()).status).toBe("ok"); | ||
|
|
||
| expect(channelFromWire(sent)?.settings?.downlinkEnabled).toBe(true); | ||
| }); | ||
|
|
||
| it("does not report the toggle as saved when it was not part of the commit", async () => { | ||
| const { client, editor, sent, gateOn, release } = setup(primaryChannel()); | ||
|
|
||
| client.events.onConfigPacket.dispatch( | ||
| create(Protobuf.Config.ConfigSchema, { | ||
| payloadVariant: { | ||
| case: "lora", | ||
| value: create(Protobuf.Config.Config_LoRaConfigSchema, { region: 1 }), | ||
| }, | ||
| }), | ||
| ); | ||
| editor.setRadioSection( | ||
| "lora", | ||
| create(Protobuf.Config.Config_LoRaConfigSchema, { region: 4 }), | ||
| ); | ||
|
|
||
| // Start an unrelated save and hold it open on the closing commitEditSettings. | ||
| gateOn("commitEditSettings"); | ||
| const pending = editor.commit(); | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
|
|
||
| // The user flips "Uplink Enabled" while that save is still in flight. | ||
| await userEvent.click(await screen.findByLabelText("Uplink Enabled")); | ||
| await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); | ||
|
|
||
| release(); | ||
| expect((await pending).status).toBe("ok"); | ||
|
|
||
| // The transaction really did open and commit on the device, but it never | ||
| // carried the channel payload — so the edit has to stay pending. | ||
| expect(sent.map((a) => a.payloadVariant.case)).toEqual([ | ||
| "beginEditSettings", | ||
| "setConfig", | ||
| "commitEditSettings", | ||
| ]); | ||
| expect(channelFromWire(sent)).toBeUndefined(); | ||
| expect(editor.dirtyChannels.value).toContain(0); | ||
| expect(editor.isDirty.value).toBe(true); | ||
|
|
||
| // The next save carries it, with `uplinkEnabled` intact. | ||
| expect((await editor.commit()).status).toBe("ok"); | ||
| expect(channelFromWire(sent)?.settings?.uplinkEnabled).toBe(true); | ||
| expect(editor.isDirty.value).toBe(false); | ||
| }); | ||
| it("saves a channel carrying the deprecated channel_num slot", async () => { | ||
| // `ChannelSettings.channel_num` is a deprecated uint32 the form does not | ||
| // render. A schema that capped it at 7 made every such channel silently | ||
| // unsavable: the toggle moved, nothing was staged, and the following save | ||
| // opened a real transaction without the change in it. | ||
| const { editor, sent } = setup(primaryChannel({ channelNum: 20 })); | ||
|
|
||
| await userEvent.click(await screen.findByLabelText("Uplink Enabled")); | ||
|
|
||
| await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); | ||
| expect((await editor.commit()).status).toBe("ok"); | ||
|
|
||
| const wire = channelFromWire(sent); | ||
| expect(wire?.settings?.uplinkEnabled).toBe(true); | ||
| expect(wire?.settings?.channelNum).toBe(20); | ||
| }); | ||
|
|
||
| it("keeps the channel's mute flag when another setting is saved", async () => { | ||
| const { editor, sent } = setup(primaryChannel({ isMuted: true })); | ||
|
|
||
| await userEvent.click(await screen.findByLabelText("Uplink Enabled")); | ||
|
|
||
| await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); | ||
| expect((await editor.commit()).status).toBe("ok"); | ||
|
|
||
| const wire = channelFromWire(sent); | ||
| expect(wire?.settings?.uplinkEnabled).toBe(true); | ||
| // `is_muted` is not rendered by this form; the resolver used to drop it, | ||
| // which reset it on the device on every channel save. | ||
| expect(wire?.settings?.moduleSettings?.isMuted).toBe(true); | ||
| }); | ||
|
|
||
| it("saves a channel whose settings sub-message is absent", async () => { | ||
| const { editor, sent } = setup( | ||
| create(Protobuf.Channel.ChannelSchema, { | ||
| index: 0, | ||
| role: Protobuf.Channel.Channel_Role.PRIMARY, | ||
| }), | ||
| ); | ||
|
|
||
| await userEvent.click(await screen.findByLabelText("Uplink Enabled")); | ||
|
|
||
| await waitFor(() => expect(editor.dirtyChannels.value).toContain(0)); | ||
| expect((await editor.commit()).status).toBe("ok"); | ||
|
|
||
| expect(channelFromWire(sent)?.settings?.uplinkEnabled).toBe(true); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🌐 Web query:
For the@bufbuild/protobufversion used by this repository, does create(MessageSchema, {}) initialize an omitted protobuf bytes field as a zero-length Uint8Array?💡 Result:
Yes, for the
@bufbuild/protobuflibrary, the create(MessageSchema, {}) method initializes an omitted protobuf bytes field as a zero-length Uint8Array [1][2][3]. According to the official documentation, the default value for a Protobuf bytes field in ECMAScript is defined as new Uint8Array(0) [1][2]. When you use the create function to instantiate a message and omit a bytes field, the resulting message object will contain that field initialized to this zero-length array [2][3].Citations:
🏁 Script executed:
Repository: meshtastic/web
Length of output: 13117
🏁 Script executed:
Repository: meshtastic/web
Length of output: 39726
🏁 Script executed:
Repository: meshtastic/web
Length of output: 50370
🌐 Web query:
site:github.com/bufbuild/protobuf-es v2.13.0 create MessageSchema bytes default Uint8Array💡 Result:
In the protobuf-es library (now at v2.13.0), bytes fields are typically represented as Uint8Array [1][2]. As of recent updates—particularly those aligning with TypeScript 5.9 changes—the library has updated relevant return types to Uint8Array to ensure compatibility with modern TypeScript standards [3]. Regarding default values for bytes fields: 1. Zero-Values: In proto3, bytes fields default to an empty Uint8Array (the zero-value) [2][4]. 2. Proto2 Default Values: When using proto2, fields can have explicitly defined default values [2]. The library handles these carefully, as mutating a default Uint8Array could impact other instances of the message [2]. 3. Immutability: The library does not currently enforce deep immutability on these arrays via sealing because of the complexities involved with built-in typed arrays and their underlying ArrayBuffer access [2]. 4. Initialization: While the standard representation is Uint8Array, the runtime has been updated to accept other inputs (like number[]) in certain contexts, such as constructors, to improve developer experience [5]. If you are encountering issues with types (e.g., TS2315: Type 'Uint8Array' is not generic), ensure your project is configured with an appropriate TypeScript version or, if on an older version, set skipLibCheck: true in your tsconfig.json [3].
Citations:
Use a nonzero PSK length when regenerating a key.
When
settingsis absent,create(Protobuf.Channel.ChannelSettingsSchema, {})givespskan emptyUint8Array, sobyteCountis0.preSharedKeyRegenerate()passes0tocryptoRandomStringbecause0 ?? 16is0. The schema accepts the resulting empty PSK, andeditor.setChannel()can stage it. Use a nonzero fallback only for regeneration, while keeping zero valid for unchanged legacy saves. Add a regression test.🤖 Prompt for AI Agents