Skip to content

Commit 6c645d7

Browse files
committed
feat: edit device name and type
1 parent 882591d commit 6c645d7

5 files changed

Lines changed: 439 additions & 2 deletions

File tree

internal/handlers/devices.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"math"
88
"net/http"
9+
"slices"
910
"sort"
1011
"strconv"
1112
"sync"
@@ -244,6 +245,96 @@ func (s *Server) RevokeDevice(c *gin.Context) {
244245
c.Redirect(http.StatusFound, "/devices")
245246
}
246247

248+
var validPlatforms = []string{"ios", "android", "windows", "linux"}
249+
250+
// EditDevice serves POST /devices/edit — self-service rename/platform update.
251+
func (s *Server) EditDevice(c *gin.Context) {
252+
username, ok := getUsername(c)
253+
if !ok {
254+
s.fail(c, http.StatusUnauthorized, "not authenticated", nil)
255+
return
256+
}
257+
258+
serialStr := c.PostForm("serial")
259+
if serialStr == "" {
260+
setFlash(c, flashError, "Invalid serial number.")
261+
c.Redirect(http.StatusFound, "/devices")
262+
return
263+
}
264+
265+
name := c.PostForm("device_name")
266+
if len(name) > 64 {
267+
setFlash(c, flashError, "Device name must be 64 characters or fewer.")
268+
c.Redirect(http.StatusFound, "/devices")
269+
return
270+
}
271+
272+
platform := c.PostForm("platform")
273+
if !slices.Contains(validPlatforms, platform) {
274+
setFlash(c, flashError, "Invalid platform.")
275+
c.Redirect(http.StatusFound, "/devices")
276+
return
277+
}
278+
279+
serial, parseErr := strconv.ParseInt(serialStr, 10, 64)
280+
if parseErr != nil {
281+
setFlash(c, flashError, "Invalid serial number.")
282+
c.Redirect(http.StatusFound, "/devices")
283+
return
284+
}
285+
286+
certList, err := s.IPA.CertFind(username, s.Cfg.IPAWirelessCAName)
287+
if err != nil {
288+
s.log().Error("devices: cert_find failed during edit", zap.String("username", username), zap.Error(err))
289+
setFlash(c, flashError, "Could not verify certificate ownership.")
290+
c.Redirect(http.StatusFound, "/devices")
291+
return
292+
}
293+
owned := false
294+
for _, cert := range certList {
295+
if cert.SerialNumber == serial {
296+
owned = true
297+
break
298+
}
299+
}
300+
if !owned {
301+
s.log().Warn("devices: edit attempt for unowned cert", zap.String("username", username), zap.Int64("serial", serial))
302+
setFlash(c, flashError, "Certificate not found.")
303+
c.Redirect(http.StatusFound, "/devices")
304+
return
305+
}
306+
307+
info, _, err := s.DM.Get(c.Request.Context(), serialStr)
308+
if err != nil {
309+
s.log().Error("devices: device map get failed during edit", zap.String("serial", serialStr), zap.Error(err))
310+
setFlash(c, flashError, "Could not load device. Please try again.")
311+
c.Redirect(http.StatusFound, "/devices")
312+
return
313+
}
314+
315+
if info.Username == "" {
316+
info.Username = username
317+
} else if info.Username != username {
318+
s.log().Warn("devices: edit attempt for cert with mismatched map owner",
319+
zap.String("requester", username), zap.String("owner", info.Username), zap.Int64("serial", serial))
320+
setFlash(c, flashError, "Certificate not found.")
321+
c.Redirect(http.StatusFound, "/devices")
322+
return
323+
}
324+
info.DeviceName = name
325+
info.Platform = platform
326+
if err := s.DM.Set(c.Request.Context(), serialStr, info); err != nil {
327+
s.log().Error("devices: device map set failed during edit", zap.String("serial", serialStr), zap.Error(err))
328+
setFlash(c, flashError, "Could not save changes. Please try again.")
329+
c.Redirect(http.StatusFound, "/devices")
330+
return
331+
}
332+
333+
s.log().Info("devices: device edited", zap.String("username", username), zap.Int64("serial", serial))
334+
setFlash(c, flashSuccess, "Device updated successfully.")
335+
c.Redirect(http.StatusFound, "/devices")
336+
}
337+
247338
// AdminDevicesPage serves GET /admin/devices — RTP view of all enrolled devices.
248339
func (s *Server) AdminDevicesPage(c *gin.Context) {
249340
allEntries, err := s.DM.All(c.Request.Context())

internal/handlers/devices_test.go

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// internal/handlers/devices_test.go
2+
package handlers_test
3+
4+
import (
5+
"context"
6+
"errors"
7+
"net/http"
8+
"net/http/httptest"
9+
"net/url"
10+
"strings"
11+
"testing"
12+
"time"
13+
14+
"github.com/ComputerScienceHouse/pint/internal/config"
15+
"github.com/ComputerScienceHouse/pint/internal/devicemap"
16+
"github.com/ComputerScienceHouse/pint/internal/freeipa"
17+
"github.com/ComputerScienceHouse/pint/internal/handlers"
18+
"github.com/gin-contrib/sessions"
19+
"github.com/gin-contrib/sessions/cookie"
20+
"github.com/gin-gonic/gin"
21+
"k8s.io/client-go/kubernetes/fake"
22+
)
23+
24+
// mockIPA implements handlers.FreeIPAClient for tests.
25+
type mockIPA struct {
26+
certs []freeipa.CertInfo
27+
certFindErr error
28+
certRevokeErr error
29+
revokeCount int
30+
}
31+
32+
func (m *mockIPA) CertFind(_, _ string) ([]freeipa.CertInfo, error) {
33+
return m.certs, m.certFindErr
34+
}
35+
36+
func (m *mockIPA) CertRevoke(_ int64, _ string, _ int) error {
37+
m.revokeCount++
38+
return m.certRevokeErr
39+
}
40+
41+
func (m *mockIPA) CertRequest(_, _, _, _ string) ([]byte, error) { return nil, nil }
42+
43+
func newDeviceServer(ipa *mockIPA) (*handlers.Server, *devicemap.DeviceMap) {
44+
dm := devicemap.New(fake.NewSimpleClientset(), "default", "pint-devices")
45+
return &handlers.Server{
46+
Cfg: &config.Config{IPAWirelessCAName: "ipa"},
47+
IPA: ipa,
48+
DM: dm,
49+
}, dm
50+
}
51+
52+
func newDeviceRouter(username string, s *handlers.Server, method, path string, h gin.HandlerFunc) *gin.Engine {
53+
r := gin.New()
54+
r.Use(sessions.Sessions("pint_session", cookie.NewStore([]byte("test"))))
55+
r.Handle(method, path, testAuth(username), h)
56+
return r
57+
}
58+
59+
func postForm(t *testing.T, r *gin.Engine, path string, vals url.Values) *httptest.ResponseRecorder {
60+
t.Helper()
61+
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(vals.Encode()))
62+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
63+
w := httptest.NewRecorder()
64+
r.ServeHTTP(w, req)
65+
return w
66+
}
67+
68+
func assertRedirect(t *testing.T, w *httptest.ResponseRecorder, loc string) {
69+
t.Helper()
70+
if w.Code != http.StatusFound {
71+
t.Errorf("status = %d, want 302", w.Code)
72+
}
73+
if got := w.Header().Get("Location"); got != loc {
74+
t.Errorf("Location = %q, want %q", got, loc)
75+
}
76+
}
77+
78+
func TestRevokeDevice(t *testing.T) {
79+
const (
80+
username = "alice"
81+
serialStr = "42"
82+
serial = int64(42)
83+
)
84+
85+
owned := []freeipa.CertInfo{{SerialNumber: serial, ValidNotAfter: "99991231235959Z"}}
86+
87+
tests := []struct {
88+
name string
89+
serial string
90+
ipa *mockIPA
91+
wantRevokeCall bool
92+
}{
93+
{
94+
name: "empty serial",
95+
serial: "",
96+
ipa: &mockIPA{certs: owned},
97+
},
98+
{
99+
name: "cert_find error",
100+
serial: serialStr,
101+
ipa: &mockIPA{certFindErr: errors.New("ipa down")},
102+
},
103+
{
104+
name: "cert not owned",
105+
serial: serialStr,
106+
ipa: &mockIPA{certs: []freeipa.CertInfo{{SerialNumber: 999}}},
107+
},
108+
{
109+
name: "revoke error",
110+
serial: serialStr,
111+
ipa: &mockIPA{certs: owned, certRevokeErr: errors.New("revoke failed")},
112+
wantRevokeCall: true,
113+
},
114+
{
115+
name: "success",
116+
serial: serialStr,
117+
ipa: &mockIPA{certs: owned},
118+
wantRevokeCall: true,
119+
},
120+
}
121+
122+
for _, tc := range tests {
123+
t.Run(tc.name, func(t *testing.T) {
124+
s, _ := newDeviceServer(tc.ipa)
125+
r := newDeviceRouter(username, s, http.MethodPost, "/devices/revoke", s.RevokeDevice)
126+
127+
w := postForm(t, r, "/devices/revoke", url.Values{"serial": {tc.serial}})
128+
129+
assertRedirect(t, w, "/devices")
130+
if got := tc.ipa.revokeCount > 0; got != tc.wantRevokeCall {
131+
t.Errorf("CertRevoke called = %v, want %v", got, tc.wantRevokeCall)
132+
}
133+
})
134+
}
135+
}
136+
137+
func TestEditDevice(t *testing.T) {
138+
const (
139+
username = "alice"
140+
serialStr = "42"
141+
serial = int64(42)
142+
)
143+
144+
owned := []freeipa.CertInfo{{SerialNumber: serial, ValidNotAfter: "99991231235959Z"}}
145+
146+
existingEntry := devicemap.DeviceInfo{
147+
Username: username,
148+
DeviceName: "Old name",
149+
Platform: "ios",
150+
EnrolledAt: time.Now(),
151+
}
152+
153+
tests := []struct {
154+
name string
155+
serial string
156+
deviceName string
157+
platform string
158+
ipa *mockIPA
159+
setupDM func(context.Context, *devicemap.DeviceMap)
160+
wantName string
161+
wantPlatform string
162+
}{
163+
{
164+
name: "empty serial",
165+
serial: "",
166+
platform: "ios",
167+
ipa: &mockIPA{certs: owned},
168+
},
169+
{
170+
name: "name too long",
171+
serial: serialStr,
172+
deviceName: strings.Repeat("a", 65),
173+
platform: "ios",
174+
ipa: &mockIPA{certs: owned},
175+
},
176+
{
177+
name: "invalid platform",
178+
serial: serialStr,
179+
deviceName: "My Device",
180+
platform: "beos",
181+
ipa: &mockIPA{certs: owned},
182+
},
183+
{
184+
name: "non-numeric serial",
185+
serial: "not-a-number",
186+
deviceName: "My Device",
187+
platform: "linux",
188+
ipa: &mockIPA{certs: owned},
189+
},
190+
{
191+
name: "cert_find error",
192+
serial: serialStr,
193+
deviceName: "My Device",
194+
platform: "linux",
195+
ipa: &mockIPA{certFindErr: errors.New("ipa down")},
196+
},
197+
{
198+
name: "cert not owned",
199+
serial: serialStr,
200+
deviceName: "My Device",
201+
platform: "linux",
202+
ipa: &mockIPA{certs: []freeipa.CertInfo{{SerialNumber: 999}}},
203+
},
204+
{
205+
name: "map owner mismatch",
206+
serial: serialStr,
207+
deviceName: "My Device",
208+
platform: "linux",
209+
ipa: &mockIPA{certs: owned},
210+
setupDM: func(ctx context.Context, dm *devicemap.DeviceMap) {
211+
_ = dm.Set(ctx, serialStr, devicemap.DeviceInfo{Username: "eve", Platform: "ios"})
212+
},
213+
},
214+
{
215+
name: "success update existing entry",
216+
serial: serialStr,
217+
deviceName: "New Name",
218+
platform: "android",
219+
ipa: &mockIPA{certs: owned},
220+
setupDM: func(ctx context.Context, dm *devicemap.DeviceMap) {
221+
_ = dm.Set(ctx, serialStr, existingEntry)
222+
},
223+
wantName: "New Name",
224+
wantPlatform: "android",
225+
},
226+
{
227+
name: "success create entry for unknown device",
228+
serial: serialStr,
229+
deviceName: "New Laptop",
230+
platform: "linux",
231+
ipa: &mockIPA{certs: owned},
232+
wantName: "New Laptop",
233+
wantPlatform: "linux",
234+
},
235+
}
236+
237+
for _, tc := range tests {
238+
t.Run(tc.name, func(t *testing.T) {
239+
s, dm := newDeviceServer(tc.ipa)
240+
ctx := context.Background()
241+
if tc.setupDM != nil {
242+
tc.setupDM(ctx, dm)
243+
}
244+
245+
r := newDeviceRouter(username, s, http.MethodPost, "/devices/edit", s.EditDevice)
246+
w := postForm(t, r, "/devices/edit", url.Values{
247+
"serial": {tc.serial},
248+
"device_name": {tc.deviceName},
249+
"platform": {tc.platform},
250+
})
251+
252+
assertRedirect(t, w, "/devices")
253+
254+
if tc.wantName != "" || tc.wantPlatform != "" {
255+
info, ok, err := dm.Get(ctx, serialStr)
256+
if err != nil {
257+
t.Fatalf("dm.Get: %v", err)
258+
}
259+
if !ok {
260+
t.Fatal("expected device map entry to exist after successful edit")
261+
}
262+
if info.DeviceName != tc.wantName {
263+
t.Errorf("DeviceName = %q, want %q", info.DeviceName, tc.wantName)
264+
}
265+
if info.Platform != tc.wantPlatform {
266+
t.Errorf("Platform = %q, want %q", info.Platform, tc.wantPlatform)
267+
}
268+
if info.Username != username {
269+
t.Errorf("Username = %q, want %q", info.Username, username)
270+
}
271+
}
272+
})
273+
}
274+
}

internal/handlers/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func (s *Server) Routes(r *gin.Engine, authMiddleware gin.HandlerFunc) {
7272
protected.GET("/profile/ca", s.CADownload)
7373
protected.GET("/profile/scep-challenge", s.SCEPChallenge)
7474
protected.GET("/devices", s.DevicesPage)
75+
protected.POST("/devices/edit", s.EditDevice)
7576
protected.POST("/devices/revoke", s.RevokeDevice)
7677
protected.GET("/radius", s.RadiusPage)
7778
protected.POST("/radius/secret", s.SaveSecret)

0 commit comments

Comments
 (0)