Gmail now connects. Authorisation-code flow with PKCE, the verifier kept server-side rather than in a cookie — the callback is a top-level navigation from Google, and a SameSite=Lax cookie is the kind of thing that works until a browser tightens its defaults. State is checked, the attempt expires after ten minutes, and the spent verifier is deleted rather than left at rest. Scope is gmail.readonly and the sync is one-way on purpose. Unstarring a message does not delete the task; completing the task does not touch the mailbox. Two-way would mean deciding what happens when both sides changed, and the only honest answer is a sync model far larger than the feature. The query carries newer_than:30d so connecting an account with a decade of starred mail does not create a decade of tasks. The update check is off unless the operator turns it on, asks GitHub at most every six hours, and sends nothing about the instance — no identifier, no version, no counts. A development build never reports an update: somebody running their own tree is ahead of every release, and telling them otherwise trains them to ignore the notice. Only administrators see it, because only they can act on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package gmail
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||
// Getting this wrong means Google refuses every exchange with a message that does
|
||
// not say which end is at fault.
|
||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||
p, err := NewPKCE()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
sum := sha256.Sum256([]byte(p.Verifier))
|
||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||
if p.Challenge != want {
|
||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||
}
|
||
|
||
// RFC 7636 requires 43–128 characters, and no padding.
|
||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||
}
|
||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||
}
|
||
if p.State == "" {
|
||
t.Error("no state was generated")
|
||
}
|
||
|
||
// Two attempts must not share anything.
|
||
other, err := NewPKCE()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if other.Verifier == p.Verifier || other.State == p.State {
|
||
t.Error("two authorisation attempts produced the same secrets")
|
||
}
|
||
}
|
||
|
||
func TestAuthURL(t *testing.T) {
|
||
cfg := Config{
|
||
ClientID: "client-123",
|
||
ClientSecret: "secret",
|
||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||
}
|
||
p, err := NewPKCE()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
raw := cfg.AuthURL(p)
|
||
parsed, err := url.Parse(raw)
|
||
if err != nil {
|
||
t.Fatalf("the URL does not parse: %v", err)
|
||
}
|
||
q := parsed.Query()
|
||
|
||
checks := map[string]string{
|
||
"client_id": cfg.ClientID,
|
||
"redirect_uri": cfg.RedirectURL,
|
||
"response_type": "code",
|
||
"scope": Scope,
|
||
"code_challenge": p.Challenge,
|
||
"code_challenge_method": "S256",
|
||
"state": p.State,
|
||
// Without both of these Google returns no refresh token on a
|
||
// re-authorisation, and the connection dies silently an hour later.
|
||
"access_type": "offline",
|
||
"prompt": "consent",
|
||
}
|
||
for key, want := range checks {
|
||
if got := q.Get(key); got != want {
|
||
t.Errorf("%s = %q, want %q", key, got, want)
|
||
}
|
||
}
|
||
|
||
// The verifier itself must never leave the server.
|
||
if strings.Contains(raw, p.Verifier) {
|
||
t.Error("the code verifier was put in the authorisation URL")
|
||
}
|
||
// Read-only, and only mail.
|
||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
||
}
|
||
}
|
||
|
||
func TestConfigured(t *testing.T) {
|
||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||
if !full.Configured() {
|
||
t.Error("a complete config is not reported as configured")
|
||
}
|
||
for _, partial := range []Config{
|
||
{},
|
||
{ClientID: "a"},
|
||
{ClientID: "a", ClientSecret: "b"},
|
||
{ClientSecret: "b", RedirectURL: "c"},
|
||
} {
|
||
if partial.Configured() {
|
||
t.Errorf("%+v was reported as configured", partial)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestQuery(t *testing.T) {
|
||
cases := []struct {
|
||
trigger, label string
|
||
wantContains []string
|
||
wantEmpty bool
|
||
}{
|
||
{"starred", "", []string{"is:starred", "newer_than:30d"}, false},
|
||
{"label", "Til handling", []string{`label:"Til handling"`, "newer_than:30d"}, false},
|
||
{"both", "Til handling", []string{"is:starred", `label:"Til handling"`}, false},
|
||
{"both", "", []string{"is:starred"}, false},
|
||
// A label trigger with no label selects nothing, which is right: it would
|
||
// otherwise match the whole mailbox.
|
||
{"label", "", nil, true},
|
||
{"", "", nil, true},
|
||
{"nonsense", "", nil, true},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.trigger+"/"+tc.label, func(t *testing.T) {
|
||
got := Query(tc.trigger, tc.label)
|
||
if tc.wantEmpty {
|
||
if got != "" {
|
||
t.Errorf("Query = %q, want empty", got)
|
||
}
|
||
return
|
||
}
|
||
for _, want := range tc.wantContains {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("Query = %q, missing %q", got, want)
|
||
}
|
||
}
|
||
// Without a window, connecting an account with a decade of starred
|
||
// mail would create a decade of tasks in one sweep.
|
||
if !strings.Contains(got, "newer_than:") {
|
||
t.Errorf("Query = %q has no time window", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSenderName(t *testing.T) {
|
||
cases := map[string]string{
|
||
"Anders Jensen <anders@example.dk>": "Anders Jensen",
|
||
`"Jensen, Anders" <anders@example.dk>`: "Jensen, Anders",
|
||
"anders@example.dk": "anders@example.dk",
|
||
"<anders@example.dk>": "anders@example.dk",
|
||
" Anders <a@b.dk> ": "Anders",
|
||
}
|
||
for in, want := range cases {
|
||
if got := SenderName(in); got != want {
|
||
t.Errorf("SenderName(%q) = %q, want %q", in, got, want)
|
||
}
|
||
}
|
||
}
|