Files
1856433721 chore(lint): clear the lint baseline and fix six empty assertions (#217)
* chore(lint): clear the lint baseline and lint the whole repository

The v1.5.0 tag run failed Lint with 42 findings that had nothing to do
with the release. The chain:

  1. .golangci.yml set issues.new: true, which makes golangci-lint's diff
     processor run `git diff HEAD~`.
  2. On PR and main-push runs golangci-lint-action fetches a patch from
     the GitHub compare API and passes --new-from-patch --new=false, which
     overrides that config. So the setting was dead on every normal run.
  3. On the tag push the compare call returned 404, so the action invoked
     golangci-lint bare and the config took effect for the first time.
  4. actions/checkout defaults to fetch-depth: 1, so HEAD~ does not exist
     and the diff processor failed. golangci-lint treats that as a warning
     and falls back to reporting everything: 42 pre-existing findings.

issues.new also made local linting lie. `make lint` and the pre-commit
hook both run golangci-lint bare, so in a clean checkout they reported
0 issues while 42 stood.

Fix the 42, then drop both filters so every event type behaves the same
and no git history is needed:

  - testifylint (29): assert.Nil(t, err) -> require/assert.NoError,
    assert.Equal(t, "", x) -> assert.Empty, assert.True(t, strings
    .Contains(..)) -> assert.Contains. Error assertions now abort the
    test rather than continue, which is what require-error asks for.
  - noctx (2): gravatar.go and handler.go already built a ctx with a
    timeout and then threw it away via NewRequest + req.WithContext;
    they now call NewRequestWithContext. No behaviour change.
  - staticcheck (6): strings.ReplaceAll x2, fmt.Fprintf, getMD5Hash
    returns its error last (unexported, two call sites), and the
    unexported gravatarBaseUrl/gravatarUrl renamed to ...URL.
  - ST1003 on the exported Gravatar.GravatarUrl field, ST1005 on the two
    capitalised SMTP error strings, gosmopolitan on the deliberate IDN
    test input and noctx on the one-shot generator are suppressed with
    reasons. The two error strings are passed through verbatim by
    ParseSMTPError as LookupError.Message, so they are user-facing text
    consistent with the Err* constants in error.go, not internal Go
    error strings.

Upstream frames new-from-* as a migration aid for a large existing
codebase, and the FAQ notes that --new (this repo's issues.new) is less
safe than --new-from-rev because generated files derail it -- this repo
has a generator. Of nine comparable Go projects sampled, none set
issues.new and six of seven using golangci-lint-action do not set
only-new-issues.

Verified: a bare `golangci-lint run` and a depth-1 clone both report
0 issues; go build, go vet and 67 offline tests pass under -race. The
network-dependent tests are left to CI, as they make real SMTP
connections.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* test: make six vacuous error assertions actually assert

testifylint's require-error check surfaced these while clearing the lint
baseline. All six passed a value that reads as the expected error into
testify's msgAndArgs parameter, which is only used to decorate a failure
message. The expectation was never applied: the assertions checked no
more than err != nil.

Demonstrated on the 404 case -- with the old form, an expected
"status_code: 403" passes against an actual 404.

  mx_test.go:26          assert.Error(t, err, ErrNoSuchHost)
  smtp_test.go:141       assert.Error(t, err, ErrNoSuchHost)
  smtp_test.go:159       assert.Error(t, err, syscall.ECONNREFUSED)
  handler_test.go:186    assert.Error(t, err, "...status_code: 404")
  handler_test.go:196    assert.Error(t, err, "...status_code: 500")
  handler_test.go:219    assert.Error(t, err, "invalid character ...")

Each now uses an assertion that matches what is being expected: a full
message (EqualError), a substring (ErrorContains) or an error value
(ErrorIs).

mx_test.go needed more than a mechanical swap. CheckMX returns the
resolver's *net.DNSError unchanged -- "lookup githubexists.com on
...:53: no such host" -- so it never contained ErrNoSuchHost ("Mail
server does not exist"); it is Verify that maps it. Asserting the
message text would also bake in the resolver address, which varies by
environment, so the test now asserts the DNS semantics via ErrorAs and
IsNotFound.

Verified: each of the six fails when its expected value is mutated
(IsNotFound -> False, ErrNoSuchHost -> ErrTimeout, ECONNREFUSED ->
ETIMEDOUT, 404 -> 403, 500 -> 503, and one character of the JSON error).
70 offline tests pass under -race. The six touched tests reach the
network only via DNS, or via a SOCKS dial to 127.0.0.1; none opens an
SMTP connection.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* chore(lint): enable usestdlibvars and intrange

Both catch a legacy literal where the standard library offers a named
form, and both fix mechanically with no behaviour change. Seven findings:

  gravatar.go, handler.go        "GET" -> http.MethodGet
  gravatar.go                    200 -> http.StatusOK
  cmd/apiserver/main.go (x2)     500 -> http.StatusInternalServerError
  smtp.go, schedule_test.go      for i := 0; i < n; i++ -> for range n

Enabling the linters rather than only editing the call sites is the
point: with the baseline at zero there is nothing to migrate around, so
the rule is enforced for new code instead of being reapplied by hand.

Turning on every usestdlibvars opt-in as well (time-weekday, time-month,
time-layout, crypto-hash, default-rpc-path, sql-isolation-level,
tls-signature-scheme, constant-kind) surfaces nothing further, so the
defaults are left in place.

Two adjacent linters were surveyed and deliberately not enabled:

  - nilnil (1): smtp.go's `return nil, nil` is the contract CheckSMTP
    documents for a disabled SMTP check. Changing it is a public API
    decision, not a cleanup.
  - godot (3): requiring declaration comments to end in a period is a
    style preference that would bind every future contribution.

Verified: golangci-lint run reports 0, go build and go vet are clean,
70 offline tests pass under -race, and GenerateRandomEmail still yields
32 alphanumeric bytes after the loop rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-11 10:09:20 +08:00

221 lines
7.7 KiB
Go

package emailverifier
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/h2non/gock.v1"
)
// restoreDisposableDomains puts the package-level set back once the test ends.
// updateDisposableDomains replaces it wholesale, and nothing used to undo
// that: every test running after one of these saw the mock list instead of the
// generated data, which is how two fixtures came to assert against domains
// that were never in the generated maps at all.
func restoreDisposableDomains(t *testing.T) {
t.Cleanup(func() {
disposableSyncDomains.Range(func(key, _ interface{}) bool {
disposableSyncDomains.Delete(key)
return true
})
for d := range disposableDomains {
disposableSyncDomains.Store(d, struct{}{})
}
additionalDisposableDomains.Range(func(key, _ interface{}) bool {
additionalDisposableDomains.Delete(key)
return true
})
})
}
func TestUpdateDisposableDomainsOK(t *testing.T) {
restoreDisposableDomains(t)
assert.False(t, verifier.IsDisposable("a.org"))
assert.False(t, verifier.IsDisposable("b.com"))
assert.True(t, verifier.IsDisposable("mailinator.com"))
mockResp := []string{"a.org", "b.com", "zzjbfwqi.shop", "dbbd8.club"}
defer gock.Off()
gock.New("https://raw.githubusercontent.com").
Get("/disposable/disposable-email-domains/master/domains.json").
Reply(http.StatusOK).
JSON(mockResp)
err := updateDisposableDomains(disposableDataURL)
require.NoError(t, err)
assert.True(t, verifier.IsDisposable("a.org"))
assert.True(t, verifier.IsDisposable("b.com"))
assert.False(t, verifier.IsDisposable("c.net"))
assert.False(t, verifier.IsDisposable("mailinator.com"))
}
// The upstream list has these domains; disposable_allowlist.txt is the only
// thing keeping them out, so a refresh that reinstated them would quietly undo
// what the generated list was built to express.
func TestUpdateDisposableDomainsKeepsAllowlistOut(t *testing.T) {
restoreDisposableDomains(t)
mockResp := []string{"a.org", "hush.com", "lavabit.com"}
defer gock.Off()
gock.New("https://raw.githubusercontent.com").
Get("/disposable/disposable-email-domains/master/domains.json").
Reply(http.StatusOK).
JSON(mockResp)
require.NoError(t, updateDisposableDomains(disposableDataURL))
assert.True(t, verifier.IsDisposable("a.org"))
assert.False(t, verifier.IsDisposable("hush.com"))
assert.False(t, verifier.IsDisposable("lavabit.com"))
}
// A caller who deliberately blocks one of the allowlisted domains outranks the
// allowlist, which is only there to correct the upstream list.
func TestAddDisposableDomainsOutranksAllowlist(t *testing.T) {
restoreDisposableDomains(t)
verifier.AddDisposableDomains([]string{"hush.com"})
mockResp := []string{"a.org", "hush.com"}
defer gock.Off()
gock.New("https://raw.githubusercontent.com").
Get("/disposable/disposable-email-domains/master/domains.json").
Reply(http.StatusOK).
JSON(mockResp)
require.NoError(t, updateDisposableDomains(disposableDataURL))
assert.True(t, verifier.IsDisposable("hush.com"))
}
// AddDisposableDomains is reachable from whatever goroutine the caller likes,
// while EnableAutoUpdateDisposable runs updateDisposableDomains on a schedule
// goroutine, and both touch additionalDisposableDomains. While that was a plain
// map the pair was a `fatal error: concurrent map iteration and map write` --
// unrecoverable, not something a caller can defend against.
//
// Nothing caught it because this is the first test in the package to run
// anything concurrently. `make test` does pass -race, but the detector reports
// only conflicting accesses that actually execute, and every other test drives
// both functions from the one goroutine; EnableAutoUpdateDisposable starts a
// schedule, but at a 24h period it never fires inside a test.
func TestAddDisposableDomainsIsConcurrentSafe(t *testing.T) {
restoreDisposableDomains(t)
// Two things keep this cheap. updateDisposableDomains walks the whole
// disposable set, all 75k entries of it, which is beside the point here, so
// empty it first -- the cleanup above puts it back. And the adder rewrites
// one key rather than adding new ones: storing an existing key is still a
// map write, so the race is unchanged, but neither map grows. An adder that
// keeps adding makes every refresh walk a longer map, and that quadratic
// cost lands only once the bug is fixed and nothing crashes early.
disposableSyncDomains.Range(func(key, _ interface{}) bool {
disposableSyncDomains.Delete(key)
return true
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `["fetched.example"]`)
}))
defer srv.Close()
seeded := make([]string, 5000)
for i := range seeded {
seeded[i] = fmt.Sprintf("seeded-%d.example", i)
}
verifier.AddDisposableDomains(seeded)
// Both loops run to a deadline rather than a count, so they overlap for the
// whole window instead of one finishing before the other starts.
deadline := time.Now().Add(500 * time.Millisecond)
var wg sync.WaitGroup
wg.Add(1)
go func() { // stands in for a caller adding domains as it goes
defer wg.Done()
for time.Now().Before(deadline) {
verifier.AddDisposableDomains([]string{"churned.example"})
}
}()
// Reported after Wait rather than asserted in the goroutine: require calls
// t.FailNow, which only works on the goroutine running the test. Single
// writer, and Wait orders the read after it.
var refreshErr error
wg.Add(1)
go func() { // stands in for the schedule goroutine
defer wg.Done()
for time.Now().Before(deadline) {
if refreshErr = updateDisposableDomains(srv.URL); refreshErr != nil {
return
}
}
}()
wg.Wait()
require.NoError(t, refreshErr)
assert.True(t, verifier.IsDisposable("seeded-0.example"), "a refresh dropped a caller's domain")
assert.True(t, verifier.IsDisposable("churned.example"))
assert.True(t, verifier.IsDisposable("fetched.example"))
}
func TestUpdateDisposableDomainsFailed_NoSuchHost(t *testing.T) {
err := updateDisposableDomains("http://abcmockxyz.aaa")
require.Error(t, err)
assert.Contains(t, err.Error(), "no such host")
}
func TestUpdateDisposableDomainsFailed_StatusNotFound(t *testing.T) {
defer gock.Off()
gock.New("https://raw.githubusercontent.com").
Get("/disposable/disposable-email-domains/master/domains.json").
Reply(http.StatusNotFound)
err := updateDisposableDomains(disposableDataURL)
require.EqualError(t, err, "get disposable domains from https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.json with status_code: 404")
}
func TestUpdateDisposableDomainsFailed_StatusInternalError(t *testing.T) {
defer gock.Off()
gock.New("https://raw.githubusercontent.com").
Get("/disposable/disposable-email-domains/master/domains.json").
Reply(http.StatusInternalServerError)
err := updateDisposableDomains(disposableDataURL)
require.EqualError(t, err, "get disposable domains from https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.json with status_code: 500")
}
func TestUpdateDisposableDomains_NoResponse(t *testing.T) {
defer gock.Off()
gock.New("https://raw.githubusercontent.com").
Get("/disposable/disposable-email-domains/master/domains.json").
Reply(http.StatusOK)
err := updateDisposableDomains(disposableDataURL)
assert.NoError(t, err)
}
func TestUpdateDisposableDomains_WrongResponse(t *testing.T) {
defer gock.Off()
gock.New("https://raw.githubusercontent.com").
Get("/disposable/disposable-email-domains/master/domains.json").
Reply(http.StatusOK).
JSON("testing")
err := updateDisposableDomains(disposableDataURL)
require.EqualError(t, err, "invalid character 'e' in literal true (expecting 'r')")
}