package utils import ( "strings" "testing" "time" ) /* The invitation a newly onboarded merchant is emailed. `setpassword` took a bare userid, which was safe only because the caller had to reach it through a sign-in — `applogin` answers 409 with the userid for an account that has no password, and nothing else hands one out. Mailing that userid as a link changes the threat completely: userids are sequential, so the link becomes a guessable capability. Walk low numbers and claim any merchant onboarded but not yet set up, and you own a real business's admin account. The empty-password check is no defence — an un-set-up account is precisely what such an attacker is looking for. So these tests are mostly about what the token REFUSES. */ const inviteSecret = "an-invitation-signing-secret-long-enough" func inviteEnv(t *testing.T) { t.Helper() t.Setenv("POS_TOKEN_SECRET", inviteSecret) } func TestAnInvitationNamesTheAccountItWasIssuedFor(t *testing.T) { inviteEnv(t) now := time.Now() token, expires, err := MintInviteToken(InviteClaims{Userid: 904, Tenantid: 1147}, now) if err != nil { t.Fatalf("minting: %v", err) } claims, err := ParseInviteToken(token, now) if err != nil { t.Fatalf("parsing: %v", err) } if claims.Userid != 904 || claims.Tenantid != 1147 { t.Fatalf("claims came back as %+v", claims) } if expires.Sub(now) != InviteTokenTTL { t.Fatalf("expiry is %v, want %v", expires.Sub(now), InviteTokenTTL) } } func TestAnEditedInvitationIsRefused(t *testing.T) { // The whole point. If the payload could be edited, the link would be a // userid in a longer coat and every account would be one base64 edit away. inviteEnv(t) now := time.Now() token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now) if err != nil { t.Fatalf("minting: %v", err) } // Re-sign nothing; just change the payload, which is what an attacker who // decoded the link and wanted a different userid would do. forged, _, err := MintInviteToken(InviteClaims{Userid: 905}, now) if err != nil { t.Fatalf("minting: %v", err) } parts := strings.Split(token, ".") other := strings.Split(forged, ".") swapped := parts[0] + "." + other[1] + "." + parts[2] if _, err := ParseInviteToken(swapped, now); err == nil { t.Fatal("a payload swapped under an old signature was accepted") } } func TestAnInvitationSignedWithAnotherSecretIsRefused(t *testing.T) { now := time.Now() t.Setenv("POS_TOKEN_SECRET", "one-secret-that-is-long-enough-here") token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now) if err != nil { t.Fatalf("minting: %v", err) } t.Setenv("POS_TOKEN_SECRET", "a-different-secret-also-long-enough") if _, err := ParseInviteToken(token, now); err == nil { t.Fatal("an invitation from another deployment was accepted") } } func TestAnExpiredInvitationSaysWhatToDo(t *testing.T) { // The one failure here somebody can resolve themselves. "Not valid" would // send them to support; naming a resend sends them to whoever onboarded // them, which is where the fix actually is. inviteEnv(t) now := time.Now() token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now) if err != nil { t.Fatalf("minting: %v", err) } _, err = ParseInviteToken(token, now.Add(InviteTokenTTL+time.Second)) if err == nil { t.Fatal("an expired invitation was accepted") } // An expired invitation is the one failure here somebody can resolve // themselves, so it has to say how. "Invalid" would send them to support // instead of to whoever onboarded them. if !strings.Contains(err.Error(), "send another") { t.Fatalf("the refusal does not say what to do: %v", err) } // Read by a merchant on `/set-password`, not by a developer in a log. A // lowercase fragment in a red banner reads as something that leaked out. if !strings.HasPrefix(err.Error(), "This") || !strings.HasSuffix(err.Error(), ".") { t.Errorf("the refusal is not written as a sentence: %q", err) } } func TestAnInvitationIsNotASession(t *testing.T) { // They are the same shape signed with the same key. Without the prefix // check an invitation would verify as a console session — and it names a // userid with no role, no tenant check and a seven-day life, which is a far // weaker credential than a session and must never be usable as one. inviteEnv(t) now := time.Now() invite, _, err := MintInviteToken(InviteClaims{Userid: 904, Tenantid: 1147}, now) if err != nil { t.Fatalf("minting: %v", err) } if _, err := ParseWebToken(invite, now); err == nil { t.Fatal("an invitation was accepted as a console session") } } func TestASessionIsNotAnInvitation(t *testing.T) { // The other direction, which matters less but costs nothing to close: a // stolen session should not double as a password-reset link. inviteEnv(t) now := time.Now() session, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now) if err != nil { t.Fatalf("minting: %v", err) } if _, err := ParseInviteToken(session, now); err == nil { t.Fatal("a console session was accepted as an invitation") } } func TestRubbishIsRefusedWithoutPanicking(t *testing.T) { inviteEnv(t) now := time.Now() for _, bad := range []string{ "", " ", "i1.", "i1..", "i1.onlyonepart", "not-a-token", "i1.!!!not-base64!!!.signature", "w1.something.else", } { if _, err := ParseInviteToken(bad, now); err == nil { t.Fatalf("%q was accepted as an invitation", bad) } } } func TestAnInvitationMustNameSomebody(t *testing.T) { // A token naming nobody authorises nothing, and must not be mistaken for // one authorising everything — the same rule the session parser applies. inviteEnv(t) if _, _, err := MintInviteToken(InviteClaims{Userid: 0}, time.Now()); err == nil { t.Fatal("an invitation was minted for user 0") } } func TestNoSigningSecretMeansNoInvitations(t *testing.T) { // Rather than issuing something unverifiable. A deployment that cannot sign // cannot invite, and saying so at the point of minting is better than an // email whose link never works. t.Setenv("POS_TOKEN_SECRET", "") t.Setenv("JWT_SECRET_KEY", "") if _, _, err := MintInviteToken(InviteClaims{Userid: 904}, time.Now()); err == nil { t.Fatal("an invitation was minted with no signing secret") } }