Skip to content

Commit 768801a

Browse files
deevusmircea-pavel-antonclaude
committed
feat: add UserService and GroupService
Add CRUD services for TrueNAS user and group management via the user.* and group.* API namespaces. Both services use the Caller interface (synchronous operations only). GroupService: Create, Get, GetByName, GetByGID, List, Update, Delete UserService: Create, Get, GetByUsername, GetByUID, List, Update, Delete Includes full test coverage for conversion logic, CRUD operations, not-found handling, and mock/interface compliance. Closes #7 Co-Authored-By: Mircea-Pavel Anton <contact@mirceanton.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c25763d commit 768801a

10 files changed

+1829
-0
lines changed

group.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package truenas
2+
3+
// GroupResponse represents a group from the TrueNAS API.
4+
type GroupResponse struct {
5+
ID int64 `json:"id"`
6+
GID int64 `json:"gid"`
7+
Name string `json:"name"`
8+
Builtin bool `json:"builtin"`
9+
SMB bool `json:"smb"`
10+
SudoCommands []string `json:"sudo_commands"`
11+
SudoCommandsNopasswd []string `json:"sudo_commands_nopasswd"`
12+
Users []int64 `json:"users"`
13+
Local bool `json:"local"`
14+
Immutable bool `json:"immutable"`
15+
}

group_service.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package truenas
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
)
8+
9+
// Group is the user-facing representation of a TrueNAS group.
10+
type Group struct {
11+
ID int64
12+
GID int64
13+
Name string
14+
Builtin bool
15+
SMB bool
16+
SudoCommands []string
17+
SudoCommandsNopasswd []string
18+
Users []int64
19+
Local bool
20+
Immutable bool
21+
}
22+
23+
// CreateGroupOpts contains options for creating a group.
24+
type CreateGroupOpts struct {
25+
Name string
26+
GID int64 // 0 = auto-assign
27+
SMB bool
28+
SudoCommands []string
29+
SudoCommandsNopasswd []string
30+
}
31+
32+
// UpdateGroupOpts contains options for updating a group.
33+
// GID is immutable and cannot be changed after creation.
34+
type UpdateGroupOpts struct {
35+
Name string
36+
SMB bool
37+
SudoCommands []string
38+
SudoCommandsNopasswd []string
39+
}
40+
41+
// GroupService provides typed methods for the group.* API namespace.
42+
type GroupService struct {
43+
client Caller
44+
version Version
45+
}
46+
47+
// NewGroupService creates a new GroupService.
48+
func NewGroupService(c Caller, v Version) *GroupService {
49+
return &GroupService{client: c, version: v}
50+
}
51+
52+
// Create creates a group and returns the full object.
53+
func (s *GroupService) Create(ctx context.Context, opts CreateGroupOpts) (*Group, error) {
54+
params := groupCreateOptsToParams(opts)
55+
result, err := s.client.Call(ctx, "group.create", params)
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
var id int64
61+
if err := json.Unmarshal(result, &id); err != nil {
62+
return nil, fmt.Errorf("parse create response: %w", err)
63+
}
64+
65+
return s.Get(ctx, id)
66+
}
67+
68+
// Get returns a group by ID, or nil if not found.
69+
func (s *GroupService) Get(ctx context.Context, id int64) (*Group, error) {
70+
result, err := s.client.Call(ctx, "group.get_instance", id)
71+
if err != nil {
72+
if isNotFoundError(err) {
73+
return nil, nil
74+
}
75+
return nil, err
76+
}
77+
78+
var resp GroupResponse
79+
if err := json.Unmarshal(result, &resp); err != nil {
80+
return nil, fmt.Errorf("parse get_instance response: %w", err)
81+
}
82+
83+
group := groupFromResponse(resp)
84+
return &group, nil
85+
}
86+
87+
// GetByName returns a group by name, or nil if not found.
88+
func (s *GroupService) GetByName(ctx context.Context, name string) (*Group, error) {
89+
return s.queryOne(ctx, "group", name)
90+
}
91+
92+
// GetByGID returns a group by GID, or nil if not found.
93+
func (s *GroupService) GetByGID(ctx context.Context, gid int64) (*Group, error) {
94+
return s.queryOne(ctx, "gid", gid)
95+
}
96+
97+
// List returns all groups.
98+
func (s *GroupService) List(ctx context.Context) ([]Group, error) {
99+
result, err := s.client.Call(ctx, "group.query", nil)
100+
if err != nil {
101+
return nil, err
102+
}
103+
104+
var responses []GroupResponse
105+
if err := json.Unmarshal(result, &responses); err != nil {
106+
return nil, fmt.Errorf("parse query response: %w", err)
107+
}
108+
109+
groups := make([]Group, len(responses))
110+
for i, resp := range responses {
111+
groups[i] = groupFromResponse(resp)
112+
}
113+
return groups, nil
114+
}
115+
116+
// Update updates a group and returns the full object.
117+
func (s *GroupService) Update(ctx context.Context, id int64, opts UpdateGroupOpts) (*Group, error) {
118+
params := groupUpdateOptsToParams(opts)
119+
_, err := s.client.Call(ctx, "group.update", []any{id, params})
120+
if err != nil {
121+
return nil, err
122+
}
123+
124+
return s.Get(ctx, id)
125+
}
126+
127+
// Delete deletes a group by ID. Does not delete member users.
128+
func (s *GroupService) Delete(ctx context.Context, id int64) error {
129+
_, err := s.client.Call(ctx, "group.delete", []any{id, map[string]any{"delete_users": false}})
130+
return err
131+
}
132+
133+
// queryOne queries for a single group by field and value.
134+
func (s *GroupService) queryOne(ctx context.Context, field string, value any) (*Group, error) {
135+
filter := [][]any{{field, "=", value}}
136+
result, err := s.client.Call(ctx, "group.query", filter)
137+
if err != nil {
138+
return nil, err
139+
}
140+
141+
var responses []GroupResponse
142+
if err := json.Unmarshal(result, &responses); err != nil {
143+
return nil, fmt.Errorf("parse query response: %w", err)
144+
}
145+
146+
if len(responses) == 0 {
147+
return nil, nil
148+
}
149+
150+
group := groupFromResponse(responses[0])
151+
return &group, nil
152+
}
153+
154+
// groupCreateOptsToParams converts CreateGroupOpts to API parameters.
155+
func groupCreateOptsToParams(opts CreateGroupOpts) map[string]any {
156+
params := map[string]any{
157+
"name": opts.Name,
158+
"smb": opts.SMB,
159+
}
160+
if opts.GID != 0 {
161+
params["gid"] = opts.GID
162+
}
163+
if opts.SudoCommands != nil {
164+
params["sudo_commands"] = opts.SudoCommands
165+
}
166+
if opts.SudoCommandsNopasswd != nil {
167+
params["sudo_commands_nopasswd"] = opts.SudoCommandsNopasswd
168+
}
169+
return params
170+
}
171+
172+
// groupUpdateOptsToParams converts UpdateGroupOpts to API parameters.
173+
func groupUpdateOptsToParams(opts UpdateGroupOpts) map[string]any {
174+
params := map[string]any{
175+
"name": opts.Name,
176+
"smb": opts.SMB,
177+
}
178+
if opts.SudoCommands != nil {
179+
params["sudo_commands"] = opts.SudoCommands
180+
}
181+
if opts.SudoCommandsNopasswd != nil {
182+
params["sudo_commands_nopasswd"] = opts.SudoCommandsNopasswd
183+
}
184+
return params
185+
}
186+
187+
// groupFromResponse converts a wire-format GroupResponse to a user-facing Group.
188+
func groupFromResponse(resp GroupResponse) Group {
189+
return Group{
190+
ID: resp.ID,
191+
GID: resp.GID,
192+
Name: resp.Name,
193+
Builtin: resp.Builtin,
194+
SMB: resp.SMB,
195+
SudoCommands: resp.SudoCommands,
196+
SudoCommandsNopasswd: resp.SudoCommandsNopasswd,
197+
Users: resp.Users,
198+
Local: resp.Local,
199+
Immutable: resp.Immutable,
200+
}
201+
}

group_service_iface.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package truenas
2+
3+
import "context"
4+
5+
// GroupServiceAPI defines the interface for group operations.
6+
type GroupServiceAPI interface {
7+
Create(ctx context.Context, opts CreateGroupOpts) (*Group, error)
8+
Get(ctx context.Context, id int64) (*Group, error)
9+
GetByName(ctx context.Context, name string) (*Group, error)
10+
GetByGID(ctx context.Context, gid int64) (*Group, error)
11+
List(ctx context.Context) ([]Group, error)
12+
Update(ctx context.Context, id int64, opts UpdateGroupOpts) (*Group, error)
13+
Delete(ctx context.Context, id int64) error
14+
}
15+
16+
// Compile-time checks.
17+
var _ GroupServiceAPI = (*GroupService)(nil)
18+
var _ GroupServiceAPI = (*MockGroupService)(nil)
19+
20+
// MockGroupService is a test double for GroupServiceAPI.
21+
type MockGroupService struct {
22+
CreateFunc func(ctx context.Context, opts CreateGroupOpts) (*Group, error)
23+
GetFunc func(ctx context.Context, id int64) (*Group, error)
24+
GetByNameFunc func(ctx context.Context, name string) (*Group, error)
25+
GetByGIDFunc func(ctx context.Context, gid int64) (*Group, error)
26+
ListFunc func(ctx context.Context) ([]Group, error)
27+
UpdateFunc func(ctx context.Context, id int64, opts UpdateGroupOpts) (*Group, error)
28+
DeleteFunc func(ctx context.Context, id int64) error
29+
}
30+
31+
func (m *MockGroupService) Create(ctx context.Context, opts CreateGroupOpts) (*Group, error) {
32+
if m.CreateFunc != nil {
33+
return m.CreateFunc(ctx, opts)
34+
}
35+
return nil, nil
36+
}
37+
38+
func (m *MockGroupService) Get(ctx context.Context, id int64) (*Group, error) {
39+
if m.GetFunc != nil {
40+
return m.GetFunc(ctx, id)
41+
}
42+
return nil, nil
43+
}
44+
45+
func (m *MockGroupService) GetByName(ctx context.Context, name string) (*Group, error) {
46+
if m.GetByNameFunc != nil {
47+
return m.GetByNameFunc(ctx, name)
48+
}
49+
return nil, nil
50+
}
51+
52+
func (m *MockGroupService) GetByGID(ctx context.Context, gid int64) (*Group, error) {
53+
if m.GetByGIDFunc != nil {
54+
return m.GetByGIDFunc(ctx, gid)
55+
}
56+
return nil, nil
57+
}
58+
59+
func (m *MockGroupService) List(ctx context.Context) ([]Group, error) {
60+
if m.ListFunc != nil {
61+
return m.ListFunc(ctx)
62+
}
63+
return nil, nil
64+
}
65+
66+
func (m *MockGroupService) Update(ctx context.Context, id int64, opts UpdateGroupOpts) (*Group, error) {
67+
if m.UpdateFunc != nil {
68+
return m.UpdateFunc(ctx, id, opts)
69+
}
70+
return nil, nil
71+
}
72+
73+
func (m *MockGroupService) Delete(ctx context.Context, id int64) error {
74+
if m.DeleteFunc != nil {
75+
return m.DeleteFunc(ctx, id)
76+
}
77+
return nil
78+
}

group_service_iface_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package truenas
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestMockGroupService_ImplementsInterface(t *testing.T) {
9+
var _ GroupServiceAPI = (*GroupService)(nil)
10+
var _ GroupServiceAPI = (*MockGroupService)(nil)
11+
}
12+
13+
func TestMockGroupService_DefaultsToNil(t *testing.T) {
14+
mock := &MockGroupService{}
15+
ctx := context.Background()
16+
17+
group, err := mock.Get(ctx, 1)
18+
if err != nil {
19+
t.Fatalf("expected nil error, got: %v", err)
20+
}
21+
if group != nil {
22+
t.Fatalf("expected nil result, got: %v", group)
23+
}
24+
25+
groups, err := mock.List(ctx)
26+
if err != nil {
27+
t.Fatalf("expected nil error from List, got: %v", err)
28+
}
29+
if groups != nil {
30+
t.Fatalf("expected nil result from List, got: %v", groups)
31+
}
32+
33+
err = mock.Delete(ctx, 1)
34+
if err != nil {
35+
t.Fatalf("expected nil error from Delete, got: %v", err)
36+
}
37+
}
38+
39+
func TestMockGroupService_CallsFunc(t *testing.T) {
40+
called := false
41+
mock := &MockGroupService{
42+
GetFunc: func(ctx context.Context, id int64) (*Group, error) {
43+
called = true
44+
return &Group{ID: id, Name: "test"}, nil
45+
},
46+
}
47+
48+
group, err := mock.Get(context.Background(), 42)
49+
if err != nil {
50+
t.Fatalf("unexpected error: %v", err)
51+
}
52+
if !called {
53+
t.Fatal("expected GetFunc to be called")
54+
}
55+
if group.ID != 42 {
56+
t.Fatalf("expected ID 42, got %d", group.ID)
57+
}
58+
}

0 commit comments

Comments
 (0)