mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
876 lines
29 KiB
Go
876 lines
29 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/application"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/openwrt"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/policy/faketime"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/wifi"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/wireless"
|
|
)
|
|
|
|
// fakeDevice answers the tools the adapter runs, and remembers what was asked.
|
|
//
|
|
// It stands in for a router, not for a process: what running a process does is
|
|
// the openwrt package's to demonstrate, against real ones. What this is for is
|
|
// the composition -- which question is asked of which object, and in which
|
|
// order -- which is the whole of what this file assembles.
|
|
type fakeDevice struct {
|
|
mu sync.Mutex
|
|
responses map[string]string
|
|
errors map[string]error
|
|
calls [][]string
|
|
}
|
|
|
|
func newDevice() *fakeDevice {
|
|
return &fakeDevice{responses: map[string]string{}, errors: map[string]error{}}
|
|
}
|
|
|
|
func (d *fakeDevice) Run(_ context.Context, program string, args ...string) (
|
|
openwrt.Result, error) {
|
|
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.calls = append(d.calls, append([]string{program}, args...))
|
|
key := program + " " + strings.Join(args, " ")
|
|
if err, ok := d.errors[key]; ok {
|
|
return openwrt.Result{Program: program}, err
|
|
}
|
|
if body, ok := d.responses[key]; ok {
|
|
return openwrt.Result{Program: program, Stdout: []byte(body)}, nil
|
|
}
|
|
return openwrt.Result{Program: program},
|
|
&openwrt.ExitError{Program: program, Code: 1}
|
|
}
|
|
|
|
// Resolve is the other half of what the adapter needs from a runner. Every
|
|
// program these tests use is present; a build with a missing tool is the
|
|
// openwrt package's case, not this one's.
|
|
func (d *fakeDevice) Resolve(program string) (string, error) {
|
|
return "/usr/bin/" + program, nil
|
|
}
|
|
|
|
func (d *fakeDevice) answer(body string, program string, args ...string) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.responses[program+" "+strings.Join(args, " ")] = body
|
|
}
|
|
|
|
func (d *fakeDevice) asked(program string, args ...string) bool {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
want := program + " " + strings.Join(args, " ")
|
|
for _, call := range d.calls {
|
|
if call[0]+" "+strings.Join(call[1:], " ") == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Constructed, not captured, and worth saying so: every station section on the
|
|
// router this project measures against is disabled, so no real capture of
|
|
// `network.wireless status` contains a station interface. The shape is the
|
|
// device's -- the same fields in the same places, checked against
|
|
// kwrt-25.12-feb/wireless-status.json -- and what is under test here is this
|
|
// package's use of it.
|
|
const statusWithStation = `{
|
|
"radio0": {"up": true, "pending": false, "disabled": false,
|
|
"retry_setup_failed": false,
|
|
"interfaces": [{"section": "default_radio0", "ifname": "phy0-ap0",
|
|
"config": {"mode": "ap", "ssid": "HomeNet-4", "network": ["lan"]}}]},
|
|
"radio1": {"up": true, "pending": false, "disabled": false,
|
|
"retry_setup_failed": false,
|
|
"interfaces": [
|
|
{"section": "default_radio1", "ifname": "phy1-ap0",
|
|
"config": {"mode": "ap", "ssid": "HomeNet-5", "network": ["lan"]}},
|
|
{"section": "jxnu_sta_radio1", "ifname": "phy1-sta0",
|
|
"config": {"mode": "sta", "ssid": "jxnu_stu", "network": ["wwan"]}}]}
|
|
}`
|
|
|
|
// The same router before this program has enabled anything: the client section
|
|
// exists in UCI but netifd lists no interface for it. This one is the real
|
|
// capture's shape.
|
|
const statusWithoutStation = `{
|
|
"radio1": {"up": true, "pending": false, "disabled": false,
|
|
"retry_setup_failed": false,
|
|
"interfaces": [{"section": "default_radio1", "ifname": "phy1-ap0",
|
|
"config": {"mode": "ap", "ssid": "HomeNet-5", "network": ["lan"]}}]}
|
|
}`
|
|
|
|
const associatedInfo = `{"phy": "phy1", "ssid": "jxnu_stu",
|
|
"bssid": "02:00:5E:00:53:02", "mode": "Client", "channel": 52,
|
|
"frequency": 5260, "signal": -52, "noise": -95,
|
|
"encryption": {"enabled": false}}`
|
|
|
|
const wwanUp = `{"up": true, "pending": false, "available": true,
|
|
"proto": "dhcp", "device": "phy1-sta0", "l3_device": "phy1-sta0",
|
|
"ipv4-address": [{"address": "10.20.30.40", "mask": 24}]}`
|
|
|
|
const wwanDown = `{"up": false, "pending": true, "available": true,
|
|
"proto": "dhcp", "ipv4-address": []}`
|
|
|
|
const liveWirelessUCI = `wireless.radio1=wifi-device
|
|
wireless.radio1.band='5g'
|
|
wireless.default_radio1=wifi-iface
|
|
wireless.default_radio1.device='radio1'
|
|
wireless.default_radio1.mode='ap'
|
|
wireless.default_radio1.ssid='HomeNet-5'
|
|
wireless.default_radio1.key='home-secret'
|
|
wireless.old_sta=wifi-iface
|
|
wireless.old_sta.device='radio1'
|
|
wireless.old_sta.mode='sta'
|
|
wireless.old_sta.ssid='jxnu_tea'
|
|
wireless.old_sta.jxnu_auto='1'
|
|
wireless.old_sta.disabled='0'
|
|
`
|
|
|
|
// recordingStore is a wireless.Store that records rather than writes.
|
|
type recordingStore struct {
|
|
mu sync.Mutex
|
|
values map[wireless.Key]wireless.Value
|
|
staged [][]wireless.Change
|
|
commits int
|
|
reloads int
|
|
failStage error
|
|
// onStage runs while the store's lock is not held, so a test can see how
|
|
// many changes are in flight at once.
|
|
onStage func()
|
|
}
|
|
|
|
func newRecordingStore() *recordingStore {
|
|
return &recordingStore{values: map[wireless.Key]wireless.Value{}}
|
|
}
|
|
|
|
func (s *recordingStore) Read(ctx context.Context, _ string, keys []wireless.Key) (
|
|
map[wireless.Key]wireless.Value, error) {
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make(map[wireless.Key]wireless.Value, len(keys))
|
|
for _, key := range keys {
|
|
out[key] = s.values[key]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *recordingStore) Stage(ctx context.Context, _ string, changes []wireless.Change) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if s.onStage != nil {
|
|
s.onStage()
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.failStage != nil {
|
|
return s.failStage
|
|
}
|
|
s.staged = append(s.staged, changes)
|
|
for _, change := range changes {
|
|
if change.Delete {
|
|
delete(s.values, change.Key)
|
|
continue
|
|
}
|
|
s.values[change.Key] = wireless.Value{Text: change.Text, Present: true}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingStore) Commit(ctx context.Context, _ string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.commits++
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingStore) PendingChanges(context.Context, string) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *recordingStore) SectionKeys(_ context.Context, _, section string) ([]wireless.Key, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var keys []wireless.Key
|
|
for key, value := range s.values {
|
|
if key.Section == section && !key.IsSection() && value.Present {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func (s *recordingStore) Reload(ctx context.Context) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.reloads++
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingStore) batches() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.staged)
|
|
}
|
|
|
|
func (s *recordingStore) lastStaged() []wireless.Change {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.staged) == 0 {
|
|
return nil
|
|
}
|
|
return s.staged[len(s.staged)-1]
|
|
}
|
|
|
|
// wroteOption finds what one option was set to in a staged batch.
|
|
func wroteOption(changes []wireless.Change, section, option string) (wireless.Change, bool) {
|
|
for _, change := range changes {
|
|
if change.Key.Section == section && change.Key.Option == option {
|
|
return change, true
|
|
}
|
|
}
|
|
return wireless.Change{}, false
|
|
}
|
|
|
|
type fixedSettings struct {
|
|
cfg domain.Config
|
|
revision uint64
|
|
}
|
|
|
|
func (s fixedSettings) Snapshot() domain.Config { return s.cfg }
|
|
func (s fixedSettings) Revision() uint64 { return s.revision }
|
|
|
|
type wirelessFixture struct {
|
|
radio *deviceWireless
|
|
device *fakeDevice
|
|
store *recordingStore
|
|
clock *faketime.Clock
|
|
settings fixedSettings
|
|
}
|
|
|
|
func newWirelessFixture(t *testing.T) *wirelessFixture {
|
|
t.Helper()
|
|
device := newDevice()
|
|
store := newRecordingStore()
|
|
clock := faketime.New(time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC))
|
|
settings := fixedSettings{revision: 11, cfg: domain.Config{
|
|
CampusAccounts: []domain.CampusAccount{{ID: "c1", SSID: "jxnu_stu"}},
|
|
}}
|
|
|
|
return &wirelessFixture{
|
|
device: device,
|
|
store: store,
|
|
clock: clock,
|
|
settings: settings,
|
|
radio: newDeviceWireless(wirelessOptions{
|
|
Adapter: openwrt.NewAdapter(device),
|
|
Store: store,
|
|
Paths: wireless.Paths{Dir: t.TempDir()},
|
|
Settings: settings,
|
|
Clock: clock,
|
|
SettleWait: 60 * time.Second,
|
|
SettlePoll: 2 * time.Second,
|
|
}),
|
|
}
|
|
}
|
|
|
|
func iwinfoInfo(device string) []string {
|
|
request, _ := json.Marshal(map[string]string{"device": device})
|
|
return []string{"call", "iwinfo", "info", string(request)}
|
|
}
|
|
|
|
func iwinfoScan(device string) []string {
|
|
request, _ := json.Marshal(map[string]string{"device": device})
|
|
return []string{"call", "iwinfo", "scan", string(request)}
|
|
}
|
|
|
|
// The device iwinfo answers to is looked up, not derived.
|
|
//
|
|
// A client on radio1 is phy1-sta0 here, phy1-sta1 on the next router, and
|
|
// absent entirely while the section is disabled. Nothing about the radio name
|
|
// says which, so netifd is asked -- and this is the test that would fail if
|
|
// somebody replaced the lookup with string formatting, which works right up
|
|
// until it does not.
|
|
func TestTheAssociationIsReadFromTheDeviceNetifdNames(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(statusWithStation, "ubus", "call", "network.wireless", "status")
|
|
fixture.device.answer(associatedInfo, "ubus", iwinfoInfo("phy1-sta0")...)
|
|
fixture.device.answer(wwanUp, "ubus", "call", "network.interface.wwan", "status")
|
|
|
|
observed, err := fixture.radio.Association(t.Context(), "radio1")
|
|
if err != nil {
|
|
t.Fatalf("Association: %v", err)
|
|
}
|
|
if observed.SSID != "jxnu_stu" {
|
|
t.Errorf("SSID = %q", observed.SSID)
|
|
}
|
|
if observed.BSSID != "02:00:5e:00:53:02" {
|
|
t.Errorf("BSSID = %q, want it lower-cased", observed.BSSID)
|
|
}
|
|
if !observed.HasIPv4 {
|
|
t.Error("the interface has an address and was reported as having none")
|
|
}
|
|
if !fixture.device.asked("ubus", iwinfoInfo("phy1-sta0")...) {
|
|
t.Error("iwinfo was not asked about the device netifd named")
|
|
}
|
|
}
|
|
|
|
// A section netifd has not brought up is not associated, and not a failure.
|
|
//
|
|
// This is the ordinary state of a router this program has not yet connected:
|
|
// the client section is in UCI and disabled, so netifd lists no interface for
|
|
// it. Reporting that as an error would make the first connection on every
|
|
// router look like broken hardware.
|
|
func TestASectionWithNoInterfaceIsNotAssociatedAndNotAnError(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(statusWithoutStation, "ubus", "call", "network.wireless", "status")
|
|
|
|
observed, err := fixture.radio.Association(t.Context(), "radio1")
|
|
if err != nil {
|
|
t.Fatalf("Association: %v", err)
|
|
}
|
|
if observed.Joined() {
|
|
t.Errorf("observed = %+v, want nothing joined", observed)
|
|
}
|
|
if fixture.device.asked("ubus", iwinfoInfo("phy1-ap0")...) {
|
|
t.Error("the household's access point was read as this program's client")
|
|
}
|
|
}
|
|
|
|
// A client that joined and got no address is not a line that can be reused.
|
|
//
|
|
// Spec 04: an association with no address is a client that associated and got
|
|
// nothing, and authenticating over it sends a request that goes nowhere.
|
|
func TestAnAssociationWithNoAddressIsNotUsable(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(statusWithStation, "ubus", "call", "network.wireless", "status")
|
|
fixture.device.answer(associatedInfo, "ubus", iwinfoInfo("phy1-sta0")...)
|
|
fixture.device.answer(wwanDown, "ubus", "call", "network.interface.wwan", "status")
|
|
|
|
observed, err := fixture.radio.Association(t.Context(), "radio1")
|
|
if err != nil {
|
|
t.Fatalf("Association: %v", err)
|
|
}
|
|
if !observed.Joined() {
|
|
t.Fatal("the client is associated")
|
|
}
|
|
if observed.HasIPv4 {
|
|
t.Error("an interface that is down was reported as carrying an address")
|
|
}
|
|
|
|
target := wifi.Target{SSID: "jxnu_stu", Policy: domain.APSelectionAuto}
|
|
if target.Satisfied(observed) {
|
|
t.Error("a line with no address was accepted as already usable")
|
|
}
|
|
}
|
|
|
|
// An address this program cannot confirm is treated as absent.
|
|
//
|
|
// The caller is deciding whether to reuse an existing line. "I could not tell"
|
|
// has to mean "do not reuse it": the other way round authenticates over a line
|
|
// nobody established was working.
|
|
func TestAnUnreadableInterfaceMeansNoAddress(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(statusWithStation, "ubus", "call", "network.wireless", "status")
|
|
fixture.device.answer(associatedInfo, "ubus", iwinfoInfo("phy1-sta0")...)
|
|
// network.interface.wwan is left unanswered, so the adapter fails.
|
|
|
|
observed, err := fixture.radio.Association(t.Context(), "radio1")
|
|
if err != nil {
|
|
t.Fatalf("Association: %v", err)
|
|
}
|
|
if observed.HasIPv4 {
|
|
t.Error("an unreadable interface was reported as carrying an address")
|
|
}
|
|
}
|
|
|
|
// A radio the configuration names but the system does not have is a
|
|
// configuration error, not an empty answer.
|
|
func TestAMissingRadioIsReported(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(statusWithoutStation, "ubus", "call", "network.wireless", "status")
|
|
|
|
_, err := fixture.radio.Association(t.Context(), "radio7")
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeInvalidConfig {
|
|
t.Fatalf("Association = %v (code %s), want InvalidConfig", err, code)
|
|
}
|
|
if _, err := fixture.radio.Scan(t.Context(), "radio7"); err == nil {
|
|
t.Error("Scan accepted a radio that is not there")
|
|
}
|
|
}
|
|
|
|
const scanResults = `{"results": [
|
|
{"ssid": "jxnu_stu", "bssid": "02:00:5E:00:53:02", "mode": "Master",
|
|
"channel": 52, "mhz": 5260, "signal": -45, "quality": 60, "quality_max": 70,
|
|
"encryption": {"enabled": false}},
|
|
{"ssid": "HomeNet-5", "bssid": "02:00:5E:00:53:03", "mode": "Master",
|
|
"channel": 149, "mhz": 5745, "signal": -60, "quality": 40, "quality_max": 70,
|
|
"encryption": {"enabled": true, "wpa": [2], "authentication": ["psk"],
|
|
"ciphers": ["ccmp"]}},
|
|
{"ssid": "a-mesh-peer", "bssid": "02:00:5E:00:53:04", "mode": "Mesh Point",
|
|
"channel": 36, "mhz": 5180, "signal": -70, "quality": 30, "quality_max": 70,
|
|
"encryption": {"enabled": false}}
|
|
]}`
|
|
|
|
// A scan comes back as candidates the wifi package can choose between, and the
|
|
// judgement about what each one offers is wifi's.
|
|
//
|
|
// Classifying here would be a second, quieter copy of a taxonomy that already
|
|
// exists and is tested -- and the one that matters, an open access point
|
|
// sharing a name with the protected network, is exactly what a second copy
|
|
// would eventually disagree about.
|
|
func TestAScanBecomesCandidatesClassifiedByWifi(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(statusWithStation, "ubus", "call", "network.wireless", "status")
|
|
fixture.device.answer(scanResults, "ubus", iwinfoScan("phy1-sta0")...)
|
|
|
|
candidates, err := fixture.radio.Scan(t.Context(), "radio1")
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
// The mesh peer is gone: it has an SSID and a signal, so nothing further
|
|
// down would have noticed it was unjoinable.
|
|
if len(candidates) != 2 {
|
|
t.Fatalf("got %d candidates, want the two joinable ones: %+v",
|
|
len(candidates), candidates)
|
|
}
|
|
|
|
campus := candidates[0]
|
|
if campus.SSID != "jxnu_stu" || campus.Signal != -45 {
|
|
t.Errorf("campus candidate = %+v", campus)
|
|
}
|
|
if campus.BSSID != "02:00:5e:00:53:02" {
|
|
t.Errorf("BSSID = %q, want it lower-cased", campus.BSSID)
|
|
}
|
|
if campus.Radio != "radio1" {
|
|
t.Errorf("Radio = %q; two radios can see the same access point and which "+
|
|
"one saw it decides which client section is written", campus.Radio)
|
|
}
|
|
if want := wifi.ClassifyScan(false, nil, nil); campus.Security != want {
|
|
t.Errorf("Security = %v, want %v -- wifi's classification, not a local one",
|
|
campus.Security, want)
|
|
}
|
|
if want := wifi.ClassifyScan(true, []string{"psk"}, []int{2}); candidates[1].Security != want {
|
|
t.Errorf("Security = %v, want %v", candidates[1].Security, want)
|
|
}
|
|
}
|
|
|
|
// A radio with nothing up on it cannot scan, and says so.
|
|
//
|
|
// An empty candidate list would be read by the selection as "the access point
|
|
// you pinned is gone", which is a different fact and one nobody established.
|
|
func TestARadioWithNothingUpCannotScan(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(`{"radio1": {"up": false, "pending": false,
|
|
"disabled": true, "retry_setup_failed": false, "interfaces": []}}`,
|
|
"ubus", "call", "network.wireless", "status")
|
|
|
|
_, err := fixture.radio.Scan(t.Context(), "radio1")
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeUnsupportedCapability {
|
|
t.Fatalf("Scan = %v (code %s), want UnsupportedCapability", err, code)
|
|
}
|
|
}
|
|
|
|
// A scan can happen before the client exists, from the access point on the same
|
|
// radio.
|
|
//
|
|
// This is the first-connection case: the section this program manages is
|
|
// disabled, so it has no device, and choosing an access point has to be
|
|
// possible anyway.
|
|
func TestAScanFallsBackToTheAccessPointOnTheRadio(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.device.answer(statusWithoutStation, "ubus", "call", "network.wireless", "status")
|
|
fixture.device.answer(scanResults, "ubus", iwinfoScan("phy1-ap0")...)
|
|
|
|
candidates, err := fixture.radio.Scan(t.Context(), "radio1")
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if len(candidates) != 2 {
|
|
t.Errorf("got %d candidates", len(candidates))
|
|
}
|
|
if !fixture.device.asked("ubus", iwinfoScan("phy1-ap0")...) {
|
|
t.Error("the scan did not fall back to the access point")
|
|
}
|
|
}
|
|
|
|
// settled makes every read report the client on the wanted network with an
|
|
// address, so Apply's wait finishes on its first look.
|
|
func (f *wirelessFixture) settled() {
|
|
f.device.answer(statusWithStation, "ubus", "call", "network.wireless", "status")
|
|
f.device.answer(strings.Replace(associatedInfo, `"enabled": false`, `"enabled": true`, 1), "ubus", iwinfoInfo("phy1-sta0")...)
|
|
f.device.answer(wwanUp, "ubus", "call", "network.interface.wwan", "status")
|
|
f.device.answer(liveWirelessUCI, "uci", "show", "wireless")
|
|
}
|
|
|
|
func TestAssociationRejectsAddressFromAnotherDevice(t *testing.T) {
|
|
f := newWirelessFixture(t)
|
|
f.settled()
|
|
f.device.answer(strings.Replace(wwanUp, `"l3_device": "phy1-sta0"`, `"l3_device": "eth0"`, 1), "ubus", "call", "network.interface.wwan", "status")
|
|
got, err := f.radio.Association(t.Context(), "radio1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.HasIPv4 {
|
|
t.Fatal("stale address on another L3 device accepted")
|
|
}
|
|
}
|
|
|
|
func TestAwaitLineRefusesWrongPinnedAPOrOpenDowngrade(t *testing.T) {
|
|
for _, mode := range []string{"pinned", "encryption"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
f := newWirelessFixture(t)
|
|
f.settled()
|
|
plan := campusPlan()
|
|
if mode == "pinned" {
|
|
plan.BSSID = "02:00:00:00:00:01"
|
|
} else {
|
|
f.device.answer(associatedInfo, "ubus", iwinfoInfo("phy1-sta0")...)
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
// A matching SSID/address must not return success before noticing
|
|
// cancellation when the actual pin/security is wrong.
|
|
if err := f.radio.awaitLine(ctx, plan); err == nil {
|
|
t.Fatal("wrong association marked ready")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func campusPlan() application.WirelessPlan {
|
|
return application.WirelessPlan{
|
|
Radio: "radio1", SSID: "jxnu_stu", Encryption: "psk2", Key: "hunter2-hunter2"}
|
|
}
|
|
|
|
// A change writes the client section this program owns, and nothing else.
|
|
//
|
|
// The negative half is the point. Spec 04 forbids touching the home access
|
|
// point, and the way that promise is kept is that no change naming one can be
|
|
// built here -- so this checks what was staged, not only what ended up working.
|
|
func TestApplyWritesOnlyTheSectionThisProgramOwns(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
|
|
if err := fixture.radio.Apply(t.Context(), campusPlan()); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
staged := fixture.store.lastStaged()
|
|
if len(staged) == 0 {
|
|
t.Fatal("nothing was staged")
|
|
}
|
|
|
|
section := stationSection("radio1")
|
|
for _, change := range staged {
|
|
switch change.Key.Section {
|
|
case section, "old_sta":
|
|
default:
|
|
t.Errorf("a change named %s, which this program does not own: %+v",
|
|
change.Key.Section, change)
|
|
}
|
|
}
|
|
// The one it does own carries what the plan asked for.
|
|
for option, want := range map[string]string{
|
|
"device": "radio1", "mode": "sta", "network": "wwan",
|
|
"ssid": "jxnu_stu", "encryption": "psk2", "key": "hunter2-hunter2",
|
|
"disabled": "0", openwrt.ManagedMarker: "1",
|
|
} {
|
|
change, found := wroteOption(staged, section, option)
|
|
if !found {
|
|
t.Errorf("%s was not written", option)
|
|
continue
|
|
}
|
|
if change.Text != want || change.Delete {
|
|
t.Errorf("%s = %+v, want %q", option, change, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The section itself is created, because uci will not set an option in one that
|
|
// is not there.
|
|
func TestApplyCreatesTheSectionItWritesInto(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
|
|
if err := fixture.radio.Apply(t.Context(), campusPlan()); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
staged := fixture.store.lastStaged()
|
|
change, found := wroteOption(staged, stationSection("radio1"), "")
|
|
if !found {
|
|
t.Fatalf("the section was never created: %+v", staged)
|
|
}
|
|
if change.Text != openwrt.SectionWifiIface {
|
|
t.Errorf("section type = %q, want %q", change.Text, openwrt.SectionWifiIface)
|
|
}
|
|
}
|
|
|
|
// An open network has no passphrase, and the option is removed rather than
|
|
// blanked.
|
|
//
|
|
// uci has no empty option: `set x.y.key=` writes nothing, leaves the option
|
|
// absent, and leaves the journal claiming a value that was never written. The
|
|
// 1.x runtime deletes these for the same reason.
|
|
func TestAnOpenNetworkRemovesThePassphraseRatherThanBlankingIt(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
|
|
plan := campusPlan()
|
|
plan.Encryption, plan.Key = "none", ""
|
|
if err := fixture.radio.Apply(t.Context(), plan); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
|
|
staged := fixture.store.lastStaged()
|
|
key, found := wroteOption(staged, stationSection("radio1"), "key")
|
|
if !found {
|
|
t.Fatal("the passphrase option was left as it was")
|
|
}
|
|
if !key.Delete || key.Text != "" {
|
|
t.Errorf("key = %+v, want a deletion", key)
|
|
}
|
|
// And the same rule for the pin, which an auto policy does not set.
|
|
bssid, found := wroteOption(staged, stationSection("radio1"), "bssid")
|
|
if !found || !bssid.Delete {
|
|
t.Errorf("bssid = %+v, want a deletion when nothing is pinned", bssid)
|
|
}
|
|
}
|
|
|
|
// A protected network with no passphrase is refused before anything is written.
|
|
func TestAProtectedNetworkWithNoPassphraseIsRefused(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
|
|
plan := campusPlan()
|
|
plan.Key = ""
|
|
err := fixture.radio.Apply(t.Context(), plan)
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeInvalidConfig {
|
|
t.Fatalf("Apply = %v (code %s), want InvalidConfig", err, code)
|
|
}
|
|
if len(fixture.store.staged) != 0 {
|
|
t.Errorf("%d batches were staged before the plan was checked",
|
|
len(fixture.store.staged))
|
|
}
|
|
}
|
|
|
|
// The other clients this program owns go down.
|
|
//
|
|
// Two enabled clients on one radio is the ambiguity openwrt.StationOnRadio
|
|
// refuses to resolve, and leaving the old one up is how a router carries on
|
|
// authenticating over the network it was told to leave.
|
|
func TestApplyDisablesTheOtherClientsThisProgramOwns(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
|
|
if err := fixture.radio.Apply(t.Context(), campusPlan()); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
staged := fixture.store.lastStaged()
|
|
|
|
change, found := wroteOption(staged, "old_sta", "disabled")
|
|
if !found {
|
|
t.Fatalf("the previous client was left enabled: %+v", staged)
|
|
}
|
|
if change.Text != "1" {
|
|
t.Errorf("old_sta.disabled = %q, want 1", change.Text)
|
|
}
|
|
// The household's access point is on the same radio and is not touched.
|
|
if _, found := wroteOption(staged, "default_radio1", "disabled"); found {
|
|
t.Error("the home access point was disabled")
|
|
}
|
|
}
|
|
|
|
// A change that never produces a usable line is undone.
|
|
//
|
|
// Spec 04's sixty seconds, and the undo at the end of them: a router left on a
|
|
// network it cannot reach is worse than one still on the network it was on, and
|
|
// it cannot ask for help from there either.
|
|
func TestAChangeThatNeverConnectsIsRolledBack(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
// Associated, but netifd never gives it an address.
|
|
fixture.device.answer(wwanDown, "ubus", "call", "network.interface.wwan", "status")
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- fixture.radio.Apply(context.Background(), campusPlan()) }()
|
|
|
|
// Spend the whole ceiling without spending it: the wait polls, and each
|
|
// poll is a timer this clock can move past.
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case err := <-done:
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeDeadlineExceeded {
|
|
t.Fatalf("Apply = %v (code %s), want DeadlineExceeded", err, code)
|
|
}
|
|
if !strings.Contains(err.Error(), "IPv4") {
|
|
t.Errorf("the failure does not say what was missing: %v", err)
|
|
}
|
|
// Staged twice: the change, and the undo.
|
|
if len(fixture.store.staged) < 2 {
|
|
t.Errorf("the change was left in place: %d batches staged",
|
|
len(fixture.store.staged))
|
|
}
|
|
return
|
|
case <-deadline:
|
|
t.Fatal("Apply did not finish")
|
|
default:
|
|
}
|
|
fixture.clock.Advance(3 * time.Second)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// One wireless change at a time, for the whole process.
|
|
//
|
|
// The coordinator's scheduling key serialises actions, which is not the same
|
|
// guarantee: recovery at startup is not an action, and neither is an RPC
|
|
// arriving while a maintenance tick runs. Spec 04 asks for a global lock as
|
|
// well, and the independent review made a point of saying one does not replace
|
|
// the other.
|
|
func TestOnlyOneWirelessChangeRunsAtATime(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
|
|
const attempts = 8
|
|
var running, peak int
|
|
var mu sync.Mutex
|
|
fixture.store.onStage = func() {
|
|
mu.Lock()
|
|
running++
|
|
if running > peak {
|
|
peak = running
|
|
}
|
|
mu.Unlock()
|
|
time.Sleep(time.Millisecond)
|
|
mu.Lock()
|
|
running--
|
|
mu.Unlock()
|
|
}
|
|
|
|
var group sync.WaitGroup
|
|
for range attempts {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
if err := fixture.radio.Apply(context.Background(), campusPlan()); err != nil {
|
|
t.Errorf("Apply: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
group.Wait()
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if peak != 1 {
|
|
t.Errorf("%d changes were in flight at once, want 1", peak)
|
|
}
|
|
}
|
|
|
|
// Two changes in the same second do not share a journal identity.
|
|
//
|
|
// The task id is what a recovery names when it reports what it undid. Two
|
|
// transactions answering to the same name would make that report ambiguous
|
|
// exactly when somebody is reading it to find out what happened.
|
|
func TestEachChangeGetsItsOwnTaskIdentity(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
|
|
for range 3 {
|
|
if err := fixture.radio.Apply(t.Context(), campusPlan()); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
}
|
|
if fixture.radio.tasks != 3 {
|
|
t.Errorf("tasks = %d after three changes", fixture.radio.tasks)
|
|
}
|
|
}
|
|
|
|
// A cancelled switch still gets to undo itself.
|
|
//
|
|
// It cannot do that on the context that was just cancelled, and leaving it
|
|
// unwound is not "safe but untidy": Begin refuses to start while a journal is
|
|
// on disk, so the next wireless change would be blocked until a restart. The
|
|
// undo therefore runs on a bounded context of its own.
|
|
func TestACancelledChangeStillRollsItselfBack(t *testing.T) {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
// Associated, but no address, so the wait does not finish on its own.
|
|
fixture.device.answer(wwanDown, "ubus", "call", "network.interface.wwan", "status")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- fixture.radio.Apply(ctx, campusPlan()) }()
|
|
|
|
// Let the change be applied and the wait begin, then stop it.
|
|
waitFor(t, func() bool { return fixture.store.batches() >= 1 })
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("a cancelled switch was reported as done")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Apply did not finish after its context was cancelled")
|
|
}
|
|
|
|
if fixture.store.batches() < 2 {
|
|
t.Fatalf("%d batches staged; the change was left in place",
|
|
fixture.store.batches())
|
|
}
|
|
// And the proof that it matters: the next change can start.
|
|
fixture.device.answer(wwanUp, "ubus", "call", "network.interface.wwan", "status")
|
|
if err := fixture.radio.Apply(t.Context(), campusPlan()); err != nil {
|
|
t.Errorf("a later change was blocked by the cancelled one: %v", err)
|
|
}
|
|
}
|
|
|
|
func waitFor(t *testing.T, done func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if done() {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatal("the condition never held")
|
|
}
|
|
|
|
// A plan with no radio or no network is refused rather than written.
|
|
func TestAnIncompletePlanIsRefused(t *testing.T) {
|
|
for name, build := range map[string]func(*application.WirelessPlan){
|
|
"no radio": func(p *application.WirelessPlan) { p.Radio = "" },
|
|
"no ssid": func(p *application.WirelessPlan) { p.SSID = "" },
|
|
} {
|
|
fixture := newWirelessFixture(t)
|
|
fixture.settled()
|
|
plan := campusPlan()
|
|
build(&plan)
|
|
|
|
if err := fixture.radio.Apply(t.Context(), plan); err == nil {
|
|
t.Errorf("%s: Apply accepted it", name)
|
|
}
|
|
if len(fixture.store.staged) != 0 {
|
|
t.Errorf("%s: something was staged anyway", name)
|
|
}
|
|
}
|
|
}
|