1
# okg — Oscar Kilo Goodness
1
# okg — Oscar Kilo Goodness
2
2
3
A command-line tool that bundles Oscar Kilo's externally usable
3
A command-line tool that bundles Oscar Kilo's externally usable
4
services into one binary. Today: git ops against
4
services into one binary. Today: git ops against
5
[klee](https://code.oscarkilo.com), and LLM embeddings against
5
[klee](https://code.oscarkilo.com), and LLM embeddings against
6
[klex](https://oscarkilo.com/klex). More LLM utilities (`one`,
6
[klex](https://oscarkilo.com/klex). More LLM utilities (`one`,
7
`exemplary`) coming as the legacy `klex-git` binaries fold in.
7
`exemplary`) coming as the legacy `klex-git` binaries fold in.
8
8
9
Designed for both humans and AI agents (Claude, OpenClaw) to
9
Designed for both humans and AI agents (Claude, OpenClaw) to
10
use directly from the command line.
10
use directly from the command line.
11
11
12
## Install
12
## Install
13
13
14
```bash
14
```bash
15
go install oscarkilo.com/okg@latest
15
go install oscarkilo.com/okg@latest
16
```
16
```
17
17
18
Or build from source:
18
Or build from source:
19
19
20
```bash
20
```bash
21
git clone https://code.oscarkilo.com/okg
21
git clone https://code.oscarkilo.com/okg
22
cd okg && go build .
22
cd okg && go build .
23
```
23
```
24
24
25
## Setup
25
## Setup
26
26
27
By default okg talks to the production klee host
27
By default okg talks to the production klee host
28
(`https://code.oscarkilo.com`); the only setup you need is your
28
(`https://code.oscarkilo.com`); the only setup you need is your
29
API key. `okg auth login` saves it to
29
API key. `okg auth login` saves it to
30
`~/.config/okg/config.json`.
30
`~/.config/okg/config.json`.
31
31
32
```bash
32
```bash
33
# Non-interactive (for agents and scripts).
33
# Non-interactive (for agents and scripts).
34
okg auth login --key sk-...
34
okg auth login --key sk-...
35
cat ~/.klex.key | okg auth login # equivalent, ps-safe
35
cat ~/.klex.key | okg auth login # equivalent, ps-safe
36
36
37
# Interactive (prompts for host then key).
37
# Interactive (prompts for host then key).
38
okg auth login
38
okg auth login
39
```
39
```
40
40
41
Override the host for dev/local work with `--host` on `auth login`.
41
Override the host for dev/local work with `--host` on `auth login`.
42
42
43
## Commands
43
## Commands
44
44
45
```
45
```
46
okg repo list
46
okg repo list
47
47
48
okg pr list [--state open|closed]
48
okg pr list [--state open|closed]
49
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
49
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
50
okg pr view NUMBER
50
okg pr view NUMBER
51
okg pr diff NUMBER
51
okg pr diff NUMBER
52
okg pr comment NUMBER --body BODY [--approve | --request-changes]
52
okg pr comment NUMBER --body BODY [--approve | --request-changes]
53
okg pr merge NUMBER
53
okg pr merge NUMBER
54
okg pr close NUMBER
54
okg pr close NUMBER
55
okg pr reopen NUMBER
55
okg pr reopen NUMBER
56
56
57
okg auth login [--key KEY] [--host HOST]
57
okg auth login [--key KEY] [--host HOST]
58
58
59
okg group list [--json]
59
okg group list [--json]
60
okg group create NAME [--full-name TEXT] [--owner USER]
60
okg group create NAME [--full-name TEXT] [--owner USER]
61
okg group add-member GROUP USER [USER ...]
61
okg group add-member GROUP USER [USER ...]
62
okg group remove-member GROUP USER [USER ...]
62
okg group remove-member GROUP USER [USER ...]
63
okg group members GROUP [--json]
63
okg group members GROUP [--json]
64
okg group delete NAME
64
okg group delete NAME
65
65
66
okg authz list [--json]
67
okg authz set URI OWNER READER
68
okg authz delete URI
69
66
okg embed [--model NAME] [--dims N] [--full-path]
70
okg embed [--model NAME] [--dims N] [--full-path]
67
(reads stdin → vectors on stdout)
71
(reads stdin → vectors on stdout)
68
okg one [--model NAME] [--system-file FILE] \
72
okg one [--model NAME] [--system-file FILE] \
69
[--prompt-file FILE] [--attach FILE] \
73
[--prompt-file FILE] [--attach FILE] \
70
[--format text|json|jsonindent] [--fast-fail]
74
[--format text|json|jsonindent] [--fast-fail]
71
(reads stdin as a JSON MessagesRequest)
75
(reads stdin as a JSON MessagesRequest)
72
```
76
```
73
77
74
### Coming next
78
### Coming next
75
79
76
- `okg exemplary` — few-shot batch runner (from
80
- `okg exemplary` — few-shot batch runner (from
77
`klex-git/exemplary`).
81
`klex-git/exemplary`).
78
82
79
Once that lands, the standalone `klex-git` binaries are
83
Once that lands, the standalone `klex-git` binaries are
80
deprecated.
84
deprecated.
81
85
82
### Flags
86
### Flags
83
87
84
- `--repo REPO` overrides auto-detected repo name
88
- `--repo REPO` overrides auto-detected repo name
85
(normally parsed from `git remote get-url origin`)
89
(normally parsed from `git remote get-url origin`)
86
- `--json` outputs raw JSON for any command
90
- `--json` outputs raw JSON for any command
87
- `OKG_REPO` env var also overrides repo detection
91
- `OKG_REPO` env var also overrides repo detection
88
92
89
## Repo Detection
93
## Repo Detection
90
94
91
Like `gh`, okg detects the repo from the current directory's
95
Like `gh`, okg detects the repo from the current directory's
92
git remote:
96
git remote:
93
97
94
```
98
```
95
git remote get-url origin
99
git remote get-url origin
96
→ https://code.oscarkilo.com/widget.git
100
→ https://code.oscarkilo.com/widget.git
97
→ repo = "widget"
101
→ repo = "widget"
98
```
102
```
99
103
100
## Dependencies
104
## Dependencies
101
105
102
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
106
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
103
to be folded in once all `klex-git` binaries have moved here).
107
to be folded in once all `klex-git` binaries have moved here).
104
- Otherwise Go standard library only.
108
- Otherwise Go standard library only.
1
package main
2
3
import "encoding/json"
4
import "flag"
5
import "fmt"
6
import "os"
7
import "text/tabwriter"
8
9
import "oscarkilo.com/okg/who"
10
11
func runAuthz(args []string) error {
12
if len(args) == 0 {
13
return fmt.Errorf(
14
"usage: okg authz SUBCOMMAND ... " +
15
"(try `okg --help`)")
16
}
17
switch args[0] {
18
case "list":
19
return runAuthzList(args[1:])
20
case "set":
21
return runAuthzSet(args[1:])
22
case "delete":
23
return runAuthzDelete(args[1:])
24
default:
25
return fmt.Errorf(
26
"unknown authz subcommand: %s", args[0])
27
}
28
}
29
30
func runAuthzList(args []string) error {
31
fs := flag.NewFlagSet("authz list", flag.ContinueOnError)
32
asJSON := fs.Bool("json", false, "output raw JSON")
33
if err := fs.Parse(args); err != nil {
34
return err
35
}
36
37
c, err := newWhoClient()
38
if err != nil {
39
return err
40
}
41
uris, err := c.ListAuthz()
42
if err != nil {
43
return err
44
}
45
46
if *asJSON {
47
buf, err := json.MarshalIndent(uris, "", " ")
48
if err != nil {
49
return err
50
}
51
fmt.Println(string(buf))
52
return nil
53
}
54
55
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
56
fmt.Fprintln(tw, "URI\tOWNER\tREADER\tYOU")
57
for _, e := range uris {
58
owner, reader := "", ""
59
if e.Owner != nil {
60
owner = e.Owner.Username
61
}
62
if e.Reader != nil {
63
reader = e.Reader.Username
64
}
65
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
66
e.Uri, owner, reader, rights(e))
67
}
68
return tw.Flush()
69
}
70
71
// rights is the human-friendly summary of the caller's
72
// effective rights on an authz entry.
73
func rights(e who.AuthzEntry) string {
74
switch {
75
case e.IsOwner && e.IsReader:
76
return "owner+reader"
77
case e.IsOwner:
78
return "owner"
79
case e.IsReader:
80
return "reader"
81
default:
82
return "-"
83
}
84
}
85
86
func runAuthzSet(args []string) error {
87
fs := flag.NewFlagSet("authz set", flag.ContinueOnError)
88
if err := fs.Parse(args); err != nil {
89
return err
90
}
91
positional := fs.Args()
92
if len(positional) != 3 {
93
return fmt.Errorf(
94
"usage: okg authz set URI OWNER READER")
95
}
96
uri := positional[0]
97
owner := positional[1]
98
reader := positional[2]
99
100
c, err := newWhoClient()
101
if err != nil {
102
return err
103
}
104
if err := c.SetAuthz(who.AuthzSetRequest{
105
Uri: uri,
106
OwnerUsername: owner,
107
ReaderUsername: reader,
108
}); err != nil {
109
return err
110
}
111
fmt.Printf(
112
"Set authz on %s (owner=%s, reader=%s)\n",
113
uri, owner, reader)
114
return nil
115
}
116
117
func runAuthzDelete(args []string) error {
118
fs := flag.NewFlagSet(
119
"authz delete", flag.ContinueOnError)
120
if err := fs.Parse(args); err != nil {
121
return err
122
}
123
positional := fs.Args()
124
if len(positional) != 1 {
125
return fmt.Errorf(
126
"usage: okg authz delete URI")
127
}
128
uri := positional[0]
129
130
c, err := newWhoClient()
131
if err != nil {
132
return err
133
}
134
if err := c.DeleteAuthz(who.AuthzDeleteRequest{
135
Uri: uri,
136
}); err != nil {
137
return err
138
}
139
fmt.Printf("Deleted authz on %s\n", uri)
140
return nil
141
}
1
package main
1
package main
2
2
3
import "fmt"
3
import "fmt"
4
import "os"
4
import "os"
5
5
6
func main() {
6
func main() {
7
args := os.Args[1:]
7
args := os.Args[1:]
8
if len(args) == 0 {
8
if len(args) == 0 {
9
printUsage()
9
printUsage()
10
os.Exit(1)
10
os.Exit(1)
11
}
11
}
12
12
13
var err error
13
var err error
14
switch args[0] {
14
switch args[0] {
15
case "pr":
15
case "pr":
16
err = runPR(args[1:])
16
err = runPR(args[1:])
17
case "repo":
17
case "repo":
18
err = runRepo(args[1:])
18
err = runRepo(args[1:])
19
case "auth":
19
case "auth":
20
err = runAuth(args[1:])
20
err = runAuth(args[1:])
21
case "embed":
21
case "embed":
22
err = runEmbed(args[1:])
22
err = runEmbed(args[1:])
23
case "one":
23
case "one":
24
err = runOne(args[1:])
24
err = runOne(args[1:])
25
case "group":
25
case "group":
26
err = runGroup(args[1:])
26
err = runGroup(args[1:])
27
case "authz":
28
err = runAuthz(args[1:])
27
case "help", "--help", "-h":
29
case "help", "--help", "-h":
28
printUsage()
30
printUsage()
29
return
31
return
30
default:
32
default:
31
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
33
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
32
printUsage()
34
printUsage()
33
os.Exit(1)
35
os.Exit(1)
34
}
36
}
35
37
36
if err != nil {
38
if err != nil {
37
fmt.Fprintf(os.Stderr, "error: %v\n", err)
39
fmt.Fprintf(os.Stderr, "error: %v\n", err)
38
os.Exit(1)
40
os.Exit(1)
39
}
41
}
40
}
42
}
41
43
42
func printUsage() {
44
func printUsage() {
43
fmt.Fprintf(os.Stderr, `NAME
45
fmt.Fprintf(os.Stderr, `NAME
44
okg — Oscar Kilo Goodness
46
okg — Oscar Kilo Goodness
45
47
46
SETUP
48
SETUP
47
okg auth login
49
okg auth login
48
--key KEY API key (also accepted via stdin)
50
--key KEY API key (also accepted via stdin)
49
--host HOST klee host (default: production)
51
--host HOST klee host (default: production)
50
52
51
GIT REPOS
53
GIT REPOS
52
okg repo list
54
okg repo list
53
--json output raw JSON
55
--json output raw JSON
54
56
55
okg repo create NAME
57
okg repo create NAME
56
--reader USER grant read to USER (default: anyone)
58
--reader USER grant read to USER (default: anyone)
57
59
58
GROUPS
60
GROUPS
59
okg group list
61
okg group list
60
--json output raw JSON
62
--json output raw JSON
61
63
62
okg group create NAME
64
okg group create NAME
63
--full-name TEXT display name (default: NAME)
65
--full-name TEXT display name (default: NAME)
64
--owner USER owner username (default: caller)
66
--owner USER owner username (default: caller)
65
67
66
okg group add-member GROUP USER [USER ...]
68
okg group add-member GROUP USER [USER ...]
67
69
68
okg group remove-member GROUP USER [USER ...]
70
okg group remove-member GROUP USER [USER ...]
69
71
70
okg group members GROUP
72
okg group members GROUP
71
--json full DAG (Up/Down/Usernames) as raw JSON
73
--json full DAG (Up/Down/Usernames) as raw JSON
72
74
73
okg group delete NAME
75
okg group delete NAME
74
76
77
AUTHZ
78
okg authz list
79
--json output raw JSON
80
81
okg authz set URI OWNER READER
82
83
okg authz delete URI
84
75
PULL REQUESTS
85
PULL REQUESTS
76
okg pr list
86
okg pr list
77
--state STATE open or closed (default: open)
87
--state STATE open or closed (default: open)
78
--json output raw JSON
88
--json output raw JSON
79
89
80
okg pr create
90
okg pr create
81
--head BRANCH source branch
91
--head BRANCH source branch
82
--base BRANCH target branch (default: master)
92
--base BRANCH target branch (default: master)
83
--title TITLE PR title
93
--title TITLE PR title
84
--body BODY PR body (optional)
94
--body BODY PR body (optional)
85
--json output raw JSON
95
--json output raw JSON
86
96
87
okg pr view NUMBER
97
okg pr view NUMBER
88
--json output raw JSON
98
--json output raw JSON
89
99
90
okg pr diff NUMBER
100
okg pr diff NUMBER
91
101
92
okg pr comment NUMBER
102
okg pr comment NUMBER
93
--body BODY comment body
103
--body BODY comment body
94
--approve also approve the PR
104
--approve also approve the PR
95
--request-changes also request changes
105
--request-changes also request changes
96
106
97
okg pr merge NUMBER
107
okg pr merge NUMBER
98
--json output raw JSON
108
--json output raw JSON
99
109
100
okg pr close NUMBER
110
okg pr close NUMBER
101
--json output raw JSON
111
--json output raw JSON
102
112
103
okg pr reopen NUMBER
113
okg pr reopen NUMBER
104
--json output raw JSON
114
--json output raw JSON
105
115
106
ARTIFICIAL INTELLIGENCE
116
ARTIFICIAL INTELLIGENCE
107
okg embed
117
okg embed
108
--model NAME embedding model
118
--model NAME embedding model
109
(default: openai:text-embedding-3-small)
119
(default: openai:text-embedding-3-small)
110
--dims N number of dimensions (default: 1536)
120
--dims N number of dimensions (default: 1536)
111
--full-path one vector per prefix of input
121
--full-path one vector per prefix of input
112
(reads stdin; writes vectors to stdout, one per line)
122
(reads stdin; writes vectors to stdout, one per line)
113
123
114
okg one
124
okg one
115
--model NAME override .Model in the request
125
--model NAME override .Model in the request
116
--system-file FILE override .System with contents of FILE
126
--system-file FILE override .System with contents of FILE
117
--prompt-file FILE append FILE as a user prompt
127
--prompt-file FILE append FILE as a user prompt
118
--attach FILE attach an image or PDF to the prompt
128
--attach FILE attach an image or PDF to the prompt
119
--format FORMAT text | json | jsonindent (default: text)
129
--format FORMAT text | json | jsonindent (default: text)
120
--fast-fail preflight attachment MIME (default: on)
130
--fast-fail preflight attachment MIME (default: on)
121
(reads stdin as a JSON MessagesRequest; flags override
131
(reads stdin as a JSON MessagesRequest; flags override
122
its fields)
132
its fields)
123
133
124
GLOBAL FLAGS
134
GLOBAL FLAGS
125
--repo REPO override auto-detected repo name
135
--repo REPO override auto-detected repo name
126
--json output raw JSON (where applicable)
136
--json output raw JSON (where applicable)
127
137
128
EXAMPLES
138
EXAMPLES
129
Setup
139
Setup
130
okg auth login --key sk-...
140
okg auth login --key sk-...
131
cat ~/.klex.key | okg auth login
141
cat ~/.klex.key | okg auth login
132
142
133
Git repos
143
Git repos
134
okg repo list
144
okg repo list
135
okg repo create my-new-repo
145
okg repo create my-new-repo
136
146
137
Groups
147
Groups
138
okg group list
148
okg group list
139
okg group create chat-bots
149
okg group create chat-bots
140
okg group add-member chat-bots claude openclaw
150
okg group add-member chat-bots claude openclaw
141
okg group remove-member chat-bots openclaw
151
okg group remove-member chat-bots openclaw
142
okg group members chat-bots
152
okg group members chat-bots
143
okg group delete chat-bots
153
okg group delete chat-bots
144
154
155
Authz
156
okg authz set chat://chat-bots#post chat-bots chat-bots
157
okg authz list
158
145
Pull requests
159
Pull requests
146
okg pr list --state open
160
okg pr list --state open
147
okg pr view 42
161
okg pr view 42
148
okg pr comment 42 --body 'LGTM' --approve
162
okg pr comment 42 --body 'LGTM' --approve
149
163
150
Artificial intelligence
164
Artificial intelligence
151
echo 'hello world' | okg embed --dims 384
165
echo 'hello world' | okg embed --dims 384
152
echo Hello? > /tmp/q.txt && \
166
echo Hello? > /tmp/q.txt && \
153
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
167
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
154
`)
168
`)
155
}
169
}
1
// Package who is the public HTTP client for OscarKilo's //who
1
// Package who is the public HTTP client for OscarKilo's //who
2
// service. It is the world-public side of //who, used by
2
// service. It is the world-public side of //who, used by
3
// `okg` and any other tool that talks to oscarkilo.com over
3
// `okg` and any other tool that talks to oscarkilo.com over
4
// the network.
4
// the network.
5
//
5
//
6
// //clients/who (intra-cluster, has the in-process MockClient,
6
// //clients/who (intra-cluster, has the in-process MockClient,
7
// the /internal/* calls, the dev/prod factory) will eventually
7
// the /internal/* calls, the dev/prod factory) will eventually
8
// import this package via `import . "oscarkilo.com/okg/who"`
8
// import this package via `import . "oscarkilo.com/okg/who"`
9
// so the two share types and request paths. To make that dot
9
// so the two share types and request paths. To make that dot
10
// import work, the struct here is named HTTPClient — leaving
10
// import work, the struct here is named HTTPClient — leaving
11
// the unqualified `Client` name free for //clients/who's
11
// the unqualified `Client` name free for //clients/who's
12
// existing interface.
12
// existing interface.
13
package who
13
package who
14
14
15
import "oscarkilo.com/okg/internal/rest"
15
import "oscarkilo.com/okg/internal/rest"
16
16
17
// HTTPClient talks to a //who server over HTTPS. Embeds the
17
// HTTPClient talks to a //who server over HTTPS. Embeds the
18
// shared rest helpers; who-specific methods live below.
18
// shared rest helpers; who-specific methods live below.
19
type HTTPClient struct {
19
type HTTPClient struct {
20
*rest.Client
20
*rest.Client
21
}
21
}
22
22
23
func NewHTTPClient(host, apiKey string) *HTTPClient {
23
func NewHTTPClient(host, apiKey string) *HTTPClient {
24
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
24
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
25
}
25
}
26
26
27
// ---- Caller identity ----
27
// ---- Caller identity ----
28
28
29
// Profile is the caller's //who profile. Mirrors the
29
// Profile is the caller's //who profile. Mirrors the
30
// JSON-serialized subset of //who.Profile that GET
30
// JSON-serialized subset of //who.Profile that GET
31
// /login/profile/get returns. Extra fields the server may
31
// /login/profile/get returns. Extra fields the server may
32
// return (ApiKeys, Logins, AppData, timestamps) are decoded
32
// return (ApiKeys, Logins, AppData, timestamps) are decoded
33
// permissively if added later; we just don't expose them here
33
// permissively if added later; we just don't expose them here
34
// until okg has a use for them.
34
// until okg has a use for them.
35
type Profile struct {
35
type Profile struct {
36
Owid string `json:"owid"`
36
Owid string `json:"owid"`
37
Username string `json:"username"`
37
Username string `json:"username"`
38
Name string `json:"name"`
38
Name string `json:"name"`
39
Email string `json:"email"`
39
Email string `json:"email"`
40
PortraitUrl string `json:"portrait_url"`
40
PortraitUrl string `json:"portrait_url"`
41
OwnerOwid string `json:"owner_owid"`
41
OwnerOwid string `json:"owner_owid"`
42
Groups []string `json:"groups"`
42
Groups []string `json:"groups"`
43
}
43
}
44
44
45
// GetProfile returns the caller's own profile, identified by
45
// GetProfile returns the caller's own profile, identified by
46
// the Bearer key.
46
// the Bearer key.
47
func (c *HTTPClient) GetProfile() (*Profile, error) {
47
func (c *HTTPClient) GetProfile() (*Profile, error) {
48
var p Profile
48
var p Profile
49
if err := c.GetJSON("/login/profile/get", &p); err != nil {
49
if err := c.GetJSON("/login/profile/get", &p); err != nil {
50
return nil, err
50
return nil, err
51
}
51
}
52
return &p, nil
52
return &p, nil
53
}
53
}
54
54
55
// ---- Groups ----
55
// ---- Groups ----
56
56
57
// Group describes a //who group (a named entity with members).
57
// Group describes a //who group (a named entity with members).
58
// Mirrors the listedGroup shape in //who/server/groups_list.go.
58
// Mirrors the listedGroup shape in //who/server/groups_list.go.
59
type Group struct {
59
type Group struct {
60
Owid string `json:"owid"`
60
Owid string `json:"owid"`
61
Username string `json:"username"`
61
Username string `json:"username"`
62
Name string `json:"name"`
62
Name string `json:"name"`
63
OwnerOwid string `json:"owner_owid"`
63
OwnerOwid string `json:"owner_owid"`
64
OwnerUsername string `json:"owner_username"`
64
OwnerUsername string `json:"owner_username"`
65
}
65
}
66
66
67
type listGroupsResponse struct {
67
type listGroupsResponse struct {
68
Groups []Group `json:"groups"`
68
Groups []Group `json:"groups"`
69
}
69
}
70
70
71
// ListGroups returns the groups visible to the caller.
71
// ListGroups returns the groups visible to the caller.
72
func (c *HTTPClient) ListGroups() ([]Group, error) {
72
func (c *HTTPClient) ListGroups() ([]Group, error) {
73
var res listGroupsResponse
73
var res listGroupsResponse
74
if err := c.GetJSON("/groups/list", &res); err != nil {
74
if err := c.GetJSON("/groups/list", &res); err != nil {
75
return nil, err
75
return nil, err
76
}
76
}
77
return res.Groups, nil
77
return res.Groups, nil
78
}
78
}
79
79
80
// CreateGroupRequest is the body for POST /groups/add. Username
80
// CreateGroupRequest is the body for POST /groups/add. Username
81
// is the group's //who username (e.g. "team"); Name is the
81
// is the group's //who username (e.g. "team"); Name is the
82
// human-facing display name; OwnerUsername is the user who'll
82
// human-facing display name; OwnerUsername is the user who'll
83
// own the new group.
83
// own the new group.
84
type CreateGroupRequest struct {
84
type CreateGroupRequest struct {
85
Username string `json:"username"`
85
Username string `json:"username"`
86
Name string `json:"name"`
86
Name string `json:"name"`
87
OwnerUsername string `json:"owner_username"`
87
OwnerUsername string `json:"owner_username"`
88
}
88
}
89
89
90
// CreateGroup creates a new //who group.
90
// CreateGroup creates a new //who group.
91
func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
91
func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
92
return c.PostJSON("/groups/add", req, nil)
92
return c.PostJSON("/groups/add", req, nil)
93
}
93
}
94
94
95
// JoinGroupsRequest is the body for POST /groups/join. The
95
// JoinGroupsRequest is the body for POST /groups/join. The
96
// server adds every (GroupUsernames[i], MemberUsernames[j])
96
// server adds every (GroupUsernames[i], MemberUsernames[j])
97
// pair, requiring the caller to be an owner of each group.
97
// pair, requiring the caller to be an owner of each group.
98
type JoinGroupsRequest struct {
98
type JoinGroupsRequest struct {
99
GroupUsernames []string `json:"group_usernames"`
99
GroupUsernames []string `json:"group_usernames"`
100
MemberUsernames []string `json:"member_usernames"`
100
MemberUsernames []string `json:"member_usernames"`
101
}
101
}
102
102
103
// JoinGroups adds members to groups (all pairwise combinations).
103
// JoinGroups adds members to groups (all pairwise combinations).
104
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
104
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
105
return c.PostJSON("/groups/join", req, nil)
105
return c.PostJSON("/groups/join", req, nil)
106
}
106
}
107
107
108
// GroupMembersResponse is the response from POST
108
// GroupMembersResponse is the response from POST
109
// /groups/members. Up lists the groups that contain the queried
109
// /groups/members. Up lists the groups that contain the queried
110
// entity (each level as a slice of owids). Down lists its
110
// entity (each level as a slice of owids). Down lists its
111
// members the same way. Usernames maps every owid mentioned to
111
// members the same way. Usernames maps every owid mentioned to
112
// its //who username.
112
// its //who username.
113
type GroupMembersResponse struct {
113
type GroupMembersResponse struct {
114
Up [][]string `json:"up"`
114
Up [][]string `json:"up"`
115
Down [][]string `json:"down"`
115
Down [][]string `json:"down"`
116
Usernames map[string]string `json:"usernames"`
116
Usernames map[string]string `json:"usernames"`
117
}
117
}
118
118
119
// GroupMembers returns membership DAG navigation for the given
119
// GroupMembers returns membership DAG navigation for the given
120
// entity owid. For a group, Down enumerates its members.
120
// entity owid. For a group, Down enumerates its members.
121
func (c *HTTPClient) GroupMembers(
121
func (c *HTTPClient) GroupMembers(
122
ownerOwid string,
122
ownerOwid string,
123
) (*GroupMembersResponse, error) {
123
) (*GroupMembersResponse, error) {
124
var res GroupMembersResponse
124
var res GroupMembersResponse
125
err := c.PostJSON("/groups/members",
125
err := c.PostJSON("/groups/members",
126
map[string]string{"owid": ownerOwid}, &res)
126
map[string]string{"owid": ownerOwid}, &res)
127
if err != nil {
127
if err != nil {
128
return nil, err
128
return nil, err
129
}
129
}
130
return &res, nil
130
return &res, nil
131
}
131
}
132
132
133
// LeaveGroupRequest is the body for POST /groups/leave. Both
133
// LeaveGroupRequest is the body for POST /groups/leave. Both
134
// IDs are owids (obfuscated wids); to translate from usernames,
134
// IDs are owids (obfuscated wids); to translate from usernames,
135
// call ListGroups + GroupMembers first.
135
// call ListGroups + GroupMembers first.
136
type LeaveGroupRequest struct {
136
type LeaveGroupRequest struct {
137
GroupOwid string `json:"group_owid"`
137
GroupOwid string `json:"group_owid"`
138
MemberOwid string `json:"member_owid"`
138
MemberOwid string `json:"member_owid"`
139
}
139
}
140
140
141
// LeaveGroup removes a member from a group. The caller must own
141
// LeaveGroup removes a member from a group. The caller must own
142
// the group.
142
// the group.
143
func (c *HTTPClient) LeaveGroup(req LeaveGroupRequest) error {
143
func (c *HTTPClient) LeaveGroup(req LeaveGroupRequest) error {
144
return c.PostJSON("/groups/leave", req, nil)
144
return c.PostJSON("/groups/leave", req, nil)
145
}
145
}
146
146
147
// DeleteGroupRequest is the body for POST /groups/delete. Owid
147
// DeleteGroupRequest is the body for POST /groups/delete. Owid
148
// is the group's obfuscated wid.
148
// is the group's obfuscated wid.
149
type DeleteGroupRequest struct {
149
type DeleteGroupRequest struct {
150
Owid string `json:"owid"`
150
Owid string `json:"owid"`
151
}
151
}
152
152
153
// DeleteGroup destroys a group. The caller must own it (or be a
153
// DeleteGroup destroys a group. The caller must own it (or be a
154
// //who admin).
154
// //who admin).
155
func (c *HTTPClient) DeleteGroup(req DeleteGroupRequest) error {
155
func (c *HTTPClient) DeleteGroup(req DeleteGroupRequest) error {
156
return c.PostJSON("/groups/delete", req, nil)
156
return c.PostJSON("/groups/delete", req, nil)
157
}
157
}
158
159
// ---- Authz ----
160
161
// User is a thin //who-user identifier used inside AuthzEntry.
162
// Mirrors the Owid/Username subset of //clients/who.User.
163
type User struct {
164
Owid string `json:"owid"`
165
Username string `json:"username"`
166
}
167
168
// AuthzEntry is one row of GET /authz/list: the URI, its owner
169
// and reader, and the caller's effective rights on it.
170
type AuthzEntry struct {
171
Uri string `json:"uri"`
172
Owner *User `json:"owner"`
173
Reader *User `json:"reader"`
174
IsOwner bool `json:"is_owner"`
175
IsReader bool `json:"is_reader"`
176
}
177
178
type listAuthzResponse struct {
179
Uris []AuthzEntry `json:"uris"`
180
}
181
182
// ListAuthz returns all authz URIs visible to the caller.
183
func (c *HTTPClient) ListAuthz() ([]AuthzEntry, error) {
184
var res listAuthzResponse
185
if err := c.GetJSON("/authz/list", &res); err != nil {
186
return nil, err
187
}
188
return res.Uris, nil
189
}
190
191
// AuthzSetRequest is the body for POST /authz/set. Both
192
// usernames must be non-empty and known to //who; the public
193
// endpoint does not honor the "anyone" sentinel.
194
type AuthzSetRequest struct {
195
Uri string `json:"uri"`
196
OwnerUsername string `json:"owner_username"`
197
ReaderUsername string `json:"reader_username"`
198
}
199
200
// SetAuthz writes an authz entry. The caller must be an owner
201
// of the URI (or a //who admin).
202
func (c *HTTPClient) SetAuthz(req AuthzSetRequest) error {
203
return c.PostJSON("/authz/set", req, nil)
204
}
205
206
// AuthzDeleteRequest is the body for POST /authz/delete.
207
type AuthzDeleteRequest struct {
208
Uri string `json:"uri"`
209
}
210
211
// DeleteAuthz removes an authz entry. The caller must own the URI.
212
func (c *HTTPClient) DeleteAuthz(req AuthzDeleteRequest) error {
213
return c.PostJSON("/authz/delete", req, nil)
214
}
1
package who
1
package who
2
2
3
import "encoding/json"
3
import "encoding/json"
4
import "net/http"
4
import "net/http"
5
import "net/http/httptest"
5
import "net/http/httptest"
6
import "testing"
6
import "testing"
7
7
8
func TestListGroups(t *testing.T) {
8
func TestListGroups(t *testing.T) {
9
srv := httptest.NewServer(http.HandlerFunc(
9
srv := httptest.NewServer(http.HandlerFunc(
10
func(w http.ResponseWriter, r *http.Request) {
10
func(w http.ResponseWriter, r *http.Request) {
11
if r.URL.Path != "/groups/list" {
11
if r.URL.Path != "/groups/list" {
12
t.Errorf("path: got %q, want /groups/list",
12
t.Errorf("path: got %q, want /groups/list",
13
r.URL.Path)
13
r.URL.Path)
14
}
14
}
15
if r.Header.Get("Authorization") !=
15
if r.Header.Get("Authorization") !=
16
"Bearer test-key" {
16
"Bearer test-key" {
17
t.Errorf("Authorization: got %q",
17
t.Errorf("Authorization: got %q",
18
r.Header.Get("Authorization"))
18
r.Header.Get("Authorization"))
19
}
19
}
20
w.Header().Set(
20
w.Header().Set(
21
"Content-Type", "application/json")
21
"Content-Type", "application/json")
22
json.NewEncoder(w).Encode(listGroupsResponse{
22
json.NewEncoder(w).Encode(listGroupsResponse{
23
Groups: []Group{
23
Groups: []Group{
24
{
24
{
25
Username: "team",
25
Username: "team",
26
Name: "Team",
26
Name: "Team",
27
OwnerUsername: "alice",
27
OwnerUsername: "alice",
28
},
28
},
29
{
29
{
30
Username: "admins",
30
Username: "admins",
31
Name: "Admins",
31
Name: "Admins",
32
OwnerUsername: "alice",
32
OwnerUsername: "alice",
33
},
33
},
34
},
34
},
35
})
35
})
36
}))
36
}))
37
defer srv.Close()
37
defer srv.Close()
38
38
39
c := NewHTTPClient(srv.URL, "test-key")
39
c := NewHTTPClient(srv.URL, "test-key")
40
groups, err := c.ListGroups()
40
groups, err := c.ListGroups()
41
if err != nil {
41
if err != nil {
42
t.Fatal(err)
42
t.Fatal(err)
43
}
43
}
44
if len(groups) != 2 {
44
if len(groups) != 2 {
45
t.Fatalf("len: got %d, want 2", len(groups))
45
t.Fatalf("len: got %d, want 2", len(groups))
46
}
46
}
47
if groups[0].Username != "team" {
47
if groups[0].Username != "team" {
48
t.Errorf(
48
t.Errorf(
49
"groups[0].Username: got %q, want team",
49
"groups[0].Username: got %q, want team",
50
groups[0].Username)
50
groups[0].Username)
51
}
51
}
52
if groups[1].OwnerUsername != "alice" {
52
if groups[1].OwnerUsername != "alice" {
53
t.Errorf(
53
t.Errorf(
54
"groups[1].OwnerUsername: got %q, want alice",
54
"groups[1].OwnerUsername: got %q, want alice",
55
groups[1].OwnerUsername)
55
groups[1].OwnerUsername)
56
}
56
}
57
}
57
}
58
58
59
func TestListGroupsHTTPError(t *testing.T) {
59
func TestListGroupsHTTPError(t *testing.T) {
60
srv := httptest.NewServer(http.HandlerFunc(
60
srv := httptest.NewServer(http.HandlerFunc(
61
func(w http.ResponseWriter, r *http.Request) {
61
func(w http.ResponseWriter, r *http.Request) {
62
w.WriteHeader(401)
62
w.WriteHeader(401)
63
w.Write([]byte("not authenticated"))
63
w.Write([]byte("not authenticated"))
64
}))
64
}))
65
defer srv.Close()
65
defer srv.Close()
66
66
67
c := NewHTTPClient(srv.URL, "bad-key")
67
c := NewHTTPClient(srv.URL, "bad-key")
68
_, err := c.ListGroups()
68
_, err := c.ListGroups()
69
if err == nil {
69
if err == nil {
70
t.Fatal("want error on 401, got nil")
70
t.Fatal("want error on 401, got nil")
71
}
71
}
72
}
72
}
73
73
74
func TestGetProfile(t *testing.T) {
74
func TestGetProfile(t *testing.T) {
75
srv := httptest.NewServer(http.HandlerFunc(
75
srv := httptest.NewServer(http.HandlerFunc(
76
func(w http.ResponseWriter, r *http.Request) {
76
func(w http.ResponseWriter, r *http.Request) {
77
if r.URL.Path != "/login/profile/get" {
77
if r.URL.Path != "/login/profile/get" {
78
t.Errorf(
78
t.Errorf(
79
"path: got %q, want /login/profile/get",
79
"path: got %q, want /login/profile/get",
80
r.URL.Path)
80
r.URL.Path)
81
}
81
}
82
w.Header().Set(
82
w.Header().Set(
83
"Content-Type", "application/json")
83
"Content-Type", "application/json")
84
json.NewEncoder(w).Encode(Profile{
84
json.NewEncoder(w).Encode(Profile{
85
Username: "alice",
85
Username: "alice",
86
Name: "Alice Liddell",
86
Name: "Alice Liddell",
87
87
88
Groups: []string{"team", "admins"},
88
Groups: []string{"team", "admins"},
89
})
89
})
90
}))
90
}))
91
defer srv.Close()
91
defer srv.Close()
92
92
93
c := NewHTTPClient(srv.URL, "test-key")
93
c := NewHTTPClient(srv.URL, "test-key")
94
p, err := c.GetProfile()
94
p, err := c.GetProfile()
95
if err != nil {
95
if err != nil {
96
t.Fatal(err)
96
t.Fatal(err)
97
}
97
}
98
if p.Username != "alice" {
98
if p.Username != "alice" {
99
t.Errorf(
99
t.Errorf(
100
"Username: got %q, want alice", p.Username)
100
"Username: got %q, want alice", p.Username)
101
}
101
}
102
if len(p.Groups) != 2 {
102
if len(p.Groups) != 2 {
103
t.Errorf(
103
t.Errorf(
104
"Groups: got %d, want 2", len(p.Groups))
104
"Groups: got %d, want 2", len(p.Groups))
105
}
105
}
106
}
106
}
107
107
108
func TestCreateGroup(t *testing.T) {
108
func TestCreateGroup(t *testing.T) {
109
var seen CreateGroupRequest
109
var seen CreateGroupRequest
110
srv := httptest.NewServer(http.HandlerFunc(
110
srv := httptest.NewServer(http.HandlerFunc(
111
func(w http.ResponseWriter, r *http.Request) {
111
func(w http.ResponseWriter, r *http.Request) {
112
if r.URL.Path != "/groups/add" {
112
if r.URL.Path != "/groups/add" {
113
t.Errorf(
113
t.Errorf(
114
"path: got %q, want /groups/add",
114
"path: got %q, want /groups/add",
115
r.URL.Path)
115
r.URL.Path)
116
}
116
}
117
if r.Method != "POST" {
117
if r.Method != "POST" {
118
t.Errorf("method: got %q, want POST", r.Method)
118
t.Errorf("method: got %q, want POST", r.Method)
119
}
119
}
120
json.NewDecoder(r.Body).Decode(&seen)
120
json.NewDecoder(r.Body).Decode(&seen)
121
w.WriteHeader(200)
121
w.WriteHeader(200)
122
}))
122
}))
123
defer srv.Close()
123
defer srv.Close()
124
124
125
c := NewHTTPClient(srv.URL, "test-key")
125
c := NewHTTPClient(srv.URL, "test-key")
126
err := c.CreateGroup(CreateGroupRequest{
126
err := c.CreateGroup(CreateGroupRequest{
127
Username: "team",
127
Username: "team",
128
Name: "Team",
128
Name: "Team",
129
OwnerUsername: "alice",
129
OwnerUsername: "alice",
130
})
130
})
131
if err != nil {
131
if err != nil {
132
t.Fatal(err)
132
t.Fatal(err)
133
}
133
}
134
if seen.Username != "team" {
134
if seen.Username != "team" {
135
t.Errorf(
135
t.Errorf(
136
"Username: got %q, want team", seen.Username)
136
"Username: got %q, want team", seen.Username)
137
}
137
}
138
if seen.OwnerUsername != "alice" {
138
if seen.OwnerUsername != "alice" {
139
t.Errorf(
139
t.Errorf(
140
"OwnerUsername: got %q, want alice",
140
"OwnerUsername: got %q, want alice",
141
seen.OwnerUsername)
141
seen.OwnerUsername)
142
}
142
}
143
}
143
}
144
144
145
func TestGroupMembers(t *testing.T) {
145
func TestGroupMembers(t *testing.T) {
146
srv := httptest.NewServer(http.HandlerFunc(
146
srv := httptest.NewServer(http.HandlerFunc(
147
func(w http.ResponseWriter, r *http.Request) {
147
func(w http.ResponseWriter, r *http.Request) {
148
if r.URL.Path != "/groups/members" {
148
if r.URL.Path != "/groups/members" {
149
t.Errorf(
149
t.Errorf(
150
"path: got %q, want /groups/members",
150
"path: got %q, want /groups/members",
151
r.URL.Path)
151
r.URL.Path)
152
}
152
}
153
var body map[string]string
153
var body map[string]string
154
json.NewDecoder(r.Body).Decode(&body)
154
json.NewDecoder(r.Body).Decode(&body)
155
if body["owid"] != "team-owid" {
155
if body["owid"] != "team-owid" {
156
t.Errorf(
156
t.Errorf(
157
"owid: got %q, want team-owid", body["owid"])
157
"owid: got %q, want team-owid", body["owid"])
158
}
158
}
159
json.NewEncoder(w).Encode(GroupMembersResponse{
159
json.NewEncoder(w).Encode(GroupMembersResponse{
160
Down: [][]string{
160
Down: [][]string{
161
{"alice-owid", "bob-owid"},
161
{"alice-owid", "bob-owid"},
162
},
162
},
163
Usernames: map[string]string{
163
Usernames: map[string]string{
164
"alice-owid": "alice",
164
"alice-owid": "alice",
165
"bob-owid": "bob",
165
"bob-owid": "bob",
166
},
166
},
167
})
167
})
168
}))
168
}))
169
defer srv.Close()
169
defer srv.Close()
170
170
171
c := NewHTTPClient(srv.URL, "test-key")
171
c := NewHTTPClient(srv.URL, "test-key")
172
res, err := c.GroupMembers("team-owid")
172
res, err := c.GroupMembers("team-owid")
173
if err != nil {
173
if err != nil {
174
t.Fatal(err)
174
t.Fatal(err)
175
}
175
}
176
if res.Usernames["alice-owid"] != "alice" {
176
if res.Usernames["alice-owid"] != "alice" {
177
t.Errorf(
177
t.Errorf(
178
"Usernames[alice-owid]: got %q",
178
"Usernames[alice-owid]: got %q",
179
res.Usernames["alice-owid"])
179
res.Usernames["alice-owid"])
180
}
180
}
181
}
181
}
182
182
183
func TestLeaveGroup(t *testing.T) {
183
func TestLeaveGroup(t *testing.T) {
184
var seen LeaveGroupRequest
184
var seen LeaveGroupRequest
185
srv := httptest.NewServer(http.HandlerFunc(
185
srv := httptest.NewServer(http.HandlerFunc(
186
func(w http.ResponseWriter, r *http.Request) {
186
func(w http.ResponseWriter, r *http.Request) {
187
if r.URL.Path != "/groups/leave" {
187
if r.URL.Path != "/groups/leave" {
188
t.Errorf(
188
t.Errorf(
189
"path: got %q, want /groups/leave",
189
"path: got %q, want /groups/leave",
190
r.URL.Path)
190
r.URL.Path)
191
}
191
}
192
json.NewDecoder(r.Body).Decode(&seen)
192
json.NewDecoder(r.Body).Decode(&seen)
193
w.WriteHeader(204)
193
w.WriteHeader(204)
194
}))
194
}))
195
defer srv.Close()
195
defer srv.Close()
196
196
197
c := NewHTTPClient(srv.URL, "test-key")
197
c := NewHTTPClient(srv.URL, "test-key")
198
err := c.LeaveGroup(LeaveGroupRequest{
198
err := c.LeaveGroup(LeaveGroupRequest{
199
GroupOwid: "team-owid",
199
GroupOwid: "team-owid",
200
MemberOwid: "alice-owid",
200
MemberOwid: "alice-owid",
201
})
201
})
202
if err != nil {
202
if err != nil {
203
t.Fatal(err)
203
t.Fatal(err)
204
}
204
}
205
if seen.GroupOwid != "team-owid" {
205
if seen.GroupOwid != "team-owid" {
206
t.Errorf(
206
t.Errorf(
207
"GroupOwid: got %q", seen.GroupOwid)
207
"GroupOwid: got %q", seen.GroupOwid)
208
}
208
}
209
if seen.MemberOwid != "alice-owid" {
209
if seen.MemberOwid != "alice-owid" {
210
t.Errorf(
210
t.Errorf(
211
"MemberOwid: got %q", seen.MemberOwid)
211
"MemberOwid: got %q", seen.MemberOwid)
212
}
212
}
213
}
213
}
214
214
215
func TestListAuthz(t *testing.T) {
216
srv := httptest.NewServer(http.HandlerFunc(
217
func(w http.ResponseWriter, r *http.Request) {
218
if r.URL.Path != "/authz/list" {
219
t.Errorf(
220
"path: got %q, want /authz/list", r.URL.Path)
221
}
222
json.NewEncoder(w).Encode(listAuthzResponse{
223
Uris: []AuthzEntry{
224
{
225
Uri: "chat://team#post",
226
Owner: &User{
227
Owid: "team-owid", Username: "team"},
228
Reader: &User{
229
Owid: "team-owid", Username: "team"},
230
IsOwner: true,
231
IsReader: true,
232
},
233
},
234
})
235
}))
236
defer srv.Close()
237
238
c := NewHTTPClient(srv.URL, "test-key")
239
uris, err := c.ListAuthz()
240
if err != nil {
241
t.Fatal(err)
242
}
243
if len(uris) != 1 {
244
t.Fatalf("len: got %d, want 1", len(uris))
245
}
246
if uris[0].Uri != "chat://team#post" {
247
t.Errorf(
248
"Uri: got %q, want chat://team#post", uris[0].Uri)
249
}
250
if uris[0].Owner.Username != "team" {
251
t.Errorf(
252
"Owner.Username: got %q", uris[0].Owner.Username)
253
}
254
}
255
256
func TestSetAuthz(t *testing.T) {
257
var seen AuthzSetRequest
258
srv := httptest.NewServer(http.HandlerFunc(
259
func(w http.ResponseWriter, r *http.Request) {
260
if r.URL.Path != "/authz/set" {
261
t.Errorf("path: got %q", r.URL.Path)
262
}
263
json.NewDecoder(r.Body).Decode(&seen)
264
w.WriteHeader(204)
265
}))
266
defer srv.Close()
267
268
c := NewHTTPClient(srv.URL, "test-key")
269
err := c.SetAuthz(AuthzSetRequest{
270
Uri: "chat://team#post",
271
OwnerUsername: "team",
272
ReaderUsername: "team",
273
})
274
if err != nil {
275
t.Fatal(err)
276
}
277
if seen.Uri != "chat://team#post" {
278
t.Errorf("Uri: got %q", seen.Uri)
279
}
280
if seen.OwnerUsername != "team" {
281
t.Errorf("OwnerUsername: got %q", seen.OwnerUsername)
282
}
283
}
284
285
func TestDeleteAuthz(t *testing.T) {
286
var seen AuthzDeleteRequest
287
srv := httptest.NewServer(http.HandlerFunc(
288
func(w http.ResponseWriter, r *http.Request) {
289
if r.URL.Path != "/authz/delete" {
290
t.Errorf("path: got %q", r.URL.Path)
291
}
292
json.NewDecoder(r.Body).Decode(&seen)
293
w.WriteHeader(204)
294
}))
295
defer srv.Close()
296
297
c := NewHTTPClient(srv.URL, "test-key")
298
err := c.DeleteAuthz(AuthzDeleteRequest{
299
Uri: "chat://team#post",
300
})
301
if err != nil {
302
t.Fatal(err)
303
}
304
if seen.Uri != "chat://team#post" {
305
t.Errorf("Uri: got %q", seen.Uri)
306
}
307
}
308
215
func TestDeleteGroup(t *testing.T) {
309
func TestDeleteGroup(t *testing.T) {
216
var seen DeleteGroupRequest
310
var seen DeleteGroupRequest
217
srv := httptest.NewServer(http.HandlerFunc(
311
srv := httptest.NewServer(http.HandlerFunc(
218
func(w http.ResponseWriter, r *http.Request) {
312
func(w http.ResponseWriter, r *http.Request) {
219
if r.URL.Path != "/groups/delete" {
313
if r.URL.Path != "/groups/delete" {
220
t.Errorf(
314
t.Errorf(
221
"path: got %q, want /groups/delete",
315
"path: got %q, want /groups/delete",
222
r.URL.Path)
316
r.URL.Path)
223
}
317
}
224
json.NewDecoder(r.Body).Decode(&seen)
318
json.NewDecoder(r.Body).Decode(&seen)
225
w.WriteHeader(200)
319
w.WriteHeader(200)
226
}))
320
}))
227
defer srv.Close()
321
defer srv.Close()
228
322
229
c := NewHTTPClient(srv.URL, "test-key")
323
c := NewHTTPClient(srv.URL, "test-key")
230
err := c.DeleteGroup(DeleteGroupRequest{
324
err := c.DeleteGroup(DeleteGroupRequest{
231
Owid: "team-owid",
325
Owid: "team-owid",
232
})
326
})
233
if err != nil {
327
if err != nil {
234
t.Fatal(err)
328
t.Fatal(err)
235
}
329
}
236
if seen.Owid != "team-owid" {
330
if seen.Owid != "team-owid" {
237
t.Errorf("Owid: got %q, want team-owid", seen.Owid)
331
t.Errorf("Owid: got %q, want team-owid", seen.Owid)
238
}
332
}
239
}
333
}
240
334
241
func TestJoinGroups(t *testing.T) {
335
func TestJoinGroups(t *testing.T) {
242
var seen JoinGroupsRequest
336
var seen JoinGroupsRequest
243
srv := httptest.NewServer(http.HandlerFunc(
337
srv := httptest.NewServer(http.HandlerFunc(
244
func(w http.ResponseWriter, r *http.Request) {
338
func(w http.ResponseWriter, r *http.Request) {
245
if r.URL.Path != "/groups/join" {
339
if r.URL.Path != "/groups/join" {
246
t.Errorf(
340
t.Errorf(
247
"path: got %q, want /groups/join",
341
"path: got %q, want /groups/join",
248
r.URL.Path)
342
r.URL.Path)
249
}
343
}
250
if r.Method != "POST" {
344
if r.Method != "POST" {
251
t.Errorf("method: got %q, want POST", r.Method)
345
t.Errorf("method: got %q, want POST", r.Method)
252
}
346
}
253
json.NewDecoder(r.Body).Decode(&seen)
347
json.NewDecoder(r.Body).Decode(&seen)
254
w.WriteHeader(200)
348
w.WriteHeader(200)
255
}))
349
}))
256
defer srv.Close()
350
defer srv.Close()
257
351
258
c := NewHTTPClient(srv.URL, "test-key")
352
c := NewHTTPClient(srv.URL, "test-key")
259
err := c.JoinGroups(JoinGroupsRequest{
353
err := c.JoinGroups(JoinGroupsRequest{
260
GroupUsernames: []string{"team"},
354
GroupUsernames: []string{"team"},
261
MemberUsernames: []string{"alice", "bob"},
355
MemberUsernames: []string{"alice", "bob"},
262
})
356
})
263
if err != nil {
357
if err != nil {
264
t.Fatal(err)
358
t.Fatal(err)
265
}
359
}
266
if len(seen.GroupUsernames) != 1 ||
360
if len(seen.GroupUsernames) != 1 ||
267
seen.GroupUsernames[0] != "team" {
361
seen.GroupUsernames[0] != "team" {
268
t.Errorf(
362
t.Errorf(
269
"GroupUsernames: got %v, want [team]",
363
"GroupUsernames: got %v, want [team]",
270
seen.GroupUsernames)
364
seen.GroupUsernames)
271
}
365
}
272
if len(seen.MemberUsernames) != 2 {
366
if len(seen.MemberUsernames) != 2 {
273
t.Errorf(
367
t.Errorf(
274
"MemberUsernames: got %v, want 2 members",
368
"MemberUsernames: got %v, want 2 members",
275
seen.MemberUsernames)
369
seen.MemberUsernames)
276
}
370
}
277
}
371
}