package public import ( "context" "errors" customermodel "git.sendico.io/tech/sxchnge/product/customers/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" "net/http/httptest" "strings" "testing" "git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" "go.uber.org/zap" ) func TestLoginRejectsUnknownClientID(t *testing.T) { testRejectsUnknownClientID(t, "/login", `{ "login":{"login":"user@example.com","password":"correct horse battery staple"}, "clientId":"missing-client", "deviceId":"device-1" }`, 0, func(router *PublicRouter, req *http.Request) http.HandlerFunc { return router.login(req) }) } func TestAPILoginRejectsUnknownClientID(t *testing.T) { testRejectsUnknownClientID(t, "/login/api", `{ "login":{"login":"user@example.com","password":"correct horse battery staple"}, "clientId":"missing-client", "deviceId":"device-1", "clientSecret":"secret" }`, 1, func(router *PublicRouter, req *http.Request) http.HandlerFunc { return router.apiLogin(req) }) } func TestLoginAuthenticatesAgainstInjectedAccountStorage(t *testing.T) { account := testActiveAccount(t) unusedDB := &testAccountDB{err: merrors.ErrNoData} activeDB := &testAccountDB{result: account} router := &PublicRouter{ logger: zap.NewNop(), db: activeDB, service: mservice.Accounts, } got, failed, h := router.authenticateAccount(context.Background(), &srequest.Login{ LoginData: customermodel.LoginData{ UserDataBase: customermodel.UserDataBase{ Login: "user@example.com", }, Password: "correct horse battery staple", }, }) if h != nil { t.Fatal("expected login authentication to succeed against injected account store") } if failed { t.Fatal("successful login was classified as a credential failure") } if got == nil || got.Login != account.Login { t.Fatalf("expected account %q, got %#v", account.Login, got) } if activeDB.getByEmailCalls != 1 { t.Fatalf("expected one active account store lookup, got %d", activeDB.getByEmailCalls) } if unusedDB.getByEmailCalls != 0 { t.Fatalf("expected no unused account store lookups, got %d", unusedDB.getByEmailCalls) } } func testRejectsUnknownClientID( t *testing.T, path string, body string, wantLookups int, handler func(*PublicRouter, *http.Request) http.HandlerFunc, ) { t.Helper() account := testActiveAccount(t) accountDB := &testAccountDB{result: account} router := &PublicRouter{ logger: zap.NewNop(), db: accountDB, rtdb: &testRefreshTokenDB{clientErr: merrors.ErrNoData}, service: mservice.Accounts, } req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, path, strings.NewReader(body)) rec := httptest.NewRecorder() handler(router, req)(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) } if accountDB.getByEmailCalls != wantLookups { t.Fatalf("expected %d account lookups, got %d", wantLookups, accountDB.getByEmailCalls) } resp := decodeErrorResponse(t, rec.Body.Bytes()) if resp.Data.Details != "unknown client id" { t.Fatalf("expected unknown client id message, got %q", resp.Data.Details) } } func TestLoginRejectsPendingVerificationAccount(t *testing.T) { account := testAccountWithStatus(t, customermodel.AccountPendingVerification) router := &PublicRouter{ logger: zap.NewNop(), db: &testAccountDB{result: account}, service: mservice.Accounts, } req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{ "login":{"login":"user@example.com","password":"correct horse battery staple"} }`)) rec := httptest.NewRecorder() router.login(req)(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) } resp := decodeErrorResponse(t, rec.Body.Bytes()) if resp.Data.Details != "Account verification required" { t.Fatalf("expected account verification required message, got %q", resp.Data.Details) } } func TestLoginRejectsBlockedAccount(t *testing.T) { account := testAccountWithStatus(t, customermodel.AccountBlocked) router := &PublicRouter{ logger: zap.NewNop(), db: &testAccountDB{result: account}, service: mservice.Accounts, } req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{ "login":{"login":"user@example.com","password":"correct horse battery staple"} }`)) rec := httptest.NewRecorder() router.login(req)(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) } resp := decodeErrorResponse(t, rec.Body.Bytes()) if resp.Data.Details != "Account verification required" { t.Fatalf("expected account verification required message, got %q", resp.Data.Details) } } func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { account := testActiveAccount(t) clientID := "api-client" deviceID := "device-1" rtdb := &testRefreshTokenDB{ client: &sessionmodel.Client{ ClientID: clientID, ClientSecret: "secret", GrantTypes: []string{"password"}, TokenEndpointAuthMethod: "client_secret_post", AccountRef: account.GetID(), }, } var cfg middleware.TokenConfig cfg.Length = 16 cfg.Expiration.Account = 1 cfg.Expiration.Refresh = 24 signature := middleware.Signature{ Algorithm: "HS256", PrivateKey: []byte("test-secret"), } router := NewRouter( zap.NewNop(), "/", &testAccountDB{result: account}, nil, rtdb, nil, chi.NewRouter(), &cfg, &signature, middleware.LoginRateLimits{}, ) body := `{ "login":{"login":"user@example.com","password":"correct horse battery staple"}, "clientId":"api-client", "deviceId":"device-1", "clientSecret":"secret" }` firstReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body)) firstRec := httptest.NewRecorder() router.apiLogin(firstReq)(firstRec, firstReq) if firstRec.Code != http.StatusOK { t.Fatalf("expected first login status %d, got %d", http.StatusOK, firstRec.Code) } firstResp := decodeLoginResponse(t, firstRec.Body.Bytes()) if firstResp.Data.RefreshToken.Token == "" { t.Fatal("expected first login to return refresh token") } err := rtdb.Revoke(context.Background(), *account.GetID(), &sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }) if err != nil { t.Fatalf("failed to revoke refresh token: %v", err) } secondReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body)) secondRec := httptest.NewRecorder() router.apiLogin(secondReq)(secondRec, secondReq) if secondRec.Code != http.StatusOK { t.Fatalf("expected second login status %d, got %d", http.StatusOK, secondRec.Code) } secondResp := decodeLoginResponse(t, secondRec.Body.Bytes()) if secondResp.Data.RefreshToken.Token == "" { t.Fatal("expected second login to return refresh token") } if secondResp.Data.RefreshToken.Token == firstResp.Data.RefreshToken.Token { t.Fatal("expected second login to issue a different refresh token after revocation") } _, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{ SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, RefreshToken: firstResp.Data.RefreshToken.Token, }) if !errors.Is(err, merrors.ErrNoData) { t.Fatalf("expected old refresh token to be unusable, got %v", err) } _, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{ SessionIdentifier: sessionmodel.SessionIdentifier{ ClientID: clientID, DeviceID: deviceID, }, RefreshToken: secondResp.Data.RefreshToken.Token, }) if err != nil { t.Fatalf("expected new refresh token to be active, got %v", err) } if got := rtdb.tokenCountForSession(*account.GetID(), clientID, deviceID); got != 2 { t.Fatalf("expected 2 refresh token records for session, got %d", got) } if got := rtdb.activeTokenCountForSession(*account.GetID(), clientID, deviceID); got != 1 { t.Fatalf("expected 1 active refresh token for session, got %d", got) } }