Support email verification.
The bulk of this commit is auto-modifying files to export variables (mostly our
request error types and our response type) so that they can be reused in a Go
client for that API.
We also implement the beginnings of a Go client for that API, implementing the
bare minimum we need for our immediate purposes: the ability to retrieve
information about a Login.
This, of course, means we need an API endpoint that will return information
about a Login, which in turn required us to implement a GetLogin method in our
profileStore. Which got in-memory and postgres implementations.
That done, we could add the Verification field and Verified field to the Login
type, to keep track of whether we've verified the user's ownership of those
communication methods (if the Login is, in fact, a communication method). This
required us to update sql/postgres_init.sql to account for the new fields we're
tracking. It also means that when creating a Login, we had to generate a UUID to
use as the Verification field.
To make things complete, we needed a verifyLogin method on the profileStore to
mark a Login as verified. That, in turn, required an endpoint to control this
through the API. While doing so, I lumped things together in an UpdateLogin
handler just so we could reuse the endpoint and logic when resending a
verification email that may have never reached the user, for whatever reason
(the quintessential "send again" button).
Finally, we implemented an email_verification listener that will pull
email_verification events off NSQ, check for the requisite data integrity, and
use mailgun to email out a verification/welcome email.
9 "code.secondbit.org/uuid.hg"
13 profileChangeName = 1 << iota
14 profileChangePassphrase
15 profileChangeIterations
17 profileChangePassphraseScheme
18 profileChangeCompromised
19 profileChangeLockedUntil
20 profileChangePassphraseReset
21 profileChangePassphraseResetCreated
26 if os.Getenv("PG_TEST_DB") != "" {
27 p, err := NewPostgres(os.Getenv("PG_TEST_DB"))
31 profileStores = append(profileStores, &p)
35 var profileStores = []profileStore{NewMemstore()}
37 func compareProfiles(profile1, profile2 Profile) (success bool, field string, val1, val2 interface{}) {
38 if !profile1.ID.Equal(profile2.ID) {
39 return false, "ID", profile1.ID, profile2.ID
41 if profile1.Name != profile2.Name {
42 return false, "name", profile1.Name, profile2.Name
44 if profile1.Passphrase != profile2.Passphrase {
45 return false, "passphrase", profile1.Passphrase, profile2.Passphrase
47 if profile1.Iterations != profile2.Iterations {
48 return false, "iterations", profile1.Iterations, profile2.Iterations
50 if profile1.Salt != profile2.Salt {
51 return false, "salt", profile1.Salt, profile2.Salt
53 if profile1.PassphraseScheme != profile2.PassphraseScheme {
54 return false, "passphrase scheme", profile1.PassphraseScheme, profile2.PassphraseScheme
56 if profile1.Compromised != profile2.Compromised {
57 return false, "compromised", profile1.Compromised, profile2.Compromised
59 if !profile1.LockedUntil.Equal(profile2.LockedUntil) {
60 return false, "locked until", profile1.LockedUntil, profile2.LockedUntil
62 if profile1.PassphraseReset != profile2.PassphraseReset {
63 return false, "passphrase reset", profile1.PassphraseReset, profile2.PassphraseReset
65 if !profile1.PassphraseResetCreated.Equal(profile2.PassphraseResetCreated) {
66 return false, "passphrase reset created", profile1.PassphraseResetCreated, profile2.PassphraseResetCreated
68 if !profile1.Created.Equal(profile2.Created) {
69 return false, "created", profile1.Created, profile2.Created
71 if !profile1.LastSeen.Equal(profile2.LastSeen) {
72 return false, "last seen", profile1.LastSeen, profile2.LastSeen
74 return true, "", nil, nil
77 func compareLogins(login1, login2 Login) (success bool, field string, val1, val2 interface{}) {
78 if login1.Type != login2.Type {
79 return false, "Type", login1.Type, login2.Type
81 if login1.Value != login2.Value {
82 return false, "Value", login1.Value, login2.Value
84 if !login1.ProfileID.Equal(login2.ProfileID) {
85 return false, "ProfileID", login1.ProfileID, login2.ProfileID
87 if !login1.Created.Equal(login2.Created) {
88 return false, "Created", login1.Created, login2.Created
90 if !login1.LastUsed.Equal(login2.LastUsed) {
91 return false, "LastUsed", login1.LastUsed, login2.LastUsed
93 if login1.Verification != login2.Verification {
94 return false, "Verification", login1.Verification, login2.Verification
96 if login1.Verified != login2.Verified {
97 return false, "Verified", login1.Verified, login2.Verified
99 return true, "", nil, nil
102 func TestProfileStoreSuccess(t *testing.T) {
107 Passphrase: "passphrase",
112 LockedUntil: time.Now().Add(time.Hour).Round(time.Millisecond),
113 PassphraseReset: "passphrase reset",
114 PassphraseResetCreated: time.Now().Round(time.Millisecond),
115 Created: time.Now().Round(time.Millisecond),
116 LastSeen: time.Now().Round(time.Millisecond),
118 for _, store := range profileStores {
119 context := Context{profiles: store}
120 err := context.SaveProfile(profile)
122 t.Errorf("Error saving profile to %T: %s", store, err)
124 err = context.SaveProfile(profile)
125 if err != ErrProfileAlreadyExists {
126 t.Errorf("Expected ErrProfileAlreadyExists from %T, got %+v", store, err)
128 retrieved, err := context.GetProfileByID(profile.ID)
130 t.Errorf("Error retrieving profile from %T: %s", store, err)
132 match, field, expectation, result := compareProfiles(profile, retrieved)
134 t.Errorf("Expected `%v` in the `%s` field of profile retrieved from %T, got `%v`", expectation, field, store, result)
136 err = context.DeleteProfile(profile.ID)
138 t.Errorf("Error removing profile from %T: %s", store, err)
140 retrieved, err = context.GetProfileByID(profile.ID)
141 if err != ErrProfileNotFound {
142 t.Errorf("Expected ErrProfileNotFound from %T, got %+v and %+v", store, retrieved, err)
147 func TestProfileUpdates(t *testing.T) {
149 variations := 1 << 10
153 Passphrase: "passphrase",
158 LockedUntil: time.Now().Add(time.Hour).Round(time.Millisecond),
159 PassphraseReset: "passphrase reset",
160 PassphraseResetCreated: time.Now().Round(time.Millisecond),
161 Created: time.Now().Round(time.Millisecond),
162 LastSeen: time.Now().Round(time.Millisecond),
164 for i := 0; i < variations; i++ {
165 var name, passphrase, salt, passphraseReset string
167 var lockedUntil, passphraseResetCreated, lastSeen time.Time
168 var passphraseScheme int
171 profile.ID = uuid.NewID()
172 change := ProfileChange{}
173 expectation := profile
175 if i&profileChangeName != 0 {
176 name = fmt.Sprintf("name-%d", i)
178 expectation.Name = name
180 if i&profileChangePassphrase != 0 {
181 passphrase = fmt.Sprintf("passphrase-%d", i)
182 change.Passphrase = &passphrase
183 expectation.Passphrase = passphrase
185 if i&profileChangeIterations != 0 {
187 change.Iterations = &iterations
188 expectation.Iterations = iterations
190 if i&profileChangeSalt != 0 {
191 salt = fmt.Sprintf("salt-%d", i)
193 expectation.Salt = salt
195 if i&profileChangePassphraseScheme != 0 {
197 change.PassphraseScheme = &passphraseScheme
198 expectation.PassphraseScheme = passphraseScheme
200 if i&profileChangeCompromised != 0 {
201 compromised = i%2 != 0
202 change.Compromised = &compromised
203 expectation.Compromised = compromised
205 if i&profileChangeLockedUntil != 0 {
206 lockedUntil = time.Now().Round(time.Millisecond)
207 change.LockedUntil = &lockedUntil
208 expectation.LockedUntil = lockedUntil
210 if i&profileChangePassphraseReset != 0 {
211 passphraseReset = fmt.Sprintf("passphraseReset-%d", i)
212 change.PassphraseReset = &passphraseReset
213 expectation.PassphraseReset = passphraseReset
215 if i&profileChangePassphraseResetCreated != 0 {
216 passphraseResetCreated = time.Now().Round(time.Millisecond)
217 change.PassphraseResetCreated = &passphraseResetCreated
218 expectation.PassphraseResetCreated = passphraseResetCreated
220 if i&profileChangeLastSeen != 0 {
221 lastSeen = time.Now().Round(time.Millisecond)
222 change.LastSeen = &lastSeen
223 expectation.LastSeen = lastSeen
225 result.ApplyChange(change)
226 match, field, expected, got := compareProfiles(expectation, result)
228 t.Errorf("Expected field `%s` to be `%v`, got `%v`", field, expected, got)
230 for _, store := range profileStores {
231 context := Context{profiles: store}
232 err := context.SaveProfile(profile)
234 t.Errorf("Error saving profile in %T: %s", store, err)
236 err = context.UpdateProfile(profile.ID, change)
238 t.Errorf("Error updating profile in %T: %s", store, err)
240 retrieved, err := context.GetProfileByID(profile.ID)
242 t.Errorf("Error getting profile from %T: %s", store, err)
244 match, field, expected, got = compareProfiles(expectation, retrieved)
246 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
252 func TestProfilesUpdates(t *testing.T) {
263 change := BulkProfileChange{
266 for _, store := range profileStores {
267 context := Context{profiles: store}
268 err := context.SaveProfile(profile1)
270 t.Errorf("Error saving profile in %T: %s", store, err)
272 err = context.SaveProfile(profile2)
274 t.Errorf("Error saving profile in %T: %s", store, err)
276 err = context.SaveProfile(profile3)
278 t.Errorf("Error saving profile in %T: %s", store, err)
280 err = context.UpdateProfiles([]uuid.ID{profile1.ID, profile3.ID}, change)
282 t.Errorf("Error updating profile in %T: %s", store, err)
284 profile1.Compromised = truth
285 profile3.Compromised = truth
286 retrieved, err := context.GetProfileByID(profile1.ID)
288 t.Errorf("Error getting profile from %T: %s", store, err)
290 match, field, expected, got := compareProfiles(profile1, retrieved)
292 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
294 retrieved, err = context.GetProfileByID(profile2.ID)
296 t.Errorf("Error getting profile from %T: %s", store, err)
298 match, field, expected, got = compareProfiles(profile2, retrieved)
300 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
302 retrieved, err = context.GetProfileByID(profile3.ID)
304 t.Errorf("Error getting profile from %T: %s", store, err)
306 match, field, expected, got = compareProfiles(profile3, retrieved)
308 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
313 func TestProfileStoreLoginSuccess(t *testing.T) {
318 ProfileID: uuid.NewID(),
319 Created: time.Now().Add(-1 * time.Hour).Round(time.Millisecond),
320 LastUsed: time.Now().Add(-1 * time.Minute).Round(time.Millisecond),
322 for _, store := range profileStores {
323 context := Context{profiles: store}
324 err := context.AddLogin(login)
326 t.Errorf("Error adding login to %T: %s", store, err)
328 err = context.AddLogin(login)
329 if err != ErrLoginAlreadyExists {
330 t.Errorf("Expected ErrLoginAlreadyExists from %T, got %+v", store, err)
332 retrieved, err := context.ListLogins(login.ProfileID, 10, 0)
334 t.Errorf("Error retrieving logins from %T: %s", store, err)
336 if len(retrieved) != 1 {
337 t.Errorf("Expected 1 login result from %T, got %d", store, len(retrieved))
339 match, field, expectation, result := compareLogins(login, retrieved[0])
341 t.Errorf("Expected `%v` in the `%s` field of login retrieved from %T, got `%v`", expectation, field, store, result)
343 lastUsed := time.Now().Round(time.Millisecond)
344 err = context.RecordLoginUse(login.Value, lastUsed)
346 t.Errorf("Error recording use of login to %T: %s", store, err)
348 login.LastUsed = lastUsed
349 retrieved, err = context.ListLogins(login.ProfileID, 10, 0)
351 t.Errorf("Error retrieving logins from %T: %s", store, err)
353 if len(retrieved) != 1 {
354 t.Errorf("Expected 1 login result from %T, got %d", store, len(retrieved))
356 match, field, expectation, result = compareLogins(login, retrieved[0])
358 t.Errorf("Expected `%v` in the `%s` field of login retrieved from %T, got `%v`", expectation, field, store, result)
360 err = context.RemoveLogin(login.Value, login.ProfileID)
362 t.Errorf("Error removing login from %T: %s", store, err)
364 retrieved, err = context.ListLogins(login.ProfileID, 10, 0)
365 if len(retrieved) != 0 {
366 t.Errorf("Expected 0 login results from %T, got %d: %+v", store, len(retrieved), retrieved)
368 err = context.RemoveLogin(login.Value, login.ProfileID)
369 if err != ErrLoginNotFound {
370 t.Errorf("Expected ErrLoginNotFound from %T, got %+v", store, err)
375 func TestProfileStoreLoginRetrieval(t *testing.T) {
380 Passphrase: "passphrase",
385 LockedUntil: time.Now().Add(time.Hour).Round(time.Millisecond),
386 PassphraseReset: "passphrase reset",
387 PassphraseResetCreated: time.Now().Round(time.Millisecond),
388 Created: time.Now().Round(time.Millisecond),
389 LastSeen: time.Now().Round(time.Millisecond),
394 ProfileID: profile.ID,
395 Created: time.Now().Add(-1 * time.Hour).Round(time.Millisecond),
396 LastUsed: time.Now().Add(-1 * time.Minute).Round(time.Millisecond),
398 for _, store := range profileStores {
399 context := Context{profiles: store}
400 err := context.SaveProfile(profile)
402 t.Errorf("Error saving profile in %T: %s", store, err)
404 err = context.AddLogin(login)
406 t.Errorf("Error storing login in %T: %s", store, err)
408 retrieved, err := context.GetProfileByLogin(login.Value)
410 t.Errorf("Error retrieving profile by login from %T: %s", store, err)
412 match, field, expectation, result := compareProfiles(profile, retrieved)
414 t.Errorf("Expected `%v` in the `%s` field of profile retrieved from %T, got `%v`", expectation, field, store, result)
419 func TestProfileChangeValidation(t *testing.T) {
421 passphraseScheme := 1
422 passphraseReset := "reset"
426 enteredName := "Paddy"
427 okPassphrase := "this is a decent passphrase"
429 lockedUntil := time.Now().Round(time.Millisecond)
430 resetCreated := time.Now().Round(time.Millisecond)
431 lastSeen := time.Now().Round(time.Millisecond)
432 changes := map[*ProfileChange]error{
433 &ProfileChange{}: ErrEmptyChange,
434 &ProfileChange{PassphraseScheme: &passphraseScheme}: ErrMissingPassphrase,
435 &ProfileChange{PassphraseScheme: &passphraseScheme, Passphrase: &okPassphrase}: nil,
436 &ProfileChange{PassphraseReset: &passphraseReset}: ErrMissingPassphraseResetCreated,
437 &ProfileChange{PassphraseReset: &passphraseReset, PassphraseResetCreated: &resetCreated}: nil,
438 &ProfileChange{Salt: &salt}: ErrMissingPassphrase,
439 &ProfileChange{Salt: &salt, Passphrase: &okPassphrase}: nil,
440 &ProfileChange{Iterations: &iterations}: ErrMissingPassphrase,
441 &ProfileChange{Iterations: &iterations, Passphrase: &okPassphrase}: nil,
442 &ProfileChange{Passphrase: &okPassphrase}: nil,
443 &ProfileChange{Name: &emptyName}: nil,
444 &ProfileChange{Name: &enteredName}: nil,
445 &ProfileChange{Compromised: &compromised}: nil,
446 &ProfileChange{LockedUntil: &lockedUntil}: nil,
447 &ProfileChange{LastSeen: &lastSeen}: nil,
448 &ProfileChange{PassphraseResetCreated: &resetCreated}: ErrMissingPassphraseReset,
450 for change, expectedErr := range changes {
451 if err := change.Validate(); err != expectedErr {
452 t.Errorf("Expected %+v to give an error of %v, gave %v", change, expectedErr, err)
457 func TestBulkProfileChangeValidation(t *testing.T) {
460 changes := map[*BulkProfileChange]error{
461 &BulkProfileChange{}: ErrEmptyChange,
462 &BulkProfileChange{Compromised: &compromised}: nil,
464 for change, expectedErr := range changes {
465 if err := change.Validate(); err != expectedErr {
466 t.Errorf("Expected %+v to give an error of %v, gave %v", change, expectedErr, err)
471 // BUG(paddy): We need to test the validateNewProfileRequest helper.
472 // BUG(paddy): We need to test the CreateProfileHandler.
473 // BUG(paddy): We need to test that deleting works as we expect.