package classes import ( "os" "strings" "testing" "time" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" ) func flipHex(b byte) string { if b == '0' { return "1" } return "0" } func TestCodes(t *testing.T) { db := throttleDB(t) src, err := os.ReadFile("codes.go") if err != nil { t.Fatal(err) } if !strings.Contains(string(src), "subtle.ConstantTimeCompare") { t.Fatal("code compare must use crypto/subtle.ConstantTimeCompare") } user := &models.User{Email: "reset@example.test", Password: "x"} if err := db.Create(user).Error; err != nil { t.Fatal(err) } code, err := IssueResetCode(t.Context(), db, user) if err != nil || code == "" { t.Fatalf("issue reset: %q %v", code, err) } if user.ResetPasswordCodeIssuedAt == nil || time.Since(*user.ResetPasswordCodeIssuedAt) > 2*time.Second { t.Fatalf("issued at = %v", user.ResetPasswordCodeIssuedAt) } act, err := IssueActivationCode(t.Context(), db, user) if err != nil || act == "" { t.Fatalf("issue activation: %q %v", act, err) } got, ok := VerifyResetCode(t.Context(), db, user.ID, code) if !ok || got == nil || !got.HasSelfSetPassword { t.Fatalf("verify reset = %v %#v", ok, got) } var row models.User if err := db.Where("id = ?", user.ID).Take(&row).Error; err != nil { t.Fatal(err) } if row.ResetPasswordCode != nil { t.Fatalf("code not cleared: %#v", row.ResetPasswordCode) } if _, ok := VerifyResetCode(t.Context(), db, user.ID, code); ok { t.Fatal("consumed code still verified") } again, err := IssueResetCode(t.Context(), db, user) if err != nil { t.Fatal(err) } if _, ok := VerifyResetCode(t.Context(), db, user.ID, again[:len(again)-1]+flipHex(again[len(again)-1])); ok { t.Fatal("last-byte mismatch verified") } if _, ok := VerifyResetCode(t.Context(), db, user.ID, flipHex(again[0])+again[1:]); ok { t.Fatal("first-byte mismatch verified") } past := time.Now().Add(-61 * time.Minute) if err := db.Model(&models.User{}).Where("id = ?", user.ID).Update("reset_password_code_issued_at", past).Error; err != nil { t.Fatal(err) } if _, ok := VerifyResetCode(t.Context(), db, user.ID, again); ok { t.Fatal("expired code verified") } fresh := "cutover-code-value" if err := db.Exec(`UPDATE users SET reset_password_code = ?, reset_password_code_issued_at = NULL WHERE id = ?`, fresh, user.ID).Error; err != nil { t.Fatal(err) } if _, ok := VerifyResetCode(t.Context(), db, user.ID, fresh); !ok { t.Fatal("null issued-at must be treated as issued now") } pending := &models.User{Email: "activate@example.test", Password: "x"} if err := db.Create(pending).Error; err != nil { t.Fatal(err) } activation, err := IssueActivationCode(t.Context(), db, pending) if err != nil { t.Fatal(err) } activated, ok := VerifyActivationCode(t.Context(), db, pending.ID, activation) if !ok || activated == nil || !activated.IsActivated { t.Fatalf("activate = %v %#v", ok, activated) } }