rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

1package aviso
2
3import (
4 "context"
5 "crypto/ecdh"
6 "crypto/rand"
7 "database/sql"
8 "encoding/base64"
9 "errors"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "amadan.net/rastrillo/rastrillo/db"
15 "amadan.net/rastrillo/rastrillo/migrate"
16)
17
18func openInternalDB(t *testing.T) *sql.DB {
19 t.Helper()
20 d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil)
21 if err != nil {
22 t.Fatal(err)
23 }
24 t.Cleanup(func() { d.Close() })
25 if _, err := migrate.Apply(context.Background(), d, Schema); err != nil {
26 t.Fatal(err)
27 }
28 return d.Writer()
29}
30
31func newInternalService(t *testing.T) *Service {
32 t.Helper()
33 key, _ := GenerateKey()
34 s, err := New(Config{DB: openInternalDB(t), PrivateKey: key, Contact: "mailto:x@y", Origin: "https://a"})
35 if err != nil {
36 t.Fatal(err)
37 }
38 return s
39}
40
41// sub builds a subscription with real keys: webpush-go decodes p256dh
42// into a P-256 point and auth into 16 bytes before any HTTP happens,
43// so a placeholder string would fail in the encryptor, not the test.
44func sub(endpoint string) Subscription {
45 k, err := ecdh.P256().GenerateKey(rand.Reader)
46 if err != nil {
47 panic(err)
48 }
49 auth := make([]byte, 16)
50 if _, err := rand.Read(auth); err != nil {
51 panic(err)
52 }
53 return Subscription{
54 Endpoint: endpoint,
55 P256dh: base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
56 Auth: base64.RawURLEncoding.EncodeToString(auth),
57 }
58}
59
60func TestPutInsertsThenUpdatesSameOwner(t *testing.T) {
61 s := newInternalService(t)
62 ctx := context.Background()
63 if err := s.put(ctx, "alice", sub("https://push.example/1"), ""); err != nil {
64 t.Fatal(err)
65 }
66 rows, _ := s.List(ctx, "alice")
67 if len(rows) != 1 || rows[0].Revision != 1 || rows[0].VAPIDKeyID != s.keyID || rows[0].Subject != "alice" {
68 t.Fatalf("after insert: %+v", rows)
69 }
70 again := sub("https://push.example/1")
71 if err := s.put(ctx, "alice", again, ""); err != nil {
72 t.Fatal(err)
73 }
74 rows, _ = s.List(ctx, "alice")
75 if len(rows) != 1 || rows[0].Revision != 2 || rows[0].Auth != again.Auth {
76 t.Fatalf("after update: %+v", rows)
77 }
78}
79
80func TestPutRefusesCrossOwner(t *testing.T) {
81 s := newInternalService(t)
82 ctx := context.Background()
83 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
84 err := s.put(ctx, "bob", sub("https://push.example/1"), "")
85 if !errors.Is(err, ErrOwnedElsewhere) {
86 t.Fatalf("got %v, want ErrOwnedElsewhere", err)
87 }
88 rows, _ := s.List(ctx, "alice")
89 if len(rows) != 1 {
90 t.Fatal("alice lost her row")
91 }
92 if rows, _ := s.List(ctx, "bob"); len(rows) != 0 {
93 t.Fatal("bob gained a row")
94 }
95}
96
97func TestPutDeletesPreviousOnlyWhenOwned(t *testing.T) {
98 s := newInternalService(t)
99 ctx := context.Background()
100 _ = s.put(ctx, "alice", sub("https://push.example/old"), "")
101 _ = s.put(ctx, "bob", sub("https://push.example/bobs"), "")
102 // alice re-subscribes and names her old endpoint: gone.
103 if err := s.put(ctx, "alice", sub("https://push.example/new"), "https://push.example/old"); err != nil {
104 t.Fatal(err)
105 }
106 rows, _ := s.List(ctx, "alice")
107 if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
108 t.Fatalf("alice rows: %+v", rows)
109 }
110 // alice names bob's endpoint as previous: bob keeps it.
111 _ = s.put(ctx, "alice", sub("https://push.example/new2"), "https://push.example/bobs")
112 rows, _ = s.List(ctx, "bob")
113 if len(rows) != 1 {
114 t.Fatal("bob's row deleted by alice's previousEndpoint")
115 }
116}
117
118func TestPutPreviousEqualToNewIsNotADelete(t *testing.T) {
119 s := newInternalService(t)
120 ctx := context.Background()
121 _ = s.put(ctx, "alice", sub("https://push.example/same"), "")
122 if err := s.put(ctx, "alice", sub("https://push.example/same"), "https://push.example/same"); err != nil {
123 t.Fatal(err)
124 }
125 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 || rows[0].Revision != 2 {
126 t.Fatalf("rows: %+v", rows)
127 }
128}
129
130func TestDeleteOwnIsOwnerScoped(t *testing.T) {
131 s := newInternalService(t)
132 ctx := context.Background()
133 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
134 if err := s.deleteOwn(ctx, "bob", "https://push.example/1"); err != nil {
135 t.Fatal(err)
136 }
137 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
138 t.Fatal("bob deleted alice's row")
139 }
140 _ = s.deleteOwn(ctx, "alice", "https://push.example/1")
141 if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
142 t.Fatal("own delete did nothing")
143 }
144}
145
146func TestConfirmAndPruneAreRevisionConditional(t *testing.T) {
147 s := newInternalService(t)
148 ctx := context.Background()
149 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
150 before, _ := s.List(ctx, "alice")
151 id := before[0].ID
152 // Browser refreshes: revision 2.
153 _ = s.put(ctx, "alice", sub("https://push.example/1"), "")
154 // A send that captured revision 1 comes back 410: must not prune.
155 if err := s.prune(ctx, id, 1); err != nil {
156 t.Fatal(err)
157 }
158 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
159 t.Fatal("stale prune deleted a refreshed subscription")
160 }
161 // Stale confirm must not touch last_confirmed_at.
162 s.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
163 _ = s.confirm(ctx, id, 1)
164 var got int64
165 _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE id=?`, id).Scan(&got)
166 if got == 1_800_000_000 {
167 t.Fatal("stale confirm bumped last_confirmed_at")
168 }
169 _ = s.confirm(ctx, id, 2)
170 _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE id=?`, id).Scan(&got)
171 if got != 1_800_000_000 {
172 t.Fatalf("current confirm did not bump: %d", got)
173 }
174 if err := s.prune(ctx, id, 2); err != nil {
175 t.Fatal(err)
176 }
177 if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
178 t.Fatal("current prune did not delete")
179 }
180}
181
182func TestSweepAndDeleteSubject(t *testing.T) {
183 s := newInternalService(t)
184 ctx := context.Background()
185 s.now = func() time.Time { return time.Unix(1000, 0) }
186 _ = s.put(ctx, "alice", sub("https://push.example/old"), "")
187 s.now = func() time.Time { return time.Unix(2000, 0) }
188 _ = s.put(ctx, "alice", sub("https://push.example/new"), "")
189 _ = s.put(ctx, "bob", sub("https://push.example/bob"), "")
190 if err := s.Sweep(ctx, time.Unix(1500, 0)); err != nil {
191 t.Fatal(err)
192 }
193 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
194 t.Fatalf("sweep: %+v", rows)
195 }
196 if err := s.DeleteSubject(ctx, "bob"); err != nil {
197 t.Fatal(err)
198 }
199 if rows, _ := s.List(ctx, "bob"); len(rows) != 0 {
200 t.Fatal("DeleteSubject left rows")
201 }
202 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
203 t.Fatal("DeleteSubject touched another subject")
204 }
205}
206
207func TestListOrdersOldestFirst(t *testing.T) {
208 s := newInternalService(t)
209 ctx := context.Background()
210 s.now = func() time.Time { return time.Unix(2000, 0) }
211 _ = s.put(ctx, "alice", sub("https://push.example/second"), "")
212 s.now = func() time.Time { return time.Unix(1000, 0) }
213 _ = s.put(ctx, "alice", sub("https://push.example/first"), "")
214 rows, _ := s.List(ctx, "alice")
215 if len(rows) != 2 || rows[0].Endpoint != "https://push.example/first" {
216 t.Fatalf("order: %+v", rows)
217 }
218}
219