testkit

package module
v0.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 9 Imported by: 0

README

nntp-testkit

Shared NNTP test tools in Go, for anyone who writes or runs an NNTP server, client, proxy or feeder:

  • cmd/nntp-conformance, a standalone black-box client that checks any server against RFC 3977, 4642, 4643, 4644 and 8054;
  • the packages the checks are built from, for writing your own tests: a strict client that reports wire-level mistakes instead of hiding them, a fake server (scripted, handler-driven or backed by a small article store, with faults), a fake transit peer to point a server's outgoing feed at, a runner for external server binaries, test certificates, and builders for test articles (valid or deliberately broken), yEnc posts, Cancel-Lock values and a stand-in sendmail.

Standard library only, Go 1.26 or later; CI tests the previous Go release and the latest on Linux and macOS (proc uses Unix signals, so Windows is not tested). The kit never contacts a host a test did not name. MIT licensed.

The e2e suites of INN, Diablo, NewsCache, go-pugleaf and OpenUsenetServer use it, and run conformance against their servers.

Test any server

go install github.com/go-while/nntp-testkit/cmd/nntp-conformance@latest
nntp-conformance -addr news.example:119 [-group test.group] [-tls] [-user u -pass p]

Each check runs on its own connection. A capability the server does not advertise is a Skip, not a Fail, and nothing is posted unless -group names a group the checks may post into. The checks cover the reader commands (GROUP, LISTGROUP ranges, ARTICLE/HEAD/BODY/STAT, NEXT/LAST, OVER, HDR, the LIST variants, NEWGROUPS, NEWNEWS, POST and the error codes), command syntax, AUTHINFO, streaming and IHAVE, STARTTLS (including commands pipelined behind it, CVE-2011-0411) and COMPRESS (including a large dot-stuffed upload). -json prints the report as JSON, -allow check=substring (or -allow-reply check:CMD=code, by the wrong reply instead of the detail text) tolerates a known server bug; nntp-conformance -h lists the checks and flags, and conformance has the details.

Write your own tests

A test against your own server is a few lines:

c := client.Dial(t, "127.0.0.1:1119")
a := article.Minimal(t, "local.test")
a.Set("Subject", "two  spaces,  kept?")
c.ExpectPost(240, a.Bytes())
c.ExpectCmd(220, "ARTICLE %s", a.MessageID())

and to check its outgoing feed, point the feed at a fake peer:

peer := fakeserver.NewPeer(t, fakeserver.Options{}, &fakeserver.PeerOptions{})
// feed to peer.Addr(), post an article, then look at
// peer.Articles() (or ArticlesRaw() for the exact bytes)

proc starts your server binary for the test, stops it (with its whole process group) and dumps its logs when the test fails; the Example section has a complete test.

What belongs in the kit

NNTP and netnews behaviour that is not specific to one server: client steps, fake servers and peers, articles, protocol checks, and general test plumbing (processes, ports, logs). Configuration, storage formats and tools of one server stay in that project. A helper moves into the kit once a second project needs it, and the projects then drop their copies.

Install

go get github.com/go-while/nntp-testkit

Releases are tagged (@latest, or a version such as @v0.3.0). For local work on the kit itself, a replace directive to a checkout works, e.g. replace github.com/go-while/nntp-testkit => ../nntp-testkit.

Hang guard

Every blocking step (dial, read, write, waits) is bounded by testkit.Deadline(): the Go duration in NNTP_TEST_DEADLINE (testkit.EnvDeadline), default 60s (testkit.DefaultDeadline). It only stops a hung test; a passing test never waits on it. fakeserver.Options.Deadline, client.Conn.Deadline (or the deadline argument of DialWith/TryDial) and proc.Options.Deadline override it per use.

Packages

package what
testkit (root) Deadline(), DefaultDeadline, EnvDeadline; WaitFor, FreePort, Listening, ShortTempDir, SocketPathFits
client strict NNTP test client; COMPRESS DEFLATE (RFC 8054); STARTTLS (RFC 4642)
fakeserver fake NNTP server: Script, Handler or Store mode; faults (also byte-level) and counters; TLS and STARTTLS; COMPRESS DEFLATE (with shaped output); transit peer (streaming and IHAVE); a corpus of wrong server replies for client suites
cmd/nntp-fakeserver the fake server as a process that a JSON file configures, for test suites in other languages (Python, shell, C)
tlscert test CA and leaf certificates (expired, not yet valid, wrong name), as tls.Certificate, PEM or files with a 0600 key
proc runs an external server binary (stdio or TCP), stops it, dumps its logs on failure
conformance black-box RFC 3977/4643/4644 checks against any NNTP address, with a strict client
cmd/nntp-conformance command-line front end of conformance: prints the report, exit 1 on any Fail
yenc yEnc-encoded test posts: single and multi-part, broken ones, data profiles, a generated group
article news articles as raw bytes: unique Message-IDs, minimal valid articles, deliberately broken ones, header edit and lookup, parse, header diff of a sent and a served article, a corpus of tricky articles, control messages
canlock Cancel-Lock and Cancel-Key values (RFC 8315): compute them, check a key against a lock
mailrec, cmd/fakesendmail a stand-in sendmail that records each mail, for servers that mail moderators or report by mail

The doc.go of each package holds the full contract; go doc -all ./<package> prints it.

testkit (root)
API what
Deadline() time.Duration, DefaultDeadline, EnvDeadline the hang guard (above)
WaitFor(tb, d, what, cond func() bool) poll cond every 50 ms until it holds; tb.Fatalf("timed out after <d> waiting for <what>") after d (0 = Deadline())
WaitForErr(tb, d, what, check func() error) WaitFor for a check that says why not yet: polls until it returns nil, tb.Fatalf("timed out after <d> waiting for <what>: <last error>") after d
Poll(d, cond func() bool) bool / PollErr(d, check func() error) error the same without a tb (no failing: usable in t.Cleanup and other goroutines); PollErr returns timed out after <d>: <last error> wrapping the last error (errors.Is/As work)
FreePort(tb) int a TCP port of 127.0.0.1 that was free a moment ago, and not in use on 0.0.0.0 or [::] either, for a server that must be told its port (another process may take it first)
Listening(addr) bool a TCP dial to addr succeeds within a second (closed at once, nothing written): a server is up, or with WaitFor it has stopped
ShortTempDir(tb) string a fresh 0700 directory under a short base (os.TempDir() if at most 32 bytes, else /tmp), named nt<hash of tb.Name()>-<random> (about 25 bytes), removed by tb.Cleanup (call it before starting servers that use it: cleanups run last-in first-out); for unix-domain sockets, whose path is limited (MaxSocketPath = 107 on Linux, 103 elsewhere: sun_path is 108 / 104 bytes with the NUL) and which t.TempDir() ($TMPDIR/<test name><random>/001, $TMPDIR is about 50 bytes on macOS) overflows with a long test name
MaxSocketPath, SocketPathFits(path) bool that limit, and a check for tests that build socket paths
client
API what
type Conn struct { Conn net.Conn; R *bufio.Reader; Greeting string; Deadline time.Duration; Strict bool } one client connection
Dial(tb, addr) *Conn / DialWith(tb, addr, deadline) *Conn dial, read the greeting, tb.Cleanup(Close); fail tb on error
TryDial(addr, deadline) (*Conn, error) the same without testing; the caller closes
New(tb, net.Conn) *Conn wrap an open conn (net.Pipe, TLS); no greeting read
NewConn(net.Conn) *Conn New without tb: nothing read or registered, for a conn whose greeting the caller read already; plain forms (Cmd, ...) panic with a client: ... message on failure, so use Try* or WithTB
(*Conn) WithTB(tb) *Conn attach a tb to a TryDial Conn
ReadGreeting, Send(raw), ReadLine, ReadStatus raw I/O; Send appends nothing (pipelining)
TryCmd(format, args...) / Cmd(...) send one line + CRLF, read the status: (code, line[, err])
TryReadDotLines() / ReadDotLines() dot block, un-stuffed, line ends stripped
RawUntilDot() []byte exact bytes up to and including the terminator
ExpectNoPending(d) error nothing may arrive within d
TryCompress() (int, string, error) / Compress() send COMPRESS DEFLATE; on 206 compress both directions (StartCompress), else the code, the line and an error (403, 500, 501, 502, 503), still plain
StartCompress() error, Compressed() bool compress from now on without sending a command (the test sent COMPRESS and read the 206)
TryStartTLS(cfg) (*Conn, error) / StartTLS(cfg) *Conn send STARTTLS (RFC 4642), expect 382, fail when any byte is already buffered after the 382 (plaintext injection, CVE-2011-0411), handshake within the deadline; a new strict Conn over the *tls.Conn (no greeting read; cfg is cloned, ServerName defaults to the remote host). A reply other than 382 is a *StartTLSError{Code, Line} and c stays plain; the injection is a *StrictError of Kind Pending; verification errors are crypto/tls's (x509.HostnameError, x509.CertificateInvalidError with Reason Expired, x509.UnknownAuthorityError). StartTLS fails tb and registers the cleanup
TryUpgradeTLS(raw net.Conn, cfg) (*Conn, error) / UpgradeTLS(raw, cfg) *Conn the handshake half of StartTLS for a test that sent STARTTLS itself (alone, or pipelined with another command in one write) and read the 382; raw is the conn the TLS client runs over (nil = c's own; or a recording wrapper around c.Conn); the same buffered-plaintext rule
TLSState() (tls.ConnectionState, bool), IsTLS() bool peer certificates, version, server name of a TLS Conn
Close() idempotent, nil-safe
ExpectCmd(want, format, args...) string Cmd, and fail tb unless the code is want; returns the status line
ExpectStatus(want, what) string read the next status line (the 240 after a POSTed article, a pipelined reply); fail tb on error or unless the code is want
MustSend(raw) Send that fails tb
Capabilities() (map[string][]string, []string) send CAPABILITIES; fail tb unless 101 and a list ParseCapabilities takes; the map and the lines in order
ParseCode, ExpectCode, ExpectLines, Unstuff text helpers (Unstuff removes the first dot of every line that starts with one, RFC 3977 3.1.1)
Stuff(lines) string the lines as a client sends them after POST, IHAVE or TAKETHIS: dot-stuffed, CRLF after each, .\r\n at the end
ParseCapabilities(lines) (map[string][]string, error) CAPABILITIES lines → label upper-cased (labels are case-insensitive, RFC 3977 §3.3.1) → arguments as sent (an empty slice for none); error on a blank line or a label listed twice, the map holds the rest
StuffBytes(article []byte) []byte an article in wire form (CRLF, not stuffed, no terminator) as a client sends it: line starts (after CRLF; a bare LF is not one) dot-stuffed, final CRLF added, .\r\n appended; every other byte (bare CR, NUL, 8-bit) untouched
TryPost(article) (code, line, sent, err), TryIhave(id, article), TryCheck(id), TryTakethis(id, article), TryModeStream(), TryLogin(user, pass) the transfer and login exchanges in one call. POST/IHAVE send the article on 340/335 and return the final reply, else the first one; TAKETHIS sends command and article without waiting; LOGIN sends PASS on 381. A refusal (435, 440, 480, 481 ...) is a result: err is for I/O and protocol errors only. sent says whether the article went out (a 436 or 437 can come before or after the transfer; TAKETHIS always sends). ExpectPost(want, article), ExpectIhave(want, id, article), ExpectTakethis(want, id, article) fail the test unless the article was sent and the final code is want. id includes the <>
SendBytes, MustSendBytes Send and MustSend for a []byte (e.g. StuffBytes(article))
Post, Ihave, Check, Takethis, ModeStream, Login the same, (code, line); fail tb only on an I/O or protocol error, never on a refusal
TryPipeline(cmds, multi) ([]Reply, error) / Pipeline(...), type Reply struct { Cmd string; Code int; Line string; Lines []string }, MultiLine(cmd, code) bool write all commands from a goroutine while reading the replies in order (no deadlock when the server stops reading); multi(cmd, code) says whether a dot block follows (nil: MultiLine; Reply.Cmd is the entry's first line); the deadline slides with every line, a dot block also ends within 10 deadlines and 64 MiB
TryDialFrom(addr, localIP, deadline) / DialFrom(tb, addr, localIP) dial from a chosen source address (127.0.0.2) and read the greeting; skip the test where the OS cannot bind it (macOS without an alias)
TryDialUntil(addr, prefix, d) / DialUntil(tb, addr, prefix, d) redial every 50 ms until the greeting starts with prefix (a server that greets 400 while it starts); refused dials count as not yet
TryReadUntilQuiet(quiet, limit) ([]byte, error) / ReadUntilQuiet(...) (data, eof) raw bytes until the server is silent for quiet or limit bytes; io.EOF when the server closed
ExpectClosed(d) ([]string, error) / MustClosed(d) []string the peer closes within d; the lines it sent before the close (QUIT 205, 400 then close); EOF and a reset count as closed
SendChunks(raw, n, pause) error, SendDrip(raw, pause) write raw n bytes (1) per write with a pause: split CRLF or the terminator across writes
type StrictError struct { Kind Kind; Line string; Bytes []byte; Err error }, type Kind int strict-mode error

Cmd, ReadDotLines, RawUntilDot, ExpectCmd, ExpectStatus, MustSend and Capabilities fail the Conn's tb (c.For(t) in a subtest); on a TryDial Conn without WithTB they panic, so use the Try forms there.

Strict mode (on for Dial, DialWith, TryDial and New) returns a *StrictError whose Kind is:

Kind when
BareLF a line ends in LF without CR
BareCR a CR inside a line
NoCRLF EOF or deadline after partial data without any line end
BadDotStuffing a dot-block line starts with "." but is neither ".." nor the terminator
NoTerminator a dot block ends (EOF or deadline) without .\r\n
Pending ExpectNoPending saw bytes (they stay in R; Bytes holds them)

Err holds the read error and Unwrap returns it, so errors.Is(err, io.EOF) works.

COMPRESS DEFLATE (RFC 8054): after StartCompress, c.Conn deflates every write (raw DEFLATE, sync-flushed per Send/Cmd and sent in one socket write, so a server that answers a command and closes, as on QUIT, cannot fail the write of that command) and c.R reads the inflated server stream, starting with the bytes buffered after the 206 line. Strict mode, ReadDotLines, RawUntilDot and ExpectNoPending work on the inflated stream as before (an empty flush block inflates to nothing, so ExpectNoPending still waits and returns nil). A goroutine inflates the socket and passes on each byte as soon as it can be decoded, whatever flush the server uses: also zlib's Z_PARTIAL_FLUSH, which INN's nnrpd sends and which Go's compress/flate reader holds back until more data arrives. Read deadlines (the hang guard, ExpectNoPending) apply to the inflated stream, so a timeout loses nothing. A closed connection reads as io.EOF, corrupt data as an error wrapping flate.CorruptInputError. Keep c.Conn from before Compress to write bytes past the compressor. A second COMPRESS, STARTTLS or AUTHINFO can still be sent to check the server's 502. ExpectNoPending(d) returns nil on timeout or EOF, never consumes data and restores the deadline; call it after a reply to catch a stray byte or a second status line (e.g. a NUL after 400 too many users). With Strict: false a bare LF is a line end, like a lenient client.

fakeserver
API what
New(tb, Options) *Server / Start(Options) (*Server, error) listen (default 127.0.0.1:0) and serve; New registers tb.Cleanup(Close)
type Options struct { Addr, Greeting string; NoGreeting, RawStall bool; TLS *tls.Config; Script []Step; Handler func(*Conn); Store *Store; StoreOpts StoreOptions; Faults Faults; Deadline time.Duration; MaxConns int; MaxConnsGreeting string } server config; MaxConns > 0: one more simultaneous connection gets MaxConnsGreeting (default 502 Too many connections) and is closed
type Step struct { Expect string; ReadDot bool; Delay time.Duration; Reply []string; Hang, CloseAfter, Compress bool; CompressShape CompressShape } one scripted exchange; Expect is an anchored regexp, "" matches any line; Compress: COMPRESS DEFLATE after the reply, its output stream shaped by CompressShape (below)
(*Server) Addr, Host, Port listen address
Lines, Articles, Mismatches client lines, dot blocks read (raw), lines no step matched
Accepted, Open, MaxOpen, Closed, WaitOpen(tb, n, d), WaitClosed(tb, n, d), WaitLines(tb, want) connection counts (MaxOpen: the most open at once, e.g. a client's parallel-connection limit; Closed: connections that ended on the server side, e.g. a RawStall conn whose dial the client aborted) and polling waits
Count(cmd) int, Counts() map[string]int commands seen
Close() close listener and connections, wait for the goroutines
type Conn: ReadLine, WriteLines(lines...), ReadDotBlock, Raw, Server, ID server side of one connection, for a Handler; ID is the accept order from 1
(*Conn) StartCompress() error, StartCompressShaped(CompressShape) error, StartCompressOutputOnly() error, Compressed() bool COMPRESS DEFLATE from now on (call it after writing the 206); Shaped is StartCompress with the output stream shaped (see CompressShape below); OutputOnly is a broken server that compresses its output but reads the client's bytes uncompressed
TLSConfig(tb) (*tls.Config, *x509.CertPool) self-signed cert for 127.0.0.1 and a pool that trusts it (package tlscert makes CA-issued ones)
NewTLSConfig() (*tls.Config, *x509.CertPool, error) TLSConfig without a tb: returns the error, for a fake server program; the certificate is server.Certificates[0].Leaf
StoreOptions.StartTLS, PeerOptions.StartTLS *StartTLSOptions{Config *tls.Config; Refuse string; HideCapability, AuthNeedsTLS bool; CloseAfterHandshake int} STARTTLS (RFC 4642), see below; nil: unknown command
(*Conn) StartTLS(cfg) error, TLS() bool, TLSState() for a Handler: TLS server side from now on (call it after writing the 382)
(*Server) TLSHandshakes(), TLSFailed(), TLSServerNames() completed and failed handshakes (implicit TLS and STARTTLS), SNI of every ClientHello
LineHandler(f func(c *Conn, line string) (reply []string, closeConn bool)) func(*Conn) answer each client line with what f returns, in any order (what Script cannot); f may c.ReadDotBlock() for an article; Faults apply
PeerHandler(*PeerOptions) func(*Conn), type PeerOptions transit peer: MODE STREAM, CHECK, TAKETHIS (RFC 4644), IHAVE (RFC 3977 6.3.2), AUTHINFO; options below
ReplyCorpus() []ReplyCase, ReplyCaseByName(name) (ReplyCase, bool) 125 named wrong and malformed server replies for the test suite of a client, see below
(*Conn) Wait(ch <-chan struct{}) bool, Sleep(d) bool for a Handler or PeerOptions.BeforeReply: block until ch fires (true) or the Deadline passes / the server closes (false); Sleep is bounded the same way
NewStore() *Store (the zero value works too) stateful store; safe for concurrent use; returns copies
(*Store) AddGroup, AddGroupAt, SetPosting, AddArticle(group, Article) int64 setup
Article, ByMessageID, Group, Groups, Numbers, Overview, Next, Last lookups
RemoveArticle(group, n), Expire(group, below), Cancel(id) expiry and cancels: the high-water mark High is kept (numbers are not reused), Low and Count follow; an emptied group has Low = High+1
AddGenerated(group, desc, Generator), SetGeneratedHigh(group, high), NumbersIn(group, from, to), GenLimit(group) generated groups, see below
HighestExisting(conn, Group) Group for StoreOptions.GroupNumbers: report the highest existing number as High, like servers whose last number goes down
AddPosted, Posted articles accepted by POST
type Article, Group, Location; (Article) Header, WithDefaults data model; Article.Arrived is the arrival time NEWNEWS uses (AddArticle sets a zero one to now)
StoreHandler(*Store, StoreOptions) func(*Conn); type StoreOptions RFC 3977 reader over a Store; options below
DefaultGreeting, DefaultGreetingNoPost, DateLayout constants

PeerOptions knobs for peers that hold, slow, cut or answer oddly (all keep Counts, Accepted, MaxOpen, Have and OnArticle, are bounded by Options.Deadline and end on Close):

field effect
HoldGreeting <-chan struct{} the greeting waits until the channel is closed (or delivers a value, releasing one connection); the connection counts as open meanwhile, so a test can assert MaxOpen <= K. Needs Options.NoGreeting: true (the peer writes Options.Greeting itself; fakeserver.NewPeer(t, opts, po) sets it for you); a hold past the Deadline (60 s by default) or Close drops the connection
GreetingDelay time.Duration wait before the greeting (after HoldGreeting), capped at the Deadline; needs NoGreeting (set by NewPeer/StartPeer)
BeforeReply func(c *Conn, cmd, line, status string) runs before every reply is written (IHAVE: before the 335 and before the final one) and may block, e.g. c.Wait(release): hold a reply at a known point. A reply held when the server closes is not sent
CapabilitiesReply []string the whole answer to CAPABILITIES, e.g. {"500 What?"}
ModeStreamReply []string the answer to MODE STREAM, e.g. {"203 StreamOK."} (Diablo's feeder, with CapabilitiesReply) or {"501 ..."}; CHECK and TAKETHIS keep working (unlike NoStream)
DropAfterChecks int, DropReply string per connection, the CHECK after the first N gets DropReply (default 400 Service temporarily unavailable) and the connection closes
SilentAfterModeStream bool answer MODE STREAM, then read and record lines but never answer, until the client closes, Close or the Deadline

Options.Faults apply to PeerHandler as to a LineHandler (Delay, Override, OverrideLine, CloseAfter, Drip, ...): OverrideLine: {"MODE STREAM": {"203 StreamOK."}} is the same as ModeStreamReply. IHAVE has two replies, as POST has: the faults apply to the 335 (and the 435/436/480 that end IHAVE before an article), the final 235/437 takes only the final-reply faults (OverrideFinal, DelayFinal, ...: see the table of Options.Faults below). TAKETHIS has one reply, which takes all faults.

Three modes, chosen per server:

  • Script: Options.Script steps run in order on every connection after the greeting. A line that does not match the current step gets 500 fakeserver: unexpected <line> and is recorded in Mismatches(); after the last step the server keeps reading and recording until EOF.
  • Handler: Options.Handler gets each connection (*fakeserver.Conn); the conn is closed when it returns. StoreHandler, PeerHandler and LineHandler are ready-made handlers.
  • Store: Options.Store serves a Store with the built-in RFC 3977 handler: CAPABILITIES, MODE READER, HELP, LIST (ACTIVE, ACTIVE.TIMES, NEWSGROUPS, OVERVIEW.FMT), GROUP, LISTGROUP, ARTICLE/HEAD/BODY/STAT, OVER/XOVER, HDR/XHDR (+ LIST HEADERS), NEXT/LAST, NEWGROUPS, NEWNEWS, DATE, POST, AUTHINFO USER/PASS, COMPRESS (with StoreOpts.Compress), QUIT. StoreOpts.User/Pass require authentication (480 before it); ReaderOnly greets 201 (unless Greeting is set) and answers POST with 440.

NEWNEWS (NEWNEWS wildmat yyyymmdd hhmmss [GMT], RFC 3977 7.4) answers 230 and the Message-IDs, each once, of the articles in matching groups whose Article.Arrived is at or after the time (date and time as NEWGROUPS, always UTC), in group name order and by number. AddArticle sets a zero Arrived to now (set it to date an article for a test; a POSTed article arrives when it is posted, whatever its Date header says); a generated article arrives at its Date.

StoreOptions make the Store server behave like particular real servers:

field effect
User, Pass string require AUTHINFO USER/PASS (480 before it); a 480 written to a logged-in connection, also one from Faults.Override, ends the login, as a login that timed out
ReaderOnly bool greet 201, POST answers 440
OverviewFmt []string the LIST OVERVIEW.FMT answer
GroupNumbers func(conn int, g Group) Group the numbers GROUP, LISTGROUP and LIST ACTIVE report, per connection: a lagging frontend of a server farm (lower High on some connections), 211 0 0 0 for an empty group, or st.HighestExisting; the articles served stay the stored ones
ListgroupRange string "reject": 501 for LISTGROUP group range (RFC 977/2980 servers); "ignore": the whole group whatever the range
NoOver, NoXover bool that command is unknown (500, not listed by HELP); NoOver also drops OVER MSGID from CAPABILITIES
NoHdr, NoXhdr bool HDR or XHDR is unknown (500); NoHdr also drops HDR from CAPABILITIES and makes LIST HEADERS a 501
NoNewnews bool NEWNEWS is unknown (500) and not in CAPABILITIES or HELP, as on the many servers without it; INN's 502 NEWNEWS command disabled by administrator is Faults.Override "NEWNEWS"
EmptyOverReply []string instead of 423 for a range without articles, e.g. {"420 No article(s) selected"} or {"224 Overview follows", "."}
MaxOverRange int64, OverRangeTooLarge []string cap one OVER/XOVER range (an open range ends at High): a larger range gets OverRangeTooLarge, or when that is nil the records of its first MaxOverRange numbers only
OverviewFields func(conn int, group string, fields []string) []string change the fields of every OVER/XOVER line (fields[0] is the number, then Subject, From, Date, Message-ID, References, :bytes, :lines, Xref: ...): overview data out of step with the articles, e.g. a Message-ID that is not the one in the article's header
HdrLines func(conn int, group, field string, lines []string) []string change the number value lines of a HDR/XHDR reply before they are sent (XHDR by <id> gives the id instead of the number, an absent header (none); HDR gives 0 and an empty value): header data out of step with the articles
Compress bool offer COMPRESS DEFLATE (RFC 8054): listed in CAPABILITIES while off (after authentication when required); COMPRESS answers 206 and compresses both directions; 501 missing or invalid algorithm name (case-sensitive), 502 once active (any valid algorithm), 503 another algorithm, 480 before required auth; AUTHINFO after it 502
CompressShape CompressShape shape the compressed output stream after the 206 (flush kind, split writes, bytes pipelined behind the 206, a final block), see "COMPRESS DEFLATE" below; the zero value is the default
CompressBeforeAuth bool with User/Pass: list and take COMPRESS before AUTHINFO as INN's nnrpd does; other reading commands still 480, AUTHINFO 502 once compressed (CAPABILITIES then lists AUTHINFO without arguments)
CompressOutputOnly bool offer COMPRESS, but after the 206 compress only the server's output and read the client's commands uncompressed: a half implementation, for client tests

Generated groups. st.AddGenerated(group, desc, fakeserver.Generator{Low: 1, High: 10_000_000}) makes a group of any size whose articles are built on demand and never stored, like a provider's group of 10^7 articles. Generator.Article(group, n) makes article n (false = a hole; nil = GeneratedArticle: Message-ID <n.group@fakeserver.invalid>, Date 2020-01-01 plus n seconds); Generator.Lookup(id) maps a Message-ID back to its number (nil = that form). Count is High-Low+1. GROUP, LIST, ARTICLE by number or Message-ID, NEXT/LAST and ranged OVER/XOVER/LISTGROUP work as for stored articles; one OVER, XOVER or LISTGROUP that spans more than Generator.Limit numbers (default DefaultGenLimit, 100000; < 0 = none) gets 503 fakeserver: ... instead of a reply the test cannot hold, so a client that asks for a whole huge group shows up as an error. NEWNEWS examines a matching generated group whole, so it gets that 503 too when the group spans more than the limit, whatever the time. Expire raises Low, SetGeneratedHigh lets new articles arrive; AddArticle panics on a generated group, RemoveArticle and Cancel find nothing.

PeerHandler is a transit peer for feeders such as innfeed and innxmit. It advertises VERSION 2, IHAVE and STREAMING (not with NoStream; no READER: MODE READER answers 502 as innd with reader access denied), keeps its state across the connections of one server and answers as innd does:

command replies
MODE STREAM 203; 501 Unknown MODE variant with NoStream
CHECK <id> 238; 438 when held; 431 once for Defer431Once; 480 before auth; 500 with NoStream
TAKETHIS <id> + article 239 (added to Have, OnArticle runs); 439 when held or in Reject; 480 before auth (after the article); 500 with NoStream (no article read)
IHAVE <id> 335, then after the article 235 (added to Have, OnArticle runs) or 437 (in Reject, or held meanwhile); at once: 435 when held or the id is not <...>, 436 once for Defer436Once, 480 before auth
PeerOptions field effect
User, Pass string require AUTHINFO USER/PASS
Have map[string]bool ids the peer already holds; accepted articles are added (read it after Close)
Defer431Once, Defer436Once map[string]bool CHECK (431) or IHAVE (436) of these ids is deferred the first time only; innd's 436 comes before the transfer (paused, or the article is arriving on another connection)
Reject map[string]bool refused once the article is read, every time, as a content filter does: CHECK 238, TAKETHIS 439, IHAVE 335 then 437; ids refused by id alone are Have
NoStream bool a peer without streaming, as innd with streaming off: MODE STREAM 501, CHECK and TAKETHIS unknown (500, the article lines after a TAKETHIS are then read as commands), no STREAMING capability; innfeed and innxmit fall back to IHAVE
StallModeStream time.Duration sleep before the first MODE STREAM reply (capped at the Deadline)
OnArticle func(msgID string, wire []string) runs before the 239/235 with the raw (dot-stuffed) lines
Compress, CompressOutputOnly bool, CompressShape CompressShape COMPRESS DEFLATE as in StoreOptions (innd itself has none; INN's nnrpd has); AUTHINFO answers 502 once active

Precedence: RawStall wins over everything (accept TCP, never write). Otherwise the greeting is written unless NoGreeting; then Handler when set, else Store when set, else Script. With NoGreeting and no Handler neither Store nor Script runs: lines are only recorded.

Options.Faults (keys are upper-cased command names, e.g. "ARTICLE") apply in Store and Script mode (in Script mode to a matched step's reply) and to a LineHandler's replies, not to another Handler; New and Start copy the maps.

field effect
Delay map[string]time.Duration sleep before the reply to that command (capped at the Deadline)
CloseAfter int close the connection after the reply to the N-th client line
Override map[string][]string write these reply lines instead of the normal reply
DropInMultiline map[string]int close after N lines of that command's reply (status line counted; 0 = before anything)
OverrideLine map[string][]string Override for one client line, e.g. "ARTICLE 2": one 430 in a pipeline; the key is the command upper-cased and the arguments joined by single spaces; wins over Override
Drip map[string]time.Duration write the reply one byte at a time with this pause between bytes: a slow server that a per-read timeout never sees as stalled
Endless map[string]bool write the reply without its . terminator, then <n> endless lines until the client closes, the server is closed or the Deadline passes; then close
LongLine map[string]int insert one line of N x bytes after the status line (in a multi-line reply: as its first block line)
LineDelay map[string]time.Duration write the reply one line at a time with this pause between lines: a slow server on a large OVER
OverrideFrom map[string]int Override/OverrideLine of that command apply only from its N-th use on a connection: e.g. "OVER": 2 with Override "OVER": {"480 Authentication required"}, a login that expires in the middle of a session
OverrideTimes map[string]int Override/OverrideLine of that command apply at most N times per connection, then the normal reply: one 480 that the client answers with AUTHINFO before it retries
Split map[string]Split write the reply in pieces: Split{At []int, Size int, Gap time.Duration}; At are byte offsets in the reply (status line first, CRLFs counted; {1, 5} writes bytes 0, 1-4 and the rest; offsets outside the reply and repeats are ignored), Size (when At is empty) a piece every N bytes, Gap the pause after each piece but the last (bounded by the Deadline): a CR and its LF, or . and its CRLF, in different packets
DropAfterBytes map[string]int close the connection after N bytes of the reply (0 = before any), also inside a status line or a line; a reply of N bytes or fewer is written whole
Abort map[string]bool a close caused by a fault of that command is abrupt: plain TCP is reset (SO_LINGER 0, the client reads ECONNRESET), TLS (also after STARTTLS) closes the TCP connection without close_notify (Go reads io.EOF at a record boundary, other stacks an error such as Python's SSLEOFError). The key is the command that was running when DropAfterBytes, DropInMultiline, DropInUpload or CloseAfter closed it; a close by the client or by Close is not affected
OverrideFinal map[string][]string POST, IHAVE, TAKETHIS only: lines written instead of the final reply (240/441, 235/437, 239/439); empty: no final reply. The article is read and handled as usual (a POST is filed before the reply is built)
DelayFinal map[string]time.Duration POST, IHAVE, TAKETHIS only: sleep before the final reply, after the article was read (bounded by the Deadline)
DropInUpload map[string]int POST, IHAVE, TAKETHIS only: close the connection after the server read N lines of the incoming dot block (0 = before the first; the terminator is not counted); the cut block is not recorded. Also for a Script step's ReadDot
Trailing map[string]Trailing Trailing{Lines []string, Gap time.Duration}: more lines in a separate write after Gap (0 = at once, but as its own write) behind the reply: data that reaches the client in the next packet, e.g. behind a STARTTLS 382 before the handshake. A cut or Endless reply gets none

The faults apply in this order: Delay; OverrideLine, else Override; LongLine; DropInMultiline; then the write, through Drip when set and with LineDelay between lines; then Endless. Drip, LineDelay and Endless are bounded by the Deadline like every server-side wait.

The byte-level faults (Split, DropAfterBytes, Trailing, Abort, DropInUpload) are nil by default. Split and DropAfterBytes work on the bytes of the whole reply after the other faults, so they combine with Override, LongLine and LineDelay; offsets count from the first byte, Split.At wins over Size, and Drip (the one-byte case) wins over Split. Trailing follows a reply that was written whole, also the 382 of STARTTLS. Without Abort a fault closes normally (a normal close with unread client data pending still makes the OS send an RST, as with a pipelined or large upload).

POST and IHAVE have two replies. The first (340, 335) takes every fault above; the final one (240/441, 235/437) takes only OverrideFinal, DelayFinal, Split, DropAfterBytes, Trailing and Abort, with the same command keys as the first reply, so Split["POST"] cuts the 340 and the 240 and offsets and byte counts are per reply: use OverrideFinal and DelayFinal to target the final one only. Trailing["IHAVE"] also follows the 435, 436 and 480 that end IHAVE without an article. Delay, Override, OverrideLine, Drip, LineDelay, Endless, LongLine, DropInMultiline, OverrideFrom and OverrideTimes do not touch the final reply. TAKETHIS has one reply, which takes all faults and also OverrideFinal (over Override) and DelayFinal, on its 480 and 500 answers too.

Count(cmd) and Counts() count the upper-cased first word of every client line read through Conn.ReadLine, in all modes and on all connections (dot-block payload lines are not counted).

COMPRESS DEFLATE (RFC 8054): compression starts after the CRLF of the 206 in both directions. Every write (replies, every fault, Raw().Write) is deflated, sync-flushed and sent in one socket write; the client stream is inflated as it arrives, whatever flush the client uses, so Lines, Counts, Articles and Mismatches record the commands as the client wrote them. The Store handler starts compression when the status it wrote for COMPRESS, after the faults, is a 206: Faults.Override "COMPRESS" {"403 Unable to activate compression"} is a server that cannot, {} one that never answers. Corrupt compressed input closes the connection. Bytes a client pipelines after COMPRESS DEFLATE are inflated too (INN's nnrpd reads them as plain commands). crypto/tls has no TLS-level compression.

CompressShape (StoreOptions, PeerOptions, Step, Conn.StartCompressShaped) shapes the compressed output stream, for client tests; the zero value is the behaviour above:

field effect
Flush CompressFlush the flush at the end of each write: FlushSync (default; 00 00 FF FF), FlushPartial (zlib's Z_PARTIAL_FLUSH like INN's nnrpd: an empty fixed-Huffman block, no marker, the last 0 to 7 bits held back until the next write; literals only, so the output is larger), FlushFinish (the first write after the 206 and a final block go in one socket write, later writes are plain bytes after the end of the stream; the kit still inflates what the client sends)
Split Split cut the compressed bytes of each write into socket writes: At offsets from the start of the write (negative = from its end: with FlushSync -1 to -3 cut inside the marker, -4 before it), Size when At is empty, Gap after each piece but the last (bounded by the Deadline): a client sees compressed bytes that end mid-block or mid-marker
EmptyFlushWith206 bool an empty flush block right after the 206 CRLF, in the same socket write as the 206 line; a client must keep those bytes for its inflater. Store, Peer and Script only: a Handler that writes its own 206 cannot use it
With206 []byte deflated and flushed into the 206's socket write: compressed payload pipelined behind the 206 (it ends with its own flush, so EmptyFlushWith206 adds nothing then)
AfterEnd []byte with FlushFinish: bytes in the socket write of the final block (junk, or plain text)

Faults on the COMPRESS reply (DropAfterBytes, Split offsets) count the EmptyFlushWith206 and With206 bytes too. compress/flate cannot flush partially and its reader holds Z_PARTIAL_FLUSH data back until more arrives; the kit writes the partial flush itself, and its client inflater reads it.

Reply corpus. fakeserver.ReplyCorpus() is 125 named wrong and malformed server replies (GROUP answered 215, a 211 without numbers, a greeting of hello there, an OVER row with a letter for the article number) for the test suite of an NNTP client, so each client project does not write the table again; ReplyCaseByName(name) finds one. A ReplyCase is {Name; Family; Cmd, Line string; Lines []string; Why string; Want Outcome; NeedsTimeout, Final bool}:

field / method what
Name, Why a unique short identifier fit for t.Run (group-211-nofields); what is wrong, with the RFC where there is one
Family FamilyCode (well-formed reply, wrong code or shape for the command: 39 cases), FamilyStatus (malformed status line: 37), FamilyGreeting (bad greeting: 10), FamilyBlock (bad rows or terminator of a multi-line reply: 39)
Cmd, Line the Faults key (upper-cased command) whose reply is replaced, and a sample client line (GROUP g); both empty for a greeting case
Lines written instead of the normal reply, each plus CRLF (a block carries its own . line); may hold bytes that are no valid NNTP (NUL, 8-bit, bare LF)
Want the generic expected client outcome: WantError (the call fails cleanly: no panic, no hang, no data handed back as good; then the connection is in sync or closed; 81 cases), WantEither (a strict client fails, a lenient one may accept; neither may panic, hang or desynchronise; 38), WantAccept (odd but legal, e.g. high below low for an empty group, 281 to AUTHINFO USER, an address in the 240 text of POST; the client must accept it; 6). Error types and reuse of the connection afterwards are for each client suite to agree on
NeedsTimeout the client waits for more data than the reply holds (a terminator that never comes): the test needs a read timeout and the call ends with one
Final Lines replace the reply after the article of POST or IHAVE (Faults.OverrideFinal): the client gets its normal 340 or 335, sends an article, then reads the case. The corpus's Final cases are POST and need a Store that allows posting
Apply(*Options), ApplyLine(*Options) Faults.Override[Cmd] (every line of that command) or Faults.OverrideLine[Line] (that line only); Faults.OverrideFinal[Cmd] for a Final case (both); Options.Greeting for a greeting case; they add to the maps already in o. A command case needs a mode that applies Faults, e.g. Options.Store
Bytes() []byte, IsGreeting() bool exactly what the server writes for the case; whether it is a greeting case (the reply to connecting)

A missing . terminator is Faults.Endless, an overlong block line Faults.LongLine, a reply cut short Faults.DropInMultiline: not repeated in the corpus. A reply that depends on an earlier answer of the same exchange (POST answered 340 and then 340 again after the article) needs a Script or a Handler and is not in it; a fixed final reply after the article is a Final case.

for _, rc := range fakeserver.ReplyCorpus() {
	t.Run(rc.Name, func(t *testing.T) {
		o := fakeserver.Options{Store: fakeserver.NewStore()}
		rc.Apply(&o)
		srv := fakeserver.New(t, o)
		// connect a client to srv.Addr(); for a greeting case connecting is the test,
		// else send rc.Line (or any line of command rc.Cmd) and look at the outcome
	})
}

STARTTLS (RFC 4642): the Store handler and PeerHandler offer it with StartTLS (StartTLSOptions; Options.TLS stays implicit TLS from the first byte). It is listed in CAPABILITIES while the connection is plain, not compressed and not authenticated; it answers 382 and the handshake follows the CRLF of that line. Afterwards it is not listed and a second STARTTLS is 502, as on an implicit TLS connection, after COMPRESS (compress after TLS works) and after a successful AUTHINFO. The upgrade forgets the session (selected group and article), and plaintext the client pipelined behind STARTTLS closes the connection. Test knobs: Refuse (a reply such as 502 ... or 580 ... instead of 382: a refusing server or a stripping attacker; in Store mode Faults.Override "STARTTLS" does the same), HideCapability, AuthNeedsTLS (with User/Pass set: AUTHINFO before TLS is 483 as RFC 4643 describes, and the Store handler lists AUTHINFO without USER) and CloseAfterHandshake (the first N upgrades are closed right after the handshake). The Server counts handshakes and records the SNI of every ClientHello. Make the certificates with tlscert.

cmd/nntp-fakeserver

fakeserver as a program, for test suites that are not written in Go: a JSON file says what the server does, the program serves until the test closes its stdin and then reports what it saw.

go install github.com/go-while/nntp-testkit/cmd/nntp-fakeserver@latest
nntp-fakeserver [-listen host:port] config.json
step what
1. start the program reads config.json and listens (default 127.0.0.1:0, or -listen, or Options.Addr of the config)
2. ready one JSON line on stdout: {"addr","host","port","ca","ca_pem"}; ca is a PEM file with the server's self-signed certificate (valid for localhost, 127.0.0.1 and ::1) and ca_pem its text: a client verifies implicit TLS and STARTTLS against it. The certificate and the file are made on every start in a temporary directory that is removed on exit
3. serve until stdin reaches end of file, or SIGINT or SIGTERM
4. summary one more JSON line: lines (client lines of all connections), mismatches, articles (dot blocks received), notes (what the server ran into itself: a failed handshake, a cut-off block), accepted, counts (lines per upper-cased command), tls_handshakes, tls_failed, server_names (SNI), max_open, closed, and exact base64 forms for any byte that is not UTF-8: lines_b64, mismatches_b64, articles_raw (the exact block bytes, still dot-stuffed). Exit 0; 2 and a message on stderr for a bad config or flag, 1 for another failure

The config is one JSON object with a Mode:

Mode runs
store a Store (Store: Groups, Articles) with Options.StoreOpts and Options.Faults: the RFC 3977 reader of the kit
peer PeerOptions: a transit peer (IHAVE, CHECK, TAKETHIS); Options.Faults apply
script Options.Script: lines of text, as fakeserver.Step
raw Raw: a byte-exact script of steps (Expect, ReadDot, Send chunks of Text or B64, Sleep, Then: close, rst, hang, starttls, inflate), run by this program

The keys are the Go field names of the kit types, matched exactly, and a key that does not exist is an error. Durations are numbers of milliseconds. Options.TLS: true is implicit TLS; a StartTLS of {} offers STARTTLS with the same certificate. Functions, channels and the Store itself cannot be set from JSON; the keys of Faults maps are upper-case command names. nntp-fakeserver -h and the package doc (go doc ./cmd/nntp-fakeserver) have the full schema with examples.

tlscert
API what
NewCA(tb) *CA; CA{Cert *x509.Certificate; Key crypto.Signer; CertPEM []byte; Pool *x509.CertPool} a test CA (ECDSA P-256)
(*CA) Issue(tb, Options) *Leaf a leaf signed by the CA
SelfSigned(tb, Options) *Leaf a self-signed leaf that is its own trust anchor
type Options struct { CommonName string; DNSNames []string; IPs []net.IP; NotBefore, NotAfter time.Time; Expired, NotYetValid bool; KeyType KeyType; ClientAuth bool } the zero value: localhost, 127.0.0.1 and ::1, valid from a day ago for two days, ECDSA. Expired/NotYetValid set a window in the past/future, DNSNames/IPs another name give a hostname mismatch; KeyType is ECDSA, RSA (2048) or Ed25519
type Leaf struct { Cert; Key; CertPEM, KeyPEM, CAPEM []byte; Pool *x509.CertPool } KeyPEM is PKCS#8; CAPEM the issuer
(*Leaf) TLSCertificate() tls.Certificate, ServerConfig() *tls.Config, ClientConfig(serverName) *tls.Config for Go servers and fakes (fakeserver.StartTLSOptions.Config, Options.TLS) and clients (client.Conn.TryStartTLS); CA.ClientConfig too
(*Leaf) Write(tb, dir) Files{Cert, Key, CA string}, WriteKeyMode(tb, dir, mode) cert.pem, key.pem (mode 0600, files replaced atomically) and ca.pem in dir ("" = tb.TempDir()), for servers that read paths from their config (INN, diablo, NewsCache); a server running as another user needs WriteKeyMode 0640 + chgrp, or 0644
(*CA) WriteCA(tb, dir) string ca.pem alone; its path
GenerateCA() (*CA, error), (*CA) GenerateLeaf(Options) (*Leaf, error), GenerateSelfSigned(Options) (*Leaf, error) NewCA, Issue and SelfSigned without a tb: return the error instead of failing a test, for code outside a test such as a fake server program run by another language's test suite (cmd/nntp-fakeserver)
(*CA) WriteCAFile(dir) (string, error), (*Leaf) WriteFiles(dir, keyMode os.FileMode) (Files, error) WriteCA and WriteKeyMode without a tb; dir is required ("" is an error, not a temp dir; a missing dir is created); WriteFiles with mode 0600 is what Write does

A leaf made to fail (Expired, NotYetValid, another name) still carries the issuer's trust, so the failure is the certificate's alone: *tls.CertificateVerificationError wrapping x509.CertificateInvalidError (Reason x509.Expired for both time windows) or x509.HostnameError.

proc
API what
type Options struct { Path string; Args, Env []string; Dir string; StopTimeout, Deadline time.Duration; Logs, Ulimit []string; LogBytes int } Dir "" = tb.TempDir(); Env appended to os.Environ(); Ulimit entries such as "-n 16" (open files) or "-f 64" (file size) are set by /bin/sh, which then execs Path: fd exhaustion and write failures (EFBIG); LogBytes caps each part of the failure dump (0 = DefaultLogBytes, 64 KiB); Daemonizes: the binary's first process exits 0 and a daemon stays, so StartTCP keeps dialling until Deadline after that exit (without it, an exit before ready fails at once); the daemon should redirect its stdio and must not call setsid, or Stop cannot reach it; the captured output is kept up to 16 MiB (Stderr() is then cut in the middle)
StartStdio(tb, Options) *Process stdin/stdout are the client connection (inetd style): Conn()
StartTCP(tb, Options, addr) *Process ready once a dial to addr yields a greeting line (one probe connection, then QUIT); with Options.Daemonizes, a daemon is waited for until Deadline
(*Process) Conn, Addr, Pid, Signal(sig), SignalGroup(sig), Wait, Done, Stderr Conn is nil in TCP mode, Addr "" in stdio mode; Signal reaches the leader, SignalGroup every process of its group; Done() is closed once the process exited (a bounded wait that, unlike Wait, never stops it)
Stop() SIGTERM to the process group, SIGKILL to the group after min(StopTimeout, Deadline) (DefaultStopTimeout = 5s) while the leader or any member is left; idempotent; run by tb.Cleanup
type RunOptions struct { Path string; Args, Env []string; Dir string; Stdin io.Reader; Timeout time.Duration; MaxOutput int; WaitDelay time.Duration } a one-shot command: Env appended to os.Environ() as in Options; Dir "" = the caller's directory; Stdin nil = /dev/null (strings.NewReader for a string); Timeout 0 = testkit.Deadline(); MaxOutput 0 = 1 MiB, < 0 = no cap; WaitDelay 0 = 3s
Run(tb, RunOptions) (out string, code int) combined output and exit code; fails tb on a start error, a timeout or death by a signal, not on a non-zero exit (the caller checks code)
TryRun(RunOptions) (out string, code int, err error) the same without tb; code is -1 and err set for a start error, a timeout (wraps ErrTimeout; names the timeout and holds the output so far) or a signal (named); err is nil for every exit status
GoBuild(tb, out, pkg string, ldflags ...string) string go build of a Go main package into directory out ("" = a shared temp directory, removed by CleanBuilds() in TestMain); timeout at least 5 min (GoBuildFloor); cached per process by (out, package directory, ldflags, GOOS, GOARCH, GOFLAGS, CGO_ENABLED) so tests share one build, also when parallel; a relative pkg counts as the directory it names; a compiler failure fails tb with the compiler output and is remembered, a timeout or I/O error is not; test processes building into the same explicit out race, so give each its own or use ""

Output cap: the first and the last half of MaxOutput bytes are kept with a [... N bytes cut ...] marker between them, so a tool stuck in a loop cannot flood the log or the memory. A command that exits while a child it started still holds the output pipe (a daemon) is given up on after WaitDelay; its exit code counts and exec.ErrWaitDelay is not an error. The daemon keeps running.

Process groups: the server and each Run command start as the leader of a new process group. Stop signals the whole group, so forked workers and a daemon that the leader started and left running are stopped too; a timed-out Run kills the group (a shell and its sleeping child go together). A process that calls setsid or setpgid leaves the group and is not reached.

When the test binary dies, the cleanups do not run. The first start of a server or command installs a handler for SIGINT, SIGTERM and SIGHUP (those not ignored) that SIGKILLs every group the kit started and has not stopped, then removes itself and raises the signal again: Ctrl-C of go test ends the binary as before and leaves no server. On Linux the direct child also gets Pdeathsig SIGKILL (the child is started, and waited for, on a goroutine locked to one thread, as the kernel ties the signal to the thread), so a binary killed with SIGKILL takes the leader with it. Not covered: only the leader has Pdeathsig, so a wrapper shell's server or forked workers are orphaned when the binary is SIGKILLed; macOS has no Pdeathsig, so a SIGKILLed binary can leave servers behind there; a panic exit such as go test -timeout raises runs neither. A normal exit, a failure or a timeout of a test is handled by the cleanup.

Log dump: the cleanup of both Start functions stops the process and, when the test failed, writes Stderr() (stdout too in TCP mode; head and tail) and the tail of every file in Options.Logs to the test output with tb.Log, each part capped at LogBytes. Stop needs a Unix system.

conformance
API what
Run(tb, Options) Report run every check; each Fail is a tb.Errorf, then one summary line; no Errorf with ReportOnly (an unreachable server is logged)
RunVerbose(tb, Options) Report Run that first logs every result that is not a Skip (tb.Logf, one line each), so a passing run shows what passed
Check(Options) (Report, error) the same without testing; the error only when the first connection cannot be opened (no listener, TLS failure, dial or greeting timeout); a greeting with a wire mistake, or a 400/502 greeting ("service unavailable: "), is the greeting check's Fail and every other check is skipped, not an error
Names() []string the check names in report order (the values of Options.Skip)
(Report) Require(tb, names ...string) fails tb once per named check that did not Pass: a Fail, a Skip (not a pass) and a name not in Names(); each line has status, RFC and Detail
(Report) RequireNoFail(tb) fails tb once per Fail (what Run does, for a ReportOnly or Check report); an empty report fails too
type Options struct { Addr, User, Pass, PostGroup string; TLS *tls.Config; StartTLS *tls.Config; Deadline time.Duration; Skip []string; Allow map[string]string; AllowReply []AllowReply; CommitWait time.Duration; ReportOnly bool; Workers int; StrictOverviewFmt bool } User empty: auth checks skipped; PostGroup empty: no check posts; TLS non-nil: TLS from the first byte (the STARTTLS checks are then skipped); StartTLS is the client config of the STARTTLS handshake (nil: not verified and the Pass says so; &tls.Config{RootCAs: pool} verifies); Allow maps a check name to a substring: a Fail of that check whose Detail contains it becomes a Skip "allowed: " (known server bugs, without dropping the rest of the check; Skip drops the whole check). Use a specific substring: a check stops at its first mistake, so everything after an allowed Fail goes unseen; AllowReply is the same by what the server did (see below); CommitWait is the window in which check-have and ihave-have repeat an offer the server deferred (0: 5s, negative: ask once); Deadline 0: testkit.Deadline(); Workers 0: 4 checks at a time; StrictOverviewFmt: require preferred overview metadata names
type AllowReply struct { Check, Cmd string; Code int } an Options.AllowReply entry: allows a Fail of check Check that is a wrong reply with code Code to command Cmd (see below)
type Result struct { Name string; Status Status; Detail, RFC, Missing, Cmd string; Code int }, Pass, Fail, Skip one check; Detail says why it failed or was skipped (on Pass an occasional note); on a Skip for a capability the server does not advertise, Missing names it ("NEWNEWS", "STREAMING", "LIST ACTIVE.TIMES") and Detail reads " not advertised": match skips on Missing; Cmd and Code are set on a Fail that is a wrong reply code to a command (and stay on one that Allow/AllowReply turned into a Skip), empty and 0 otherwise
type Report struct { Results []Result; Capabilities []string }; Failed(), Counts(), String(), ByName() Capabilities is the list read right after the greeting, before MODE READER or AUTHINFO; String() ends a Fail line that is a wrong reply with [reply: CMD code] (e.g. [reply: IHAVE 335])

Every check opens its own connection (Options.Workers at a time, default 4; the results keep the order of Names()), so one failure cannot poison the next. The checks that post run after all the others, so no article arrives in PostGroup while a reader check counts its articles. Before the dial a check is skipped when it is in Options.Skip ("skipped by option"), needs credentials without User or posts without PostGroup; after the dial and CAPABILITIES it is skipped when its capability is not listed (unless the server lists MODE-READER: the check then sends MODE READER and tests again). A broken greeting fails greeting once and skips the rest; a panic in a check is its Fail. The articles that checks post carry a Message-ID under @conformance.invalid.

Options.AllowReply tolerates a known server bug by what the server did, not by the words of a Detail, which a later version of the kit may reword. An entry {Check, Cmd, Code} allows a Fail of that check that is a wrong reply with that code to that command ({"ihave-have", "IHAVE", 335}); the Fail becomes a Skip "allowed: ", as with Allow, and the two work side by side. Check is required and must be a check name; Cmd is the command's keywords (IHAVE, AUTHINFO PASS, MODE STREAM), compared case-insensitively word by word as a prefix of Result.Cmd (LIST matches LIST ACTIVE), empty: any command; Code 0: any code; an entry with neither allows nothing. Result.Cmd and Result.Code of the Fail (shown by Report.String as [reply: IHAVE 335], by -json as cmd and code) tell what to write. A Fail that is no wrong reply code (a wire mistake, a stray byte, a malformed list) has no Cmd and Code and is allowed by Options.Allow only. As with Allow, an allowed Fail ends its check.

Options.CommitWait: a server that stores articles asynchronously answers the immediate re-offer in check-have (CHECK after TAKETHIS) and ihave-have (IHAVE after IHAVE) with 431 or 436 while the article is in flight. The check offers again, pausing 100ms and doubling up to 1s, until the server answers 438 / 435 (Pass, the Detail counts the re-offers), 238 / 335 (Fail: the server forgot the article) or the window ends (Skip, as before: a server may defer forever). Default 5s; negative: no retry, the first 431 / 436 is the Skip.

Options.StrictOverviewFmt requires the RFC 3977 section 8.4.2 preferred :bytes and :lines metadata names in both the format and OVER checks. The zero value remains compatible with the RFC-permitted legacy Bytes:/Lines: aliases. This opt-in policy does not make those legacy spellings RFC violations; field-name case and optional overview extensions are still handled as before.

RFC checks
RFC 3977 greeting capabilities mode-reader group listgroup listgroup-range next-last article head body stat no-group no-current-article article-out-of-range article-unknown-id list-active list-active-times list-newsgroups list-overview-fmt list-headers list-unknown-keyword over over-errors hdr newgroups newnews post (with PostGroup) date help unknown-command bad-arguments command-syntax quit-eof
RFC 4643 (with User) authinfo-user-pass authinfo-wrong-pass authinfo-pass-without-user authinfo-480 authinfo-after-auth authinfo-capabilities
RFC 4644 and RFC 3977 §6.3.2 (STREAMING / IHAVE; posting ones with PostGroup) mode-stream mode-stream-capability check-new check-pipelined takethis check-have takethis-duplicate ihave ihave-have ihave-malformed
RFC 4642, RFC 8143 (STARTTLS advertised; tls-capabilities with Options.TLS) starttls starttls-twice starttls-pipelined tls-capabilities
RFC 8054 (COMPRESS DEFLATE advertised; uploads with PostGroup) compress compress-unknown compress-twice compress-upload compress-upload-lines

The STARTTLS and COMPRESS checks run only when CAPABILITIES lists the capability (a Skip with Missing "STARTTLS" or "COMPRESS" otherwise; "COMPRESS DEFLATE" when COMPRESS is listed without DEFLATE, which compress itself fails). Decisions worth knowing: the STARTTLS checks never log in (STARTTLS is invalid after AUTHINFO) and are skipped when Options.TLS is set; the handshake is not verified unless Options.StartTLS (flags -ca, -starttls-verify) says so, and starttls then passes with the Detail "certificate not verified"; a 580 passes starttls with a Detail (the server may have no certificate) and skips the other two; a 502 or 483 from a plain, unauthenticated connection that lists STARTTLS fails. starttls-pipelined sends a DATE in the same write as STARTTLS, then does the handshake and listens: it fails when the DATE is answered in plaintext after the 382 or run after the handshake (RFC 8143 §4: the server MUST discard it; RFC 4642 forbids pipelining STARTTLS, so a dropped connection, a TLS alert or a failed handshake all pass, and any other reply than 382 or 580 to it skips). Without a verifying Options.StartTLS the handshake accepts TLS 1.0 and up. tls-capabilities (only with Options.TLS) fails when CAPABILITIES over implicit TLS lists STARTTLS; a SASL EXTERNAL listed without a client certificate is only noted. The COMPRESS checks log in only when COMPRESS is not listed before (or is answered 480); a 403 answer to COMPRESS (unable to activate) skips the check. compress-unknown expects 503 for an unknown algorithm and 501 for none; compress-twice expects 502 for a second COMPRESS DEFLATE, and for STARTTLS and AUTHINFO when they were advertised before (RFC 8054 §2.2.2); the unknown algorithm gets 502 or 503 there. The upload checks post 3000 lines of 100 bytes (some dot-stuffed, some empty, one a lone dot) and fail when the reply is not there within Deadline (INN issue #269: nnrpd waited for socket data while compressed input was pending).

go run github.com/go-while/nntp-testkit/cmd/nntp-conformance -addr 127.0.0.1:1119 [-user u -pass p] [-group g]
flag effect
-addr host:port the server under test (required)
-user, -pass AUTHINFO USER/PASS; without -user the RFC 4643 checks are skipped
-group a group the checks may post into (POST, TAKETHIS, IHAVE); without it nothing is posted
-tls, -tls-insecure TLS from the first byte, ServerName = the host of -addr; -tls-insecure skips the certificate check (and implies -tls)
-ca file PEM file of CA certificates that verify the server certificate of -tls and of the STARTTLS checks (instead of the system roots); implies -starttls-verify; not with -tls-insecure
-servername name TLS server name (SNI, and the name the certificate must carry) for -tls and STARTTLS; default the host of -addr
-starttls-verify verify the certificate in the STARTTLS checks (against -ca, else the system roots); without it (and without -ca) the handshake is not verified and the report says so
-allow name=substring repeatable: a Fail of check name whose detail contains the substring becomes a Skip "allowed: ..." (a known server bug); the substring must not be empty, an unknown check is a usage error, and a check named twice is one too (one substring per check); use a specific substring, because everything after the check's first mistake goes unseen
-allow-reply name:CMD=code repeatable: Options.AllowReply: a Fail of check name that is a wrong reply with code to a command starting with CMD (words case-insensitive) becomes a Skip "allowed: ..."; CMD or =code may be left out, not both (help:HELP, ihave-have:IHAVE=335, mode-stream:=502); the code is 100-599, an unknown check is a usage error; the report and -json show the cmd and code of a Fail
-commit-wait d how long check-have and ihave-have re-offer an article the server deferred (431/436) while it commits it asynchronously; default 5s (0), negative: do not retry
-deadline hang guard per read and write, default 10s
-workers checks at the same time, each on its own connection, default 4; lower it for a server that allows fewer connections (a refused dial is a Fail)
-skip a,b check names to leave out (an unknown name is a usage error)
-json the report as JSON (results with name, status, rfc, detail and, for a skip for a missing capability, missing, for a wrong reply cmd and code; capabilities; pass/fail/skip counts) instead of text
-strict-overview-fmt require preferred :bytes/:lines instead of the RFC-permitted legacy aliases; off by default

Exit status: 0 no Fail, 1 any Fail (also a 400/502 greeting), 2 a usage error or a server that cannot be reached.

Self-test (the conformance tests): the kit's own Store server passes every check it can be given (the stream and IHAVE checks skip: it lists neither STREAMING nor IHAVE; the STARTTLS and COMPRESS checks skip unless StoreOptions.StartTLS and Compress are set, and then pass), the transit peer (PeerHandler) passes the stream checks, and fakes with one deliberate mistake (a bare-LF greeting, an ARTICLE reply cut before its terminator, a NUL after a 411, 7 OVER fields, a LISTGROUP that ignores its range, a bad LIST ACTIVE.TIMES time, a LIST HEADERS name with its colon, wrong HDR values, NEWGROUPS and NEWNEWS that ignore the date, a POST that is never filed) fail exactly the matching check.

yenc

yEnc-encoded article bodies, for testing decoders and clients that fetch binaries. The lines go into a fakeserver.Article body like any other: no CRLF and no dot-stuffing (the server adds it).

API what
type Options struct { Line int; RawDots, RawSpaces bool; ExtraEscapes string; NoBegin, NoEnd, BadSize, BadCRC bool } the zero value encodes as yEnc 1.3 recommends: NUL, LF, CR and = escaped, tab and space at line ends, . at line starts; Line 0 = 128
Encode(name, data, Options) []string a single-part post: =ybegin, the data lines, =yend size= crc32=
EncodeParts(name, data, partSize, Options) [][]string one post per part: =ybegin part= total=, =ypart begin= end=, the data, =yend size= part= pcrc32= (and crc32= of the whole data on the last part)
Data(Profile, size, seed) []byte; Random, Plain, Escapes, Edges reproducible data: random bytes (most of Usenet), no escapes, an escape in every second byte, a third of the bytes at ., tab and space
Generator(Profile, low, high, size func(n int64) int, Options) fakeserver.Generator a generated group: article n is Data(p, size(n), n) named <p>-<n>.bin, with GeneratedArticle's headers and a yEnc Subject
Corpus() []Case, CaseByName(name) (Case, bool); type Case struct { Name string; Lines []string; Data []byte; Why string; Want fakeserver.Outcome } broken and odd posts for the test suite of a decoder, with the reply corpus's outcome classes: a stray =yend after a complete block and text after =yend (accept, decode to Data), a bad CRC or size (error), no =yend (either)

RawDots and RawSpaces model posting tools that leave those characters to the server's dot-stuffing and to the reader. The broken-post options model corrupt uploads; Faults.DropInMultiline cuts a post off mid-body instead.

article

Articles as raw bytes, for suites that post and fetch. Nothing is normalised: an Article keeps the order of its header fields, the exact case of their names, duplicates, empty values, folds, lines without a colon, bare CR or LF, NUL and 8-bit bytes, so a test can send what a normal library would refuse to write. Bytes() is the wire form: CRLF line ends, not dot-stuffed, without the terminating .; client.Stuff (or the client's send) adds both.

API what
MsgID(tb) string / MsgIDAt(tb, domain) / NewMsgID(name, domain) a unique Message-ID <TestName.time.nonce.seq@testkit.invalid>: the test name reduced to letters, digits, - and _, the time, a per-process nonce, a process-wide sequence; ValidMsgID(id) checks the form (RFC 5536)
New(tb, Options) *Article / Minimal(tb, group) a minimal valid article: Path, From, Newsgroups, Subject, Message-ID, Date (in that order: Message-ID before Date; for Date first omit both and write them in Extra) and a short body; Options sets Newsgroups, Subject, From, PathHost, MessageID, Domain, Date, Body, Approved, Extra, Omit (more raw header lines; generated headers to leave out, e.g. Path, Date, Message-ID for a newsreader's POST); NewArticle(Options) (*Article, error) is New without a tb (needs Subject and MessageID, or their Omit)
type Article struct { Fields []Field; Sep string; Body []byte }, type Field struct { Raw, EOL string } the raw form: a field is its exact text (folds included) and its line end ("" = CRLF, "\n", NoEOL); Sep is what separates head and body (NoEOL: no empty line)
Add(name, value), AddRaw(raw), Insert(i, raw), PrependPath(host), Set(name, value), SetRaw(name, raw), Del(name) edit, chainable; AddRaw("Path:") is an empty Path (SetRaw("Path", "Path:") empties it in place), AddRaw("Path: a\r\n !b") a folded one, AddRaw("no colon") a line without colon; Set replaces in place keeping the old spelling of the name and drops duplicates; PrependPath puts host! in front of the Path keeping its folds
SetBody([]byte), SetBodyLines(lines...) the body as bytes, or lines each plus CRLF (a leading . is not doubled)
Get(name), Lookup(name), Values(name), Has, Count, Duplicates(), MessageID(), PathEntries() read, case-insensitive; values are unfolded; PathEntries splits the Path at ! (folds removed). Field.Name() is the naive text before the first colon (an mbox From nobody Thu Dec 8 12:55:04 2016 line gives From nobody Thu Dec 8 12); Field.ValidName() tells a real field name
Bytes(), String(), Lines(), BodyLines(), Clone(), Canon() the wire form, and CRLF-split lines (what client.Stuff takes); Canon() is Parse(Bytes()): a bare LF inside a field is a line end on the wire, so it splits the field
Parse(raw) *Article / ParseDotBlock(raw) / ParseLines(lines) split at the first empty line (CRLF or LF), keep every header line and the body as they are; Parse(b).Bytes() equals b for any b. ParseDotBlock takes the client's RawUntilDot bytes (drops the terminator and the stuffing dots, nothing else), the right way to read a served article back; ParseLines on ReadDotLines output loses bare LF and CR-before-CRLF
Compare(sent, got, DiffOptions) Diff, ExpectSame(tb, sent, got, opts) the header diff: fields added, lost, changed (unfolded values) or reordered, ignoring what servers touch (DefaultAllowed(): Path, Xref, Injection-Info, Injection-Date, NNTP-Posting-Host, NNTP-Posting-Date, Lines, X-Trace, X-Complaints-To, Organization, plus Allow; Strict drops the defaults; IgnoreOrder drops the Reordered report), and the body byte for byte (IgnoreTrailingSpace to relax); both sides are canonicalised, folds count as one space, CaseSensitiveNames checks the spelling; a server that normalises bare CR/LF cannot be compared byte for byte. PathExtends(sent, got): the sent Path entries are the tail of the served ones
Corpus() []Case, CaseNames(), CaseByName(name); Case.Article(tb, group) tricky articles by name: latin1, utf8, nul, bare-cr, bare-lf, dot-lines, long-line, max-line, long-message-id, folded-subject, folded-path, lf-only, no-colon, dup-header, empty-path, empty-subject, ... (Case.Invalid marks the RFC-breaking ones)
Cancel(tb, Options, target), Newgroup(tb, Options, group, moderated), Rmgroup(tb, Options, group) control messages: the Control and cmsg Subject headers and a body, no PGP signature; Cancel's Newsgroups is Options.Newsgroups, "test" when empty
for _, c := range article.Corpus() {
	t.Run(c.Name, func(t *testing.T) {
		sent := c.Article(t, "test")
		cl.ExpectCmd(340, "POST")
		cl.MustSend(client.Stuff(sent.Lines())) // stuffing and the final "." are the client's
		cl.ExpectStatus(240, "post")
		// ... later fetch it back with ARTICLE: the raw block keeps bare CR and LF
		cl.ExpectCmd(220, "ARTICLE %s", sent.MessageID())
		got := article.ParseDotBlock(cl.RawUntilDot())
		article.ExpectSame(t, sent, got, article.DiffOptions{Allow: []string{"Content-Length"}})
	})
}
canlock

Cancel-Lock and Cancel-Key header values (RFC 8315 section 4), for testing servers that check them: a key is base64(HMAC-H(secret, uid + Message-ID)), its lock base64(H(key)). The results match INN's tests/lib/canlock-t.c vectors.

API what
type Hash string; SHA1, SHA256 the schemes; another name panics in the functions below
Key(h, secret, uid, msgid) string / KeyValue(...) the Cancel-Key element sha256:<b64> / its bare value; uid "" for an administrator's key
Lock(h, key) string / LockValue(h, key) the Cancel-Lock element / bare value for a key (an element or a bare value)
Elements(v) []string the elements of a header value; comments in parentheses dropped
Verify(lock, key) bool whether some key element opens a lock element of the same scheme (case-insensitive); unknown schemes ignored
key := canlock.Key(canlock.SHA256, "secret", "", "<1@example.invalid>")
art.Add("Cancel-Lock", canlock.Lock(canlock.SHA256, key))
// ... the cancel carries "Cancel-Key: "+key
mailrec

A stand-in for sendmail that records mail instead of sending it, for servers that mail a moderated group's submissions or send reports. New builds cmd/fakesendmail for one test with its record directory built in, so it records even when the server clears the environment of the programs it runs; give Recorder.Path to the server as its sendmail or mailer.

API what
New(tb) *Recorder; Recorder{Path, Dir string} build the stand-in into tb.TempDir() (needs the go command)
(*Recorder) Mails(tb), Wait(tb, n), ExpectNone(tb) the mails so far; wait for n of them (bounded by testkit.Deadline()); fail if one arrives within 200ms
(*Recorder) SetExit(tb, code), Env() make the stand-in exit with code from now on (the mail is still recorded); NNTP_TEST_MAIL_DIR=<Dir> for a server's environment
type Mail struct { N int; Argv []string; Msg []byte } one mail: Recipients(), From() (-f), HeaderMode() (-t), Header(name) (unfolded), Body()
Read(dir), Record(dir, argv, msg), Main(...) read or write a record directory without a Recorder; Main is the command
EnvDir (NNTP_TEST_MAIL_DIR), EnvExit (NNTP_TEST_MAIL_EXIT) the command's record directory (wins over the built-in one) and its default exit status

A record directory holds N/argv (one argument per line, argv[0] first), N/msg (stdin) and N/done (written last; a mail without it is still being written) for N = 1, 2, ...; the file exit in it, if present, is the exit status. Without a directory the command reads the mail and drops it. For a server whose sendmail path is compiled in, install the built command there on a test machine only and set NNTP_TEST_MAIL_DIR per test; never on a machine that sends real mail.

mr := mailrec.New(t)
srv := proc.StartTCP(t, proc.Options{Path: serverBin, Args: []string{"-mailer", mr.Path}}, addr)
// ... post to a moderated group
m := mr.Wait(t, 1)[0]
if got := m.Recipients(); len(got) != 1 || got[0] != "mod@example.invalid" { t.Errorf("to %q", got) }

Example

A fake upstream in Store mode and the strict client. This is Example from example_test.go, copied verbatim so go test checks it; edit both together. The file also holds Example_scriptCloseAfter (Script mode, Faults.CloseAfter, the client sees EOF) and Example_dropInMultiline (a cut reply gives a NoTerminator error).

import (
	"fmt"
	"strings"
	"time"

	"github.com/go-while/nntp-testkit/client"
	"github.com/go-while/nntp-testkit/fakeserver"
)

func Example() {
	check := func(err error) {
		if err != nil {
			panic(err)
		}
	}
	st := fakeserver.NewStore()
	st.AddGroup("test.group", "a group for the example")
	for i, subj := range []string{"first", "second"} {
		st.AddArticle("test.group", fakeserver.Article{
			MessageID: fmt.Sprintf("<%d@example.invalid>", i+1),
			Headers:   []string{"From: alice@example.invalid", "Subject: " + subj},
			Body:      []string{"line one", "line two", ".a line that gets dot-stuffed"},
		})
	}
	s, err := fakeserver.Start(fakeserver.Options{Store: st})
	check(err)
	defer s.Close()

	c, err := client.TryDial(s.Addr(), 0) // 0 = testkit.Deadline() (NNTP_TEST_DEADLINE)
	check(err)
	defer c.Close()
	fmt.Println(c.Greeting)

	// block sends a command and reads its multi-line reply in strict mode (CRLF line ends,
	// dot-stuffing, ".\r\n" terminator); the client un-stuffs the lines.
	block := func(format string, args ...any) (string, []string) {
		_, status, err := c.TryCmd(format, args...)
		check(err)
		lines, err := c.TryReadDotLines()
		check(err)
		return status, lines
	}

	_, caps := block("CAPABILITIES")
	overMsgID := false
	for _, l := range caps {
		overMsgID = overMsgID || l == "OVER MSGID"
	}
	fmt.Println("OVER MSGID listed:", overMsgID)

	_, status, err := c.TryCmd("GROUP test.group")
	check(err)
	fmt.Println(status)

	_, art := block("ARTICLE 1") // head, blank line, body
	subject, bodyLines := "", 0
	for i, l := range art {
		if v, ok := strings.CutPrefix(l, "Subject: "); ok {
			subject = v
		}
		if l == "" {
			bodyLines = len(art) - i - 1
			break
		}
	}
	fmt.Println("subject:", subject, "body lines:", bodyLines, "last:", art[len(art)-1])

	_, over := block("OVER 1-2")
	fmt.Println("overview lines:", len(over))

	// POST: 340, then the article (dot-stuffed) and ".", then 240.
	_, status, err = c.TryCmd("POST")
	check(err)
	fmt.Println(status)
	check(c.Send("Newsgroups: test.group\r\nFrom: bob@example.invalid\r\nSubject: third\r\n" +
		"Message-ID: <3@example.invalid>\r\n\r\nposted body\r\n.\r\n"))
	_, status, err = c.ReadStatus()
	check(err)
	fmt.Println(status)

	status, _ = block("ARTICLE <3@example.invalid>")
	fmt.Println(status)

	fmt.Println("ARTICLE:", s.Count("ARTICLE"), "POST:", s.Count("POST"), "posted:", len(st.Posted()))
	// Nothing may follow a complete reply: no stray bytes, no second status line.
	fmt.Println("pending:", c.ExpectNoPending(50*time.Millisecond))

	// Output:
	// 200 fakeserver ready
	// OVER MSGID listed: true
	// 211 2 1 2 test.group
	// subject: first body lines: 3 last: .a line that gets dot-stuffed
	// overview lines: 2
	// 340 Send article to be posted
	// 240 Article received <3@example.invalid>
	// 220 3 <3@example.invalid> Article follows
	// ARTICLE: 2 POST: 1 posted: 1
	// pending: <nil>
}

Checks

gofmt -l . && go vet ./... && go test -race -count=1 ./...

Which Go versions CI covers, and what to change when Go 1.28 is out: NOTES.md.

License

MIT, see LICENSE. Versions before v0.11.2 were published as GPLv2; the author, who wrote all of it, allows them to be used under the MIT license as well.

Documentation

Overview

Package testkit is the root of nntp-testkit: shared NNTP test tools (a scripted or stateful fake server, a strict test client, an external-process runner) for projects that implement or consume NNTP. Only the standard library is used.

The sub-packages are client, fakeserver, proc, conformance, yenc, canlock and mailrec. This package holds what they share, the hang guard (Deadline), and small helpers for any test: WaitFor polls a condition within a deadline (WaitForErr one that gives a reason, Poll and PollErr without failing a tb, for t.Cleanup), FreePort picks a TCP port for a server that must be told one, Listening probes whether something accepts connections on an address, and ShortTempDir makes a temporary directory whose path is short enough for a unix-domain socket (MaxSocketPath, SocketPathFits; t.TempDir() often is not).

Example

Example runs a fake upstream in Store mode and talks to it with the strict client. An Example has no testing.TB, so it uses fakeserver.Start and client.TryDial with the Try forms of the client methods and panics on errors; a test would use fakeserver.New(t, ...) and client.Dial(t, ...) with Cmd and ReadDotLines, which fail t. README.md shows this function verbatim: change both together.

package main

import (
	"fmt"
	"strings"
	"time"

	"github.com/go-while/nntp-testkit/client"
	"github.com/go-while/nntp-testkit/fakeserver"
)

func main() {
	check := func(err error) {
		if err != nil {
			panic(err)
		}
	}
	st := fakeserver.NewStore()
	st.AddGroup("test.group", "a group for the example")
	for i, subj := range []string{"first", "second"} {
		st.AddArticle("test.group", fakeserver.Article{
			MessageID: fmt.Sprintf("<%d@example.invalid>", i+1),
			Headers:   []string{"From: alice@example.invalid", "Subject: " + subj},
			Body:      []string{"line one", "line two", ".a line that gets dot-stuffed"},
		})
	}
	s, err := fakeserver.Start(fakeserver.Options{Store: st})
	check(err)
	defer s.Close()

	c, err := client.TryDial(s.Addr(), 0) // 0 = testkit.Deadline() (NNTP_TEST_DEADLINE)
	check(err)
	defer c.Close()
	fmt.Println(c.Greeting)

	// block sends a command and reads its multi-line reply in strict mode (CRLF line ends,
	// dot-stuffing, ".\r\n" terminator); the client un-stuffs the lines.
	block := func(format string, args ...any) (string, []string) {
		_, status, err := c.TryCmd(format, args...)
		check(err)
		lines, err := c.TryReadDotLines()
		check(err)
		return status, lines
	}

	_, caps := block("CAPABILITIES")
	overMsgID := false
	for _, l := range caps {
		overMsgID = overMsgID || l == "OVER MSGID"
	}
	fmt.Println("OVER MSGID listed:", overMsgID)

	_, status, err := c.TryCmd("GROUP test.group")
	check(err)
	fmt.Println(status)

	_, art := block("ARTICLE 1") // head, blank line, body
	subject, bodyLines := "", 0
	for i, l := range art {
		if v, ok := strings.CutPrefix(l, "Subject: "); ok {
			subject = v
		}
		if l == "" {
			bodyLines = len(art) - i - 1
			break
		}
	}
	fmt.Println("subject:", subject, "body lines:", bodyLines, "last:", art[len(art)-1])

	_, over := block("OVER 1-2")
	fmt.Println("overview lines:", len(over))

	// POST: 340, then the article (dot-stuffed) and ".", then 240.
	_, status, err = c.TryCmd("POST")
	check(err)
	fmt.Println(status)
	check(c.Send("Newsgroups: test.group\r\nFrom: bob@example.invalid\r\nSubject: third\r\n" +
		"Message-ID: <3@example.invalid>\r\n\r\nposted body\r\n.\r\n"))
	_, status, err = c.ReadStatus()
	check(err)
	fmt.Println(status)

	status, _ = block("ARTICLE <3@example.invalid>")
	fmt.Println(status)

	fmt.Println("ARTICLE:", s.Count("ARTICLE"), "POST:", s.Count("POST"), "posted:", len(st.Posted()))
	// Nothing may follow a complete reply: no stray bytes, no second status line.
	fmt.Println("pending:", c.ExpectNoPending(50*time.Millisecond))

}
Output:
200 fakeserver ready
OVER MSGID listed: true
211 2 1 2 test.group
subject: first body lines: 3 last: .a line that gets dot-stuffed
overview lines: 2
340 Send article to be posted
240 Article received <3@example.invalid>
220 3 <3@example.invalid> Article follows
ARTICLE: 2 POST: 1 posted: 1
pending: <nil>
Example (DropInMultiline)

Example_dropInMultiline cuts a multi-line reply after its status line and one more line: the strict client reports a NoTerminator error that wraps io.EOF.

package main

import (
	"errors"
	"fmt"
	"io"

	"github.com/go-while/nntp-testkit/client"
	"github.com/go-while/nntp-testkit/fakeserver"
)

func main() {
	s, err := fakeserver.Start(fakeserver.Options{
		Script: []fakeserver.Step{{
			Expect: "ARTICLE 1",
			Reply:  []string{"220 1 <1@example.invalid>", "Subject: cut", "", "body", "."},
		}},
		Faults: fakeserver.Faults{DropInMultiline: map[string]int{"ARTICLE": 2}},
	})
	if err != nil {
		fmt.Println("start:", err)
		return
	}
	defer s.Close()

	c, err := client.TryDial(s.Addr(), 0)
	if err != nil {
		fmt.Println("dial:", err)
		return
	}
	defer c.Close()

	code, _, err := c.TryCmd("ARTICLE 1")
	fmt.Println(code, err)
	lines, err := c.TryReadDotLines()
	var serr *client.StrictError
	if errors.As(err, &serr) {
		fmt.Println("kind:", serr.Kind, "EOF:", errors.Is(err, io.EOF), "lines:", lines)
	}

}
Output:
220 <nil>
kind: NoTerminator EOF: true lines: [Subject: cut]
Example (ScriptCloseAfter)

Example_scriptCloseAfter runs a fake server in Script mode with the CloseAfter fault: the connection is closed after the reply to the first command, so the client's next read sees EOF.

package main

import (
	"errors"
	"fmt"
	"io"

	"github.com/go-while/nntp-testkit/client"
	"github.com/go-while/nntp-testkit/fakeserver"
)

func main() {
	s, err := fakeserver.Start(fakeserver.Options{
		Script: []fakeserver.Step{
			{Expect: "MODE READER", Reply: []string{"200 reader mode"}},
			{Expect: "GROUP .*", Reply: []string{"211 0 1 0 never.reached"}},
		},
		Faults: fakeserver.Faults{CloseAfter: 1},
	})
	if err != nil {
		fmt.Println("start:", err)
		return
	}
	defer s.Close()

	c, err := client.TryDial(s.Addr(), 0)
	if err != nil {
		fmt.Println("dial:", err)
		return
	}
	defer c.Close()

	_, line, err := c.TryCmd("MODE READER")
	fmt.Println(line, err)
	_, err = c.ReadLine()
	fmt.Println("next read EOF:", errors.Is(err, io.EOF))
	fmt.Println("MODE:", s.Count("MODE"), "mismatches:", len(s.Mismatches()))

}
Output:
200 reader mode <nil>
next read EOF: true
MODE: 1 mismatches: 0

Index

Examples

Constants

View Source
const DefaultDeadline = 60 * time.Second

DefaultDeadline is the hang guard when NNTP_TEST_DEADLINE is not set.

View Source
const EnvDeadline = "NNTP_TEST_DEADLINE"

EnvDeadline is the environment variable that overrides the hang guard: a Go duration such as "120s".

View Source
const MaxSocketPath = 107

MaxSocketPath is the longest path, in bytes, a unix-domain socket address can have on this system (sun_path is 108 bytes on Linux and Solaris, 104 on macOS and the BSDs, each with a terminating NUL). Where it is not certain the smaller value is used.

Variables

This section is empty.

Functions

func Deadline

func Deadline() time.Duration

Deadline returns the hang guard of the kit: the outer bound of every blocking step (dial, read, write, waits). It only stops a hung test; a passing test never waits on it. It is NNTP_TEST_DEADLINE when that is a positive duration, else DefaultDeadline. Each package also accepts an explicit deadline per server or connection, which wins over this default.

func FreePort added in v0.11.0

func FreePort(tb testing.TB) int

FreePort returns a TCP port of 127.0.0.1 that was free a moment ago: it listens on port 0 and closes the listener. Another process may take the port before the test uses it, so prefer handing a server a listener (or ":0" and reading its address back) when it can take one; FreePort is for a server that must be told its port in advance. A port that something else already uses on 0.0.0.0 or [::] is skipped (see wildcardFree). A listen error fails tb.

func Listening added in v0.11.0

func Listening(addr string) bool

Listening reports whether a TCP dial to addr ("host:port") succeeds within a second (or Deadline() when that is shorter); the connection is closed at once, without reading or writing, so the server sees a client that goes away before its first command. Use it to check that a server is up, or with WaitFor that it has stopped: a refused dial returns false at once.

func Poll added in v0.13.0

func Poll(d time.Duration, cond func() bool) bool

Poll is WaitFor without a tb: it evaluates cond at once and then every 50 ms, and reports whether cond returned true within d (d <= 0 is Deadline()); it runs cond once more after d before it gives up. Nothing fails, so it works where t.Fatal must not be called, such as a t.Cleanup function (which may not stop the test but can report with t.Errorf), and in goroutines other than the test's.

func PollErr added in v0.13.0

func PollErr(d time.Duration, check func() error) error

PollErr is Poll for a condition that gives a reason: it evaluates check at once and every 50 ms until it returns nil (then PollErr returns nil), or until d has passed (d <= 0 is Deadline()) and check, run once more, still fails. The error then is "timed out after <d>" wrapping the last error of check, so errors.Is and errors.As see it.

func ShortTempDir added in v0.12.0

func ShortTempDir(tb testing.TB) string

ShortTempDir returns a new empty directory (mode 0700) with a short path, removed with its contents by tb.Cleanup, for files whose full path must stay short: above all unix-domain sockets. The address of a socket (sockaddr_un.sun_path) holds at most 107 bytes on Linux and 103 on macOS and the BSDs (MaxSocketPath); a longer path makes bind or connect fail with "invalid argument" or "file name too long", often with a message that does not name the length.

tb.TempDir() is os.TempDir() + "/" + the test name (cut and sanitised) + a random number + "/001". On macOS os.TempDir() alone is about 50 bytes, and a long test name, or a subtest name built from a table entry, takes the rest: five virtual machines of one project failed this way with a long TMPDIR. ShortTempDir takes os.TempDir() when it is at most 32 bytes and /tmp otherwise (os.TempDir() when /tmp is not a directory), and names the directory "nt" + 6 hex digits of a hash of tb.Name() + "-" + random digits: about 25 bytes in all, whatever the test is called, and still traceable to its test when someone looks at /tmp. That leaves about 75 bytes for the names below it; assert with SocketPathFits.

Call it before starting servers that use the directory: cleanups run last-in first-out, and the directory must be removed after the servers have stopped.

func SocketPathFits added in v0.12.0

func SocketPathFits(path string) bool

SocketPathFits reports whether path can be the address of a unix-domain socket on this system: at most MaxSocketPath bytes.

func WaitFor added in v0.11.0

func WaitFor(tb testing.TB, d time.Duration, what string, cond func() bool)

WaitFor polls cond every 50 ms until it returns true; when d has passed without that it fails tb with tb.Fatalf, naming what ("timed out after 10s waiting for <what>"). d <= 0 is Deadline(). cond runs at once and on the test's goroutine, so it may use tb, send commands on a client connection or read files; it runs once more after d before the failure. Use WaitForErr when cond has a reason to give, Poll where tb.Fatalf is wrong (t.Cleanup).

func WaitForErr added in v0.13.0

func WaitForErr(tb testing.TB, d time.Duration, what string, check func() error)

WaitForErr is WaitFor for a condition that says why it does not hold yet: it polls check every 50 ms until it returns nil; when d has passed without that it fails tb with the last error ("timed out after 10s waiting for <what>: <last error>"). d <= 0 is Deadline().

Types

This section is empty.

Directories

Path Synopsis
Package article builds, parses and compares news articles as raw bytes for tests.
Package article builds, parses and compares news articles as raw bytes for tests.
Package canlock computes and checks Cancel-Lock and Cancel-Key header values (RFC 8315), for tests of servers that honour them: the Cancel-Lock an article carries, the Cancel-Key a cancel or supersede must present, and whether a key opens a lock.
Package canlock computes and checks Cancel-Lock and Cancel-Key header values (RFC 8315), for tests of servers that honour them: the Cancel-Lock an article carries, the Cancel-Key a cancel or supersede must present, and whether a key opens a lock.
Package client is a test client for NNTP servers: a connection with a hang guard, one-call commands, dot-block reading and a strict mode that reports wire-level mistakes of the server.
Package client is a test client for NNTP servers: a connection with a hang guard, one-call commands, dot-block reading and a strict mode that reports wire-level mistakes of the server.
cmd
fakesendmail command
Command fakesendmail stands in for sendmail in tests: it records each mail into a directory instead of sending it, and exits with a status the test chooses.
Command fakesendmail stands in for sendmail in tests: it records each mail into a directory instead of sending it, and exits with a status the test chooses.
nntp-conformance command
Command nntp-conformance runs the conformance checks (package conformance) against an NNTP server and prints the report.
Command nntp-conformance runs the conformance checks (package conformance) against an NNTP server and prints the report.
nntp-fakeserver command
Command nntp-fakeserver runs the kit's fake NNTP server (package fakeserver) as a process that a JSON file configures, so test suites in other languages (Python, shell, C) can use it.
Command nntp-fakeserver runs the kit's fake NNTP server (package fakeserver) as a process that a JSON file configures, so test suites in other languages (Python, shell, C) can use it.
Package conformance runs black-box protocol checks against any NNTP address.
Package conformance runs black-box protocol checks against any NNTP address.
Package fakeserver is a fake NNTP server for tests: scripted (a fixed sequence of expected client lines and replies), handler-driven (your func gets the connection) or stateful (a Store of groups and articles served by the built-in RFC 3977 handler), with fault hooks and command counters.
Package fakeserver is a fake NNTP server for tests: scripted (a fixed sequence of expected client lines and replies), handler-driven (your func gets the connection) or stateful (a Store of groups and articles served by the built-in RFC 3977 handler), with fault hooks and command counters.
internal
dotstuff
Package dotstuff is the dot-stuffing of NNTP multi-line blocks (RFC 3977 section 3.1.1), shared by the fake server (replies) and the client (client.Stuff).
Package dotstuff is the dot-stuffing of NNTP multi-line blocks (RFC 3977 section 3.1.1), shared by the fake server (replies) and the client (client.Stuff).
inflate
Package inflate decodes a raw DEFLATE stream (RFC 1951) as it arrives on a connection: the receiving side of NNTP COMPRESS DEFLATE (RFC 8054) for the client and the fake server.
Package inflate decodes a raw DEFLATE stream (RFC 1951) as it arrives on a connection: the receiving side of NNTP COMPRESS DEFLATE (RFC 8054) for the client and the fake server.
Package mailrec records mail instead of sending it, for tests of servers that hand mail to sendmail (a moderated group's submissions, control-message reports, newgroup notices).
Package mailrec records mail instead of sending it, for tests of servers that hand mail to sendmail (a moderated group's submissions, control-message reports, newgroup notices).
Package proc starts an external NNTP server binary for a test, either with stdin/stdout as the client connection (inetd style) or on a TCP address with readiness detected by dialling until a greeting arrives, and stops it afterwards (SIGTERM, then SIGKILL after a timeout).
Package proc starts an external NNTP server binary for a test, either with stdin/stdout as the client connection (inetd style) or on a TCP address with readiness detected by dialling until a greeting arrives, and stops it afterwards (SIGTERM, then SIGKILL after a timeout).
Package tlscert makes throw-away TLS certificates for tests: a CA that issues leaf certificates, a self-signed shortcut, and every result as a tls.Certificate, as PEM and as files (the key at mode 0600) for servers that read their certificate from a path (INN's tls_cert_file/tls_key_file, diablo, NewsCache).
Package tlscert makes throw-away TLS certificates for tests: a CA that issues leaf certificates, a self-signed shortcut, and every result as a tls.Certificate, as PEM and as files (the key at mode 0600) for servers that read their certificate from a path (INN's tls_cert_file/tls_key_file, diablo, NewsCache).
Package yenc writes yEnc-encoded article bodies for tests: single-part and multi-part posts as body lines, ready for a fakeserver.Article, and a fakeserver.Generator that serves a group of them.
Package yenc writes yEnc-encoded article bodies for tests: single-part and multi-part posts as body lines, ready for a fakeserver.Article, and a fakeserver.Generator that serves a group of them.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL