package server import ( "context" "database/sql" "errors" "io" "log/slog" "net/http" "net/http/httptest" neturl "net/url" "os" "strings" "testing" "golang.org/x/oauth2" "github.com/danyel/go-loose/internal/config" "github.com/danyel/go-loose/internal/database" "github.com/danyel/go-loose/internal/session" "github.com/danyel/go-loose/internal/store" "github.com/danyel/go-loose/web" ) // logoutServer builds a server with a session manager so logout can be exercised // end to end. The store is nil, so the client-session revocation is expected to // panic-free no-op only where it is guarded; these tests therefore exercise the // redirect behaviour, which is what a person sees. func logoutServer(t *testing.T) *Server { t.Helper() secret := strings.Repeat("s", 32) return &Server{ cfg: config.Config{BaseURL: "https://auth.example.test", AuthDomain: "auth.example.test"}, sessions: session.New(secret, false), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } } // TestLogoutClearsTheSessionAndReturnsToSignIn is the baseline: with no identity // provider logout endpoint configured, signing out lands back on the sign-in page. func TestLogoutClearsTheSessionAndReturnsToSignIn(t *testing.T) { server := logoutServer(t) response := httptest.NewRecorder() server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil)) if response.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther) } if location := response.Header().Get("Location"); location != "/login" { t.Fatalf("location = %q, want /login", location) } var cleared bool for _, cookie := range response.Result().Cookies() { if cookie.MaxAge < 0 { cleared = true } } if !cleared { t.Error("logout did not clear the session cookie") } } // TestLogoutEndsTheProviderSessionWhenAdvertised covers the cross-application half: // when the provider publishes an end_session_endpoint the browser is sent there, so // the single sign-on session the other applications share is ended too. func TestLogoutEndsTheProviderSessionWhenAdvertised(t *testing.T) { server := logoutServer(t) server.endSession = "https://idp.example.test/protocol/openid-connect/logout" server.oauth = &oauth2ConfigStub response := httptest.NewRecorder() server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil)) location := response.Header().Get("Location") if !strings.HasPrefix(location, server.endSession) { t.Fatalf("location = %q, want the provider logout endpoint", location) } for _, want := range []string{ "client_id=go-loose", "post_logout_redirect_uri=https%3A%2F%2Fauth.example.test%2Flogin", } { if !strings.Contains(location, want) { t.Errorf("location = %q, want it to contain %q", location, want) } } } // TestLogoutSkipsTheProviderWhenItIsUnconfigured documents the fallback: a // deployment whose provider does not advertise a logout endpoint still signs the // user out locally rather than erroring. func TestLogoutSkipsTheProviderWhenItIsUnconfigured(t *testing.T) { for name, configure := range map[string]func(*Server){ "no endpoint": func(s *Server) {}, "no client id": func(s *Server) { s.endSession = "https://idp.example.test/logout" }, "unparseable": func(s *Server) { s.endSession = "://nope"; s.oauth = &oauth2ConfigStub }, "nothing set up": func(s *Server) { s.endSession = "" }, } { t.Run(name, func(t *testing.T) { server := logoutServer(t) configure(server) response := httptest.NewRecorder() server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil)) if location := response.Header().Get("Location"); location != "/login" { t.Fatalf("location = %q, want the local fallback /login", location) } }) } } // TestAccountMenuIsEmbedded guards the component: it is mounted by every page, so a // page that forgets the placeholder loses the whole control. func TestAccountMenuIsEmbedded(t *testing.T) { for _, page := range []string{"index.html", "profile.html", "roles.html"} { t.Run(page, func(t *testing.T) { content, err := web.Files.ReadFile(page) if err != nil { t.Fatalf("%s must be embedded: %v", page, err) } text := string(content) for _, marker := range []string{"data-account-menu", "/assets/account.js"} { if !strings.Contains(text, marker) { t.Errorf("%s is missing %q", page, marker) } } // The account control replaces the separate sign-out button, so the // page must not still carry one or the header shows two of each. if strings.Contains(text, `