mirror of
https://github.com/unpoller/unpoller.git
synced 2026-10-02 20:41:57 +02:00
Found by running this against a real UNVR4 rather than only the fake one. setDefaults fills an unset user with the placeholder "unifipoller". For a Protect-only console that placeholder was reaching Login(), the console answered 403, and the controller died during initialisation -- the same symptom #1066 set out to fix, one layer further in. A Protect API key and no local account is the config an operator actually writes for a UNVR, so this was the common case, not an edge one. Nothing on such a console uses a session unless Protect logs are wanted: the Integration API authenticates with the key alone. So withhold the credentials entirely in that case, and say so in the startup summary rather than naming a username that is never sent. Also pins the go.mod bump to unifi v6.0.1, which is the release that carries NewProtectClient (unpoller/unifi#240). Verified end to end against a UNVR4 (UniFi OS 5.1.31, Protect 7.2.105): the controller comes up, logs "Auth: Protect API key only (no session needed)", makes no login request at all, and exports 37 unpoller_protect_* series across 9 cameras and 2 bridges with unpoller_controller_up = 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVreutpEATmBjm6PBw9RjQ
430 lines
14 KiB
Go
430 lines
14 KiB
Go
package inputunifi_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/unpoller/unifi/v6"
|
|
"github.com/unpoller/unpoller/pkg/inputunifi"
|
|
"github.com/unpoller/unpoller/pkg/poller"
|
|
"golift.io/cnfg"
|
|
"golift.io/cnfgfile"
|
|
)
|
|
|
|
const (
|
|
metaInfoJSON = `{"applicationVersion":"7.2.105"}`
|
|
camerasJSON = `[{"id":"cam-1","modelKey":"camera","state":"CONNECTED","name":"Driveway","mac":"001122334455"}]`
|
|
sensorsJSON = `[{"id":"sen-1","modelKey":"sensor","state":"CONNECTED","name":"Garage","mac":"001122334466"}]`
|
|
nvrJSON = `{"id":"nvr-1","modelKey":"nvr","state":"CONNECTED","name":"UNVR4","mac":"001122334477"}`
|
|
emptyListJSON = `[]`
|
|
protectLogsJSON = `{"items":[{"id":"evt-1","modelKey":"event","type":"motion","camera":"cam-1","timestamp":1735689600000}]}`
|
|
unifiOSSPA = `<!doctype html><html lang="en"><body></body></html>`
|
|
protectAPIKeyStr = "protect-key-abc"
|
|
)
|
|
|
|
// unvr is a fake Protect-only console. It answers the Protect Integration paths and the
|
|
// UniFi OS login, and serves the console's own SPA HTML for everything else -- which is
|
|
// exactly what a UNVR does with /proxy/network/*, there being no Network application to
|
|
// route to. That HTML is what breaks NewUnifi in unpoller/unpoller#1066.
|
|
type unvr struct {
|
|
mu sync.Mutex
|
|
requested []string
|
|
}
|
|
|
|
func (s *unvr) start(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
|
|
record := func(r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.requested = append(s.requested, r.URL.Path)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
for path, body := range map[string]string{
|
|
unifi.APIProtectMetaInfoPath: metaInfoJSON,
|
|
unifi.APIProtectCamerasPath: camerasJSON,
|
|
unifi.APIProtectSensorsPath: sensorsJSON,
|
|
unifi.APIProtectNVRPath: nvrJSON,
|
|
unifi.APIProtectLightsPath: emptyListJSON,
|
|
unifi.APIProtectBridgesPath: emptyListJSON,
|
|
unifi.APIProtectLinkStationsPath: emptyListJSON,
|
|
unifi.APIProtectLogPath: protectLogsJSON,
|
|
} {
|
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
|
record(r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
})
|
|
}
|
|
|
|
mux.HandleFunc(unifi.APILoginPathNew, func(w http.ResponseWriter, r *http.Request) {
|
|
record(r)
|
|
w.Header().Set("x-csrf-token", "csrf-token")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
record(r)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte(unifiOSSPA))
|
|
})
|
|
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv
|
|
}
|
|
|
|
// paths returns every path requested so far, and networkPaths only those under /proxy/network.
|
|
func (s *unvr) paths() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return append([]string(nil), s.requested...)
|
|
}
|
|
|
|
func (s *unvr) networkPaths() []string {
|
|
var network []string
|
|
|
|
for _, p := range s.paths() {
|
|
if strings.HasPrefix(p, "/proxy/network") || p == unifi.APIStatusPath {
|
|
network = append(network, p)
|
|
}
|
|
}
|
|
|
|
return network
|
|
}
|
|
|
|
// newProtectOnlyInput builds an input with a single Protect-only controller. Options are
|
|
// applied before defaults are filled in, so a test can flip any flag it needs.
|
|
func newProtectOnlyInput(url string, opts ...func(*inputunifi.Controller)) *inputunifi.InputUnifi {
|
|
enabled := true
|
|
c := &inputunifi.Controller{
|
|
URL: url,
|
|
User: "ro-user",
|
|
Pass: "secret",
|
|
ProtectAPIKey: protectAPIKeyStr,
|
|
DisableNetwork: &enabled,
|
|
SaveProtectDevices: &enabled,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
|
|
return &inputunifi.InputUnifi{Config: &inputunifi.Config{Controllers: []*inputunifi.Controller{c}}}
|
|
}
|
|
|
|
// TestProtectOnlyControllerInitializes is the headline of unpoller/unpoller#1066: a UNVR is
|
|
// configured like any other controller and must come up. Before disable_network existed it
|
|
// died in NewUnifi and never even printed its config summary.
|
|
func TestProtectOnlyControllerInitializes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &unvr{}
|
|
srv := fake.start(t)
|
|
|
|
u := newProtectOnlyInput(srv.URL)
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
require.Len(t, u.Controllers, 1)
|
|
c := u.Controllers[0]
|
|
|
|
a := assert.New(t)
|
|
require.NotNil(t, c.Unifi, "controller client is nil, so initialization failed")
|
|
// NewProtectClient reports the Protect application version, there being no Network one.
|
|
a.Equal("7.2.105", c.Unifi.ServerVersion)
|
|
// Site discovery is skipped, so /proxy/network is never touched at all.
|
|
a.Empty(fake.networkPaths())
|
|
}
|
|
|
|
// The control case: the same console without disable_network still fails, so this test
|
|
// pins that the flag -- not some unrelated change -- is what makes the difference.
|
|
func TestProtectOnlyControllerFailsWithoutFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unvr{}).start(t)
|
|
|
|
disabled := false
|
|
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) { c.DisableNetwork = &disabled })
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
assert.Nil(t, u.Controllers[0].Unifi, "NewUnifi should not survive a console with no Network application")
|
|
}
|
|
|
|
func TestProtectOnlyControllerCollectsMetrics(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &unvr{}
|
|
srv := fake.start(t)
|
|
|
|
u := newProtectOnlyInput(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.ProtectDevices, 1)
|
|
|
|
pd, ok := m.ProtectDevices[0].(*unifi.ProtectDevices)
|
|
require.True(t, ok, "outputs type-assert to *unifi.ProtectDevices, so nothing else may be appended")
|
|
|
|
a := assert.New(t)
|
|
a.Equal("7.2.105", pd.Version)
|
|
a.Len(pd.Cameras, 1)
|
|
a.Len(pd.Sensors, 1)
|
|
require.NotNil(t, pd.NVR)
|
|
a.Equal("UNVR4", pd.NVR.Name)
|
|
|
|
// Nothing Network-shaped is collected, and nothing Network-shaped is requested.
|
|
a.Empty(m.Devices)
|
|
a.Empty(m.Clients)
|
|
a.Empty(m.Sites)
|
|
a.Empty(fake.networkPaths())
|
|
|
|
require.Len(t, m.ControllerStatuses, 1)
|
|
a.True(m.ControllerStatuses[0].Up)
|
|
}
|
|
|
|
// A Prometheus scrape of one target passes filter.Path. That path counts a poll as successful
|
|
// only if it produced devices or clients, and a Protect-only console produces neither -- so
|
|
// without ProtectDevices in that check a working console reports ErrDynamicLookupsDisabled.
|
|
func TestProtectOnlyControllerSucceedsOnFilteredScrape(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unvr{}).start(t)
|
|
|
|
u := newProtectOnlyInput(srv.URL)
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
m, err := u.Metrics(&poller.Filter{Path: srv.URL})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, m)
|
|
assert.Len(t, m.ProtectDevices, 1)
|
|
}
|
|
|
|
// Protect event logs come from the legacy Protect endpoint, which is site-independent. Every
|
|
// other event collector is per-site, so all of them have to be skipped for this to work.
|
|
func TestProtectOnlyControllerCollectsEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enabled := true
|
|
fake := &unvr{}
|
|
srv := fake.start(t)
|
|
|
|
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) { c.SaveProtectLogs = &enabled })
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
e, err := u.Events(nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, e)
|
|
assert.Len(t, e.Logs, 1)
|
|
assert.Empty(t, fake.networkPaths())
|
|
}
|
|
|
|
// RawMetrics' device/client kinds are all site-scoped. Returning an empty result for them
|
|
// would look like "this console has no devices"; the error says why instead.
|
|
func TestRawMetricsRejectsNetworkKindsWhenDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unvr{}).start(t)
|
|
|
|
u := newProtectOnlyInput(srv.URL)
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
_, err := u.RawMetrics(&poller.Filter{Kind: "devices"})
|
|
require.ErrorIs(t, err, inputunifi.ErrNetworkDisabled)
|
|
|
|
// The raw-path kind still works: it is the only way to inspect a Protect-only console.
|
|
body, err := u.RawMetrics(&poller.Filter{Kind: "other", Path: unifi.APIProtectMetaInfoPath})
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, metaInfoJSON, string(body))
|
|
}
|
|
|
|
// A Protect-only controller that saves nothing is always a mistake, and silently collecting
|
|
// nothing is the failure mode hardest to spot in a log, so it must be called out at startup.
|
|
func TestProtectOnlyWarnsWhenNothingWillBeCollected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unvr{}).start(t)
|
|
|
|
disabled := false
|
|
logger := &captureLogger{}
|
|
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) { c.SaveProtectDevices = &disabled })
|
|
require.NoError(t, u.Initialize(logger))
|
|
|
|
assert.Contains(t, logger.errors(), "nothing will be collected from it")
|
|
}
|
|
|
|
// The same, for a console that wants Protect devices but has no key to ask for them with.
|
|
func TestProtectOnlyWarnsWithoutAPIKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := (&unvr{}).start(t)
|
|
|
|
logger := &captureLogger{}
|
|
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) { c.ProtectAPIKey = "" })
|
|
require.NoError(t, u.Initialize(logger))
|
|
|
|
assert.Contains(t, logger.errors(), "no protect_api_key")
|
|
}
|
|
|
|
// A key-only config is what an operator actually writes for a UNVR, and setDefaults fills the
|
|
// unset user with "unifipoller". Sending that placeholder made the console answer 403 and
|
|
// killed the controller outright, so the credentials must not be sent when no session can be
|
|
// used -- and the startup summary must not claim an authentication that never happens.
|
|
func TestProtectOnlyDoesNotSendUnusedCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &unvr{}
|
|
srv := fake.start(t)
|
|
|
|
logger := &captureLogger{}
|
|
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) {
|
|
c.User = ""
|
|
c.Pass = ""
|
|
})
|
|
require.NoError(t, u.Initialize(logger))
|
|
|
|
a := assert.New(t)
|
|
require.NotNil(t, u.Controllers[0].Unifi)
|
|
a.NotContains(fake.paths(), unifi.APILoginPathNew, "no session is usable, so none should be attempted")
|
|
a.Contains(logger.infoLines(), "Protect API key only (no session needed)")
|
|
a.NotContains(logger.infoLines(), "unifipoller")
|
|
}
|
|
|
|
// With Protect logs enabled the session is genuinely needed, so the credentials are sent.
|
|
func TestProtectOnlyLogsInForProtectLogs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enabled := true
|
|
fake := &unvr{}
|
|
srv := fake.start(t)
|
|
|
|
u := newProtectOnlyInput(srv.URL, func(c *inputunifi.Controller) { c.SaveProtectLogs = &enabled })
|
|
require.NoError(t, u.Initialize(nil))
|
|
|
|
assert.Contains(t, fake.paths(), unifi.APILoginPathNew)
|
|
}
|
|
|
|
// disable_network 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
|
|
// option silently inert for users of that format. Note the env name derives from the xml tag.
|
|
func TestDisableNetworkBindsInEveryConfigFormat(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", "[[unifi.controller]]\n url = \"https://unvr\"\n disable_network = true\n"},
|
|
{"json", "up.json", `{"unifi":{"controllers":[{"url":"https://unvr","disable_network":true}]}}`},
|
|
{"yaml", "up.yaml", "unifi:\n controllers:\n - url: \"https://unvr\"\n disable_network: true\n"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := &inputunifi.InputUnifi{Config: &inputunifi.Config{}}
|
|
require.NoError(t, cnfgfile.Unmarshal(input, write(t, tc.file, tc.body)))
|
|
require.Len(t, input.Controllers, 1)
|
|
require.NotNil(t, input.Controllers[0].DisableNetwork, "disable_network did not bind from %s", tc.name)
|
|
assert.True(t, *input.Controllers[0].DisableNetwork)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 TestDisableNetworkBindsFromEnvironment(t *testing.T) {
|
|
t.Setenv("UP_UNIFI_CONTROLLER_0_URL", "https://unvr")
|
|
t.Setenv("UP_UNIFI_CONTROLLER_0_DISABLE_NETWORK", "true")
|
|
|
|
input := &inputunifi.InputUnifi{Config: &inputunifi.Config{}}
|
|
_, err := cnfg.UnmarshalENV(input, "UP")
|
|
require.NoError(t, err)
|
|
require.Len(t, input.Controllers, 1)
|
|
require.NotNil(t, input.Controllers[0].DisableNetwork, "UP_UNIFI_CONTROLLER_0_DISABLE_NETWORK did not bind")
|
|
assert.True(t, *input.Controllers[0].DisableNetwork)
|
|
}
|
|
|
|
// The shipped examples must keep the Network application enabled, so upgrading unpoller never
|
|
// silently stops collecting from a working controller.
|
|
func TestShippedExamplesDoNotDisableNetwork(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 := &inputunifi.InputUnifi{Config: &inputunifi.Config{}}
|
|
require.NoError(t, cnfgfile.Unmarshal(input, filepath.Join("..", "..", "examples", name)))
|
|
|
|
for i, c := range input.Controllers {
|
|
if c.DisableNetwork != nil {
|
|
assert.False(t, *c.DisableNetwork, "%s controller %d ships with disable_network set", name, i)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// captureLogger records what Initialize logs, so the startup warnings can be asserted on.
|
|
type captureLogger struct {
|
|
mu sync.Mutex
|
|
errs []string
|
|
infos []string
|
|
}
|
|
|
|
func (l *captureLogger) LogDebugf(string, ...any) {}
|
|
|
|
func (l *captureLogger) Logf(msg string, v ...any) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.infos = append(l.infos, fmt.Sprintf(msg, v...))
|
|
}
|
|
|
|
func (l *captureLogger) LogErrorf(msg string, v ...any) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.errs = append(l.errs, fmt.Sprintf(msg, v...))
|
|
}
|
|
|
|
func (l *captureLogger) errors() string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return strings.Join(l.errs, "\n")
|
|
}
|
|
|
|
func (l *captureLogger) infoLines() string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return strings.Join(l.infos, "\n")
|
|
}
|