package vkid import ( "context" "encoding/base64" "errors" "io" "net/http" "net/http/httptest" "net/url" "testing" ) // testExchanger builds an Exchanger pointed at a test server. func testExchanger(endpoint string) *Exchanger { return &Exchanger{ appID: "app-1", clientSecret: "secret-1", redirectURI: "https://example.test/app/", endpoint: endpoint, httpClient: &http.Client{}, } } func TestExchangeSuccessSendsConfidentialPKCE(t *testing.T) { var gotForm url.Values var gotContentType string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotContentType = r.Header.Get("Content-Type") body, _ := io.ReadAll(r.Body) gotForm, _ = url.ParseQuery(string(body)) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"user_id":"12345","access_token":"a","id_token":"h.e.s"}`) })) defer srv.Close() id, err := testExchanger(srv.URL).Exchange(context.Background(), "the-code", "dev-9", "verifier-xyz") if err != nil { t.Fatalf("Exchange: %v", err) } if id.ExternalID != "12345" { t.Fatalf("ExternalID = %q, want 12345", id.ExternalID) } if gotContentType != "application/x-www-form-urlencoded" { t.Errorf("Content-Type = %q", gotContentType) } // The confidential exchange must carry the PKCE inputs and the app credentials. want := map[string]string{ "grant_type": "authorization_code", "code": "the-code", "code_verifier": "verifier-xyz", "device_id": "dev-9", "client_id": "app-1", "client_secret": "secret-1", "redirect_uri": "https://example.test/app/", } for k, v := range want { if gotForm.Get(k) != v { t.Errorf("form[%s] = %q, want %q", k, gotForm.Get(k), v) } } } func TestExchangeAcceptsNumericUserID(t *testing.T) { // VK returns user_id as a bare JSON number in some responses; it must parse too. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{"user_id":1234567890,"access_token":"a"}`) })) defer srv.Close() id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") if err != nil { t.Fatalf("Exchange: %v", err) } if id.ExternalID != "1234567890" { t.Fatalf("ExternalID = %q, want 1234567890", id.ExternalID) } } func TestExchangeFallsBackToIDTokenSub(t *testing.T) { sub := "987654321" payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"` + sub + `"}`)) idToken := "header." + payload + ".sig" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{"access_token":"a","id_token":"`+idToken+`"}`) })) defer srv.Close() id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") if err != nil { t.Fatalf("Exchange: %v", err) } if id.ExternalID != sub { t.Fatalf("ExternalID = %q, want %q (from id_token sub)", id.ExternalID, sub) } } func TestExchangeRejectedIsErrInvalid(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = io.WriteString(w, `{"error":"invalid_grant","error_description":"code expired"}`) })) defer srv.Close() _, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") if !errors.Is(err, ErrInvalid) { t.Fatalf("err = %v, want ErrInvalid", err) } } func TestExchangeEmptyInputsFailFast(t *testing.T) { // Missing PKCE inputs are rejected without any network call. ex := testExchanger("http://127.0.0.1:0/never") for _, args := range [][3]string{{"", "d", "v"}, {"c", "", "v"}, {"c", "d", ""}} { if _, err := ex.Exchange(context.Background(), args[0], args[1], args[2]); !errors.Is(err, ErrInvalid) { t.Errorf("Exchange%v err = %v, want ErrInvalid", args, err) } } } func TestExchangeTransportErrorIsNotErrInvalid(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) srv.Close() // closed listener → connection refused _, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") if err == nil { t.Fatal("want a transport error") } if errors.Is(err, ErrInvalid) { t.Fatalf("transport error must not be ErrInvalid: %v", err) } }