Files
DDI/server/internal/httpapi/push_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 00b9ffb65b feat(server): operating modes, push deployments, rollback, diff, pending changes and SSE
- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
  ApplyRevisionCommand with the deployment ID, re-sends open pushes on
  connect and announces them in GetDesiredState; reports with the ID
  complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
  with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:47 +02:00

583 lines
21 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"bufio"
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// officeNetwork creates a network with a name server entry.
func (c *client) officeNetwork() {
c.e.t.Helper()
c.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"}},
})
c.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.53", "type": "static", "hostname": "ns1"})
}
func (c *client) addHost(ip, name string) {
c.e.t.Helper()
c.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": ip, "type": "static", "hostname": name})
}
func (c *client) newRevision() service.RevisionDTO {
c.e.t.Helper()
var rev service.RevisionDTO
c.mustStatus(201, "POST", "/api/v1/revisions", nil).json(c.e.t, &rev)
return rev
}
func (c *client) agent(id string) service.AgentDTO {
c.e.t.Helper()
var ag service.AgentDTO
c.mustStatus(200, "GET", "/api/v1/agents/"+id, nil).json(c.e.t, &ag)
return ag
}
type deployResponse = listResponse[service.DeployTarget]
func (c *client) deploy(n int64, agentIDs ...string) []service.DeployTarget {
c.e.t.Helper()
var body any
if len(agentIDs) > 0 {
body = map[string]any{"agent_ids": agentIDs}
}
var out deployResponse
c.mustStatus(200, "POST", "/api/v1/revisions/"+strconv.FormatInt(n, 10)+"/deploy", body).json(c.e.t, &out)
return out.Items
}
// command returns the next command of conn or fails.
func command(t *testing.T, conn *agenthub.Conn) *agentv1.Command {
t.Helper()
select {
case cmd := <-conn.Commands():
return cmd
case <-time.After(2 * time.Second):
t.Fatal("no command")
return nil
}
}
func noCommand(t *testing.T, conn *agenthub.Conn) {
t.Helper()
select {
case cmd := <-conn.Commands():
t.Errorf("unexpected command %v", cmd)
default:
}
}
func (e *env) deployment(id int64) service.DeploymentDTO {
e.t.Helper()
page, err := e.svc.ListDeployments(context.Background(), service.DeploymentFilter{Limit: 500})
if err != nil {
e.t.Fatal(err)
}
for _, d := range page.Items {
if d.ID == id {
return d
}
}
e.t.Fatalf("deployment %d not found", id)
return service.DeploymentDTO{}
}
func TestAgentModesAndPush(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.officeNetwork()
admin.configureDNS()
auto := e.enroll(admin.createToken("dns").Secret, "dns")
manual := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
if rev := operator.newRevision(); rev.Number != 1 {
t.Fatalf("revision %d", rev.Number)
}
// Switching to manual keeps the agent on the latest revision and
// reconnects it.
conn := e.hub.Attach(manual, "dhcp", agenthub.ModeAutoPull)
viewer.mustStatus(403, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"})
operator.mustStatus(403, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"})
admin.mustStatus(422, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "sometimes"}, skipRequestValidation, "1")
admin.mustStatus(404, "PATCH", "/api/v1/agents/00000000-0000-4000-8000-000000000000", map[string]any{"mode": "manual"})
var ag service.AgentDTO
admin.mustStatus(200, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"}).json(t, &ag)
if ag.Mode != "manual" || ag.TargetRevision != 1 || ag.DesiredRevision != 1 || ag.Connected {
t.Errorf("manual agent %+v", ag)
}
if conn.Reason() != agenthub.ReasonModeChanged {
t.Errorf("stream not closed: %q", conn.Reason())
}
// Unchanged mode: no audit entry, no reconnect.
mconn := e.hub.Attach(manual, "dhcp", agenthub.ModeManual)
admin.mustStatus(200, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "manual"})
select {
case <-mconn.Done():
t.Error("unchanged mode closed the stream")
default:
}
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?action=agent.update", nil).json(t, &audit)
if len(audit.Items) != 1 {
t.Errorf("mode audit %+v", audit.Items)
}
// A new revision goes to auto_pull agents only.
aconn := e.hub.Attach(auto, "dns", agenthub.ModeAutoPull)
admin.addHost("192.0.2.10", "www")
if rev := operator.newRevision(); rev.Number != 2 {
t.Fatalf("revision %d", rev.Number)
}
if cmd := command(t, aconn); cmd.GetApplyRevision().GetRevision() != 2 {
t.Errorf("auto_pull command %v", cmd)
}
noCommand(t, mconn)
if a := viewer.agent(auto); a.DesiredRevision != 2 || a.TargetRevision != 0 {
t.Errorf("auto_pull agent %+v", a)
}
if m := viewer.agent(manual); m.DesiredRevision != 1 {
t.Errorf("manual agent %+v", m)
}
// Pushing an older revision to an auto_pull agent needs a rollback.
r := operator.mustStatus(422, "POST", "/api/v1/revisions/1/deploy", nil)
if p := r.problem(t); p.Code != "rollback_required" || p.Params["latest"] != "2" || len(p.Problems) != 1 ||
p.Problems[0].Params["agent_id"] != auto {
t.Errorf("rollback required %+v", p)
}
viewer.mustStatus(403, "POST", "/api/v1/revisions/1/deploy", map[string]any{"agent_ids": []string{manual}})
operator.mustStatus(404, "POST", "/api/v1/revisions/99/deploy", nil)
r = operator.mustStatus(422, "POST", "/api/v1/revisions/1/deploy",
map[string]any{"agent_ids": []string{"nonsense", "00000000-0000-4000-8000-000000000000"}})
if p := r.problem(t); p.Code != "validation_failed" || len(p.Problems) != 2 || p.Problems[0].Code != "unknown_agent" ||
p.Problems[0].Field != "agent_ids" {
t.Errorf("unknown agents %+v", p)
}
// Push revision 1 to the manual agent: pending deployment, command
// with the deployment ID.
targets := operator.deploy(1, manual)
if len(targets) != 1 || targets[0].AgentID != manual || !targets[0].Sent || targets[0].DeploymentID == 0 ||
targets[0].AgentHostname != "host-dhcp" {
t.Fatalf("targets %+v", targets)
}
first := targets[0].DeploymentID
cmd := command(t, mconn)
if cmd.GetApplyRevision().GetRevision() != 1 || cmd.GetApplyRevision().GetDeploymentId() != strconv.FormatInt(first, 10) {
t.Errorf("push command %v", cmd)
}
if d := e.deployment(first); d.Outcome != "pending" || d.FinishedAt != nil || d.RequestedByName != "otto" || d.Trigger != "push" {
t.Errorf("pending deployment %+v", d)
}
st, err := e.svc.AgentDesiredState(ctx, manual, nil)
if err != nil || st.DesiredRevision != 1 || st.PushDeploymentID != strconv.FormatInt(first, 10) {
t.Errorf("desired state %+v %v", st, err)
}
// A newer push supersedes the open one and moves the target.
second := operator.deploy(2, manual)[0].DeploymentID
command(t, mconn)
if d := e.deployment(first); d.Outcome != "superseded" || d.FinishedAt == nil {
t.Errorf("superseded %+v", d)
}
if m := viewer.agent(manual); m.TargetRevision != 2 || m.DesiredRevision != 2 || m.LastDeployment == nil ||
m.LastDeployment.ID != second {
t.Errorf("manual agent after push %+v", m)
}
// The agent reports: the deployment is completed, polling no longer
// announces it, a resent report changes nothing.
started := e.now.Add(-time.Second).Truncate(time.Microsecond)
rep := service.DeploymentReport{CorrelationID: strconv.FormatInt(second, 10), Revision: 2, Trigger: "push",
Outcome: "succeeded", Message: "ok", ActiveRevision: 2, StartedAt: started, FinishedAt: *e.now}
for range 2 {
if err := e.svc.RecordDeployment(ctx, manual, rep); err != nil {
t.Fatal(err)
}
}
if d := e.deployment(second); d.Outcome != "succeeded" || d.FinishedAt == nil || d.Message != "ok" || !d.StartedAt.Equal(started) {
t.Errorf("completed %+v", d)
}
if st, _ := e.svc.AgentDesiredState(ctx, manual, nil); st.PushDeploymentID != "" || st.DesiredRevision != 2 {
t.Errorf("desired state after report %+v", st)
}
var deps service.DeploymentPage
viewer.mustStatus(200, "GET", "/api/v1/deployments?agent_id="+manual, nil).json(t, &deps)
if len(deps.Items) != 2 {
t.Errorf("deployments %+v", deps.Items)
}
if m := viewer.agent(manual); m.AppliedRevision != 2 || m.LastGoodRevision != 2 {
t.Errorf("agent state after report %+v", m)
}
// A report with an unknown push ID is stored like a pull report.
if err := e.svc.RecordDeployment(ctx, manual, service.DeploymentReport{CorrelationID: "999999", Revision: 2,
Trigger: "push", Outcome: "succeeded", ActiveRevision: 2, StartedAt: *e.now, FinishedAt: *e.now}); err != nil {
t.Fatal(err)
}
// No agent goes back behind the revision it runs.
r = operator.mustStatus(422, "POST", "/api/v1/revisions/1/deploy", map[string]any{"agent_ids": []string{manual}})
if p := r.problem(t); p.Code != "rollback_required" || p.Problems[0].Params["applied_revision"] != "2" {
t.Errorf("older than applied %+v", p)
}
// Push to all agents: the latest revision is fine for both modes.
all := operator.deploy(2)
if len(all) != 2 {
t.Fatalf("push to all %+v", all)
}
if cmd := command(t, aconn); cmd.GetApplyRevision().GetDeploymentId() == "" {
t.Errorf("auto_pull push %v", cmd)
}
command(t, mconn)
// Disconnected agents get the push later (sent false).
e.hub.Disconnect(auto, "test")
var autoPush int64
for _, tg := range operator.deploy(2, auto) {
if tg.Sent {
t.Errorf("sent to a disconnected agent %+v", tg)
}
autoPush = tg.DeploymentID
}
set, err := e.svc.AgentHello(ctx, auto, service.HelloInput{AppliedRevision: 2, LastGoodRevision: 2})
if err != nil || set.PushDeploymentID != strconv.FormatInt(autoPush, 10) || set.DesiredRevision != 2 {
t.Errorf("hello settings %+v %v", set, err)
}
// A new revision supersedes the open push of the auto_pull agent.
admin.addHost("192.0.2.11", "mail")
operator.newRevision()
if d := e.deployment(autoPush); d.Outcome != "superseded" {
t.Errorf("auto_pull push after a new revision %+v", d)
}
// Back to auto_pull: open pushes of older revisions are superseded.
open := operator.deploy(2, manual)[0].DeploymentID
admin.mustStatus(200, "PATCH", "/api/v1/agents/"+manual, map[string]any{"mode": "auto_pull"}).json(t, &ag)
if ag.DesiredRevision != 3 || ag.TargetRevision != 2 {
t.Errorf("auto_pull again %+v", ag)
}
if d := e.deployment(open); d.Outcome != "superseded" {
t.Errorf("open push after mode change %+v", d)
}
// Revoked agents are rejected; read-only mode blocks pushes.
admin.mustStatus(200, "POST", "/api/v1/agents/"+manual+"/revoke", nil)
r = operator.mustStatus(422, "POST", "/api/v1/revisions/3/deploy", map[string]any{"agent_ids": []string{manual}})
if p := r.problem(t); len(p.Problems) != 1 || p.Problems[0].Code != "agent_revoked" {
t.Errorf("revoked %+v", p)
}
if got := operator.deploy(3); len(got) != 1 || got[0].AgentID != auto {
t.Errorf("push to all skips revoked agents: %+v", got)
}
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.deploy", nil).json(t, &audit)
if len(audit.Items) != 6 {
t.Errorf("deploy audit %d", len(audit.Items))
}
e.advance(31 * 24 * time.Hour)
operator.login("otto", "changed-password-1")
if p := operator.mustStatus(403, "POST", "/api/v1/revisions/3/deploy", nil).problem(t); p.Code != "license_read_only" {
t.Errorf("read-only %+v", p)
}
}
func TestRollbackAndDiff(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
operator := e.userClient(admin, "otto", "operator")
admin.officeNetwork()
admin.configureDNS()
auto := e.enroll(admin.createToken("dns").Secret, "dns")
conn := e.hub.Attach(auto, "dns", agenthub.ModeAutoPull)
operator.newRevision()
command(t, conn)
admin.addHost("192.0.2.10", "www")
operator.newRevision()
command(t, conn)
var d service.RevisionDiff
viewer.mustStatus(200, "GET", "/api/v1/revisions/2/diff", nil).json(t, &d)
if d.Number != 2 || d.Against != 1 || len(d.Files) == 0 {
t.Fatalf("diff %+v", d)
}
var zone *service.FileDiff
for i := range d.Files {
if d.Files[i].Status != "modified" || d.Files[i].Role != "dns" {
t.Errorf("file %+v", d.Files[i])
}
if d.Files[i].Path == "zones/db.office.test" {
zone = &d.Files[i]
}
}
if zone == nil || !strings.Contains(zone.Diff, "192.0.2.10") || zone.Inserted < 3 || zone.Deleted < 2 || !strings.HasPrefix(zone.Diff, "@@ ") {
t.Errorf("zone diff %+v", zone)
}
d = service.RevisionDiff{}
viewer.mustStatus(200, "GET", "/api/v1/revisions/2/diff?against=0", nil).json(t, &d)
for _, f := range d.Files {
if f.Status != "added" || f.Deleted != 0 || f.Inserted == 0 {
t.Errorf("against nothing %+v", f)
}
}
if len(d.Files) < 2 || d.Files[len(d.Files)-1].Role != "dhcp" {
t.Errorf("files %+v", d.Files)
}
d = service.RevisionDiff{}
viewer.mustStatus(200, "GET", "/api/v1/revisions/1/diff?against=2", nil).json(t, &d)
if len(d.Files) == 0 || d.Files[0].Deleted == 0 {
t.Errorf("reverse diff %+v", d)
}
viewer.mustStatus(404, "GET", "/api/v1/revisions/2/diff?against=99", nil)
viewer.mustStatus(404, "GET", "/api/v1/revisions/99/diff", nil)
viewer.mustStatus(422, "GET", "/api/v1/revisions/2/diff?against=x", nil, skipRequestValidation, "1")
// Rollback: a new revision with the content of revision 1 and a new
// SOA serial.
viewer.mustStatus(403, "POST", "/api/v1/revisions/1/rollback", nil)
operator.mustStatus(404, "POST", "/api/v1/revisions/99/rollback", nil)
var rev service.RevisionDTO
operator.mustStatus(201, "POST", "/api/v1/revisions/1/rollback", nil).json(t, &rev)
if rev.Number != 3 || rev.Comment != "Rollback to revision 1" || rev.CreatedByName != "otto" {
t.Errorf("rollback %+v", rev)
}
if cmd := command(t, conn); cmd.GetApplyRevision().GetRevision() != 3 {
t.Errorf("rollback not pushed: %v", cmd)
}
d = service.RevisionDiff{}
viewer.mustStatus(200, "GET", "/api/v1/revisions/3/diff?against=1", nil).json(t, &d)
if len(d.Files) == 0 {
t.Fatal("no serial change")
}
for _, f := range d.Files {
// Only the revision (header comment, SOA serial) differs.
if f.Role != "dns" || f.Status != "modified" || f.Inserted != f.Deleted || f.Inserted > 2 ||
!strings.Contains(f.Diff, "+") || !strings.Contains(f.Diff, "(revision 3)") || strings.Contains(f.Diff, "192.0.2.10") {
t.Errorf("rollback differs from revision 1: %+v", f)
}
}
d = service.RevisionDiff{}
viewer.mustStatus(200, "GET", "/api/v1/revisions/3/diff", nil).json(t, &d)
if zone := findDiff(d.Files, "zones/db.office.test"); zone == nil || !strings.Contains(zone.Diff, "192.0.2.10") || zone.Deleted < 3 {
t.Errorf("rollback diff against 2: %+v", d.Files)
}
rev = service.RevisionDTO{}
operator.mustStatus(201, "POST", "/api/v1/revisions/3/rollback", map[string]any{"comment": " again "}).json(t, &rev)
if rev.Number != 4 || rev.Comment != "again" {
t.Errorf("second rollback %+v", rev)
}
command(t, conn)
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.rollback", nil).json(t, &audit)
if len(audit.Items) != 2 || audit.Items[1].ObjectID == nil || *audit.Items[1].ObjectID != "3" {
t.Errorf("rollback audit %+v", audit.Items)
}
// Normal creation still detects unchanged data: the data has www,
// revision 4 does not.
if rev := operator.newRevision(); rev.Number != 5 {
t.Errorf("revision after rollback %+v", rev)
}
// Revisions without generator input (before M4) cannot be rolled back
// to.
if err := e.st.Q().InsertRevision(context.Background(), &store.Revision{Number: 6, CreatedAt: *e.now, CreatedByName: "old"}, nil); err != nil {
t.Fatal(err)
}
if p := operator.mustStatus(409, "POST", "/api/v1/revisions/6/rollback", nil).problem(t); p.Code != "rollback_unavailable" {
t.Errorf("no source %+v", p)
}
}
func findDiff(files []service.FileDiff, path string) *service.FileDiff {
for i := range files {
if files[i].Path == path {
return &files[i]
}
}
return nil
}
func TestPendingChanges(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
admin.officeNetwork()
var p service.PendingChanges
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
if p.LatestRevision != 0 || !p.Changed || p.DNSError == nil || p.DNSError.Code != "dns_settings_missing" || p.DHCPError != nil ||
len(p.Files) != 1 || p.Files[0].Status != "added" || p.Files[0].Role != "dhcp" || len(p.Changes) != 2 {
t.Fatalf("before the first revision %+v", p)
}
if p.Changes[0].Action != "address.create" || p.Changes[1].Action != "network.create" || p.Changes[0].IP != nil {
t.Errorf("changes %+v", p.Changes)
}
admin.configureDNS()
admin.newRevision()
p = service.PendingChanges{}
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
if p.LatestRevision != 1 || p.Changed || len(p.Files) != 0 || len(p.Changes) != 0 || p.DNSError != nil {
t.Fatalf("after the revision %+v", p)
}
admin.addHost("192.0.2.10", "www")
admin.mustStatus(201, "POST", "/api/v1/users", map[string]any{"username": "x", "role": "viewer", "password": "initial-password-1"})
p = service.PendingChanges{}
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
if !p.Changed || len(p.Changes) != 1 || p.Changes[0].Action != "address.create" || findDiff(p.Files, "zones/db.office.test") == nil {
t.Fatalf("pending %+v", p)
}
for _, f := range p.Files {
if f.Status != "modified" || f.Role != "dns" {
t.Errorf("pending file %+v", f)
}
}
// The serial is not a change: the pending zone diff contains only the
// new host.
if z := findDiff(p.Files, "zones/db.office.test"); z.Deleted != 0 {
t.Errorf("zone diff %+v", z)
}
}
// sse reads an event stream line by line.
type sse struct {
lines chan string
done chan struct{}
keepalives atomic.Int64
}
func (c *client) openEvents(ctx context.Context) (int, http.Header, *sse) {
c.e.t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.e.srv.URL+"/api/v1/events", nil)
if err != nil {
c.e.t.Fatal(err)
}
res, err := c.http.Do(req) //nolint:bodyclose // closed by the reader goroutine
if err != nil {
c.e.t.Fatal(err)
}
s := &sse{lines: make(chan string, 1000), done: make(chan struct{})}
go func() {
defer close(s.done)
defer res.Body.Close() //nolint:errcheck // test
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
if sc.Text() == ": keepalive" {
s.keepalives.Add(1)
}
s.lines <- sc.Text()
}
}()
return res.StatusCode, res.Header, s
}
// event waits for the next event of type typ and returns its data.
func (s *sse) event(t *testing.T, typ string) string {
t.Helper()
timeout := time.After(5 * time.Second)
for {
select {
case l := <-s.lines:
if l != "event: "+typ {
continue
}
select {
case data := <-s.lines:
return strings.TrimPrefix(data, "data: ")
case <-timeout:
}
t.Fatalf("event %s without data", typ)
case <-timeout:
t.Fatalf("no %s event", typ)
}
}
}
func TestEventStream(t *testing.T) {
// Short server timeouts: the stream must outlive them.
e := newEnvServer(t, nil, func(s *http.Server) {
s.ReadTimeout, s.WriteTimeout = 4*time.Second, 4*time.Second
})
admin := e.adminClient()
e.client().mustStatus(401, "GET", "/api/v1/events", nil)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
status, header, stream := admin.openEvents(ctx)
if status != http.StatusOK || header.Get("Content-Type") != "text/event-stream" || header.Get("Cache-Control") != "no-store" {
t.Fatalf("stream response %d %v", status, header)
}
if l := <-stream.lines; l != "retry: 5000" {
t.Errorf("first line %q", l)
}
if e.bus.Subscribers() != 1 {
t.Errorf("subscribers %d", e.bus.Subscribers())
}
admin.officeNetwork()
if data := stream.event(t, "pending"); data != "{}" {
t.Errorf("pending data %q", data)
}
agentID := e.enroll(admin.createToken("dns").Secret, "dns")
if data := stream.event(t, "agent"); data != `{"id":"`+agentID+`"}` {
t.Errorf("agent data %q", data)
}
// Past the server's write timeout the stream still delivers.
time.Sleep(5 * time.Second)
admin.configureDNS()
stream.event(t, "pending")
rev := admin.newRevision()
if data := stream.event(t, "revision"); data != `{"number":`+strconv.FormatInt(rev.Number, 10)+`}` {
t.Errorf("revision data %q", data)
}
var targets deployResponse
admin.mustStatus(200, "POST", "/api/v1/revisions/1/deploy", nil).json(t, &targets)
var dep struct {
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
Revision int64 `json:"revision"`
}
if err := json.Unmarshal([]byte(stream.event(t, "deployment")), &dep); err != nil || dep.ID != targets.Items[0].DeploymentID ||
dep.AgentID != agentID || dep.Revision != 1 {
t.Errorf("deployment event %+v %v", dep, err)
}
if n := stream.keepalives.Load(); n < 10 {
t.Errorf("keepalives %d", n)
}
// Logging out ends the stream at the next session check.
admin.mustStatus(204, "POST", "/api/v1/auth/logout", nil)
select {
case <-stream.done:
case <-time.After(5 * time.Second):
t.Fatal("stream outlived the session")
}
time.Sleep(100 * time.Millisecond)
if n := e.bus.Subscribers(); n != 0 {
t.Errorf("subscribers after the end %d", n)
}
}