From 087161ec1ce5762c6721f64c8d07abb0e9d78530 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 6 Oct 2026 13:53:35 +0200 Subject: [PATCH 1/2] fix(ui): check unchanged JsVar proposals on hidden-field stores A partial JsVarStore whose Go type has unexported or json:"-" fields let a WebSocket peer learn whether that hidden state was zero. jq skips the ClientCheck callback for Go-equal values, so a forged `path=null` was a silent no-op when hidden fields were zero but reached ClientCheck (alert on deny, publish on allow) when they were not, with identical browser JSON. Record at store creation whether a plain type has such fields, and for those stores check and publish unchanged proposals like changed ones. The check sees the same next value either way, so the outcome depends only on its decision. This adds no work beyond what a changing proposal already costs. --- doc/ui/jsvar.md | 4 +- lib/ui/jsvar.go | 53 +++++++++----- lib/ui/jsvar_oracle_test.go | 140 ++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 17 deletions(-) diff --git a/doc/ui/jsvar.md b/doc/ui/jsvar.md index 6dfaf4f9..a9dd2751 100644 --- a/doc/ui/jsvar.md +++ b/doc/ui/jsvar.md @@ -37,7 +37,9 @@ write, and connection behavior. `ClientCheck` is required for browser writes; nil denies them. It receives the complete tentative Go value under the store's write lock. Validate authorization through the originating Element and validate the full value, including parent and -root changes. An error or panic rolls the proposal back. The callback must only +root changes and Go fields omitted from JSON: a `null` proposal zeroes the whole +Go struct. If the type has such fields, unchanged proposals are checked and +published like changed ones. An error or panic rolls the proposal back. The callback must only inspect: do not mutate or retain tentative data, reacquire the lock, or call a store setter. Every binding receives the same JSON value; use separate stores for data with different visibility. diff --git a/lib/ui/jsvar.go b/lib/ui/jsvar.go index 27f7b4ec..3648ce70 100644 --- a/lib/ui/jsvar.go +++ b/lib/ui/jsvar.go @@ -82,11 +82,13 @@ func validateJsVarPath(path string) error { // JsVarCheck validates the complete tentative state of a browser proposal. // // A nil check denies browser writes. The check runs under the store's write lock -// after jq tentatively applies a changed value. It must only inspect next; +// after jq tentatively applies a changed value. If T has Go fields omitted from +// JSON, unchanged proposals are checked and published like changed ones. It +// must only inspect next; // an error or panic rolls the proposal back. The source can authorize a user // or session. Validate the complete value, including changes through parent -// and root paths. The check sees converted Go values, not raw browser JSON; -// see [JsVarBinding.JawsInput]. +// and root paths and Go fields omitted from JSON. The check sees converted Go +// values, not raw browser JSON; see [JsVarBinding.JawsInput]. type JsVarCheck[T any] func(source *jaws.Element, next *T, path string) error // JSONSizeCheck limits the encoded size of a tentative JsVar store value. @@ -138,6 +140,7 @@ type JsVarStore[T any] struct { locker bind.RWLocker value *T partial bool // plain JSON tree with matching jq paths and safe partial patches + hidden bool // partial tree with Go fields omitted from JSON version uint64 floor uint64 // older bindings need a root patch changes []jsVarChange @@ -167,12 +170,12 @@ func NewJsVarStore[T any](jw *jaws.Jaws, name string, locker sync.Locker, value err = jq.ErrInvalidReceiver } else { store = &JsVarStore[T]{ - jaws: jw, - name: name, - locker: bind.AsRWLocker(locker), - value: value, - partial: plainJsVarType(reflect.TypeOf(value).Elem()), + jaws: jw, + name: name, + locker: bind.AsRWLocker(locker), + value: value, } + store.partial, store.hidden = classifyJsVarType(reflect.TypeOf(value).Elem()) } } return @@ -651,7 +654,7 @@ func (binding *JsVarBinding[T]) applyProposal(elem *jaws.Element, path string, v if store.partial { // Plain trees map decoded proposals to the same jq/JSON paths. // Check the changed subtree before accepting it. - changed, err = jq.SetChecked(store.value, path, value, func() error { + check := func() error { var next any = store.value if path != "" { var getErr error @@ -663,7 +666,15 @@ func (binding *JsVarBinding[T]) applyProposal(elem *jaws.Element, path string, v return encodeErr } return store.ClientCheck(elem, store.value, path) - }) + } + if changed, err = jq.SetChecked(store.value, path, value, check); err == nil && !changed && store.hidden { + // jq skips the check for equal Go values. Whether a proposal is equal + // can depend on fields absent from browser JSON, so check and publish + // as if it changed: the check sees the same next value either way. + if err = check(); err == nil { + changed = true + } + } if changed { store.record(path) } @@ -768,8 +779,17 @@ var jsVarMarshalers = [...]reflect.Type{ // plainJsVarType is conservative about encoder paths and reference aliases. // Map branches are partial under the documented JSON-tree/no-alias contract. -func plainJsVarType(t reflect.Type) bool { - return plainJsVarTypeSeen(t, make(map[reflect.Type]bool)) +func plainJsVarType(t reflect.Type) (plain bool) { + plain, _ = classifyJsVarType(t) + return +} + +// classifyJsVarType reports whether t is plain, and whether a plain t has Go +// fields omitted from JSON. +func classifyJsVarType(t reflect.Type) (plain, hidden bool) { + plain = plainJsVarTypeSeen(t, make(map[reflect.Type]bool), &hidden) + hidden = plain && hidden + return } func plainJsVarScalar(t reflect.Type) bool { @@ -782,7 +802,7 @@ func plainJsVarScalar(t reflect.Type) bool { return false } -func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool) bool { +func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool, hidden *bool) bool { if seen[t] { return false } @@ -805,6 +825,7 @@ func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool) bool { return false } if !field.IsExported() || field.Tag.Get("json") == "-" { + *hidden = true continue } name, options, _ := strings.Cut(field.Tag.Get("json"), ",") @@ -829,15 +850,15 @@ func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool) bool { return false } } - if !plainJsVarTypeSeen(field.Type, seen) { + if !plainJsVarTypeSeen(field.Type, seen, hidden) { return false } } return true case reflect.Map: - return t.Key() == reflect.TypeFor[string]() && plainJsVarTypeSeen(t.Elem(), seen) + return t.Key() == reflect.TypeFor[string]() && plainJsVarTypeSeen(t.Elem(), seen, hidden) case reflect.Array: - return plainJsVarTypeSeen(t.Elem(), seen) + return plainJsVarTypeSeen(t.Elem(), seen, hidden) } return false } diff --git a/lib/ui/jsvar_oracle_test.go b/lib/ui/jsvar_oracle_test.go index 460c37f4..ce31b9cf 100644 --- a/lib/ui/jsvar_oracle_test.go +++ b/lib/ui/jsvar_oracle_test.go @@ -5,6 +5,7 @@ package ui import ( "encoding/json" "errors" + "reflect" "strings" "sync" "testing" @@ -129,3 +130,142 @@ func TestJsVarStoreEmbeddedAliasNoOpProposalRejects(t *testing.T) { }) } } + +type oraclePartialProfile struct { + Name string `json:"name"` + Secret string `json:"-"` + pin int +} + +type oracleVisibleProfile struct { + Name string `json:"name"` +} + +type oraclePartialState struct { + Profile oraclePartialProfile `json:"profile"` + Users map[string]oraclePartialProfile `json:"users"` +} + +type oraclePartialOutcome struct { + denied bool + checks int + sourcePatches []string + peerPatches []string +} + +func probeJsVarPartialHidden(t *testing.T, state oraclePartialState, check JsVarCheck[oraclePartialState], proposal string) (outcome oraclePartialOutcome, after oraclePartialState) { + t.Helper() + jw, source := newLiveJsVarOracleRequest(t) + peer := jawstest.NewTestRequest(jw, nil) + t.Cleanup(func() { + peer.Close() + <-peer.DoneCh + }) + <-peer.ReadyCh + var mu sync.RWMutex + store := newTestJsVarStore(t, jw, "state", &mu, &state) + if !store.partial || !store.hidden { + t.Fatalf("partial=%v hidden=%v, want both true", store.partial, store.hidden) + } + store.ClientCheck = func(elem *jaws.Element, next *oraclePartialState, path string) error { + outcome.checks++ + return check(elem, next, path) + } + binding, elem, html := renderTestJsVar(t, source, store) + peerBinding, _, peerHTML := renderTestJsVar(t, peer.Request, store) + const visible = `{"profile":{"name":""},"users":{"a":{"name":""}}}` + if !strings.Contains(html, visible) || !strings.Contains(peerHTML, visible) { + t.Fatalf("unexpected initial JSON %q", html) + } + err := jaws.CallEventHandlers(binding, elem, what.JsVar, proposal) + outcome.denied = errors.Is(err, errOracleDenied) + if err != nil && !outcome.denied { + t.Fatalf("proposal error = %v", err) + } + if outcome.sourcePatches, err = binding.pendingPatches(); err != nil { + t.Fatal(err) + } + if outcome.peerPatches, err = peerBinding.pendingPatches(); err != nil { + t.Fatal(err) + } + mu.RLock() + after = state + mu.RUnlock() + return +} + +var errOracleDenied = errors.New("denied") + +func TestJsVarStorePartialHiddenProposalOutcome(t *testing.T) { + zero := oraclePartialState{Users: map[string]oraclePartialProfile{"a": {}}} + hiddenStates := []struct { + name string + state oraclePartialState + }{ + {name: "ignored field", state: oraclePartialState{ + Profile: oraclePartialProfile{Secret: "s3cr3t"}, + Users: map[string]oraclePartialProfile{"a": {Secret: "s3cr3t"}}, + }}, + {name: "unexported field", state: oraclePartialState{ + Profile: oraclePartialProfile{pin: 1234}, + Users: map[string]oraclePartialProfile{"a": {pin: 1234}}, + }}, + } + for _, check := range []struct { + name string + check JsVarCheck[oraclePartialState] + }{ + {name: "deny", check: func(*jaws.Element, *oraclePartialState, string) error { return errOracleDenied }}, + {name: "allow", check: func(*jaws.Element, *oraclePartialState, string) error { return nil }}, + } { + for _, proposal := range []string{"profile=null", "users.a=null", "=null"} { + t.Run(check.name+"/"+proposal, func(t *testing.T) { + want, _ := probeJsVarPartialHidden(t, zero, check.check, proposal) + if want.checks != 1 { + t.Fatalf("zero hidden state: checks = %d, want 1", want.checks) + } + for _, hs := range hiddenStates { + got, after := probeJsVarPartialHidden(t, hs.state, check.check, proposal) + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s: outcome %+v differs from zero hidden state %+v", hs.name, got, want) + } + if check.name == "allow" { + var cleared oraclePartialProfile + switch proposal { + case "profile=null": + cleared = after.Profile + case "users.a=null": + cleared = after.Users["a"] + default: + if !reflect.DeepEqual(after, oraclePartialState{}) { + t.Fatalf("%s: allowed root state %+v, want zero", hs.name, after) + } + } + if cleared != (oraclePartialProfile{}) { + t.Fatalf("%s: allowed proposal left %+v, want zero", hs.name, cleared) + } + } + } + }) + } + } +} + +func TestJsVarStorePartialVisibleNoOpSkipsCheck(t *testing.T) { + jw, rq := newLiveJsVarOracleRequest(t) + var mu sync.RWMutex + state := oracleVisibleProfile{Name: "Ada"} + store := newTestJsVarStore(t, jw, "state", &mu, &state) + if !store.partial || store.hidden { + t.Fatalf("partial=%v hidden=%v, want true false", store.partial, store.hidden) + } + checks := 0 + store.ClientCheck = func(*jaws.Element, *oracleVisibleProfile, string) error { + checks++ + return errOracleDenied + } + binding, elem, _ := renderTestJsVar(t, rq, store) + if err := jaws.CallEventHandlers(binding, elem, what.JsVar, `name="Ada"`); err != nil || checks != 0 { + t.Fatalf("unchanged visible proposal: err=%v checks=%d, want nil 0", err, checks) + } +} From 760fc41b3907d8fa879a8782baf5bcf071c1c7ca Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Tue, 6 Oct 2026 14:30:20 +0200 Subject: [PATCH 2/2] docs(ui): clarify JsVar proposals and hidden Go fields --- doc/ui/jsvar.md | 17 +++-- lib/ui/jsvar.go | 59 ++++++--------- lib/ui/jsvar_oracle_test.go | 140 ------------------------------------ 3 files changed, 30 insertions(+), 186 deletions(-) diff --git a/doc/ui/jsvar.md b/doc/ui/jsvar.md index a9dd2751..d96c923f 100644 --- a/doc/ui/jsvar.md +++ b/doc/ui/jsvar.md @@ -36,13 +36,16 @@ write, and connection behavior. `ClientCheck` is required for browser writes; nil denies them. It receives the complete tentative Go value under the store's write lock. Validate authorization -through the originating Element and validate the full value, including parent and -root changes and Go fields omitted from JSON: a `null` proposal zeroes the whole -Go struct. If the type has such fields, unchanged proposals are checked and -published like changed ones. An error or panic rolls the proposal back. The callback must only -inspect: do not mutate or retain tentative data, reacquire the lock, or call a -store setter. Every binding receives the same JSON value; use separate stores -for data with different visibility. +through the originating Element and validate the full value, including fields +omitted from JSON and changes through parent or root paths. A `null` proposal for +a struct zeroes all its fields, including unexported and `json:"-"` fields. An +error or panic rolls the proposal back. The callback must only inspect: do not +mutate or retain tentative data, reacquire the lock, or call a store setter. + +Every binding receives the same JSON value; use separate stores for data with +different visibility. Proposal outcomes can depend on Go fields omitted from +JSON, even when the JSON values are identical. Keep secrets outside the bound +value. Browser JSON numbers are converted to the destination Go type before validation. Conversion can truncate or wrap values: `2.9` becomes `2` for `int`, and `300` diff --git a/lib/ui/jsvar.go b/lib/ui/jsvar.go index 3648ce70..c5bd4bde 100644 --- a/lib/ui/jsvar.go +++ b/lib/ui/jsvar.go @@ -82,13 +82,12 @@ func validateJsVarPath(path string) error { // JsVarCheck validates the complete tentative state of a browser proposal. // // A nil check denies browser writes. The check runs under the store's write lock -// after jq tentatively applies a changed value. If T has Go fields omitted from -// JSON, unchanged proposals are checked and published like changed ones. It -// must only inspect next; +// after jq tentatively applies a changed Go value. It must only inspect next; // an error or panic rolls the proposal back. The source can authorize a user -// or session. Validate the complete value, including changes through parent -// and root paths and Go fields omitted from JSON. The check sees converted Go -// values, not raw browser JSON; see [JsVarBinding.JawsInput]. +// or session. Validate the complete value, including fields omitted from JSON +// and changes through parent or root paths. A null proposal for a struct zeroes +// all its fields, including unexported and json:"-" fields. The check sees +// converted Go values, not raw browser JSON; see [JsVarBinding.JawsInput]. type JsVarCheck[T any] func(source *jaws.Element, next *T, path string) error // JSONSizeCheck limits the encoded size of a tentative JsVar store value. @@ -119,7 +118,8 @@ func JSONSizeCheck[T any](maxBytes int) (check JsVarCheck[T]) { // ClientCheck and ExtraTags must be configured before first use and remain // unchanged while the store is active. // Every binding sees the same JSON value; ClientCheck controls writes, not -// disclosure. +// disclosure. Proposal outcomes can depend on Go fields omitted from JSON, +// even when the JSON values are identical. Keep secrets outside the bound value. // // All reads and writes of the bound value must use the supplied locker. Server // mutations must use SetPath, DeletePath, or WriteLocked to publish changes. @@ -140,7 +140,6 @@ type JsVarStore[T any] struct { locker bind.RWLocker value *T partial bool // plain JSON tree with matching jq paths and safe partial patches - hidden bool // partial tree with Go fields omitted from JSON version uint64 floor uint64 // older bindings need a root patch changes []jsVarChange @@ -170,12 +169,12 @@ func NewJsVarStore[T any](jw *jaws.Jaws, name string, locker sync.Locker, value err = jq.ErrInvalidReceiver } else { store = &JsVarStore[T]{ - jaws: jw, - name: name, - locker: bind.AsRWLocker(locker), - value: value, + jaws: jw, + name: name, + locker: bind.AsRWLocker(locker), + value: value, + partial: plainJsVarType(reflect.TypeOf(value).Elem()), } - store.partial, store.hidden = classifyJsVarType(reflect.TypeOf(value).Elem()) } } return @@ -654,7 +653,7 @@ func (binding *JsVarBinding[T]) applyProposal(elem *jaws.Element, path string, v if store.partial { // Plain trees map decoded proposals to the same jq/JSON paths. // Check the changed subtree before accepting it. - check := func() error { + changed, err = jq.SetChecked(store.value, path, value, func() error { var next any = store.value if path != "" { var getErr error @@ -666,15 +665,7 @@ func (binding *JsVarBinding[T]) applyProposal(elem *jaws.Element, path string, v return encodeErr } return store.ClientCheck(elem, store.value, path) - } - if changed, err = jq.SetChecked(store.value, path, value, check); err == nil && !changed && store.hidden { - // jq skips the check for equal Go values. Whether a proposal is equal - // can depend on fields absent from browser JSON, so check and publish - // as if it changed: the check sees the same next value either way. - if err = check(); err == nil { - changed = true - } - } + }) if changed { store.record(path) } @@ -779,17 +770,8 @@ var jsVarMarshalers = [...]reflect.Type{ // plainJsVarType is conservative about encoder paths and reference aliases. // Map branches are partial under the documented JSON-tree/no-alias contract. -func plainJsVarType(t reflect.Type) (plain bool) { - plain, _ = classifyJsVarType(t) - return -} - -// classifyJsVarType reports whether t is plain, and whether a plain t has Go -// fields omitted from JSON. -func classifyJsVarType(t reflect.Type) (plain, hidden bool) { - plain = plainJsVarTypeSeen(t, make(map[reflect.Type]bool), &hidden) - hidden = plain && hidden - return +func plainJsVarType(t reflect.Type) bool { + return plainJsVarTypeSeen(t, make(map[reflect.Type]bool)) } func plainJsVarScalar(t reflect.Type) bool { @@ -802,7 +784,7 @@ func plainJsVarScalar(t reflect.Type) bool { return false } -func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool, hidden *bool) bool { +func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool) bool { if seen[t] { return false } @@ -825,7 +807,6 @@ func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool, hidden *bool return false } if !field.IsExported() || field.Tag.Get("json") == "-" { - *hidden = true continue } name, options, _ := strings.Cut(field.Tag.Get("json"), ",") @@ -850,15 +831,15 @@ func plainJsVarTypeSeen(t reflect.Type, seen map[reflect.Type]bool, hidden *bool return false } } - if !plainJsVarTypeSeen(field.Type, seen, hidden) { + if !plainJsVarTypeSeen(field.Type, seen) { return false } } return true case reflect.Map: - return t.Key() == reflect.TypeFor[string]() && plainJsVarTypeSeen(t.Elem(), seen, hidden) + return t.Key() == reflect.TypeFor[string]() && plainJsVarTypeSeen(t.Elem(), seen) case reflect.Array: - return plainJsVarTypeSeen(t.Elem(), seen, hidden) + return plainJsVarTypeSeen(t.Elem(), seen) } return false } diff --git a/lib/ui/jsvar_oracle_test.go b/lib/ui/jsvar_oracle_test.go index ce31b9cf..460c37f4 100644 --- a/lib/ui/jsvar_oracle_test.go +++ b/lib/ui/jsvar_oracle_test.go @@ -5,7 +5,6 @@ package ui import ( "encoding/json" "errors" - "reflect" "strings" "sync" "testing" @@ -130,142 +129,3 @@ func TestJsVarStoreEmbeddedAliasNoOpProposalRejects(t *testing.T) { }) } } - -type oraclePartialProfile struct { - Name string `json:"name"` - Secret string `json:"-"` - pin int -} - -type oracleVisibleProfile struct { - Name string `json:"name"` -} - -type oraclePartialState struct { - Profile oraclePartialProfile `json:"profile"` - Users map[string]oraclePartialProfile `json:"users"` -} - -type oraclePartialOutcome struct { - denied bool - checks int - sourcePatches []string - peerPatches []string -} - -func probeJsVarPartialHidden(t *testing.T, state oraclePartialState, check JsVarCheck[oraclePartialState], proposal string) (outcome oraclePartialOutcome, after oraclePartialState) { - t.Helper() - jw, source := newLiveJsVarOracleRequest(t) - peer := jawstest.NewTestRequest(jw, nil) - t.Cleanup(func() { - peer.Close() - <-peer.DoneCh - }) - <-peer.ReadyCh - var mu sync.RWMutex - store := newTestJsVarStore(t, jw, "state", &mu, &state) - if !store.partial || !store.hidden { - t.Fatalf("partial=%v hidden=%v, want both true", store.partial, store.hidden) - } - store.ClientCheck = func(elem *jaws.Element, next *oraclePartialState, path string) error { - outcome.checks++ - return check(elem, next, path) - } - binding, elem, html := renderTestJsVar(t, source, store) - peerBinding, _, peerHTML := renderTestJsVar(t, peer.Request, store) - const visible = `{"profile":{"name":""},"users":{"a":{"name":""}}}` - if !strings.Contains(html, visible) || !strings.Contains(peerHTML, visible) { - t.Fatalf("unexpected initial JSON %q", html) - } - err := jaws.CallEventHandlers(binding, elem, what.JsVar, proposal) - outcome.denied = errors.Is(err, errOracleDenied) - if err != nil && !outcome.denied { - t.Fatalf("proposal error = %v", err) - } - if outcome.sourcePatches, err = binding.pendingPatches(); err != nil { - t.Fatal(err) - } - if outcome.peerPatches, err = peerBinding.pendingPatches(); err != nil { - t.Fatal(err) - } - mu.RLock() - after = state - mu.RUnlock() - return -} - -var errOracleDenied = errors.New("denied") - -func TestJsVarStorePartialHiddenProposalOutcome(t *testing.T) { - zero := oraclePartialState{Users: map[string]oraclePartialProfile{"a": {}}} - hiddenStates := []struct { - name string - state oraclePartialState - }{ - {name: "ignored field", state: oraclePartialState{ - Profile: oraclePartialProfile{Secret: "s3cr3t"}, - Users: map[string]oraclePartialProfile{"a": {Secret: "s3cr3t"}}, - }}, - {name: "unexported field", state: oraclePartialState{ - Profile: oraclePartialProfile{pin: 1234}, - Users: map[string]oraclePartialProfile{"a": {pin: 1234}}, - }}, - } - for _, check := range []struct { - name string - check JsVarCheck[oraclePartialState] - }{ - {name: "deny", check: func(*jaws.Element, *oraclePartialState, string) error { return errOracleDenied }}, - {name: "allow", check: func(*jaws.Element, *oraclePartialState, string) error { return nil }}, - } { - for _, proposal := range []string{"profile=null", "users.a=null", "=null"} { - t.Run(check.name+"/"+proposal, func(t *testing.T) { - want, _ := probeJsVarPartialHidden(t, zero, check.check, proposal) - if want.checks != 1 { - t.Fatalf("zero hidden state: checks = %d, want 1", want.checks) - } - for _, hs := range hiddenStates { - got, after := probeJsVarPartialHidden(t, hs.state, check.check, proposal) - if !reflect.DeepEqual(got, want) { - t.Fatalf("%s: outcome %+v differs from zero hidden state %+v", hs.name, got, want) - } - if check.name == "allow" { - var cleared oraclePartialProfile - switch proposal { - case "profile=null": - cleared = after.Profile - case "users.a=null": - cleared = after.Users["a"] - default: - if !reflect.DeepEqual(after, oraclePartialState{}) { - t.Fatalf("%s: allowed root state %+v, want zero", hs.name, after) - } - } - if cleared != (oraclePartialProfile{}) { - t.Fatalf("%s: allowed proposal left %+v, want zero", hs.name, cleared) - } - } - } - }) - } - } -} - -func TestJsVarStorePartialVisibleNoOpSkipsCheck(t *testing.T) { - jw, rq := newLiveJsVarOracleRequest(t) - var mu sync.RWMutex - state := oracleVisibleProfile{Name: "Ada"} - store := newTestJsVarStore(t, jw, "state", &mu, &state) - if !store.partial || store.hidden { - t.Fatalf("partial=%v hidden=%v, want true false", store.partial, store.hidden) - } - checks := 0 - store.ClientCheck = func(*jaws.Element, *oracleVisibleProfile, string) error { - checks++ - return errOracleDenied - } - binding, elem, _ := renderTestJsVar(t, rq, store) - if err := jaws.CallEventHandlers(binding, elem, what.JsVar, `name="Ada"`); err != nil || checks != 0 { - t.Fatalf("unchanged visible proposal: err=%v checks=%d, want nil 0", err, checks) - } -}