| new file mode 100644 |
| index 000000000..24e7633c8 |
| --- /dev/null |
| +++ b/internal/agentsock/agentsock_test.go |
| @@ -0,0 +1,558 @@ |
| +package agentsock |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// fakeWorkers records every effect so a test can assert the ABSENCE of one. |
| +// "the call was refused" is only half the property; "and nothing happened" is |
| +// the half that catches an auth check placed after the action. |
| +type fakeWorkers struct { |
| + mu sync.Mutex |
| + tokens map[string]string |
| + contacts []string |
| + due map[string]int64 |
| + dueErr error |
| + slept []string |
| + ready map[string]string |
| +} |
| + |
| +func (f *fakeWorkers) WorkerToken(host string) string { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.tokens[host] |
| +} |
| + |
| +func (f *fakeWorkers) NoteWorkerContact(host string) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.contacts = append(f.contacts, host) |
| +} |
| + |
| +func (f *fakeWorkers) SetWorkerNextDue(host string, at int64) error { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if f.dueErr != nil { |
| + return f.dueErr |
| + } |
| + if f.due == nil { |
| + f.due = map[string]int64{} |
| + } |
| + f.due[host] = at |
| + return nil |
| +} |
| + |
| +func (f *fakeWorkers) SleepWorker(host string) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.slept = append(f.slept, host) |
| +} |
| + |
| +func (f *fakeWorkers) NoteWorkerReady(host, version string) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if f.ready == nil { |
| + f.ready = map[string]string{} |
| + } |
| + f.ready[host] = version |
| +} |
| + |
| +// effects renders every recorded side effect, so a test can assert a refused |
| +// call changed NOTHING rather than checking one slice and missing the rest. |
| +func (f *fakeWorkers) effects() (contacts, slept int, due map[string]int64, ready map[string]string) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + due = map[string]int64{} |
| + for k, v := range f.due { |
| + due[k] = v |
| + } |
| + ready = map[string]string{} |
| + for k, v := range f.ready { |
| + ready[k] = v |
| + } |
| + return len(f.contacts), len(f.slept), due, ready |
| +} |
| + |
| +func (f *fakeWorkers) assertNoEffects(t *testing.T, why string) { |
| + t.Helper() |
| + contacts, slept, due, ready := f.effects() |
| + if contacts != 0 || slept != 0 || len(due) != 0 || len(ready) != 0 { |
| + t.Fatalf("%s: a refused call must have no effect at all; contacts=%d slept=%d due=%v ready=%v", |
| + why, contacts, slept, due, ready) |
| + } |
| +} |
| + |
| +func post(h http.Handler, path, host, tok, body string) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest("POST", path, strings.NewReader(body)) |
| + if host != "" { |
| + req.Header.Set("Carlos-Sidecar-Host", host) |
| + } |
| + if tok != "" { |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + } |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +func waitFor(t *testing.T, what string, cond func() bool) { |
| + t.Helper() |
| + deadline := time.Now().Add(5 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if cond() { |
| + return |
| + } |
| + time.Sleep(2 * time.Millisecond) |
| + } |
| + t.Fatalf("timed out waiting for %s", what) |
| +} |
| + |
| +var allVerbs = []string{"/v1/ready", "/v1/next-due", "/v1/idle", "/v1/heartbeat"} |
| + |
| +func TestAgentSockRejectsWrongToken(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + h := New(f).Handler() |
| + |
| + for _, tc := range []struct{ name, host, tok string }{ |
| + {"wrong token", "bot.x.internal", "bad"}, |
| + {"no token", "bot.x.internal", ""}, |
| + {"unknown host", "nope.x.internal", "good"}, |
| + {"token for another host", "other.x.internal", "good"}, |
| + {"no host header", "", "good"}, |
| + {"empty-string token against an asleep host", "asleep.x.internal", ""}, |
| + } { |
| + rec := post(h, "/v1/heartbeat", tc.host, tc.tok, "") |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("%s: code = %d, want 401", tc.name, rec.Code) |
| + } |
| + } |
| + f.assertNoEffects(t, "wrong-token sweep") |
| +} |
| + |
| +// The inversion this guards is total: WorkerToken("") means "unknown, asleep, |
| +// or stopped", never "no token required". A caller who presents nothing must |
| +// not match a host that holds nothing. |
| +func TestAgentSockEmptyStoredTokenIsNotABypass(t *testing.T) { |
| + // "asleep" is present in the map with the empty token the real activator |
| + // returns for a host that is not currently awake or waking. |
| + f := &fakeWorkers{tokens: map[string]string{"asleep.x.internal": ""}} |
| + h := New(f).Handler() |
| + |
| + for _, tok := range []string{"", " ", "Bearer", "anything"} { |
| + for _, path := range allVerbs { |
| + body := "" |
| + switch path { |
| + case "/v1/ready": |
| + body = `{"version":"v1"}` |
| + case "/v1/next-due": |
| + body = `{"at":1756}` |
| + } |
| + rec := post(h, path, "asleep.x.internal", tok, body) |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("%s with token %q: code = %d, want 401", path, tok, rec.Code) |
| + } |
| + } |
| + } |
| + // The bare header form, which no post() helper shape produces: an |
| + // Authorization header whose credential is literally empty. |
| + req := httptest.NewRequest("POST", "/v1/heartbeat", nil) |
| + req.Header.Set("Carlos-Sidecar-Host", "asleep.x.internal") |
| + req.Header.Set("Authorization", "Bearer ") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("empty bearer credential: code = %d, want 401", rec.Code) |
| + } |
| + f.assertNoEffects(t, "empty stored token") |
| +} |
| + |
| +func TestAgentSockTokenIsScopedToOneHost(t *testing.T) { |
| + // The security property: possession of A's token says nothing about B. |
| + f := &fakeWorkers{tokens: map[string]string{"a.x.internal": "tok-a", "b.x.internal": "tok-b"}} |
| + h := New(f).Handler() |
| + if rec := post(h, "/v1/idle", "b.x.internal", "tok-a", ""); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("A's token spoke about B: code = %d", rec.Code) |
| + } |
| + if rec := post(h, "/v1/ready", "b.x.internal", "tok-a", `{"version":"pwned"}`); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("A's token reported B's version: code = %d", rec.Code) |
| + } |
| + if rec := post(h, "/v1/next-due", "b.x.internal", "tok-a", `{"at":1}`); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("A's token rescheduled B: code = %d", rec.Code) |
| + } |
| + if rec := post(h, "/v1/heartbeat", "b.x.internal", "tok-a", ""); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("A's token kept B alive: code = %d", rec.Code) |
| + } |
| + f.assertNoEffects(t, "cross-host token") |
| +} |
| + |
| +// Every refusal reason must be indistinguishable on the wire: status, headers |
| +// worth reading, and body. A caller must not be able to probe "does host X |
| +// exist" or "is host X awake" by reading the difference. |
| +func TestAgentSockRefusalsAreIndistinguishable(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{ |
| + "awake.x.internal": "good", |
| + "asleep.x.internal": "", |
| + }} |
| + h := New(f).Handler() |
| + |
| + type shape struct { |
| + code int |
| + ctype, body string |
| + } |
| + var seen []shape |
| + for _, tc := range []struct{ name, host, tok string }{ |
| + {"wrong token", "awake.x.internal", "bad"}, |
| + {"wrong token, same length", "awake.x.internal", "goo1"}, |
| + {"absent token", "awake.x.internal", ""}, |
| + {"unknown host", "ghost.x.internal", "good"}, |
| + {"asleep host", "asleep.x.internal", "good"}, |
| + {"absent host header", "", "good"}, |
| + } { |
| + rec := post(h, "/v1/heartbeat", tc.host, tc.tok, "") |
| + seen = append(seen, shape{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()}) |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("%s: code = %d, want 401", tc.name, rec.Code) |
| + } |
| + } |
| + for i := 1; i < len(seen); i++ { |
| + if seen[i] != seen[0] { |
| + t.Fatalf("refusals are distinguishable: %+v vs %+v", seen[0], seen[i]) |
| + } |
| + } |
| + f.assertNoEffects(t, "indistinguishable refusals") |
| +} |
| + |
| +func TestAgentSockVerbs(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + h := New(f).Handler() |
| + |
| + if rec := post(h, "/v1/ready", "bot.x.internal", "good", `{"version":"abc123"}`); rec.Code != http.StatusNoContent { |
| + t.Fatalf("ready: %d", rec.Code) |
| + } |
| + if f.ready["bot.x.internal"] != "abc123" { |
| + t.Fatal("ready must record the running version — it is a sidecar's only version signal") |
| + } |
| + if rec := post(h, "/v1/next-due", "bot.x.internal", "good", `{"at":1756}`); rec.Code != http.StatusNoContent { |
| + t.Fatalf("next-due: %d", rec.Code) |
| + } |
| + if f.due["bot.x.internal"] != 1756 { |
| + t.Fatalf("due = %d", f.due["bot.x.internal"]) |
| + } |
| + if rec := post(h, "/v1/idle", "bot.x.internal", "good", ""); rec.Code != http.StatusNoContent { |
| + t.Fatalf("idle: %d", rec.Code) |
| + } |
| + if len(f.slept) != 1 { |
| + t.Fatal("idle must ask for a stop") |
| + } |
| + if rec := post(h, "/v1/heartbeat", "bot.x.internal", "good", ""); rec.Code != http.StatusNoContent { |
| + t.Fatalf("heartbeat: %d", rec.Code) |
| + } |
| + if len(f.contacts) != 1 || f.contacts[0] != "bot.x.internal" { |
| + t.Fatalf("heartbeat must stamp contact for the CALLER's host; contacts = %v", f.contacts) |
| + } |
| +} |
| + |
| +// The verb acts on the host the TOKEN authorized, never on a host named |
| +// anywhere in the body — there is no host field in any body, and adding one |
| +// would be the confused-deputy bug this pins. |
| +func TestAgentSockActsOnlyOnTheAuthorizedHost(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"a.x.internal": "tok-a", "b.x.internal": "tok-b"}} |
| + h := New(f).Handler() |
| + |
| + if rec := post(h, "/v1/next-due", "a.x.internal", "tok-a", `{"at":99,"host":"b.x.internal"}`); rec.Code != http.StatusNoContent { |
| + t.Fatalf("next-due: %d", rec.Code) |
| + } |
| + if _, ok := f.due["b.x.internal"]; ok { |
| + t.Fatal("a body field must never redirect an effect onto another host") |
| + } |
| + if f.due["a.x.internal"] != 99 { |
| + t.Fatalf("due for the authorized host = %d, want 99", f.due["a.x.internal"]) |
| + } |
| +} |
| + |
| +func TestAgentSockRefusesGarbage(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + h := New(f).Handler() |
| + for _, body := range []string{"not json", `{"at":"soon"}`, strings.Repeat("x", 1<<20), `{"at":-1}`} { |
| + rec := post(h, "/v1/next-due", "bot.x.internal", "good", body) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Fatalf("body %.20q: code = %d, want 400", body, rec.Code) |
| + } |
| + } |
| + // An oversized body on a JSON-bearing verb must be refused, not truncated |
| + // into something that happens to parse. |
| + big := `{"version":"` + strings.Repeat("v", 1<<20) + `"}` |
| + if rec := post(h, "/v1/ready", "bot.x.internal", "good", big); rec.Code != http.StatusBadRequest { |
| + t.Fatalf("oversized ready: code = %d, want 400", rec.Code) |
| + } |
| + // The SIZE CAP itself, not the field bounds behind it: a body that is |
| + // perfectly well-formed and whose fields are all in range, but megabytes |
| + // long. Without a bounded reader this parses and acts. |
| + pad := strings.Repeat("x", 1<<20) |
| + if rec := post(h, "/v1/next-due", "bot.x.internal", "good", `{"at":1756,"pad":"`+pad+`"}`); rec.Code != http.StatusBadRequest { |
| + t.Fatalf("padded next-due: code = %d, want 400 — the body must be read under a cap", rec.Code) |
| + } |
| + if rec := post(h, "/v1/ready", "bot.x.internal", "good", `{"version":"abc","pad":"`+pad+`"}`); rec.Code != http.StatusBadRequest { |
| + t.Fatalf("padded ready: code = %d, want 400 — the body must be read under a cap", rec.Code) |
| + } |
| + for _, body := range []string{"not json", `{"version":42}`, "{\"version\":\"a\\u0000b\"}"} { |
| + if rec := post(h, "/v1/ready", "bot.x.internal", "good", body); rec.Code != http.StatusBadRequest { |
| + t.Fatalf("ready body %.20q: code = %d, want 400", body, rec.Code) |
| + } |
| + } |
| + if _, ok := f.due["bot.x.internal"]; ok { |
| + t.Fatal("a malformed body must leave no effect") |
| + } |
| + if len(f.ready) != 0 { |
| + t.Fatalf("a malformed ready must leave no effect: %v", f.ready) |
| + } |
| + |
| + if rec := post(h, "/v1/nope", "bot.x.internal", "good", ""); rec.Code != http.StatusNotFound { |
| + t.Fatalf("unknown verb: %d", rec.Code) |
| + } |
| + req := httptest.NewRequest("GET", "/v1/idle", nil) |
| + // GET on a POST-only verb |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("GET: %d", rec.Code) |
| + } |
| +} |
| + |
| +// A GET must not act even when it carries a perfectly good credential — the |
| +// method gate has to come before the effect, not merely before the 200. |
| +func TestAgentSockGetNeverActs(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + h := New(f).Handler() |
| + for _, path := range allVerbs { |
| + for _, method := range []string{"GET", "HEAD", "PUT", "DELETE"} { |
| + req := httptest.NewRequest(method, path, nil) |
| + req.Header.Set("Carlos-Sidecar-Host", "bot.x.internal") |
| + req.Header.Set("Authorization", "Bearer good") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code == http.StatusNoContent { |
| + t.Fatalf("%s %s succeeded; only POST may act", method, path) |
| + } |
| + } |
| + } |
| + f.assertNoEffects(t, "non-POST methods") |
| +} |
| + |
| +// An empty next-due body is malformed: "at" is the whole message, and a |
| +// zero-valued default would silently reschedule the worker to the epoch. |
| +func TestAgentSockNextDueRequiresAt(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + h := New(f).Handler() |
| + for _, body := range []string{"", "{}", "null"} { |
| + if rec := post(h, "/v1/next-due", "bot.x.internal", "good", body); rec.Code != http.StatusBadRequest { |
| + t.Fatalf("next-due body %q: code = %d, want 400", body, rec.Code) |
| + } |
| + } |
| + if len(f.due) != 0 { |
| + t.Fatalf("due must be untouched: %v", f.due) |
| + } |
| +} |
| + |
| +// The version field must be PRESENT — a typo'd body is a loud 400, never a |
| +// silent "unknown" that also ends the settle window. It may be empty, which is |
| +// a child honestly saying it does not know its own build. |
| +func TestAgentSockReadyRequiresTheVersionField(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + h := New(f).Handler() |
| + for _, body := range []string{"", "{}", "null", `{"verison":"abc"}`} { |
| + if rec := post(h, "/v1/ready", "bot.x.internal", "good", body); rec.Code != http.StatusBadRequest { |
| + t.Fatalf("ready body %q: code = %d, want 400", body, rec.Code) |
| + } |
| + } |
| + if len(f.ready) != 0 { |
| + t.Fatalf("ready must be untouched: %v", f.ready) |
| + } |
| + if rec := post(h, "/v1/ready", "bot.x.internal", "good", `{"version":""}`); rec.Code != http.StatusNoContent { |
| + t.Fatalf("explicitly-unknown version: code = %d, want 204", rec.Code) |
| + } |
| + if v, ok := f.ready["bot.x.internal"]; !ok || v != "" { |
| + t.Fatalf("ready = %q, %v; an explicit empty version is honest, and still ready", v, ok) |
| + } |
| +} |
| + |
| +// Bodyless verbs stay bodyless: idle and heartbeat carry no arguments, so |
| +// nothing a caller sends can steer them. |
| +func TestAgentSockBodylessVerbsIgnoreTheirBody(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + h := New(f).Handler() |
| + if rec := post(h, "/v1/heartbeat", "bot.x.internal", "good", `{"host":"other.x.internal"}`); rec.Code != http.StatusNoContent { |
| + t.Fatalf("heartbeat with a body: %d", rec.Code) |
| + } |
| + if len(f.contacts) != 1 || f.contacts[0] != "bot.x.internal" { |
| + t.Fatalf("contacts = %v", f.contacts) |
| + } |
| +} |
| + |
| +func TestAgentSockNextDueStoreFailureIsNot204(t *testing.T) { |
| + f := &fakeWorkers{ |
| + tokens: map[string]string{"bot.x.internal": "good"}, |
| + dueErr: errors.New("index write failed"), |
| + } |
| + h := New(f).Handler() |
| + rec := post(h, "/v1/next-due", "bot.x.internal", "good", `{"at":1756}`) |
| + if rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("code = %d, want 500 — a child must learn its schedule did not land", rec.Code) |
| + } |
| +} |
| + |
| +func TestAgentSockServeCleansUpItsSocket(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "agent.sock") |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + done := make(chan error, 1) |
| + go func() { done <- New(&fakeWorkers{}).Serve(ctx, path) }() |
| + waitFor(t, "socket to appear", func() bool { _, err := os.Stat(path); return err == nil }) |
| + cancel() |
| + if err := <-done; err != nil { |
| + t.Fatalf("Serve returned %v on a clean shutdown", err) |
| + } |
| + if _, err := os.Stat(path); !os.IsNotExist(err) { |
| + t.Fatal("a stale socket file must not survive shutdown — the next start would fail to bind") |
| + } |
| +} |
| + |
| +// A hard kill leaves the socket file behind. The next start must take it over, |
| +// not die on "address already in use". |
| +func TestAgentSockServeReplacesAStaleSocketFile(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "agent.sock") |
| + if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { done <- New(&fakeWorkers{}).Serve(ctx, path) }() |
| + waitFor(t, "socket to appear", func() bool { |
| + c, err := net.Dial("unix", path) |
| + if err != nil { |
| + return false |
| + } |
| + c.Close() |
| + return true |
| + }) |
| + cancel() |
| + <-done |
| +} |
| + |
| +// The other half of stale-socket removal: a socket somebody is LISTENING on is |
| +// not stale. Unlinking it would orphan the live agent — its children would go |
| +// on talking to a process nothing else can reach — so refuse instead. |
| +func TestAgentSockServeRefusesALiveSocket(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "agent.sock") |
| + ln, err := net.Listen("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer ln.Close() |
| + go func() { |
| + for { |
| + c, err := ln.Accept() |
| + if err != nil { |
| + return |
| + } |
| + c.Close() |
| + } |
| + }() |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| + defer cancel() |
| + if err := New(&fakeWorkers{}).Serve(ctx, path); err == nil { |
| + t.Fatal("Serve must refuse a socket another listener already owns") |
| + } |
| + if _, err := os.Stat(path); err != nil { |
| + t.Fatalf("the live socket was unlinked: %v", err) |
| + } |
| +} |
| + |
| +// End to end over a real unix socket: the shape a sidecar actually dials. |
| +func TestAgentSockOverTheRealSocket(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "agent.sock") |
| + f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}} |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + done := make(chan error, 1) |
| + go func() { done <- New(f).Serve(ctx, path) }() |
| + defer func() { cancel(); <-done }() |
| + waitFor(t, "socket to appear", func() bool { _, err := os.Stat(path); return err == nil }) |
| + |
| + if fi, err := os.Stat(path); err != nil { |
| + t.Fatal(err) |
| + } else if perm := fi.Mode().Perm(); perm&0o007 != 0 { |
| + t.Fatalf("socket mode %o is reachable by other — the token is the boundary, but the mode should not widen the audience", perm) |
| + } |
| + |
| + cl := &http.Client{ |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", path) |
| + }, |
| + }, |
| + Timeout: 5 * time.Second, |
| + } |
| + call := func(tok string) int { |
| + req, err := http.NewRequest("POST", "http://agent/v1/heartbeat", nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Carlos-Sidecar-Host", "bot.x.internal") |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + resp, err := cl.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + if code := call("good"); code != http.StatusNoContent { |
| + t.Fatalf("live heartbeat: %d", code) |
| + } |
| + if code := call("bad"); code != http.StatusUnauthorized { |
| + t.Fatalf("live bad token: %d", code) |
| + } |
| + contacts, _, _, _ := f.effects() |
| + if contacts != 1 { |
| + t.Fatalf("contacts = %d, want exactly the authorized one", contacts) |
| + } |
| +} |
| + |
| +// Many children hammering one agent at once — the handler holds no per-request |
| +// state, and -race proves it. |
| +func TestAgentSockConcurrentCallers(t *testing.T) { |
| + f := &fakeWorkers{tokens: map[string]string{"a.x.internal": "tok-a", "b.x.internal": "tok-b"}} |
| + h := New(f).Handler() |
| + var wg sync.WaitGroup |
| + for i := range 50 { |
| + wg.Add(1) |
| + go func(i int) { |
| + defer wg.Done() |
| + if i%2 == 0 { |
| + post(h, "/v1/heartbeat", "a.x.internal", "tok-a", "") |
| + } else { |
| + post(h, "/v1/heartbeat", "b.x.internal", "tok-a", "") |
| + } |
| + }(i) |
| + } |
| + wg.Wait() |
| + contacts, _, _, _ := f.effects() |
| + if contacts != 25 { |
| + t.Fatalf("contacts = %d, want 25 — only a.x.internal's own token may stamp it", contacts) |
| + } |
| +} |