mirror of
https://github.com/unpoller/unpoller.git
synced 2026-09-30 11:36:41 +02:00
Collects Protect device data (sensors, cameras, lights, bridges, link stations, NVR) via the official Integration API and exports it through Prometheus, InfluxDB, and DataDog. Opt-in via save_protect_devices, gated by protect_api_key. Bumps github.com/unpoller/unifi to v6, which added the Protect API client (breaking change: FlexInt/FlexBool/FlexFloat replace nullable pointers).
301 lines
9.6 KiB
Go
301 lines
9.6 KiB
Go
package inputunas_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/unpoller/unifi/v6"
|
|
"github.com/unpoller/unpoller/pkg/inputunas"
|
|
"github.com/unpoller/unpoller/pkg/poller"
|
|
"golift.io/cnfg"
|
|
"golift.io/cnfgfile"
|
|
)
|
|
|
|
const (
|
|
deviceInfoJSON = `{"name":"UNAS-Pro","model":"UNASPro","version":"4.0.6","firmwareVersion":"4.0.6",
|
|
"status":"HEALTHY","cpu":{"currentLoad":12,"temperature":45},
|
|
"memory":{"free":1000,"total":8000,"available":6000}}`
|
|
storageJSON = `{"pools":[{"number":1,"id":"pool-1","type":"RAID","status":"HEALTHY",
|
|
"capacity":1000,"usage":400,"activeRaidGroupId":"rg-1",
|
|
"raidGroups":[{"number":1,"id":"rg-1","currentLevel":"RAID5","configLevel":"RAID5",
|
|
"currentProtection":1,"expectedProtection":1,"progress":100}]}],
|
|
"disks":[{"slotId":"1","poolId":"pool-1","type":"HDD","state":"HEALTHY",
|
|
"model":"HAT5300","serial":"ABC123","temperature":38,"healthScore":100,"size":500}]}`
|
|
drivesJSON = `{"drives":[{"id":"d-1","name":"media","type":"SHARE","status":"HEALTHY","quota":100,"usage":50,"memberCount":3}]}`
|
|
networkIOJSON = `{"receiveKBPS":120,"transmitKBPS":340,"timestamp":"2026-01-01T00:00:00Z"}`
|
|
)
|
|
|
|
// unasServer is a fake UNAS Pro console. failData, when set, makes every data endpoint
|
|
// return 401 -- which is how an expired session presents itself.
|
|
type unasServer struct {
|
|
logins atomic.Int64
|
|
failData atomic.Bool
|
|
}
|
|
|
|
func (s *unasServer) start(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, _ *http.Request) {
|
|
s.logins.Add(1)
|
|
w.Header().Set("x-csrf-token", "csrf-token")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
for path, body := range map[string]string{
|
|
unifi.APIUNASDeviceInfoPath: deviceInfoJSON,
|
|
unifi.APIUNASStoragePath: storageJSON,
|
|
unifi.APIUNASDrivesPath: drivesJSON,
|
|
unifi.APIUNASNetworkIOPath: networkIOJSON,
|
|
} {
|
|
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
|
|
if s.failData.Load() {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
})
|
|
}
|
|
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv
|
|
}
|
|
|
|
func newInput(t *testing.T, urls ...string) *inputunas.InputUNAS {
|
|
t.Helper()
|
|
|
|
devices := make([]*inputunas.Device, len(urls))
|
|
for i, u := range urls {
|
|
devices[i] = &inputunas.Device{URL: u, User: "unpoller", Pass: "secret"}
|
|
}
|
|
|
|
return &inputunas.InputUNAS{Config: &inputunas.Config{Enable: true, Devices: devices}}
|
|
}
|
|
|
|
func TestMetricsCollectsConsole(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unasServer{}).start(t)
|
|
u := newInput(t, srv.URL)
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
m, err := u.Metrics(nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, m)
|
|
require.Len(t, m.UNASDevices, 1)
|
|
|
|
d, ok := m.UNASDevices[0].(*unifi.UNASDevice)
|
|
require.True(t, ok, "outputs type-assert to *unifi.UNASDevice, so nothing else may be appended")
|
|
|
|
a := assert.New(t)
|
|
a.Equal("UNAS-Pro", d.Name())
|
|
a.Equal("UNASPro", d.Model())
|
|
a.Equal(srv.URL, d.SourceName)
|
|
require.NotNil(t, d.Storage)
|
|
a.Len(d.Storage.Pools, 1)
|
|
a.Len(d.Storage.Disks, 1)
|
|
a.Len(d.Drives, 1)
|
|
require.NotNil(t, d.NetworkIO)
|
|
a.InDelta(120, d.NetworkIO.ReceiveKBPS.Val, 0.01)
|
|
}
|
|
|
|
// A UNAS session cookie expires after roughly two hours. When it does, every endpoint fails
|
|
// at once, and the plugin must log back in and re-attach fresh cookies rather than reuse the
|
|
// dead jar -- otherwise a long-running poller reports nothing until it is restarted.
|
|
func TestMetricsReauthenticatesAfterSessionExpiry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &unasServer{}
|
|
srv := fake.start(t)
|
|
u := newInput(t, srv.URL)
|
|
require.NoError(t, u.Initialize(nil))
|
|
require.Equal(t, int64(1), fake.logins.Load())
|
|
|
|
fake.failData.Store(true)
|
|
|
|
m, err := u.Metrics(nil)
|
|
require.Error(t, err, "a console that fails every endpoint even after re-auth is a real error")
|
|
require.Nil(t, m)
|
|
require.Equal(t, int64(2), fake.logins.Load(), "a total failure must trigger exactly one re-login")
|
|
|
|
// Once the console answers again, the refreshed session works without another login.
|
|
fake.failData.Store(false)
|
|
|
|
m, err = u.Metrics(nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, m.UNASDevices, 1)
|
|
require.Equal(t, int64(2), fake.logins.Load(), "a healthy poll must not re-authenticate")
|
|
}
|
|
|
|
// One dead console must not discard the metrics of a healthy one. poller.collectMetrics
|
|
// drops the result entirely when an input returns both metrics and an error, so returning
|
|
// them together here would throw away everything the working console reported.
|
|
func TestMetricsPartialFailureStillReportsHealthyConsole(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
good := (&unasServer{}).start(t)
|
|
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
|
|
defer dead.Close()
|
|
|
|
u := newInput(t, dead.URL, good.URL)
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
m, err := u.Metrics(nil)
|
|
require.NoError(t, err, "one bad console out of two is not an error")
|
|
require.Len(t, m.UNASDevices, 1)
|
|
}
|
|
|
|
// enable defaults to false, so a config that names consoles but never sets it must not poll
|
|
// them. Configuring the devices here is the point: an empty config would pass even if the
|
|
// switch were ignored entirely.
|
|
func TestNotEnabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unasServer{}).start(t)
|
|
u := &inputunas.InputUNAS{Config: &inputunas.Config{
|
|
Devices: []*inputunas.Device{{URL: srv.URL, User: "unpoller", Pass: "secret"}},
|
|
}}
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
m, err := u.Metrics(nil)
|
|
require.NoError(t, err)
|
|
require.Nil(t, m, "enable is false, so nothing is polled")
|
|
|
|
ok, err := u.DebugInput()
|
|
require.NoError(t, err)
|
|
require.True(t, ok, "--debugio must not reach a console the operator has not enabled")
|
|
}
|
|
|
|
// With no [unas] block the plugin must be silent and inert: that is what makes UNAS support
|
|
// opt-in. It must not synthesize a default device the way inputunifi does with localhost.
|
|
func TestUnconfiguredIsInert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := &inputunas.InputUNAS{}
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
m, err := u.Metrics(nil)
|
|
require.NoError(t, err)
|
|
require.Nil(t, m, "an unconfigured plugin stays off")
|
|
|
|
ok, err := u.DebugInput()
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
}
|
|
|
|
func TestDeviceWithNoURLIsIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := &inputunas.InputUNAS{Config: &inputunas.Config{Enable: true, Devices: []*inputunas.Device{{}, nil}}}
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
m, err := u.Metrics(nil)
|
|
require.NoError(t, err)
|
|
require.Empty(t, m.UNASDevices)
|
|
}
|
|
|
|
func TestEventsAreEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e, err := (&inputunas.InputUNAS{}).Events(&poller.Filter{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, e)
|
|
require.Empty(t, e.Logs)
|
|
}
|
|
|
|
func TestRawMetrics(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unasServer{}).start(t)
|
|
u := newInput(t, srv.URL)
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
body, err := u.RawMetrics(&poller.Filter{Kind: "storage"})
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(body), "pool-1")
|
|
|
|
_, err = u.RawMetrics(&poller.Filter{Unit: 9})
|
|
require.ErrorIs(t, err, inputunas.ErrNoDevices)
|
|
}
|
|
|
|
// The enable switch has to survive four independent binding paths -- toml, json, yaml and the
|
|
// UP_ environment -- each driven by its own struct tag. A typo in any one tag leaves the
|
|
// plugin silently off for users of that format, which is the same class of invisible failure
|
|
// as a missing tag anywhere else in this config. Note the env name derives from the xml tag,
|
|
// not the json one.
|
|
func TestEnableBindsInEveryConfigFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
write := func(t *testing.T, name, body string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), name)
|
|
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
|
|
|
|
return path
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
file string
|
|
body string
|
|
}{
|
|
{"toml", "up.conf", "[unas]\n enable = true\n[[unas.device]]\n url = \"https://nas\"\n"},
|
|
{"json", "up.json", `{"unas":{"enable":true,"devices":[{"url":"https://nas"}]}}`},
|
|
{"yaml", "up.yaml", "unas:\n enable: true\n devices:\n - url: \"https://nas\"\n"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := &inputunas.InputUNAS{Config: &inputunas.Config{}}
|
|
require.NoError(t, cnfgfile.Unmarshal(input, write(t, tc.file, tc.body)))
|
|
assert.True(t, input.Enable, "enable did not bind from %s", tc.name)
|
|
require.Len(t, input.Devices, 1)
|
|
assert.Equal(t, "https://nas", input.Devices[0].URL)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Not parallel: t.Setenv is incompatible with a parallel test or parent.
|
|
//
|
|
// The env variable name comes from the xml tag, not the json one, which is why this is worth
|
|
// asserting rather than assuming from the toml/json/yaml cases above.
|
|
func TestEnableBindsFromEnvironment(t *testing.T) {
|
|
t.Setenv("UP_UNAS_ENABLE", "true")
|
|
|
|
input := &inputunas.InputUNAS{Config: &inputunas.Config{}}
|
|
_, err := cnfg.UnmarshalENV(input, "UP")
|
|
require.NoError(t, err)
|
|
assert.True(t, input.Enable, "UP_UNAS_ENABLE did not bind")
|
|
}
|
|
|
|
// The shipped examples must all default to off, so that installing unpoller never starts
|
|
// polling a storage console nobody asked for.
|
|
func TestShippedExamplesDoNotEnableUNAS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, name := range []string{"up.conf.example", "up.json.example", "up.yaml.example"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := &inputunas.InputUNAS{Config: &inputunas.Config{}}
|
|
require.NoError(t, cnfgfile.Unmarshal(input, filepath.Join("..", "..", "examples", name)))
|
|
assert.False(t, input.Enable, "%s ships with UNAS enabled", name)
|
|
})
|
|
}
|
|
}
|