Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
421 lines
17 KiB
Go
421 lines
17 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/brand"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
)
|
|
|
|
// enroll registers an agent through the service (the gRPC layer is tested
|
|
// in package agentapi).
|
|
func (e *env) enroll(token, role string) string {
|
|
e.t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
res, err := e.svc.EnrollAgent(context.Background(), service.EnrollInput{Token: token, CSR: csr, Role: role,
|
|
Hostname: "host-" + role, AgentVersion: "1.0.0"})
|
|
if err != nil {
|
|
e.t.Fatalf("enroll: %v", err)
|
|
}
|
|
return res.AgentID
|
|
}
|
|
|
|
func (c *client) createToken(role string) service.EnrollmentTokenCreated {
|
|
c.e.t.Helper()
|
|
var out service.EnrollmentTokenCreated
|
|
c.mustStatus(201, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": role, "description": "for " + role}).json(c.e.t, &out)
|
|
return out
|
|
}
|
|
|
|
// configureDNS stores generator settings that allow DNS generation.
|
|
func (c *client) configureDNS() {
|
|
c.e.t.Helper()
|
|
var g service.GeneratorSettings
|
|
c.mustStatus(200, "GET", "/api/v1/settings/generator", nil).json(c.e.t, &g)
|
|
g.DNS.PrimaryNS = "ns1.office.test"
|
|
g.DNS.Hostmaster = "hostmaster@office.test"
|
|
g.DNS.NameServers = []service.GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}}
|
|
c.mustStatus(200, "PUT", "/api/v1/settings/generator", g)
|
|
}
|
|
|
|
type agentList = listResponse[service.AgentDTO]
|
|
|
|
func TestEnrollmentTokensAndAgents(t *testing.T) {
|
|
e := newEnv(t)
|
|
admin := e.adminClient()
|
|
viewer := e.userClient(admin, "vera", "viewer")
|
|
operator := e.userClient(admin, "otto", "operator")
|
|
|
|
var agents agentList
|
|
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
|
|
if len(agents.Items) != 0 {
|
|
t.Fatalf("agents %+v", agents.Items)
|
|
}
|
|
viewer.mustStatus(403, "GET", "/api/v1/enrollment-tokens", nil)
|
|
operator.mustStatus(403, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns"})
|
|
|
|
tok := admin.createToken("dns")
|
|
id, secret, ok := strings.Cut(tok.Secret, ".")
|
|
if !ok || len(id) != 16 || id != tok.Token.ID || len(secret) != 43 {
|
|
t.Errorf("token format %q", tok.Secret)
|
|
}
|
|
if tok.CAFingerprint != e.pki.Fingerprint() || tok.ServerAddress != "ddi.example.test:8444" {
|
|
t.Errorf("token %+v", tok)
|
|
}
|
|
wantCmd := brand.AgentDNSBinary + " enroll --server ddi.example.test:8444 --token " + tok.Secret + " --ca-hash " + e.pki.Fingerprint()
|
|
if tok.Command != wantCmd {
|
|
t.Errorf("command %q, want %q", tok.Command, wantCmd)
|
|
}
|
|
if dhcp := admin.createToken("dhcp"); !strings.HasPrefix(dhcp.Command, brand.AgentDHCPBinary+" enroll ") {
|
|
t.Errorf("dhcp command %q", dhcp.Command)
|
|
}
|
|
r := admin.mustStatus(422, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns", "valid_hours": 721},
|
|
skipRequestValidation, "1")
|
|
if p := r.problem(t).Problems; len(p) != 1 || p[0].Field != "valid_hours" {
|
|
t.Errorf("problems %+v", p)
|
|
}
|
|
|
|
var tokens listResponse[service.EnrollmentTokenDTO]
|
|
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
|
|
if len(tokens.Items) != 2 || strings.Contains(string(admin.do("GET", "/api/v1/enrollment-tokens", nil).Body), secret) {
|
|
t.Fatalf("tokens %+v", tokens.Items)
|
|
}
|
|
|
|
// Enrollment through the service: wrong role, wrong secret, success,
|
|
// reuse.
|
|
ctx := context.Background()
|
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
|
|
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dhcp"}); !errors.Is(err, service.ErrTokenRoleMismatch) {
|
|
t.Errorf("wrong role: %v", err)
|
|
}
|
|
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: id + ".wrong", CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
|
|
t.Errorf("wrong secret: %v", err)
|
|
}
|
|
agentID := e.enroll(tok.Secret, "dns")
|
|
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
|
|
t.Errorf("reused token: %v", err)
|
|
}
|
|
|
|
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
|
|
var used *service.EnrollmentTokenDTO
|
|
for i := range tokens.Items {
|
|
if tokens.Items[i].ID == id {
|
|
used = &tokens.Items[i]
|
|
}
|
|
}
|
|
if used == nil || used.UsedAt == nil || used.AgentID != agentID {
|
|
t.Errorf("used token %+v", used)
|
|
}
|
|
|
|
var ag service.AgentDTO
|
|
viewer.mustStatus(200, "GET", "/api/v1/agents/"+agentID, nil).json(t, &ag)
|
|
if ag.Role != "dns" || ag.Hostname != "host-dns" || ag.Connected || ag.Mode != "auto_pull" || ag.Health.State != "unknown" ||
|
|
ag.DesiredRevision != 0 || ag.CertExpiresAt.IsZero() {
|
|
t.Errorf("agent %+v", ag)
|
|
}
|
|
viewer.mustStatus(404, "GET", "/api/v1/agents/not-a-uuid", nil)
|
|
viewer.mustStatus(404, "GET", "/api/v1/agents/00000000-0000-4000-8000-000000000000", nil)
|
|
|
|
// Status from the agent.
|
|
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{Hostname: "ns1.office.test", AgentVersion: "1.0.1",
|
|
ServiceVersion: "BIND 9.20.3", Health: &service.AgentHealth{State: "ok", Checks: []service.HealthCheck{{Name: "rndc", State: "ok"}}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn := e.hub.Attach(agentID, "dns", "auto_pull")
|
|
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
|
|
if len(agents.Items) != 1 || !agents.Items[0].Connected || agents.Items[0].ServiceVersion != "BIND 9.20.3" ||
|
|
agents.Items[0].Health.State != "ok" || len(agents.Items[0].Health.Checks) != 1 || agents.Items[0].LastSeenAt == nil {
|
|
t.Fatalf("agents %+v", agents.Items)
|
|
}
|
|
|
|
// Revocation closes the stream and is audited.
|
|
viewer.mustStatus(403, "POST", "/api/v1/agents/"+agentID+"/revoke", nil)
|
|
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil).json(t, &ag)
|
|
if ag.RevokedAt == nil || ag.Connected {
|
|
t.Errorf("revoked agent %+v", ag)
|
|
}
|
|
select {
|
|
case <-conn.Done():
|
|
default:
|
|
t.Error("stream of the revoked agent is still open")
|
|
}
|
|
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{}); !errors.Is(err, service.ErrAgentRevoked) {
|
|
t.Errorf("hello after revocation: %v", err)
|
|
}
|
|
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil) // idempotent
|
|
|
|
var audit auditPage
|
|
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=agent", nil).json(t, &audit)
|
|
if len(audit.Items) != 2 { // enroll, revoke
|
|
t.Errorf("agent audit %+v", audit.Items)
|
|
}
|
|
|
|
viewer.mustStatus(403, "DELETE", "/api/v1/agents/"+agentID, nil)
|
|
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+agentID, nil)
|
|
admin.mustStatus(404, "GET", "/api/v1/agents/"+agentID, nil)
|
|
admin.mustStatus(404, "DELETE", "/api/v1/agents/"+agentID, nil)
|
|
|
|
admin.mustStatus(204, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
|
|
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
|
|
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/nonsense", nil)
|
|
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=enrollment_token", nil).json(t, &audit)
|
|
if len(audit.Items) != 3 { // two creates, one delete
|
|
t.Errorf("token audit %+v", audit.Items)
|
|
}
|
|
}
|
|
|
|
func TestRevisionsAndDeployments(t *testing.T) {
|
|
e := newEnv(t)
|
|
admin := e.adminClient()
|
|
viewer := e.userClient(admin, "vera", "viewer")
|
|
operator := e.userClient(admin, "otto", "operator")
|
|
ctx := context.Background()
|
|
|
|
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{
|
|
"cidr": "192.0.2.0/24", "name": "Office", "gateway": "192.0.2.1", "domain": "office.test",
|
|
"dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}},
|
|
})
|
|
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.53", "type": "static", "hostname": "ns1"})
|
|
|
|
// Without DNS settings nothing is stored.
|
|
r := operator.mustStatus(422, "POST", "/api/v1/revisions", map[string]any{"comment": "first"})
|
|
if p := r.problem(t); p.Code != "dns_settings_missing" || p.Params["part"] != "dns" {
|
|
t.Errorf("problem %+v", p)
|
|
}
|
|
admin.configureDNS()
|
|
|
|
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
|
|
conn := e.hub.Attach(dnsAgent, "dns", "auto_pull")
|
|
|
|
viewer.mustStatus(403, "POST", "/api/v1/revisions", nil)
|
|
var rev service.RevisionDTO
|
|
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{"comment": "first"}).json(t, &rev)
|
|
if rev.Number != 1 || rev.Comment != "first" || rev.CreatedByName != "otto" || len(rev.DNS) == 0 || len(rev.DHCP) != 1 {
|
|
t.Fatalf("revision %+v", rev)
|
|
}
|
|
select {
|
|
case cmd := <-conn.Commands():
|
|
if cmd.GetApplyRevision().GetRevision() != 1 {
|
|
t.Errorf("command %v", cmd)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Error("connected agent was not notified")
|
|
}
|
|
|
|
// Unchanged data: the latest revision with 200, no notification.
|
|
rev = service.RevisionDTO{}
|
|
operator.mustStatus(200, "POST", "/api/v1/revisions", nil).json(t, &rev)
|
|
if rev.Number != 1 || rev.Comment != "first" {
|
|
t.Errorf("unchanged %+v", rev)
|
|
}
|
|
select {
|
|
case cmd := <-conn.Commands():
|
|
t.Errorf("unexpected command %v", cmd)
|
|
default:
|
|
}
|
|
|
|
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.10", "type": "static", "hostname": "www"})
|
|
rev = service.RevisionDTO{}
|
|
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{}).json(t, &rev)
|
|
if rev.Number != 2 {
|
|
t.Fatalf("second revision %+v", rev)
|
|
}
|
|
zone := fileByPath(rev.DNS, "zones/db.office.test")
|
|
if zone == nil || !strings.Contains(zone.Content, "192.0.2.10") {
|
|
t.Errorf("zone %+v", zone)
|
|
}
|
|
|
|
var page service.RevisionPage
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions", nil).json(t, &page)
|
|
if len(page.Items) != 2 || page.Items[0].Number != 2 || page.NextBefore != nil || page.Items[0].DNSFiles != len(rev.DNS) || page.Items[0].DHCPFiles != 1 {
|
|
t.Fatalf("revisions %+v", page)
|
|
}
|
|
page = service.RevisionPage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1", nil).json(t, &page)
|
|
if len(page.Items) != 1 || page.NextBefore == nil || *page.NextBefore != 2 {
|
|
t.Fatalf("first page %+v", page)
|
|
}
|
|
page = service.RevisionPage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1&before=2", nil).json(t, &page)
|
|
if len(page.Items) != 1 || page.Items[0].Number != 1 || page.NextBefore != nil {
|
|
t.Fatalf("second page %+v", page)
|
|
}
|
|
rev = service.RevisionDTO{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/revisions/1", nil).json(t, &rev)
|
|
if rev.Number != 1 || fileByPath(rev.DNS, "zones/db.office.test") == nil {
|
|
t.Errorf("revision 1 %+v", rev)
|
|
}
|
|
viewer.mustStatus(404, "GET", "/api/v1/revisions/99", nil)
|
|
|
|
var audit auditPage
|
|
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.create", nil).json(t, &audit)
|
|
if len(audit.Items) != 2 {
|
|
t.Errorf("revision audit %+v", audit.Items)
|
|
}
|
|
|
|
// Deployment reports.
|
|
started := e.now.Add(-time.Minute)
|
|
for _, rep := range []service.DeploymentReport{
|
|
{Revision: 1, Trigger: "push", Outcome: "succeeded", ActiveRevision: 1, StartedAt: started, FinishedAt: started.Add(time.Second)},
|
|
{Revision: 2, Trigger: "pull", Outcome: "rolled_back", Message: "SOA serial mismatch", ActiveRevision: 1, StartedAt: started},
|
|
} {
|
|
if err := e.svc.RecordDeployment(ctx, dnsAgent, rep); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := e.svc.RecordDeployment(ctx, dnsAgent, service.DeploymentReport{Revision: 0, Trigger: "pull", Outcome: "succeeded"}); err == nil {
|
|
t.Error("revision 0 accepted")
|
|
}
|
|
var deps service.DeploymentPage
|
|
viewer.mustStatus(200, "GET", "/api/v1/deployments", nil).json(t, &deps)
|
|
if len(deps.Items) != 2 || deps.Items[0].Outcome != "rolled_back" || deps.Items[0].AgentHostname != "host-dns" || deps.NextBeforeID != nil {
|
|
t.Fatalf("deployments %+v", deps)
|
|
}
|
|
deps = service.DeploymentPage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1&revision=1&agent_id="+dnsAgent, nil).json(t, &deps)
|
|
if len(deps.Items) != 1 || deps.Items[0].Revision != 1 || deps.NextBeforeID != nil {
|
|
t.Errorf("filtered %+v", deps)
|
|
}
|
|
deps = service.DeploymentPage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1", nil).json(t, &deps)
|
|
if deps.NextBeforeID == nil {
|
|
t.Fatalf("paging %+v", deps)
|
|
}
|
|
before := *deps.NextBeforeID
|
|
deps = service.DeploymentPage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/deployments?before_id="+strconv.FormatInt(before, 10), nil).json(t, &deps)
|
|
if len(deps.Items) != 1 || deps.Items[0].Outcome != "succeeded" {
|
|
t.Errorf("before_id %+v", deps)
|
|
}
|
|
viewer.mustStatus(200, "GET", "/api/v1/deployments?agent_id=unknown", nil)
|
|
|
|
var ag service.AgentDTO
|
|
viewer.mustStatus(200, "GET", "/api/v1/agents/"+dnsAgent, nil).json(t, &ag)
|
|
if ag.AppliedRevision != 1 || ag.LastGoodRevision != 1 || ag.DesiredRevision != 2 || ag.LastDeployment == nil ||
|
|
ag.LastDeployment.Outcome != "rolled_back" {
|
|
t.Errorf("agent after deployments %+v", ag)
|
|
}
|
|
|
|
// Read-only mode blocks revisions but not the agents.
|
|
e.advance(31 * 24 * time.Hour)
|
|
admin.login("admin", "admin-password-123")
|
|
if p := admin.mustStatus(403, "POST", "/api/v1/revisions", nil).problem(t); p.Code != "license_read_only" {
|
|
t.Errorf("read-only %+v", p)
|
|
}
|
|
if err := e.svc.AgentHeartbeat(ctx, dnsAgent, service.HeartbeatInput{AppliedRevision: 2, LastGoodRevision: 2}); err != nil {
|
|
t.Errorf("heartbeat in read-only mode: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLeases(t *testing.T) {
|
|
e := newEnv(t)
|
|
admin := e.adminClient()
|
|
viewer := e.userClient(admin, "vera", "viewer")
|
|
ctx := context.Background()
|
|
|
|
var netw service.NetworkDTO
|
|
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.0/24", "name": "Office"}).json(t, &netw)
|
|
ids, err := e.st.Q().SubnetIDs(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subnet := ids[netw.ID]
|
|
|
|
dhcpAgent := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
|
|
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
|
|
exp := e.now.Add(time.Hour)
|
|
leases := []service.LeaseInput{
|
|
{IP: "192.0.2.100", MAC: "00:11:22:33:44:55", Hostname: "laptop", State: "active", ExpiresAt: &exp, SubnetID: subnet},
|
|
{IP: "192.0.2.101", MAC: "00:11:22:33:44:66", Hostname: "phone", State: "released", SubnetID: subnet},
|
|
{IP: "198.51.100.7", MAC: "00:11:22:33:44:77", State: "active", SubnetID: 9999},
|
|
{IP: "not-an-ip", State: "active"},
|
|
}
|
|
res, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, leases)
|
|
if err != nil || res.Acknowledged != 1 || res.Duplicate || res.Skipped != 1 {
|
|
t.Fatalf("batch %+v %v", res, err)
|
|
}
|
|
// A duplicate batch is dropped and acknowledged with the stored sequence.
|
|
res, err = e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, nil)
|
|
if err != nil || !res.Duplicate || res.Acknowledged != 1 {
|
|
t.Errorf("duplicate %+v %v", res, err)
|
|
}
|
|
if _, err := e.svc.StoreLeaseBatch(ctx, dnsAgent, 1, true, leases); !errors.Is(err, service.ErrNotDHCPAgent) {
|
|
t.Errorf("DNS agent leases: %v", err)
|
|
}
|
|
|
|
var page service.LeasePage
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
|
|
if len(page.Items) != 3 || page.NextAfter != nil || page.Items[0].NetworkID != netw.ID || page.Items[2].NetworkID != "" ||
|
|
page.Items[0].AgentID != dhcpAgent || page.Items[0].ExpiresAt == nil {
|
|
t.Fatalf("leases %+v", page)
|
|
}
|
|
page = service.LeasePage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases?network_id="+netw.ID+"&state=active", nil).json(t, &page)
|
|
if len(page.Items) != 1 || page.Items[0].Hostname != "laptop" {
|
|
t.Errorf("filtered %+v", page)
|
|
}
|
|
page = service.LeasePage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases?q=44:66", nil).json(t, &page)
|
|
if len(page.Items) != 1 || page.Items[0].Hostname != "phone" {
|
|
t.Errorf("search %+v", page)
|
|
}
|
|
page = service.LeasePage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2", nil).json(t, &page)
|
|
if len(page.Items) != 2 || page.NextAfter == nil || *page.NextAfter != "192.0.2.101" {
|
|
t.Fatalf("first page %+v", page)
|
|
}
|
|
page = service.LeasePage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2&after=192.0.2.101", nil).json(t, &page)
|
|
if len(page.Items) != 1 || page.Items[0].IP.String() != "198.51.100.7" || page.NextAfter != nil {
|
|
t.Errorf("second page %+v", page)
|
|
}
|
|
viewer.mustStatus(422, "GET", "/api/v1/leases?after=nonsense", nil)
|
|
|
|
// Changes are merged; a full sync replaces the set.
|
|
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 2, false, []service.LeaseInput{
|
|
{IP: "192.0.2.101", MAC: "00:11:22:33:44:66", Hostname: "phone", State: "active", SubnetID: subnet},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page = service.LeasePage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases?state=active", nil).json(t, &page)
|
|
if len(page.Items) != 3 {
|
|
t.Errorf("after delta %+v", page)
|
|
}
|
|
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 3, true, leases[:1]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page = service.LeasePage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
|
|
if len(page.Items) != 1 {
|
|
t.Errorf("after full sync %+v", page)
|
|
}
|
|
|
|
// Deleting the agent removes its leases.
|
|
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+dhcpAgent, nil)
|
|
page = service.LeasePage{}
|
|
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
|
|
if len(page.Items) != 0 {
|
|
t.Errorf("leases of deleted agent %+v", page)
|
|
}
|
|
}
|