internal/mailreply/mailreply_test.go
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}