670ec35ece
Found by the operator, in the way that matters: it cost a real login.
The v0.84.0 Console access card shipped its Copy button DISABLED until a Reveal.
Clicking it did nothing, silently, so the clipboard kept whatever was already in
it — another host's console password from an earlier reveal. That got pasted into
demo-hp's PVE login, which failed with no explanation: the box logged a plain
`password check failed for user (root)`, the credential was never at fault, and
nothing on screen said the copy had not happened.
A copy button that silently no-ops is worse than no copy button. The operator
cannot tell "copied" from "did nothing", and the stale value left behind is a
VALID secret for a DIFFERENT machine — so the failure looks like a stale
credential and sends you diagnosing the wrong thing.
Copy now works without revealing, and that is the safer default rather than a
concession: the secret goes straight to the clipboard and never renders on
screen, so it cannot be shoulder-surfed or caught in a screenshot. Reveal remains
for when it must be read.
Three silent-failure branches closed, all in the same eight-line function:
- not yet revealed -> was a disabled no-op; now fetches and copies
- navigator.clipboard absent -> was silently skipped; now shows it and says why
- writeText() REJECTED -> promise was ignored, so the operator believed it
copied; now shows it and reports the refusal
The success path names the host ("Copied demo-hp-bb76ea's root@pam password"),
because the clipboard is fleet-wide and every box has a different console
password — "copied" alone cannot say for WHICH box, which is the confusion that
produced the incident.
One retrieval path, shared: the endpoint is defined once (data-reveal-url) and
read back with getAttribute, so Copy cannot drift onto a different, unaudited URL
than Reveal. Server-side is unchanged — both buttons hit the same CSRF-gated
endpoint and both write the same recovery_credential_revealed event, which is
correct: the register records accesses, and a copy is an access.
Tests 566 -> 568. Red-proof: re-adding `disabled` reproduces the shipped bug.
443 lines
17 KiB
Go
443 lines
17 KiB
Go
package web
|
||
|
||
// Scenarios A–G (hub v0.84.0) — the break-glass Console access card + the operator-session reveal
|
||
// endpoint.
|
||
//
|
||
// SEAM DISCIPLINE: every test here drives RequireAuth(ServeHTTP), never a handler function
|
||
// directly. Scenario E (route ordering) is INVISIBLE to a handler-level test — the handler is
|
||
// correct and simply never runs — and that is the shape of the inert-seam defects on record.
|
||
//
|
||
// The load-bearing assertion is negative in the way that matters: the plaintext must not appear
|
||
// ANYWHERE in a rendered host page. revealCanary is deliberately distinctive so an accidental leak
|
||
// is greppable across the tree.
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||
)
|
||
|
||
const revealCanary = "REVEAL-CANARY-9f2b7c"
|
||
|
||
// newRevealServer builds a hub with a live operator password (so RequireAuth + the ServeHTTP CSRF
|
||
// check are both armed) and a CAPTURED logger, so the "the secret never reaches the log" assertion
|
||
// has something to read.
|
||
func newRevealServer(t *testing.T) (*Server, *store.Store, *bytes.Buffer) {
|
||
t.Helper()
|
||
var logBuf bytes.Buffer
|
||
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
||
if err != nil {
|
||
t.Fatalf("store.New: %v", err)
|
||
}
|
||
t.Cleanup(func() { st.Close() })
|
||
s := New(st, "", "", "test", 30*time.Minute, log.New(&logBuf, "", 0))
|
||
h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s.configPasswordHash = string(h)
|
||
return s, st, &logBuf
|
||
}
|
||
|
||
// newRevealSession mints a live operator session and returns (cookie, csrfToken).
|
||
func newRevealSession(t *testing.T, s *Server) (*http.Cookie, string) {
|
||
t.Helper()
|
||
s.sessionsMu.Lock()
|
||
s.sessions["sess-token-reveal"] = &hubSession{
|
||
expiresAt: time.Now().Add(time.Hour),
|
||
csrfToken: "csrf-token-reveal",
|
||
}
|
||
s.sessionsMu.Unlock()
|
||
return &http.Cookie{Name: "hub_session", Value: "sess-token-reveal"}, "csrf-token-reveal"
|
||
}
|
||
|
||
// seedRevealHost creates a host (optionally bound to a customer) with a vaulted credential.
|
||
func seedRevealHost(t *testing.T, st *store.Store, hostID, customerID, secret string) {
|
||
t.Helper()
|
||
if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: "k-" + hostID}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if secret != "" {
|
||
if err := st.SaveHostRecoveryCredential(hostID, "root@pam", secret); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// serveReveal drives the REAL stack: RequireAuth → ServeHTTP → routing → handler.
|
||
func serveReveal(t *testing.T, s *Server, req *http.Request) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
rr := httptest.NewRecorder()
|
||
s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req)
|
||
return rr
|
||
}
|
||
|
||
func countEvents(t *testing.T, st *store.Store, customerID, eventType string) int {
|
||
t.Helper()
|
||
evs, err := st.GetRecentEvents(customerID, 100)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
n := 0
|
||
for _, e := range evs {
|
||
if e.EventType == eventType {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// --- Scenario A: the rendered host page carries presence + username + set_at, NEVER the secret ---
|
||
// RED-PROOF A: add `data-secret="{{.RecoverySecret}}"` to the card and a "RecoverySecret" key
|
||
// holding cred.Secret to hostDetailData → the canary assertion below goes RED.
|
||
func TestReveal_A_PageNeverCarriesTheSecret(t *testing.T) {
|
||
s, st, _ := newRevealServer(t)
|
||
cookie, _ := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-8363b5", nil)
|
||
req.AddCookie(cookie)
|
||
rr := serveReveal(t, s, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("host page = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
|
||
// THE load-bearing assertion — the whole response, attributes, comments, scripts and all.
|
||
if strings.Contains(body, revealCanary) {
|
||
t.Fatal("SECRET LEAK: the vaulted console password appears in the rendered host page")
|
||
}
|
||
for _, want := range []string{
|
||
"Console access",
|
||
"root@pam",
|
||
"/hosts/demo-felhom-8363b5/reveal-recovery-credential",
|
||
"Reveal",
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("host page is missing %q", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Copy must work WITHOUT a reveal (regression, 2026-07-31) ---
|
||
//
|
||
// THE INCIDENT: Copy shipped `disabled` until a Reveal. Clicking it did nothing, silently, so the
|
||
// operator's clipboard kept its previous contents — ANOTHER HOST'S console password — which was then
|
||
// pasted into a PVE login that failed with no explanation. The box logged a plain
|
||
// `password check failed for user (root)` and the credential was never at fault.
|
||
//
|
||
// Copying without revealing is also the SAFER path: the secret never renders on screen, so it cannot
|
||
// be shoulder-surfed or captured in a screenshot.
|
||
//
|
||
// RED-PROOF: restore `disabled` on the Copy button → this test goes red.
|
||
func TestReveal_CopyIsNotGatedOnReveal(t *testing.T) {
|
||
s, st, _ := newRevealServer(t)
|
||
cookie, _ := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "demo-hp-bb76ea", "demo-hp", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-hp-bb76ea", nil)
|
||
req.AddCookie(cookie)
|
||
body := serveReveal(t, s, req).Body.String()
|
||
|
||
// Locate the Copy button and assert it ships ENABLED.
|
||
i := strings.Index(body, `id="console-copy-demo-hp-bb76ea"`)
|
||
if i < 0 {
|
||
t.Fatal("no Copy button on the card")
|
||
}
|
||
end := strings.Index(body[i:], ">")
|
||
if end < 0 {
|
||
t.Fatal("malformed Copy button tag")
|
||
}
|
||
tag := body[i : i+end]
|
||
if strings.Contains(tag, "disabled") {
|
||
t.Fatalf("the Copy button ships DISABLED — clicking it is a silent no-op that leaves a stale "+
|
||
"secret in the clipboard: %s", tag)
|
||
}
|
||
|
||
// It must still be the case that no secret is in the document.
|
||
if strings.Contains(body, revealCanary) {
|
||
t.Fatal("SECRET LEAK: enabling Copy put the password in the page")
|
||
}
|
||
|
||
// And the copy path must be wired to the SAME audited endpoint, not a second one.
|
||
if !strings.Contains(body, "copyConsolePassword('demo-hp-bb76ea')") {
|
||
t.Error("the Copy button is not wired to a handler")
|
||
}
|
||
// The endpoint URL must be DEFINED exactly once (the data-reveal-url attribute); the script
|
||
// reads it back with getAttribute rather than rebuilding it, so Copy cannot drift onto a
|
||
// different — unaudited — path than Reveal.
|
||
if n := strings.Count(body, "/hosts/demo-hp-bb76ea/reveal-recovery-credential"); n != 1 {
|
||
t.Errorf("the retrieval URL is written %d times; it must be defined once and read back", n)
|
||
}
|
||
}
|
||
|
||
// Every failure branch of the copy path must report itself. A disabled button, a missing clipboard
|
||
// API and a refused clipboard write all previously ended in silence.
|
||
func TestReveal_CopyPathHasNoSilentFailureBranch(t *testing.T) {
|
||
s, st, _ := newRevealServer(t)
|
||
cookie, _ := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "demo-hp-bb76ea", "demo-hp", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-hp-bb76ea", nil)
|
||
req.AddCookie(cookie)
|
||
body := serveReveal(t, s, req).Body.String()
|
||
|
||
for _, want := range []struct{ frag, why string }{
|
||
{"will not give the page clipboard access", "no clipboard API → must say so, not no-op"},
|
||
{"clipboard write was refused", "a rejected writeText → must never claim success"},
|
||
{"Could not copy the credential", "a failed fetch → must surface the status"},
|
||
{"Copied ", "a SUCCESSFUL copy must confirm, or the operator cannot tell it worked"},
|
||
} {
|
||
if !strings.Contains(body, want.frag) {
|
||
t.Errorf("missing outcome message %q (%s)", want.frag, want.why)
|
||
}
|
||
}
|
||
|
||
// The confirmation must NAME the host: the clipboard is fleet-wide and every box has a different
|
||
// console password, so "copied" alone cannot say copied for WHICH box — the exact confusion that
|
||
// produced the incident.
|
||
if !strings.Contains(body, "' + hostID + '") {
|
||
t.Error("the copy confirmation does not name the host it copied for")
|
||
}
|
||
}
|
||
|
||
// --- Scenario B: reveal delivers the secret, records exactly one event, and never logs it ---
|
||
// RED-PROOF B: delete the SaveEvent call in handleHostRevealRecoveryCredential → the event
|
||
// assertion goes RED.
|
||
func TestReveal_B_RevealDeliversAndAudits(t *testing.T) {
|
||
s, st, logBuf := newRevealServer(t)
|
||
cookie, csrf := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||
req.AddCookie(cookie)
|
||
req.Header.Set("X-CSRF-Token", csrf)
|
||
rr := serveReveal(t, s, req)
|
||
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("reveal = %d, want 200 (body %q)", rr.Code, rr.Body.String())
|
||
}
|
||
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
|
||
t.Errorf("Cache-Control = %q, want no-store", got)
|
||
}
|
||
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||
}
|
||
var got struct {
|
||
HostID string `json:"host_id"`
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
SetAt string `json:"set_at"`
|
||
}
|
||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("decode: %v (body %q)", err, rr.Body.String())
|
||
}
|
||
if got.Password != revealCanary {
|
||
t.Errorf("password = %q, want the vaulted secret", got.Password)
|
||
}
|
||
if got.Username != "root@pam" || got.HostID != "demo-felhom-8363b5" {
|
||
t.Errorf("unexpected payload: %+v", got)
|
||
}
|
||
if got.SetAt == "" {
|
||
t.Error("set_at missing — the operator cannot judge staleness without it")
|
||
}
|
||
|
||
// Exactly ONE timeline row, on the bound customer, info severity, source "hub", Hungarian.
|
||
evs, err := st.GetRecentEvents("demo-felhom", 100)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
n := 0
|
||
for _, e := range evs {
|
||
if e.EventType != "recovery_credential_revealed" {
|
||
continue
|
||
}
|
||
n++
|
||
if e.Severity != "info" {
|
||
t.Errorf("severity = %q, want info", e.Severity)
|
||
}
|
||
if e.Source != "hub" {
|
||
t.Errorf("source = %q, want hub", e.Source)
|
||
}
|
||
if !strings.Contains(e.Message, "konzolos hozzáférési jelszavát") {
|
||
t.Errorf("message is not the Hungarian customer-facing line: %q", e.Message)
|
||
}
|
||
if strings.Contains(e.Message, revealCanary) || strings.Contains(e.DetailsJSON, revealCanary) {
|
||
t.Fatal("SECRET LEAK: the password is in the event row")
|
||
}
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("recovery_credential_revealed rows = %d, want exactly 1", n)
|
||
}
|
||
|
||
// The hub log records the access — username + length only.
|
||
if strings.Contains(logBuf.String(), revealCanary) {
|
||
t.Fatal("SECRET LEAK: the password reached the hub log")
|
||
}
|
||
if !strings.Contains(logBuf.String(), "operator revealed break-glass console credential") {
|
||
t.Errorf("the access is not recorded in the hub log: %q", logBuf.String())
|
||
}
|
||
|
||
// No dispatcher call exists on this path — nothing was emailed.
|
||
notifs, err := st.GetRecentNotifications("demo-felhom", 100)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(notifs) != 0 {
|
||
t.Errorf("reveal produced %d notification_log row(s); it must email nobody", len(notifs))
|
||
}
|
||
}
|
||
|
||
// --- Scenario C: nothing vaulted → the explanatory card, no control, 404 on POST, zero events ---
|
||
func TestReveal_C_NotVaulted(t *testing.T) {
|
||
s, st, _ := newRevealServer(t)
|
||
cookie, csrf := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "sess-f-2670b5", "sess-f", "") // no credential row
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/hosts/sess-f-2670b5", nil)
|
||
req.AddCookie(cookie)
|
||
body := serveReveal(t, s, req).Body.String()
|
||
if !strings.Contains(body, "not vaulted") {
|
||
t.Error("the not-vaulted state does not render its badge")
|
||
}
|
||
if !strings.Contains(body, "byo host") {
|
||
t.Error("the not-vaulted state does not explain WHY (byo / step 4b)")
|
||
}
|
||
if strings.Contains(body, "/hosts/sess-f-2670b5/reveal-recovery-credential") {
|
||
t.Error("a Reveal control is offered for a host with nothing vaulted")
|
||
}
|
||
|
||
req = httptest.NewRequest(http.MethodPost, "/hosts/sess-f-2670b5/reveal-recovery-credential", nil)
|
||
req.AddCookie(cookie)
|
||
req.Header.Set("X-CSRF-Token", csrf)
|
||
rr := serveReveal(t, s, req)
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Fatalf("reveal on an unvaulted host = %d, want 404", rr.Code)
|
||
}
|
||
// A 404 is not an access: only a DELIVERED secret is recorded.
|
||
if n := countEvents(t, st, "sess-f", "recovery_credential_revealed"); n != 0 {
|
||
t.Errorf("a 404 wrote %d event row(s); it must write none", n)
|
||
}
|
||
}
|
||
|
||
// --- Scenario D: the CSRF gate (security) ---
|
||
// RED-PROOF D: the companion below sends the token and asserts 200, proving this test
|
||
// discriminates on CSRF rather than passing because the request was malformed some other way.
|
||
func TestReveal_D_CSRFRequired(t *testing.T) {
|
||
s, st, _ := newRevealServer(t)
|
||
cookie, csrf := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||
req.AddCookie(cookie) // session, but NO CSRF token
|
||
rr := serveReveal(t, s, req)
|
||
if rr.Code != http.StatusForbidden {
|
||
t.Fatalf("reveal without CSRF = %d, want 403", rr.Code)
|
||
}
|
||
if strings.Contains(rr.Body.String(), revealCanary) {
|
||
t.Fatal("SECRET LEAK: the 403 body carries the password")
|
||
}
|
||
if n := countEvents(t, st, "demo-felhom", "recovery_credential_revealed"); n != 0 {
|
||
t.Errorf("a CSRF refusal wrote %d event row(s); it must write none", n)
|
||
}
|
||
|
||
// The discriminator: the SAME request WITH the token succeeds.
|
||
req = httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||
req.AddCookie(cookie)
|
||
req.Header.Set("X-CSRF-Token", csrf)
|
||
if rr := serveReveal(t, s, req); rr.Code != http.StatusOK {
|
||
t.Fatalf("the same request WITH a CSRF token = %d, want 200 — the 403 above proves nothing", rr.Code)
|
||
}
|
||
}
|
||
|
||
// --- Scenario E: the method gate — and, through it, the ROUTE ORDER (seam test) ---
|
||
// RED-PROOF E: move the new case BELOW `case strings.HasPrefix(path, "/hosts/")` → the GET falls
|
||
// through to the catch-all, renders the host detail page 200, and this test goes RED.
|
||
func TestReveal_E_MethodGateAndRouteOrder(t *testing.T) {
|
||
s, st, _ := newRevealServer(t)
|
||
cookie, _ := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||
req.AddCookie(cookie)
|
||
rr := serveReveal(t, s, req)
|
||
if rr.Code != http.StatusMethodNotAllowed {
|
||
t.Fatalf("GET on the reveal route = %d, want 405", rr.Code)
|
||
}
|
||
// The route-order half: a fall-through to /hosts/ would render the host page instead.
|
||
if strings.Contains(rr.Body.String(), "Console access") {
|
||
t.Fatal("the reveal route fell through to the /hosts/ catch-all — the case is registered AFTER it")
|
||
}
|
||
if strings.Contains(rr.Body.String(), revealCanary) {
|
||
t.Fatal("SECRET LEAK: the 405 body carries the password")
|
||
}
|
||
}
|
||
|
||
// --- Scenario F: unknown host ---
|
||
func TestReveal_F_UnknownHost(t *testing.T) {
|
||
s, _, _ := newRevealServer(t)
|
||
cookie, csrf := newRevealSession(t, s)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/hosts/does-not-exist/reveal-recovery-credential", nil)
|
||
req.AddCookie(cookie)
|
||
req.Header.Set("X-CSRF-Token", csrf)
|
||
if rr := serveReveal(t, s, req); rr.Code != http.StatusNotFound {
|
||
t.Fatalf("reveal on an unknown host = %d, want 404", rr.Code)
|
||
}
|
||
}
|
||
|
||
// --- Scenario G: unauthenticated ---
|
||
func TestReveal_G_Unauthenticated(t *testing.T) {
|
||
s, st, _ := newRevealServer(t)
|
||
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
|
||
req.Header.Set("X-Requested-With", "XMLHttpRequest") // API-like → 401 rather than a login redirect
|
||
rr := serveReveal(t, s, req)
|
||
if rr.Code != http.StatusUnauthorized {
|
||
t.Fatalf("unauthenticated reveal = %d, want 401", rr.Code)
|
||
}
|
||
if strings.Contains(rr.Body.String(), revealCanary) {
|
||
t.Fatal("SECRET LEAK: the 401 body carries the password")
|
||
}
|
||
if n := countEvents(t, st, "demo-felhom", "recovery_credential_revealed"); n != 0 {
|
||
t.Errorf("an unauthenticated call wrote %d event row(s); it must write none", n)
|
||
}
|
||
}
|
||
|
||
// --- Edge case (§8): an UNBOUND host reveals fine and writes no event (SaveEvent needs a customer) ---
|
||
func TestReveal_UnboundHostRevealsWithoutAnEvent(t *testing.T) {
|
||
s, st, logBuf := newRevealServer(t)
|
||
cookie, csrf := newRevealSession(t, s)
|
||
seedRevealHost(t, st, "unbound-01", "", revealCanary)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/hosts/unbound-01/reveal-recovery-credential", nil)
|
||
req.AddCookie(cookie)
|
||
req.Header.Set("X-CSRF-Token", csrf)
|
||
rr := serveReveal(t, s, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("reveal on an unbound host = %d, want 200", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), revealCanary) {
|
||
t.Error("the unbound host's secret was not delivered")
|
||
}
|
||
// No placeholder customer id is invented — the hub log is the only record.
|
||
if n := countEvents(t, st, "", "recovery_credential_revealed"); n != 0 {
|
||
t.Errorf("an unbound host wrote %d event row(s) against an empty customer id", n)
|
||
}
|
||
if !strings.Contains(logBuf.String(), "unbound-01") {
|
||
t.Error("the unbound host's reveal is recorded nowhere at all")
|
||
}
|
||
}
|