Skip to content

Commit 8f3da78

Browse files
Merge pull request #219 from unpoller/feat/authorize-guest
feat: add AuthorizeGuest helper for cmd/stamgr
2 parents 2baa93a + 7f53dd3 commit 8f3da78

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

mocks/mock_client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,3 +1228,8 @@ func (m *MockUnifi) GetSSLCertificate(_ *unifi.Site) (*unifi.SSLCertificate, err
12281228

12291229
return &a, nil
12301230
}
1231+
1232+
// AuthorizeGuest authorizes a guest client by MAC on a site.
1233+
func (m *MockUnifi) AuthorizeGuest(_ *unifi.Site, _ string, _ int) error {
1234+
return nil
1235+
}

stamgr.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package unifi
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
// ErrInvalidMinutes is returned when AuthorizeGuest is called with a negative duration.
9+
var ErrInvalidMinutes = fmt.Errorf("minutes must be >= 0")
10+
11+
// ErrEmptyMAC is returned when AuthorizeGuest is called with an empty MAC.
12+
var ErrEmptyMAC = fmt.Errorf("mac must not be empty")
13+
14+
// AuthorizeGuest authorizes a guest client (identified by MAC address) on the
15+
// given site via the stamgr command endpoint. minutes sets the authorization
16+
// duration; pass 0 to use the controller's default. This wraps a POST to
17+
// /api/s/{site}/cmd/stamgr with cmd=authorize-guest.
18+
func (u *Unifi) AuthorizeGuest(site *Site, mac string, minutes int) error {
19+
if site == nil || site.Name == "" {
20+
return ErrNoSiteProvided
21+
}
22+
23+
if mac == "" {
24+
return ErrEmptyMAC
25+
}
26+
27+
if minutes < 0 {
28+
return ErrInvalidMinutes
29+
}
30+
31+
payload := struct {
32+
Cmd string `json:"cmd"`
33+
MAC string `json:"mac"`
34+
Minutes int `json:"minutes,omitempty"`
35+
}{
36+
Cmd: "authorize-guest",
37+
MAC: mac,
38+
Minutes: minutes,
39+
}
40+
41+
body, err := json.Marshal(payload)
42+
if err != nil {
43+
return fmt.Errorf("marshalling authorize-guest payload: %w", err)
44+
}
45+
46+
path := fmt.Sprintf(APIStaMgrPath, site.Name)
47+
48+
if _, err := u.PostJSON(path, string(body)); err != nil {
49+
return fmt.Errorf("authorize-guest %s: %w", mac, err)
50+
}
51+
52+
return nil
53+
}

stamgr_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package unifi // nolint: testpackage
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestAuthorizeGuest(t *testing.T) {
16+
t.Parallel()
17+
18+
type stamgrBody struct {
19+
Cmd string `json:"cmd"`
20+
MAC string `json:"mac"`
21+
Minutes int `json:"minutes,omitempty"`
22+
}
23+
24+
var got stamgrBody
25+
26+
var gotPath, gotMethod string
27+
28+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+
gotPath = r.URL.Path
30+
gotMethod = r.Method
31+
32+
body, err := io.ReadAll(r.Body)
33+
if err != nil {
34+
w.WriteHeader(http.StatusInternalServerError)
35+
36+
return
37+
}
38+
39+
if err := json.Unmarshal(body, &got); err != nil {
40+
w.WriteHeader(http.StatusBadRequest)
41+
42+
return
43+
}
44+
45+
w.WriteHeader(http.StatusOK)
46+
_, _ = w.Write([]byte(`{"data":[],"meta":{"rc":"ok"}}`))
47+
}))
48+
defer srv.Close()
49+
50+
u := &Unifi{
51+
Client: &http.Client{},
52+
Config: &Config{URL: srv.URL, DebugLog: discardLogs, ErrorLog: discardLogs},
53+
}
54+
site := &Site{Name: "default"}
55+
56+
err := u.AuthorizeGuest(site, "aa:bb:cc:dd:ee:ff", 60)
57+
require.NoError(t, err)
58+
59+
a := assert.New(t)
60+
a.Equal("/api/s/default/cmd/stamgr", gotPath)
61+
a.Equal(http.MethodPost, gotMethod)
62+
a.Equal("authorize-guest", got.Cmd)
63+
a.Equal("aa:bb:cc:dd:ee:ff", got.MAC)
64+
a.Equal(60, got.Minutes)
65+
}
66+
67+
func TestAuthorizeGuestZeroMinutesOmitted(t *testing.T) {
68+
t.Parallel()
69+
70+
var raw map[string]any
71+
72+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73+
body, err := io.ReadAll(r.Body)
74+
if err != nil {
75+
w.WriteHeader(http.StatusInternalServerError)
76+
77+
return
78+
}
79+
80+
if err := json.Unmarshal(body, &raw); err != nil {
81+
w.WriteHeader(http.StatusBadRequest)
82+
83+
return
84+
}
85+
86+
w.WriteHeader(http.StatusOK)
87+
_, _ = w.Write([]byte(`{"data":[],"meta":{"rc":"ok"}}`))
88+
}))
89+
defer srv.Close()
90+
91+
u := &Unifi{
92+
Client: &http.Client{},
93+
Config: &Config{URL: srv.URL, DebugLog: discardLogs, ErrorLog: discardLogs},
94+
}
95+
96+
err := u.AuthorizeGuest(&Site{Name: "default"}, "aa:bb:cc:dd:ee:ff", 0)
97+
require.NoError(t, err)
98+
99+
a := assert.New(t)
100+
_, hasMinutes := raw["minutes"]
101+
a.False(hasMinutes, "minutes should be omitted when 0")
102+
}
103+
104+
func TestAuthorizeGuestValidation(t *testing.T) {
105+
t.Parallel()
106+
107+
u := &Unifi{Client: &http.Client{}, Config: &Config{URL: "http://invalid", DebugLog: discardLogs, ErrorLog: discardLogs}}
108+
109+
a := assert.New(t)
110+
a.ErrorIs(u.AuthorizeGuest(nil, "aa:bb:cc:dd:ee:ff", 0), ErrNoSiteProvided)
111+
a.ErrorIs(u.AuthorizeGuest(&Site{Name: ""}, "aa:bb:cc:dd:ee:ff", 0), ErrNoSiteProvided)
112+
a.ErrorIs(u.AuthorizeGuest(&Site{Name: "default"}, "", 0), ErrEmptyMAC)
113+
a.ErrorIs(u.AuthorizeGuest(&Site{Name: "default"}, "aa:bb:cc:dd:ee:ff", -1), ErrInvalidMinutes)
114+
}
115+
116+
func TestAuthorizeGuestControllerError(t *testing.T) {
117+
t.Parallel()
118+
119+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
120+
w.WriteHeader(http.StatusUnauthorized)
121+
_, _ = w.Write([]byte(`{"data":[],"meta":{"rc":"error","msg":"api.err.LoginRequired"}}`))
122+
}))
123+
defer srv.Close()
124+
125+
u := &Unifi{
126+
Client: &http.Client{},
127+
Config: &Config{URL: srv.URL, DebugLog: discardLogs, ErrorLog: discardLogs},
128+
}
129+
130+
err := u.AuthorizeGuest(&Site{Name: "default"}, "aa:bb:cc:dd:ee:ff", 0)
131+
132+
a := assert.New(t)
133+
a.Error(err)
134+
a.True(errors.Is(err, ErrInvalidStatusCode), "error should unwrap to ErrInvalidStatusCode")
135+
a.Contains(err.Error(), "aa:bb:cc:dd:ee:ff", "error should include MAC for context")
136+
}

types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ const (
163163
APISystemLogPath string = "/v2/api/site/%s/system-log/all"
164164
APICommandPath string = "/api/s/%s/cmd"
165165
APIDevMgrPath string = APICommandPath + "/devmgr"
166+
APIStaMgrPath string = APICommandPath + "/stamgr"
166167
APIClientTrafficPath string = "/v2/api/site/%s/traffic?start=%d&end=%d&includeUnidentified=%t"
167168
APIClientTrafficByMacPath string = "/v2/api/site/%s/traffic/%s?start=%d&end=%d&includeUnidentified=%t&mac=%s"
168169
APICountryTrafficPath string = "/v2/api/site/%s/country-traffic?start=%d&end=%d"
@@ -721,6 +722,9 @@ type UnifiClient interface { //nolint: revive
721722
GetPortForwards(site *Site) ([]*PortForward, error)
722723
// GetSSLCertificate returns SSL certificate information for a site.
723724
GetSSLCertificate(site *Site) (*SSLCertificate, error)
725+
// AuthorizeGuest authorizes a guest client by MAC on a site for the given
726+
// number of minutes. Pass minutes=0 to use the controller default.
727+
AuthorizeGuest(site *Site, mac string, minutes int) error
724728
}
725729

726730
// Unifi is what you get in return for providing a password! Unifi represents

0 commit comments

Comments
 (0)