Merge lp:~mfoord/gomaasapi/devices into lp:gomaasapi

Proposed by Michael Foord
Status: Merged
Approved by: Michael Foord
Approved revision: 95
Merged at revision: 63
Proposed branch: lp:~mfoord/gomaasapi/devices
Merge into: lp:gomaasapi
Diff against target: 559 lines (+478/-5)
2 files modified
testservice.go (+250/-5)
testservice_test.go (+228/-0)
To merge this branch: bzr merge lp:~mfoord/gomaasapi/devices
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Dimiter Naydenov (community) Approve
Review via email: mp+263370@code.launchpad.net

Commit message

Adding devices support to gomaasapi test server.

Description of the change

Adding devices support to gomaasapi test server.

To post a comment you must log in.
Revision history for this message
Dimiter Naydenov (dimitern) wrote :

LGTM in general, apart from the one comment inline below, if I wish we had logging in places where StatusBadRequest is returned to explain the reason and make the test server a bit easier to use.

Also, as discussed on IRC we need support for op=claim_sticky_ip (or was it claim-sticky-ip?).

review: Approve
lp:~mfoord/gomaasapi/devices updated
95. By Michael Foord

Updated comments

Revision history for this message
Michael Foord (mfoord) wrote :

> LGTM in general, apart from the one comment inline below, if I wish we had
> logging in places where StatusBadRequest is returned to explain the reason and
> make the test server a bit easier to use.
>
> Also, as discussed on IRC we need support for op=claim_sticky_ip (or was it
> claim-sticky-ip?).

There's no logging in gomaasapi I'm afraid. The rest is now done.

Revision history for this message
Dimiter Naydenov (dimitern) wrote :

Looks good to land, thanks!

review: Approve
Revision history for this message
Raphaël Badin (rvb) wrote :

Looks good to me as well… lots of TODOs in there but I understand creating a test double is costly and you're only implementing what you need right now.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'testservice.go'
2--- testservice.go 2015-06-02 02:28:04 +0000
3+++ testservice.go 2015-07-02 16:26:32 +0000
4@@ -5,6 +5,7 @@
5
6 import (
7 "bufio"
8+ "bytes"
9 "encoding/base64"
10 "encoding/json"
11 "fmt"
12@@ -18,6 +19,7 @@
13 "sort"
14 "strconv"
15 "strings"
16+ "text/template"
17 "time"
18
19 "gopkg.in/mgo.v2/bson"
20@@ -85,6 +87,24 @@
21 // nodegroupsInterfaces is a map of nodegroup UUIDs to interface
22 // objects.
23 nodegroupsInterfaces map[string][]JSONObject
24+
25+ // versionJSON is the response to the /version/ endpoint listing the
26+ // capabilities of the MAAS server.
27+ versionJSON string
28+
29+ // devices is a map of device UUIDs to devices.
30+ devices map[string]*device
31+}
32+
33+type device struct {
34+ IPAddresses []string
35+ SystemId string
36+ MACAddress string
37+ Parent string
38+ Hostname string
39+
40+ // Not part of the device definition but used by the template.
41+ APIVersion string
42 }
43
44 func getNodesEndpoint(version string) string {
45@@ -100,6 +120,19 @@
46 return regexp.MustCompile(reString)
47 }
48
49+func getDevicesEndpoint(version string) string {
50+ return fmt.Sprintf("/api/%s/devices/", version)
51+}
52+
53+func getDeviceURL(version, systemId string) string {
54+ return fmt.Sprintf("/api/%s/devices/%s/", version, systemId)
55+}
56+
57+func getDeviceURLRE(version string) *regexp.Regexp {
58+ reString := fmt.Sprintf("^/api/%s/devices/([^/]*)/$", regexp.QuoteMeta(version))
59+ return regexp.MustCompile(reString)
60+}
61+
62 func getFilesEndpoint(version string) string {
63 return fmt.Sprintf("/api/%s/files/", version)
64 }
65@@ -141,10 +174,6 @@
66 return fmt.Sprintf("/api/%s/version/", version)
67 }
68
69-func getVersionJSON() string {
70- return `{"capabilities": ["networks-management","static-ipaddresses"]}`
71-}
72-
73 func getNodegroupsEndpoint(version string) string {
74 return fmt.Sprintf("/api/%s/nodegroups/", version)
75 }
76@@ -185,6 +214,14 @@
77 server.bootImages = make(map[string][]JSONObject)
78 server.nodegroupsInterfaces = make(map[string][]JSONObject)
79 server.zones = make(map[string]JSONObject)
80+ server.versionJSON = `{"capabilities": ["networks-management","static-ipaddresses"]}`
81+ server.devices = make(map[string]*device)
82+}
83+
84+// SetVersionJSON sets the JSON response (capabilities) returned from the
85+// /version/ endpoint.
86+func (server *TestServer) SetVersionJSON(json string) {
87+ server.versionJSON = json
88 }
89
90 // NodesOperations returns the list of operations performed at the /nodes/
91@@ -466,6 +503,11 @@
92 server := &TestServer{version: version}
93
94 serveMux := http.NewServeMux()
95+ devicesURL := getDevicesEndpoint(server.version)
96+ // Register handler for '/api/<version>/devices/*'.
97+ serveMux.HandleFunc(devicesURL, func(w http.ResponseWriter, r *http.Request) {
98+ devicesHandler(server, w, r)
99+ })
100 nodesURL := getNodesEndpoint(server.version)
101 // Register handler for '/api/<version>/nodes/*'.
102 serveMux.HandleFunc(nodesURL, func(w http.ResponseWriter, r *http.Request) {
103@@ -513,6 +555,209 @@
104 return server
105 }
106
107+// devicesHandler handles requests for '/api/<version>/devices/*'.
108+func devicesHandler(server *TestServer, w http.ResponseWriter, r *http.Request) {
109+ values, err := url.ParseQuery(r.URL.RawQuery)
110+ checkError(err)
111+ op := values.Get("op")
112+ deviceURLRE := getDeviceURLRE(server.version)
113+ deviceURLMatch := deviceURLRE.FindStringSubmatch(r.URL.Path)
114+ devicesURL := getDevicesEndpoint(server.version)
115+ switch {
116+ case r.URL.Path == devicesURL:
117+ devicesTopLevelHandler(server, w, r, op)
118+ case deviceURLMatch != nil:
119+ // Request for a single device.
120+ deviceHandler(server, w, r, deviceURLMatch[1], op)
121+ default:
122+ // Default handler: not found.
123+ http.NotFoundHandler().ServeHTTP(w, r)
124+ }
125+}
126+
127+// devicesTopLevelHandler handles a request for /api/<version>/devices/
128+// (with no device id following as part of the path).
129+func devicesTopLevelHandler(server *TestServer, w http.ResponseWriter, r *http.Request, op string) {
130+ switch {
131+ case r.Method == "GET" && op == "list":
132+ // Device listing operation.
133+ deviceListingHandler(server, w, r)
134+ case r.Method == "POST" && op == "new":
135+ newDeviceHandler(server, w, r)
136+ default:
137+ w.WriteHeader(http.StatusBadRequest)
138+ }
139+}
140+
141+func macMatches(device *device, macs []string, hasMac bool) bool {
142+ if !hasMac {
143+ return true
144+ }
145+ return contains(macs, device.MACAddress)
146+}
147+
148+// deviceListingHandler handles requests for '/devices/'.
149+func deviceListingHandler(server *TestServer, w http.ResponseWriter, r *http.Request) {
150+ values, err := url.ParseQuery(r.URL.RawQuery)
151+ checkError(err)
152+ // TODO(mfoord): support filtering by hostname and id
153+ macs, hasMac := values["mac_address"]
154+ var matchedDevices []string
155+ for _, device := range server.devices {
156+ if macMatches(device, macs, hasMac) {
157+ matchedDevices = append(matchedDevices, renderDevice(device))
158+ }
159+ }
160+ json := fmt.Sprintf("[%v]", strings.Join(matchedDevices, ", "))
161+
162+ w.WriteHeader(http.StatusOK)
163+ fmt.Fprint(w, json)
164+}
165+
166+var templateFuncs = template.FuncMap{
167+ "quotedList": func(items []string) string {
168+ var pieces []string
169+ for _, item := range items {
170+ pieces = append(pieces, fmt.Sprintf("%q", item))
171+ }
172+ return strings.Join(pieces, ", ")
173+ },
174+}
175+
176+const (
177+ // The json template for generating new devices.
178+ // TODO(mfoord): set resource_uri in MAC addresses
179+ deviceTemplate = `{
180+ "macaddress_set": [
181+ {
182+ "mac_address": "{{.MACAddress}}"
183+ }
184+ ],
185+ "zone": {
186+ "resource_uri": "/MAAS/api/{{.APIVersion}}/zones/default/",
187+ "name": "default",
188+ "description": ""
189+ },
190+ "parent": "{{.Parent}}",
191+ "ip_addresses": [{{.IPAddresses | quotedList }}],
192+ "hostname": "{{.Hostname}}",
193+ "tag_names": [],
194+ "owner": "maas-admin",
195+ "system_id": "{{.SystemId}}",
196+ "resource_uri": "/MAAS/api/{{.APIVersion}}/devices/{{.SystemId}}/"
197+}`
198+)
199+
200+func renderDevice(device *device) string {
201+ t := template.New("Device template")
202+ t = t.Funcs(templateFuncs)
203+ t, err := t.Parse(deviceTemplate)
204+ checkError(err)
205+ var buf bytes.Buffer
206+ err = t.Execute(&buf, device)
207+ checkError(err)
208+ return buf.String()
209+}
210+
211+func getValue(values url.Values, value string) (string, bool) {
212+ result, hasResult := values[value]
213+ if !hasResult || len(result) != 1 || result[0] == "" {
214+ return "", false
215+ }
216+ return result[0], true
217+}
218+
219+// newDeviceHandler creates, stores and returns new devices.
220+func newDeviceHandler(server *TestServer, w http.ResponseWriter, r *http.Request) {
221+ err := r.ParseForm()
222+ checkError(err)
223+ values := r.PostForm
224+
225+ // TODO(mfood): generate a "proper" uuid for the system Id.
226+ uuid, err := generateNonce()
227+ checkError(err)
228+ systemId := fmt.Sprintf("node-%v", uuid)
229+ // At least one MAC address must be specified.
230+ // TODO(mfoord) we only support a single MAC in the test server.
231+ mac, hasMac := getValue(values, "mac_addresses")
232+
233+ // hostname and parent are optional.
234+ // TODO(mfoord): we require both to be set in the test server.
235+ hostname, hasHostname := getValue(values, "hostname")
236+ parent, hasParent := getValue(values, "parent")
237+ if !hasHostname || !hasMac || !hasParent {
238+ w.WriteHeader(http.StatusBadRequest)
239+ return
240+ }
241+
242+ device := &device{
243+ MACAddress: mac,
244+ APIVersion: server.version,
245+ Parent: parent,
246+ Hostname: hostname,
247+ SystemId: systemId,
248+ }
249+
250+ deviceJSON := renderDevice(device)
251+ server.devices[systemId] = device
252+
253+ w.WriteHeader(http.StatusOK)
254+ fmt.Fprint(w, deviceJSON)
255+ return
256+}
257+
258+// deviceHandler handles requests for '/api/<version>/devices/<system_id>/'.
259+func deviceHandler(server *TestServer, w http.ResponseWriter, r *http.Request, systemId string, operation string) {
260+ device, ok := server.devices[systemId]
261+ if !ok {
262+ http.NotFoundHandler().ServeHTTP(w, r)
263+ return
264+ }
265+ if r.Method == "GET" {
266+ deviceJSON := renderDevice(device)
267+ if operation == "" {
268+ w.WriteHeader(http.StatusOK)
269+ fmt.Fprint(w, deviceJSON)
270+ return
271+ } else {
272+ w.WriteHeader(http.StatusBadRequest)
273+ return
274+ }
275+ }
276+ if r.Method == "POST" {
277+ if operation == "claim_sticky_ip_address" {
278+ err := r.ParseForm()
279+ checkError(err)
280+ values := r.PostForm
281+ // TODO(mfoord): support optional mac_address parameter
282+ // TODO(mfoord): requested_address should be optional
283+ // and we should generate one if it isn't provided.
284+ address, hasAddress := getValue(values, "requested_address")
285+ if !hasAddress {
286+ w.WriteHeader(http.StatusBadRequest)
287+ return
288+ }
289+ checkError(err)
290+ device.IPAddresses = append(device.IPAddresses, address)
291+ deviceJSON := renderDevice(device)
292+ w.WriteHeader(http.StatusOK)
293+ fmt.Fprint(w, deviceJSON)
294+ return
295+ } else {
296+ w.WriteHeader(http.StatusBadRequest)
297+ return
298+ }
299+ } else if r.Method == "DELETE" {
300+ delete(server.devices, systemId)
301+ w.WriteHeader(http.StatusNoContent)
302+ return
303+
304+ }
305+
306+ // TODO(mfoord): support PUT method for updating device
307+ http.NotFoundHandler().ServeHTTP(w, r)
308+}
309+
310 // nodesHandler handles requests for '/api/<version>/nodes/*'.
311 func nodesHandler(server *TestServer, w http.ResponseWriter, r *http.Request) {
312 values, err := url.ParseQuery(r.URL.RawQuery)
313@@ -1188,7 +1433,7 @@
314 }
315 w.Header().Set("Content-Type", "application/json; charset=utf-8")
316 w.WriteHeader(http.StatusOK)
317- fmt.Fprint(w, getVersionJSON())
318+ fmt.Fprint(w, server.versionJSON)
319 }
320
321 // nodegroupsHandler handles requests for '/api/<version>/nodegroups/*'.
322
323=== modified file 'testservice_test.go'
324--- testservice_test.go 2015-01-13 03:11:30 +0000
325+++ testservice_test.go 2015-07-02 16:26:32 +0000
326@@ -49,6 +49,234 @@
327 c.Check(getNodeURL("0.1", "test"), Equals, "/api/0.1/nodes/test/")
328 }
329
330+func (suite *TestServerSuite) TestSetVersionJSON(c *C) {
331+ capabilities := `{"capabilities": ["networks-management","static-ipaddresses", "devices-management"]}`
332+ suite.server.SetVersionJSON(capabilities)
333+
334+ url := fmt.Sprintf("/api/%s/version/", suite.server.version)
335+ resp, err := http.Get(suite.server.Server.URL + url)
336+ c.Assert(err, IsNil)
337+ c.Check(resp.StatusCode, Equals, http.StatusOK)
338+ content, err := readAndClose(resp.Body)
339+ c.Assert(err, IsNil)
340+ c.Assert(string(content), Equals, capabilities)
341+}
342+
343+func (suite *TestServerSuite) createDevice(c *C, mac, hostname, parent string) string {
344+ devicesURL := fmt.Sprintf("/api/%s/devices/", suite.server.version) + "?op=new"
345+ values := url.Values{}
346+ values.Add("mac_addresses", mac)
347+ values.Add("hostname", hostname)
348+ values.Add("parent", parent)
349+ result := suite.post(c, devicesURL, values)
350+ resultMap, err := result.GetMap()
351+ c.Assert(err, IsNil)
352+ systemId, err := resultMap["system_id"].GetString()
353+ c.Assert(err, IsNil)
354+ return systemId
355+}
356+
357+func getString(c *C, object map[string]JSONObject, key string) string {
358+ value, err := object[key].GetString()
359+ c.Assert(err, IsNil)
360+ return value
361+}
362+
363+func (suite *TestServerSuite) post(c *C, url string, values url.Values) JSONObject {
364+ resp, err := http.Post(suite.server.Server.URL+url, "application/x-www-form-urlencoded", strings.NewReader(values.Encode()))
365+ c.Assert(err, IsNil)
366+ c.Check(resp.StatusCode, Equals, http.StatusOK)
367+ content, err := readAndClose(resp.Body)
368+ c.Assert(err, IsNil)
369+ result, err := Parse(suite.server.client, content)
370+ c.Assert(err, IsNil)
371+ return result
372+}
373+
374+func (suite *TestServerSuite) get(c *C, url string) JSONObject {
375+ resp, err := http.Get(suite.server.Server.URL + url)
376+ c.Assert(err, IsNil)
377+ c.Assert(resp.StatusCode, Equals, http.StatusOK)
378+
379+ content, err := readAndClose(resp.Body)
380+ c.Assert(err, IsNil)
381+
382+ result, err := Parse(suite.server.client, content)
383+ c.Assert(err, IsNil)
384+ return result
385+}
386+
387+func checkDevice(c *C, device map[string]JSONObject, mac, hostname, parent string) {
388+ macArray, err := device["macaddress_set"].GetArray()
389+ c.Assert(err, IsNil)
390+ c.Assert(macArray, HasLen, 1)
391+ macMap, err := macArray[0].GetMap()
392+ c.Assert(err, IsNil)
393+
394+ actualMac := getString(c, macMap, "mac_address")
395+ c.Assert(actualMac, Equals, mac)
396+
397+ actualParent := getString(c, device, "parent")
398+ c.Assert(actualParent, Equals, parent)
399+ actualHostname := getString(c, device, "hostname")
400+ c.Assert(actualHostname, Equals, hostname)
401+}
402+
403+func (suite *TestServerSuite) TestNewDeviceRequiredParameters(c *C) {
404+ devicesURL := fmt.Sprintf("/api/%s/devices/", suite.server.version) + "?op=new"
405+ values := url.Values{}
406+ values.Add("mac_addresses", "foo")
407+ values.Add("hostname", "bar")
408+ post := func(values url.Values) int {
409+ resp, err := http.Post(suite.server.Server.URL+devicesURL, "application/x-www-form-urlencoded", strings.NewReader(values.Encode()))
410+ c.Assert(err, IsNil)
411+ return resp.StatusCode
412+ }
413+ c.Check(post(values), Equals, http.StatusBadRequest)
414+ values.Del("hostname")
415+ values.Add("parent", "baz")
416+ c.Check(post(values), Equals, http.StatusBadRequest)
417+ values.Del("mac_addresses")
418+ values.Add("hostname", "bam")
419+ c.Check(post(values), Equals, http.StatusBadRequest)
420+}
421+
422+func (suite *TestServerSuite) TestNewDevice(c *C) {
423+ devicesURL := fmt.Sprintf("/api/%s/devices/", suite.server.version) + "?op=new"
424+
425+ values := url.Values{}
426+ values.Add("mac_addresses", "foo")
427+ values.Add("hostname", "bar")
428+ values.Add("parent", "baz")
429+ result := suite.post(c, devicesURL, values)
430+
431+ resultMap, err := result.GetMap()
432+ c.Assert(err, IsNil)
433+
434+ macArray, err := resultMap["macaddress_set"].GetArray()
435+ c.Assert(err, IsNil)
436+ c.Assert(macArray, HasLen, 1)
437+ macMap, err := macArray[0].GetMap()
438+ c.Assert(err, IsNil)
439+
440+ mac := getString(c, macMap, "mac_address")
441+ c.Assert(mac, Equals, "foo")
442+
443+ parent := getString(c, resultMap, "parent")
444+ c.Assert(parent, Equals, "baz")
445+ hostname := getString(c, resultMap, "hostname")
446+ c.Assert(hostname, Equals, "bar")
447+
448+ addresses, err := resultMap["ip_addresses"].GetArray()
449+ c.Assert(err, IsNil)
450+ c.Assert(addresses, HasLen, 0)
451+
452+ systemId := getString(c, resultMap, "system_id")
453+ resourceURI := getString(c, resultMap, "resource_uri")
454+ c.Assert(resourceURI, Equals, fmt.Sprintf("/MAAS/api/%v/devices/%v/", suite.server.version, systemId))
455+}
456+
457+func (suite *TestServerSuite) TestGetDevice(c *C) {
458+ systemId := suite.createDevice(c, "foo", "bar", "baz")
459+ deviceURL := fmt.Sprintf("/api/%v/devices/%v/", suite.server.version, systemId)
460+
461+ result := suite.get(c, deviceURL)
462+ resultMap, err := result.GetMap()
463+ c.Assert(err, IsNil)
464+ checkDevice(c, resultMap, "foo", "bar", "baz")
465+ actualId, err := resultMap["system_id"].GetString()
466+ c.Assert(actualId, Equals, systemId)
467+}
468+
469+func (suite *TestServerSuite) TestDevicesList(c *C) {
470+ firstId := suite.createDevice(c, "foo", "bar", "baz")
471+ c.Assert(firstId, Not(Equals), "")
472+ secondId := suite.createDevice(c, "bam", "bing", "bong")
473+ c.Assert(secondId, Not(Equals), "")
474+
475+ devicesURL := fmt.Sprintf("/api/%s/devices/", suite.server.version) + "?op=list"
476+ result := suite.get(c, devicesURL)
477+
478+ devicesArray, err := result.GetArray()
479+ c.Assert(err, IsNil)
480+ c.Assert(devicesArray, HasLen, 2)
481+
482+ for _, device := range devicesArray {
483+ deviceMap, err := device.GetMap()
484+ c.Assert(err, IsNil)
485+ systemId, err := deviceMap["system_id"].GetString()
486+ c.Assert(err, IsNil)
487+ switch systemId {
488+ case firstId:
489+ checkDevice(c, deviceMap, "foo", "bar", "baz")
490+ case secondId:
491+ checkDevice(c, deviceMap, "bam", "bing", "bong")
492+ default:
493+ c.Fatalf("unknown system id %q", systemId)
494+ }
495+ }
496+}
497+
498+func (suite *TestServerSuite) TestDevicesListMacFiltering(c *C) {
499+ firstId := suite.createDevice(c, "foo", "bar", "baz")
500+ c.Assert(firstId, Not(Equals), "")
501+ secondId := suite.createDevice(c, "bam", "bing", "bong")
502+ c.Assert(secondId, Not(Equals), "")
503+
504+ op := fmt.Sprintf("?op=list&mac_address=%v", "foo")
505+ devicesURL := fmt.Sprintf("/api/%s/devices/", suite.server.version) + op
506+ result := suite.get(c, devicesURL)
507+
508+ devicesArray, err := result.GetArray()
509+ c.Assert(err, IsNil)
510+ c.Assert(devicesArray, HasLen, 1)
511+ deviceMap, err := devicesArray[0].GetMap()
512+ c.Assert(err, IsNil)
513+ checkDevice(c, deviceMap, "foo", "bar", "baz")
514+}
515+
516+func (suite *TestServerSuite) TestDeviceClaimStickyIPRequiresAddress(c *C) {
517+ systemId := suite.createDevice(c, "foo", "bar", "baz")
518+ op := "?op=claim_sticky_ip_address"
519+ deviceURL := fmt.Sprintf("/api/%s/devices/%s/%s", suite.server.version, systemId, op)
520+ values := url.Values{}
521+ resp, err := http.Post(suite.server.Server.URL+deviceURL, "application/x-www-form-urlencoded", strings.NewReader(values.Encode()))
522+ c.Assert(err, IsNil)
523+ c.Assert(resp.StatusCode, Equals, http.StatusBadRequest)
524+}
525+
526+func (suite *TestServerSuite) TestDeviceClaimStickyIP(c *C) {
527+ systemId := suite.createDevice(c, "foo", "bar", "baz")
528+ op := "?op=claim_sticky_ip_address"
529+ deviceURL := fmt.Sprintf("/api/%s/devices/%s/", suite.server.version, systemId)
530+ values := url.Values{}
531+ values.Add("requested_address", "127.0.0.1")
532+ result := suite.post(c, deviceURL+op, values)
533+ resultMap, err := result.GetMap()
534+ c.Assert(err, IsNil)
535+
536+ addresses, err := resultMap["ip_addresses"].GetArray()
537+ c.Assert(err, IsNil)
538+ c.Assert(addresses, HasLen, 1)
539+ address, err := addresses[0].GetString()
540+ c.Assert(err, IsNil)
541+ c.Assert(address, Equals, "127.0.0.1")
542+}
543+
544+func (suite *TestServerSuite) TestDeleteDevice(c *C) {
545+ systemId := suite.createDevice(c, "foo", "bar", "baz")
546+ deviceURL := fmt.Sprintf("/api/%s/devices/%s/", suite.server.version, systemId)
547+ req, err := http.NewRequest("DELETE", suite.server.Server.URL+deviceURL, nil)
548+ c.Assert(err, IsNil)
549+ resp, err := http.DefaultClient.Do(req)
550+ c.Assert(err, IsNil)
551+ c.Assert(resp.StatusCode, Equals, http.StatusNoContent)
552+
553+ resp, err = http.Get(suite.server.Server.URL + deviceURL)
554+ c.Assert(err, IsNil)
555+ c.Assert(resp.StatusCode, Equals, http.StatusNotFound)
556+}
557+
558 func (suite *TestServerSuite) TestInvalidOperationOnNodesIsBadRequest(c *C) {
559 badURL := getNodesEndpoint(suite.server.version) + "?op=procrastinate"
560

Subscribers

People subscribed via source and target branches

to all changes: