internal/mailreply/mailreply_test.go

v1.43.1
gitbay/internal/mailreply/mailreply_test.go history · blame · raw

144 lines · 4263 bytes

  1package mailreply
  2
  3import (
  4	"errors"
  5	"strings"
  6	"testing"
  7	"time"
  8)
  9
 10var (
 11	keyA = []byte("0123456789abcdef0123456789abcdef")
 12	keyB = []byte("fedcba9876543210fedcba9876543210")
 13	now  = time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
 14)
 15
 16func mint(t *testing.T, keys [][]byte, tg Target) string {
 17	t.Helper()
 18	tok, err := Mint(keys, tg, now.Add(Lifetime))
 19	if err != nil {
 20		t.Fatal(err)
 21	}
 22	return tok
 23}
 24
 25func TestRoundTrip(t *testing.T) {
 26	for _, tg := range []Target{
 27		{UserID: 1, RepoID: 2, Kind: "issue", Number: 3},
 28		{UserID: 1 << 40, RepoID: 99999, Kind: "mr", Number: 123456},
 29	} {
 30		tok := mint(t, [][]byte{keyA}, tg)
 31		if tok != strings.ToLower(tok) {
 32			t.Errorf("token %q is not lower case", tok)
 33		}
 34		// The address's local part stays within 64 octets.
 35		if l := len("reply+" + tok); l > 64 {
 36			t.Errorf("local part is %d octets", l)
 37		}
 38		got, err := Verify([][]byte{keyA}, tok, now)
 39		tg.Expires = now.Add(Lifetime)
 40		if err != nil || got != tg {
 41			t.Errorf("Verify = %+v, %v; want %+v", got, err, tg)
 42		}
 43		if !got.Issued().Equal(now) {
 44			t.Errorf("Issued = %v, want %v", got.Issued(), now)
 45		}
 46		// A mail system that upper-cases the local part does not break it.
 47		if got, err := Verify([][]byte{keyA}, strings.ToUpper(tok), now); err != nil || got != tg {
 48			t.Errorf("upper-case Verify = %+v, %v", got, err)
 49		}
 50	}
 51}
 52
 53// A token minted before a rotation verifies while the old key is in the file.
 54func TestRotation(t *testing.T) {
 55	tg := Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3}
 56	tok := mint(t, [][]byte{keyA}, tg)
 57	if _, err := Verify([][]byte{keyB, keyA}, tok, now); err != nil {
 58		t.Fatal(err)
 59	}
 60	if _, err := Verify([][]byte{keyB}, tok, now); !errors.Is(err, ErrBadMAC) {
 61		t.Fatalf("retired key: %v", err)
 62	}
 63}
 64
 65func TestTamper(t *testing.T) {
 66	tok := mint(t, [][]byte{keyA}, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3})
 67	for i := range tok {
 68		c := byte('a')
 69		if tok[i] == 'a' {
 70			c = 'b'
 71		}
 72		bad := tok[:i] + string(c) + tok[i+1:]
 73		if _, err := Verify([][]byte{keyA}, bad, now); err == nil {
 74			t.Fatalf("changed character %d verified", i)
 75		}
 76	}
 77	for _, bad := range []string{"", "x", "!!!!", tok[:len(tok)-1], tok + "a"} {
 78		if _, err := Verify([][]byte{keyA}, bad, now); err == nil {
 79			t.Errorf("%q verified", bad)
 80		}
 81	}
 82}
 83
 84func TestExpiry(t *testing.T) {
 85	tg := Target{UserID: 1, RepoID: 2, Kind: "mr", Number: 3}
 86	tok := mint(t, [][]byte{keyA}, tg)
 87	if _, err := Verify([][]byte{keyA}, tok, now.Add(Lifetime-time.Hour)); err != nil {
 88		t.Fatalf("before expiry: %v", err)
 89	}
 90	got, err := Verify([][]byte{keyA}, tok, now.Add(Lifetime+time.Hour))
 91	tg.Expires = now.Add(Lifetime)
 92	if !errors.Is(err, ErrExpired) || got != tg {
 93		t.Fatalf("after expiry: %+v, %v", got, err)
 94	}
 95}
 96
 97// Two recipients of one notification get different tokens, and neither
 98// verifies as the other.
 99func TestCrossUser(t *testing.T) {
100	a := mint(t, [][]byte{keyA}, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3})
101	b := mint(t, [][]byte{keyA}, Target{UserID: 4, RepoID: 2, Kind: "issue", Number: 3})
102	if a == b {
103		t.Fatal("two recipients share a token")
104	}
105	ga, _ := Verify([][]byte{keyA}, a, now)
106	gb, _ := Verify([][]byte{keyA}, b, now)
107	if ga.UserID != 1 || gb.UserID != 4 {
108		t.Fatalf("got users %d and %d", ga.UserID, gb.UserID)
109	}
110}
111
112func TestMintRefuses(t *testing.T) {
113	for _, tg := range []Target{
114		{UserID: 1, RepoID: 2, Kind: "build", Number: 3},
115		{UserID: 0, RepoID: 2, Kind: "issue", Number: 3},
116	} {
117		if _, err := Mint([][]byte{keyA}, tg, now); err == nil {
118			t.Errorf("minted %+v", tg)
119		}
120	}
121	if _, err := Mint(nil, Target{UserID: 1, RepoID: 2, Kind: "issue", Number: 3}, now); err == nil {
122		t.Error("minted with no key")
123	}
124}
125
126func TestAddress(t *testing.T) {
127	a := Address("reply@gitbay.example", "abc")
128	if a != "reply+abc@gitbay.example" {
129		t.Fatalf("Address = %q", a)
130	}
131	for addr, want := range map[string]string{
132		"reply+abc@gitbay.example": "abc",
133		"Reply+ABC@GITBAY.example": "ABC",
134		"reply@gitbay.example":     "",
135		"reply+@gitbay.example":    "",
136		"reply+abc@other.example":  "",
137		"other+abc@gitbay.example": "",
138	} {
139		got, ok := TokenFrom("reply@gitbay.example", addr)
140		if got != want || ok != (want != "") {
141			t.Errorf("TokenFrom(%q) = %q, %v", addr, got, ok)
142		}
143	}
144}