From 6532f16efc3b41f2ab201d8f570a812ca3ac560a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 1 May 2026 12:43:21 +0400 Subject: [PATCH] Add e2e tests for cookie banner console GraphQL API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover CRUD, activation, versioning, translations, categories, patterns, RBAC, and tenant isolation with factory helpers for test data creation. Signed-off-by: Émile Ré --- e2e/console/cookie_banner_test.go | 924 ++++++++++++++++++++++++++++ e2e/console/cookie_category_test.go | 548 +++++++++++++++++ e2e/console/cookie_pattern_test.go | 575 +++++++++++++++++ e2e/internal/factory/factory.go | 182 ++++++ 4 files changed, 2229 insertions(+) create mode 100644 e2e/console/cookie_banner_test.go create mode 100644 e2e/console/cookie_category_test.go create mode 100644 e2e/console/cookie_pattern_test.go diff --git a/e2e/console/cookie_banner_test.go b/e2e/console/cookie_banner_test.go new file mode 100644 index 000000000..f7a4e6b8d --- /dev/null +++ b/e2e/console/cookie_banner_test.go @@ -0,0 +1,924 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestCookieBanner_Create(t *testing.T) { + t.Parallel() + + t.Run("with required fields", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + const query = ` + mutation CreateCookieBanner($input: CreateCookieBannerInput!) { + createCookieBanner(input: $input) { + cookieBannerEdge { + node { + id + name + origin + state + cookiePolicyUrl + consentExpiryDays + consentMode + showBranding + defaultLanguage + createdAt + updatedAt + } + } + } + } + ` + + name := factory.SafeName("Banner") + origin := factory.SafeOrigin() + + var result struct { + CreateCookieBanner struct { + CookieBannerEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Origin string `json:"origin"` + State string `json:"state"` + CookiePolicyUrl string `json:"cookiePolicyUrl"` + ConsentExpiryDays int `json:"consentExpiryDays"` + ConsentMode string `json:"consentMode"` + ShowBranding bool `json:"showBranding"` + DefaultLanguage string `json:"defaultLanguage"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` + } `json:"cookieBannerEdge"` + } `json:"createCookieBanner"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": name, + "origin": origin, + "cookiePolicyUrl": "https://example.com/cookies", + "consentExpiryDays": 365, + "consentMode": "OPT_IN", + }, + }, &result) + + require.NoError(t, err) + node := result.CreateCookieBanner.CookieBannerEdge.Node + assert.NotEmpty(t, node.ID) + assert.Equal(t, name, node.Name) + assert.Equal(t, "ACTIVE", node.State) + assert.Equal(t, "https://example.com/cookies", node.CookiePolicyUrl) + assert.Equal(t, 365, node.ConsentExpiryDays) + assert.Equal(t, "OPT_IN", node.ConsentMode) + assert.Equal(t, "en", node.DefaultLanguage) + assert.NotEmpty(t, node.CreatedAt) + assert.NotEmpty(t, node.UpdatedAt) + }) + + t.Run("with all fields including privacyPolicyUrl", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + const query = ` + mutation CreateCookieBanner($input: CreateCookieBannerInput!) { + createCookieBanner(input: $input) { + cookieBannerEdge { + node { + id + privacyPolicyUrl + consentMode + } + } + } + } + ` + + var result struct { + CreateCookieBanner struct { + CookieBannerEdge struct { + Node struct { + ID string `json:"id"` + PrivacyPolicyUrl *string `json:"privacyPolicyUrl"` + ConsentMode string `json:"consentMode"` + } `json:"node"` + } `json:"cookieBannerEdge"` + } `json:"createCookieBanner"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": factory.SafeName("Banner"), + "origin": factory.SafeOrigin(), + "cookiePolicyUrl": "https://example.com/cookies", + "privacyPolicyUrl": "https://example.com/privacy", + "consentExpiryDays": 180, + "consentMode": "OPT_OUT", + }, + }, &result) + + require.NoError(t, err) + node := result.CreateCookieBanner.CookieBannerEdge.Node + assert.NotEmpty(t, node.ID) + require.NotNil(t, node.PrivacyPolicyUrl) + assert.Equal(t, "https://example.com/privacy", *node.PrivacyPolicyUrl) + assert.Equal(t, "OPT_OUT", node.ConsentMode) + }) + + t.Run("creates default categories", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + categories(first: 10) { + totalCount + edges { + node { + id + name + kind + } + } + } + } + } + } + ` + + var result struct { + Node struct { + Categories struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + } `json:"node"` + } `json:"edges"` + } `json:"categories"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{"id": bannerID}, &result) + require.NoError(t, err) + assert.Greater(t, result.Node.Categories.TotalCount, 0) + + kinds := make(map[string]bool) + for _, e := range result.Node.Categories.Edges { + kinds[e.Node.Kind] = true + } + assert.True(t, kinds["NECESSARY"], "should have a NECESSARY category") + assert.True(t, kinds["UNCATEGORISED"], "should have an UNCATEGORISED category") + }) + + t.Run("duplicate origin conflict", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + origin := factory.SafeOrigin() + factory.NewCookieBanner(owner).WithOrigin(origin).Create() + + _, err := owner.Do(` + mutation CreateCookieBanner($input: CreateCookieBannerInput!) { + createCookieBanner(input: $input) { + cookieBannerEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": factory.SafeName("Banner"), + "origin": origin, + "cookiePolicyUrl": "https://example.com/cookies", + "consentExpiryDays": 365, + "consentMode": "OPT_IN", + }, + }) + require.Error(t, err) + }) + + t.Run("validation error on missing name", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + _, err := owner.Do(` + mutation CreateCookieBanner($input: CreateCookieBannerInput!) { + createCookieBanner(input: $input) { + cookieBannerEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": "", + "origin": factory.SafeOrigin(), + "cookiePolicyUrl": "https://example.com/cookies", + "consentExpiryDays": 365, + "consentMode": "OPT_IN", + }, + }) + require.Error(t, err) + }) +} + +func TestCookieBanner_Update(t *testing.T) { + t.Parallel() + + t.Run("partial update name only", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + mutation UpdateCookieBanner($input: UpdateCookieBannerInput!) { + updateCookieBanner(input: $input) { + cookieBanner { + id + name + consentExpiryDays + } + } + } + ` + + newName := factory.SafeName("Updated") + var result struct { + UpdateCookieBanner struct { + CookieBanner struct { + ID string `json:"id"` + Name string `json:"name"` + ConsentExpiryDays int `json:"consentExpiryDays"` + } `json:"cookieBanner"` + } `json:"updateCookieBanner"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "name": newName, + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, bannerID, result.UpdateCookieBanner.CookieBanner.ID) + assert.Equal(t, newName, result.UpdateCookieBanner.CookieBanner.Name) + assert.Equal(t, 365, result.UpdateCookieBanner.CookieBanner.ConsentExpiryDays) + }) + + t.Run("update consent settings", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + mutation UpdateCookieBanner($input: UpdateCookieBannerInput!) { + updateCookieBanner(input: $input) { + cookieBanner { + consentExpiryDays + consentMode + defaultLanguage + } + } + } + ` + + var result struct { + UpdateCookieBanner struct { + CookieBanner struct { + ConsentExpiryDays int `json:"consentExpiryDays"` + ConsentMode string `json:"consentMode"` + DefaultLanguage string `json:"defaultLanguage"` + } `json:"cookieBanner"` + } `json:"updateCookieBanner"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "consentExpiryDays": 90, + "consentMode": "OPT_OUT", + "defaultLanguage": "fr", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, 90, result.UpdateCookieBanner.CookieBanner.ConsentExpiryDays) + assert.Equal(t, "OPT_OUT", result.UpdateCookieBanner.CookieBanner.ConsentMode) + assert.Equal(t, "fr", result.UpdateCookieBanner.CookieBanner.DefaultLanguage) + }) +} + +func TestCookieBanner_Delete(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + mutation DeleteCookieBanner($input: DeleteCookieBannerInput!) { + deleteCookieBanner(input: $input) { + deletedCookieBannerId + } + } + ` + + var result struct { + DeleteCookieBanner struct { + DeletedCookieBannerID string `json:"deletedCookieBannerId"` + } `json:"deleteCookieBanner"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, bannerID, result.DeleteCookieBanner.DeletedCookieBannerID) + }) +} + +func TestCookieBanner_ActivateDeactivate(t *testing.T) { + t.Parallel() + + t.Run("deactivate active banner", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + mutation DeactivateCookieBanner($input: DeactivateCookieBannerInput!) { + deactivateCookieBanner(input: $input) { + cookieBanner { + id + state + } + } + } + ` + + var result struct { + DeactivateCookieBanner struct { + CookieBanner struct { + ID string `json:"id"` + State string `json:"state"` + } `json:"cookieBanner"` + } `json:"deactivateCookieBanner"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "INACTIVE", result.DeactivateCookieBanner.CookieBanner.State) + }) + + t.Run("activate inactive banner", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + // Deactivate first + var deactivateResult struct { + DeactivateCookieBanner struct { + CookieBanner struct { + State string `json:"state"` + } `json:"cookieBanner"` + } `json:"deactivateCookieBanner"` + } + err := owner.Execute(` + mutation($input: DeactivateCookieBannerInput!) { + deactivateCookieBanner(input: $input) { + cookieBanner { state } + } + } + `, map[string]any{"input": map[string]any{"cookieBannerId": bannerID}}, &deactivateResult) + require.NoError(t, err) + require.Equal(t, "INACTIVE", deactivateResult.DeactivateCookieBanner.CookieBanner.State) + + // Activate + const query = ` + mutation ActivateCookieBanner($input: ActivateCookieBannerInput!) { + activateCookieBanner(input: $input) { + cookieBanner { + id + state + } + } + } + ` + + var result struct { + ActivateCookieBanner struct { + CookieBanner struct { + ID string `json:"id"` + State string `json:"state"` + } `json:"cookieBanner"` + } `json:"activateCookieBanner"` + } + + err = owner.Execute(query, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &result) + + require.NoError(t, err) + assert.Equal(t, "ACTIVE", result.ActivateCookieBanner.CookieBanner.State) + }) + + t.Run("deactivate already inactive returns error", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + deactivateQuery := ` + mutation($input: DeactivateCookieBannerInput!) { + deactivateCookieBanner(input: $input) { + cookieBanner { state } + } + } + ` + + var result struct { + DeactivateCookieBanner struct { + CookieBanner struct { + State string `json:"state"` + } `json:"cookieBanner"` + } `json:"deactivateCookieBanner"` + } + + err := owner.Execute(deactivateQuery, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &result) + require.NoError(t, err) + + _, err = owner.Do(deactivateQuery, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }) + require.Error(t, err) + }) +} + +func TestCookieBanner_List(t *testing.T) { + t.Parallel() + + t.Run("lists banners via organization", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + factory.CreateCookieBanner(owner) + factory.CreateCookieBanner(owner) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + cookieBanners(first: 10) { + totalCount + edges { + node { + id + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + } + } + ` + + var result struct { + Node struct { + CookieBanners struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + } `json:"pageInfo"` + } `json:"cookieBanners"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{ + "id": owner.GetOrganizationID().String(), + }, &result) + + require.NoError(t, err) + assert.GreaterOrEqual(t, result.Node.CookieBanners.TotalCount, 2) + assert.GreaterOrEqual(t, len(result.Node.CookieBanners.Edges), 2) + }) +} + +func TestCookieBanner_Node(t *testing.T) { + t.Parallel() + + t.Run("fetch by ID", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + id + name + origin + state + consentMode + } + } + } + ` + + var result struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Origin string `json:"origin"` + State string `json:"state"` + ConsentMode string `json:"consentMode"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{"id": bannerID}, &result) + require.NoError(t, err) + assert.Equal(t, bannerID, result.Node.ID) + assert.NotEmpty(t, result.Node.Name) + assert.Equal(t, "ACTIVE", result.Node.State) + }) +} + +func TestCookieBanner_PublishVersion(t *testing.T) { + t.Parallel() + + t.Run("publish draft version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const publishQuery = ` + mutation PublishCookieBannerVersion($input: PublishCookieBannerVersionInput!) { + publishCookieBannerVersion(input: $input) { + cookieBannerVersion { + id + version + state + } + cookieBanner { + id + } + } + } + ` + + var publishResult struct { + PublishCookieBannerVersion struct { + CookieBannerVersion struct { + ID string `json:"id"` + Version int `json:"version"` + State string `json:"state"` + } `json:"cookieBannerVersion"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"publishCookieBannerVersion"` + } + + err := owner.Execute(publishQuery, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &publishResult) + + require.NoError(t, err) + assert.Equal(t, 1, publishResult.PublishCookieBannerVersion.CookieBannerVersion.Version) + assert.Equal(t, "published", publishResult.PublishCookieBannerVersion.CookieBannerVersion.State) + assert.Equal(t, bannerID, publishResult.PublishCookieBannerVersion.CookieBanner.ID) + }) + + t.Run("latestVersion resolver", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + latestVersion { + id + version + state + } + } + } + } + ` + + var result struct { + Node struct { + LatestVersion *struct { + ID string `json:"id"` + Version int `json:"version"` + State string `json:"state"` + } `json:"latestVersion"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{"id": bannerID}, &result) + require.NoError(t, err) + require.NotNil(t, result.Node.LatestVersion) + assert.Equal(t, "draft", result.Node.LatestVersion.State) + assert.Equal(t, 1, result.Node.LatestVersion.Version) + }) +} + +func TestCookieBanner_UpsertTranslation(t *testing.T) { + t.Parallel() + + t.Run("insert new language", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + mutation UpsertCookieBannerTranslation($input: UpsertCookieBannerTranslationInput!) { + upsertCookieBannerTranslation(input: $input) { + cookieBannerTranslation { + id + language + translations + } + cookieBanner { + id + } + } + } + ` + + var result struct { + UpsertCookieBannerTranslation struct { + CookieBannerTranslation struct { + ID string `json:"id"` + Language string `json:"language"` + Translations string `json:"translations"` + } `json:"cookieBannerTranslation"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"upsertCookieBannerTranslation"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "language": "de", + "translations": `{"title":"Cookie Einstellungen","description":"Wir verwenden Cookies"}`, + }, + }, &result) + + require.NoError(t, err) + assert.NotEmpty(t, result.UpsertCookieBannerTranslation.CookieBannerTranslation.ID) + assert.Equal(t, "de", result.UpsertCookieBannerTranslation.CookieBannerTranslation.Language) + assert.Equal(t, bannerID, result.UpsertCookieBannerTranslation.CookieBanner.ID) + }) + + t.Run("update existing language", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + mutation UpsertCookieBannerTranslation($input: UpsertCookieBannerTranslationInput!) { + upsertCookieBannerTranslation(input: $input) { + cookieBannerTranslation { + id + language + translations + } + } + } + ` + + input := map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "language": "es", + "translations": `{"title":"Configuracion de cookies"}`, + }, + } + + var result1 struct { + UpsertCookieBannerTranslation struct { + CookieBannerTranslation struct { + ID string `json:"id"` + } `json:"cookieBannerTranslation"` + } `json:"upsertCookieBannerTranslation"` + } + err := owner.Execute(query, input, &result1) + require.NoError(t, err) + firstID := result1.UpsertCookieBannerTranslation.CookieBannerTranslation.ID + + input["input"].(map[string]any)["translations"] = `{"title":"Ajustes de cookies"}` + + var result2 struct { + UpsertCookieBannerTranslation struct { + CookieBannerTranslation struct { + ID string `json:"id"` + Translations string `json:"translations"` + } `json:"cookieBannerTranslation"` + } `json:"upsertCookieBannerTranslation"` + } + err = owner.Execute(query, input, &result2) + require.NoError(t, err) + assert.Equal(t, firstID, result2.UpsertCookieBannerTranslation.CookieBannerTranslation.ID) + assert.Contains(t, result2.UpsertCookieBannerTranslation.CookieBannerTranslation.Translations, "Ajustes de cookies") + }) + + t.Run("translations resolver on banner", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + translations { + id + language + } + } + } + } + ` + + var result struct { + Node struct { + Translations []struct { + ID string `json:"id"` + Language string `json:"language"` + } `json:"translations"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{"id": bannerID}, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.Node.Translations) + }) +} + +func TestCookieBanner_RBAC(t *testing.T) { + t.Parallel() + + t.Run("viewer cannot create", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + _, err := viewer.Do(` + mutation CreateCookieBanner($input: CreateCookieBannerInput!) { + createCookieBanner(input: $input) { + cookieBannerEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": viewer.GetOrganizationID().String(), + "name": factory.SafeName("Banner"), + "origin": factory.SafeOrigin(), + "cookiePolicyUrl": "https://example.com/cookies", + "consentExpiryDays": 365, + "consentMode": "OPT_IN", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to create cookie banner") + }) + + t.Run("viewer cannot update", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + + _, err := viewer.Do(` + mutation UpdateCookieBanner($input: UpdateCookieBannerInput!) { + updateCookieBanner(input: $input) { + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "name": "Updated", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to update cookie banner") + }) + + t.Run("viewer cannot delete", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + + _, err := viewer.Do(` + mutation DeleteCookieBanner($input: DeleteCookieBannerInput!) { + deleteCookieBanner(input: $input) { + deletedCookieBannerId + } + } + `, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to delete cookie banner") + }) +} + +func TestCookieBanner_TenantIsolation(t *testing.T) { + t.Parallel() + + t.Run("other org cannot access banner", func(t *testing.T) { + t.Parallel() + owner1 := testutil.NewClient(t, testutil.RoleOwner) + owner2 := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner1) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + id + name + } + } + } + ` + + var result struct { + Node *struct { + ID string `json:"id"` + } `json:"node"` + } + + err := owner2.Execute(query, map[string]any{"id": bannerID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "cookie banner") + }) +} diff --git a/e2e/console/cookie_category_test.go b/e2e/console/cookie_category_test.go new file mode 100644 index 000000000..4e1f0ce93 --- /dev/null +++ b/e2e/console/cookie_category_test.go @@ -0,0 +1,548 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestCookieCategory_Create(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const query = ` + mutation CreateCookieCategory($input: CreateCookieCategoryInput!) { + createCookieCategory(input: $input) { + cookieCategoryEdge { + node { + id + name + slug + description + kind + rank + gcmConsentTypes + posthogConsent + createdAt + updatedAt + } + } + cookieBanner { + id + } + } + } + ` + + var result struct { + CreateCookieCategory struct { + CookieCategoryEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Kind string `json:"kind"` + Rank int `json:"rank"` + GcmConsentTypes []string `json:"gcmConsentTypes"` + PosthogConsent bool `json:"posthogConsent"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` + } `json:"cookieCategoryEdge"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"createCookieCategory"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "name": "Marketing", + "slug": "marketing", + "description": "Marketing cookies for tracking", + "rank": 5, + }, + }, &result) + + require.NoError(t, err) + node := result.CreateCookieCategory.CookieCategoryEdge.Node + assert.NotEmpty(t, node.ID) + assert.Equal(t, "Marketing", node.Name) + assert.Equal(t, "marketing", node.Slug) + assert.Equal(t, "Marketing cookies for tracking", node.Description) + assert.Equal(t, "NORMAL", node.Kind) + assert.Equal(t, 5, node.Rank) + assert.Empty(t, node.GcmConsentTypes) + assert.False(t, node.PosthogConsent) + assert.Equal(t, bannerID, result.CreateCookieCategory.CookieBanner.ID) + }) + + t.Run("duplicate slug conflict", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "analytics"}) + + _, err := owner.Do(` + mutation CreateCookieCategory($input: CreateCookieCategoryInput!) { + createCookieCategory(input: $input) { + cookieCategoryEdge { node { id } } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "name": "Another Analytics", + "slug": "analytics", + "description": "Duplicate slug", + "rank": 10, + }, + }) + require.Error(t, err) + }) +} + +func TestCookieCategory_Update(t *testing.T) { + t.Parallel() + + t.Run("partial update", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{ + "name": "Analytics", + "slug": "analytics-update", + "description": "Original description", + }) + + const query = ` + mutation UpdateCookieCategory($input: UpdateCookieCategoryInput!) { + updateCookieCategory(input: $input) { + cookieCategory { + id + name + description + slug + } + cookieBanner { + id + } + } + } + ` + + var result struct { + UpdateCookieCategory struct { + CookieCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Slug string `json:"slug"` + } `json:"cookieCategory"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"updateCookieCategory"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "name": "Updated Analytics", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, categoryID, result.UpdateCookieCategory.CookieCategory.ID) + assert.Equal(t, "Updated Analytics", result.UpdateCookieCategory.CookieCategory.Name) + assert.Equal(t, "Original description", result.UpdateCookieCategory.CookieCategory.Description) + assert.Equal(t, bannerID, result.UpdateCookieCategory.CookieBanner.ID) + }) + + t.Run("update gcmConsentTypes", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + const query = ` + mutation UpdateCookieCategory($input: UpdateCookieCategoryInput!) { + updateCookieCategory(input: $input) { + cookieCategory { + id + gcmConsentTypes + } + } + } + ` + + var result struct { + UpdateCookieCategory struct { + CookieCategory struct { + ID string `json:"id"` + GcmConsentTypes []string `json:"gcmConsentTypes"` + } `json:"cookieCategory"` + } `json:"updateCookieCategory"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "gcmConsentTypes": []string{"ad_storage", "analytics_storage"}, + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, []string{"ad_storage", "analytics_storage"}, result.UpdateCookieCategory.CookieCategory.GcmConsentTypes) + }) + + t.Run("update posthogConsent", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + const query = ` + mutation UpdateCookieCategory($input: UpdateCookieCategoryInput!) { + updateCookieCategory(input: $input) { + cookieCategory { + id + posthogConsent + } + } + } + ` + + var result struct { + UpdateCookieCategory struct { + CookieCategory struct { + ID string `json:"id"` + PosthogConsent bool `json:"posthogConsent"` + } `json:"cookieCategory"` + } `json:"updateCookieCategory"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "posthogConsent": true, + }, + }, &result) + + require.NoError(t, err) + assert.True(t, result.UpdateCookieCategory.CookieCategory.PosthogConsent) + }) +} + +func TestCookieCategory_Delete(t *testing.T) { + t.Parallel() + + t.Run("success for normal category", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + const query = ` + mutation DeleteCookieCategory($input: DeleteCookieCategoryInput!) { + deleteCookieCategory(input: $input) { + deletedCookieCategoryId + cookieBanner { + id + } + } + } + ` + + var result struct { + DeleteCookieCategory struct { + DeletedCookieCategoryID string `json:"deletedCookieCategoryId"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"deleteCookieCategory"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{"cookieCategoryId": categoryID}, + }, &result) + + require.NoError(t, err) + assert.Equal(t, categoryID, result.DeleteCookieCategory.DeletedCookieCategoryID) + assert.Equal(t, bannerID, result.DeleteCookieCategory.CookieBanner.ID) + }) + + t.Run("cannot delete system category", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + // Fetch the NECESSARY category + const listQuery = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + categories(first: 20) { + edges { + node { + id + kind + } + } + } + } + } + } + ` + + var listResult struct { + Node struct { + Categories struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"node"` + } `json:"edges"` + } `json:"categories"` + } `json:"node"` + } + + err := owner.Execute(listQuery, map[string]any{"id": bannerID}, &listResult) + require.NoError(t, err) + + var necessaryCategoryID string + for _, e := range listResult.Node.Categories.Edges { + if e.Node.Kind == "NECESSARY" { + necessaryCategoryID = e.Node.ID + break + } + } + require.NotEmpty(t, necessaryCategoryID, "should find a NECESSARY category") + + _, err = owner.Do(` + mutation DeleteCookieCategory($input: DeleteCookieCategoryInput!) { + deleteCookieCategory(input: $input) { + deletedCookieCategoryId + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{"cookieCategoryId": necessaryCategoryID}, + }) + require.Error(t, err) + }) +} + +func TestCookieCategory_Reorder(t *testing.T) { + t.Parallel() + + t.Run("change rank", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"rank": 5}) + + const query = ` + mutation ReorderCookieCategory($input: ReorderCookieCategoryInput!) { + reorderCookieCategory(input: $input) { + cookieBanner { + id + } + } + } + ` + + var result struct { + ReorderCookieCategory struct { + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"reorderCookieCategory"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "rank": 1, + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, bannerID, result.ReorderCookieCategory.CookieBanner.ID) + }) +} + +func TestCookieCategory_List(t *testing.T) { + t.Parallel() + + t.Run("via banner categories connection", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"rank": 20}) + factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"rank": 30}) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + categories(first: 20, orderBy: {field: RANK, direction: ASC}) { + totalCount + edges { + node { + id + rank + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + } + } + ` + + var result struct { + Node struct { + Categories struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Rank int `json:"rank"` + } `json:"node"` + } `json:"edges"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + } `json:"pageInfo"` + } `json:"categories"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{"id": bannerID}, &result) + require.NoError(t, err) + + // Default categories + 2 custom ones + assert.GreaterOrEqual(t, result.Node.Categories.TotalCount, 4) + + // Verify ordering (ranks should be ascending) + for i := 1; i < len(result.Node.Categories.Edges); i++ { + assert.GreaterOrEqual(t, + result.Node.Categories.Edges[i].Node.Rank, + result.Node.Categories.Edges[i-1].Node.Rank, + ) + } + }) +} + +func TestCookieCategory_RBAC(t *testing.T) { + t.Parallel() + + t.Run("viewer cannot create category", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + + _, err := viewer.Do(` + mutation CreateCookieCategory($input: CreateCookieCategoryInput!) { + createCookieCategory(input: $input) { + cookieCategoryEdge { node { id } } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "name": "Test", + "slug": "test-rbac", + "description": "Test category", + "rank": 10, + }, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to create cookie category") + }) + + t.Run("viewer cannot update category", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + _, err := viewer.Do(` + mutation UpdateCookieCategory($input: UpdateCookieCategoryInput!) { + updateCookieCategory(input: $input) { + cookieCategory { id } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "name": "Updated", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to update cookie category") + }) + + t.Run("viewer cannot delete category", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + _, err := viewer.Do(` + mutation DeleteCookieCategory($input: DeleteCookieCategoryInput!) { + deleteCookieCategory(input: $input) { + deletedCookieCategoryId + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{"cookieCategoryId": categoryID}, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to delete cookie category") + }) +} diff --git a/e2e/console/cookie_pattern_test.go b/e2e/console/cookie_pattern_test.go new file mode 100644 index 000000000..2ccbdd332 --- /dev/null +++ b/e2e/console/cookie_pattern_test.go @@ -0,0 +1,575 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestCookiePattern_Create(t *testing.T) { + t.Parallel() + + t.Run("with EXACT match type", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + const query = ` + mutation CreateCookiePattern($input: CreateCookiePatternInput!) { + createCookiePattern(input: $input) { + cookiePatternEdge { + node { + id + pattern + matchType + displayName + maxAgeSeconds + description + source + createdAt + updatedAt + } + } + cookieBanner { + id + } + } + } + ` + + var result struct { + CreateCookiePattern struct { + CookiePatternEdge struct { + Node struct { + ID string `json:"id"` + Pattern string `json:"pattern"` + MatchType string `json:"matchType"` + DisplayName string `json:"displayName"` + MaxAgeSeconds *int `json:"maxAgeSeconds"` + Description string `json:"description"` + Source string `json:"source"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` + } `json:"cookiePatternEdge"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"createCookiePattern"` + } + + maxAge := 86400 + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "pattern": "_ga", + "matchType": "EXACT", + "displayName": "Google Analytics", + "maxAgeSeconds": maxAge, + "description": "Google Analytics tracking cookie", + }, + }, &result) + + require.NoError(t, err) + node := result.CreateCookiePattern.CookiePatternEdge.Node + assert.NotEmpty(t, node.ID) + assert.Equal(t, "_ga", node.Pattern) + assert.Equal(t, "EXACT", node.MatchType) + assert.Equal(t, "Google Analytics", node.DisplayName) + require.NotNil(t, node.MaxAgeSeconds) + assert.Equal(t, maxAge, *node.MaxAgeSeconds) + assert.Equal(t, "Google Analytics tracking cookie", node.Description) + assert.Equal(t, "SCRIPT", node.Source) + assert.Equal(t, bannerID, result.CreateCookiePattern.CookieBanner.ID) + }) + + t.Run("with PREFIX match type", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + const query = ` + mutation CreateCookiePattern($input: CreateCookiePatternInput!) { + createCookiePattern(input: $input) { + cookiePatternEdge { + node { + id + pattern + matchType + displayName + maxAgeSeconds + } + } + } + } + ` + + var result struct { + CreateCookiePattern struct { + CookiePatternEdge struct { + Node struct { + ID string `json:"id"` + Pattern string `json:"pattern"` + MatchType string `json:"matchType"` + DisplayName string `json:"displayName"` + MaxAgeSeconds *int `json:"maxAgeSeconds"` + } `json:"node"` + } `json:"cookiePatternEdge"` + } `json:"createCookiePattern"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "pattern": "_gat_", + "matchType": "PREFIX", + "displayName": "GA Throttle", + "description": "Google Analytics rate limiting", + }, + }, &result) + + require.NoError(t, err) + node := result.CreateCookiePattern.CookiePatternEdge.Node + assert.Equal(t, "_gat_", node.Pattern) + assert.Equal(t, "PREFIX", node.MatchType) + assert.Nil(t, node.MaxAgeSeconds) + }) + + t.Run("duplicate pattern conflict", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + factory.CreateCookiePattern(owner, categoryID, factory.Attrs{ + "pattern": "duplicate_cookie", + "displayName": "First", + }) + + _, err := owner.Do(` + mutation CreateCookiePattern($input: CreateCookiePatternInput!) { + createCookiePattern(input: $input) { + cookiePatternEdge { node { id } } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "pattern": "duplicate_cookie", + "matchType": "EXACT", + "displayName": "Second", + "description": "Duplicate", + }, + }) + require.Error(t, err) + }) +} + +func TestCookiePattern_Update(t *testing.T) { + t.Parallel() + + t.Run("update displayName and description", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + patternID := factory.CreateCookiePattern(owner, categoryID, factory.Attrs{ + "displayName": "Original Name", + "description": "Original description", + }) + + const query = ` + mutation UpdateCookiePattern($input: UpdateCookiePatternInput!) { + updateCookiePattern(input: $input) { + cookiePattern { + id + displayName + description + } + cookieBanner { + id + } + } + } + ` + + var result struct { + UpdateCookiePattern struct { + CookiePattern struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + } `json:"cookiePattern"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"updateCookiePattern"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "displayName": "Updated Name", + "description": "Updated description", + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, patternID, result.UpdateCookiePattern.CookiePattern.ID) + assert.Equal(t, "Updated Name", result.UpdateCookiePattern.CookiePattern.DisplayName) + assert.Equal(t, "Updated description", result.UpdateCookiePattern.CookiePattern.Description) + assert.Equal(t, bannerID, result.UpdateCookiePattern.CookieBanner.ID) + }) + + t.Run("update maxAgeSeconds", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + patternID := factory.CreateCookiePattern(owner, categoryID) + + const query = ` + mutation UpdateCookiePattern($input: UpdateCookiePatternInput!) { + updateCookiePattern(input: $input) { + cookiePattern { + id + maxAgeSeconds + } + } + } + ` + + var result struct { + UpdateCookiePattern struct { + CookiePattern struct { + ID string `json:"id"` + MaxAgeSeconds *int `json:"maxAgeSeconds"` + } `json:"cookiePattern"` + } `json:"updateCookiePattern"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "maxAgeSeconds": 7200, + }, + }, &result) + + require.NoError(t, err) + require.NotNil(t, result.UpdateCookiePattern.CookiePattern.MaxAgeSeconds) + assert.Equal(t, 7200, *result.UpdateCookiePattern.CookiePattern.MaxAgeSeconds) + }) +} + +func TestCookiePattern_Delete(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + patternID := factory.CreateCookiePattern(owner, categoryID) + + const query = ` + mutation DeleteCookiePattern($input: DeleteCookiePatternInput!) { + deleteCookiePattern(input: $input) { + deletedCookiePatternId + cookieBanner { + id + } + } + } + ` + + var result struct { + DeleteCookiePattern struct { + DeletedCookiePatternID string `json:"deletedCookiePatternId"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"deleteCookiePattern"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{"cookiePatternId": patternID}, + }, &result) + + require.NoError(t, err) + assert.Equal(t, patternID, result.DeleteCookiePattern.DeletedCookiePatternID) + assert.Equal(t, bannerID, result.DeleteCookiePattern.CookieBanner.ID) + }) +} + +func TestCookiePattern_MoveToCategory(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryA := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "cat-a-move"}) + categoryB := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "cat-b-move"}) + patternID := factory.CreateCookiePattern(owner, categoryA) + + const query = ` + mutation MoveCookiePatternToCategory($input: MoveCookiePatternToCategoryInput!) { + moveCookiePatternToCategory(input: $input) { + cookiePattern { + id + cookieCategory { + id + } + } + cookieBanner { + id + } + } + } + ` + + var result struct { + MoveCookiePatternToCategory struct { + CookiePattern struct { + ID string `json:"id"` + CookieCategory struct { + ID string `json:"id"` + } `json:"cookieCategory"` + } `json:"cookiePattern"` + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"moveCookiePatternToCategory"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "targetCookieCategoryId": categoryB, + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, patternID, result.MoveCookiePatternToCategory.CookiePattern.ID) + assert.Equal(t, categoryB, result.MoveCookiePatternToCategory.CookiePattern.CookieCategory.ID) + assert.Equal(t, bannerID, result.MoveCookiePatternToCategory.CookieBanner.ID) + }) + + t.Run("cross-banner mismatch error", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + banner1 := factory.CreateCookieBanner(owner) + banner2 := factory.CreateCookieBanner(owner) + category1 := factory.CreateCookieCategory(owner, banner1, factory.Attrs{"slug": "cat-x-mismatch"}) + category2 := factory.CreateCookieCategory(owner, banner2, factory.Attrs{"slug": "cat-y-mismatch"}) + patternID := factory.CreateCookiePattern(owner, category1) + + _, err := owner.Do(` + mutation MoveCookiePatternToCategory($input: MoveCookiePatternToCategoryInput!) { + moveCookiePatternToCategory(input: $input) { + cookiePattern { id } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "targetCookieCategoryId": category2, + }, + }) + require.Error(t, err) + }) +} + +func TestCookiePattern_List(t *testing.T) { + t.Parallel() + + t.Run("via category cookiePatterns connection", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + factory.CreateCookiePattern(owner, categoryID) + factory.CreateCookiePattern(owner, categoryID) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieCategory { + cookiePatterns(first: 10) { + totalCount + edges { + node { + id + pattern + displayName + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + } + } + ` + + var result struct { + Node struct { + CookiePatterns struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Pattern string `json:"pattern"` + DisplayName string `json:"displayName"` + } `json:"node"` + } `json:"edges"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + } `json:"pageInfo"` + } `json:"cookiePatterns"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{"id": categoryID}, &result) + require.NoError(t, err) + assert.Equal(t, 2, result.Node.CookiePatterns.TotalCount) + assert.Len(t, result.Node.CookiePatterns.Edges, 2) + }) +} + +func TestCookiePattern_RBAC(t *testing.T) { + t.Parallel() + + t.Run("viewer cannot create pattern", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + + _, err := viewer.Do(` + mutation CreateCookiePattern($input: CreateCookiePatternInput!) { + createCookiePattern(input: $input) { + cookiePatternEdge { node { id } } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "pattern": "test_viewer", + "matchType": "EXACT", + "displayName": "Test Viewer Pattern", + "description": "Should fail", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to create cookie pattern") + }) + + t.Run("viewer cannot update pattern", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + patternID := factory.CreateCookiePattern(owner, categoryID) + + _, err := viewer.Do(` + mutation UpdateCookiePattern($input: UpdateCookiePatternInput!) { + updateCookiePattern(input: $input) { + cookiePattern { id } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "displayName": "Updated by Viewer", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to update cookie pattern") + }) + + t.Run("viewer cannot delete pattern", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + patternID := factory.CreateCookiePattern(owner, categoryID) + + _, err := viewer.Do(` + mutation DeleteCookiePattern($input: DeleteCookiePatternInput!) { + deleteCookiePattern(input: $input) { + deletedCookiePatternId + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{"cookiePatternId": patternID}, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to delete cookie pattern") + }) + + t.Run("viewer cannot move pattern", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + bannerID := factory.CreateCookieBanner(owner) + categoryA := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "rbac-move-a"}) + categoryB := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "rbac-move-b"}) + patternID := factory.CreateCookiePattern(owner, categoryA) + + _, err := viewer.Do(` + mutation MoveCookiePatternToCategory($input: MoveCookiePatternToCategoryInput!) { + moveCookiePatternToCategory(input: $input) { + cookiePattern { id } + cookieBanner { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "targetCookieCategoryId": categoryB, + }, + }) + testutil.RequireForbiddenError(t, err, "viewer should not be able to move cookie pattern") + }) +} diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index feb7dac55..f4b7923d0 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -1207,3 +1207,185 @@ func CreatePublicOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResul ClientSecret: resp.ClientSecret, } } + +func SafeOrigin() string { + return fmt.Sprintf("https://%s.example.com", strings.ToLower(gofakeit.LetterN(10))) +} + +func CreateCookieBanner(c *testutil.Client, attrs ...Attrs) string { + c.T.Helper() + + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + + const query = ` + mutation($input: CreateCookieBannerInput!) { + createCookieBanner(input: $input) { + cookieBannerEdge { + node { id } + } + } + } + ` + + input := map[string]any{ + "organizationId": c.GetOrganizationID().String(), + "name": a.getString("name", SafeName("CookieBanner")), + "origin": a.getString("origin", SafeOrigin()), + "cookiePolicyUrl": a.getString("cookiePolicyUrl", "https://example.com/cookies"), + "consentExpiryDays": a.getInt("consentExpiryDays", 365), + "consentMode": a.getString("consentMode", "OPT_IN"), + } + if ppURL := a.getStringPtr("privacyPolicyUrl"); ppURL != nil { + input["privacyPolicyUrl"] = *ppURL + } + + var result struct { + CreateCookieBanner struct { + CookieBannerEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"cookieBannerEdge"` + } `json:"createCookieBanner"` + } + + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createCookieBanner mutation failed") + + return result.CreateCookieBanner.CookieBannerEdge.Node.ID +} + +type CookieBannerBuilder struct { + client *testutil.Client + attrs Attrs +} + +func NewCookieBanner(c *testutil.Client) *CookieBannerBuilder { + return &CookieBannerBuilder{client: c, attrs: Attrs{}} +} + +func (b *CookieBannerBuilder) WithName(name string) *CookieBannerBuilder { + b.attrs["name"] = name + return b +} + +func (b *CookieBannerBuilder) WithOrigin(origin string) *CookieBannerBuilder { + b.attrs["origin"] = origin + return b +} + +func (b *CookieBannerBuilder) WithCookiePolicyUrl(url string) *CookieBannerBuilder { + b.attrs["cookiePolicyUrl"] = url + return b +} + +func (b *CookieBannerBuilder) WithPrivacyPolicyUrl(url string) *CookieBannerBuilder { + b.attrs["privacyPolicyUrl"] = url + return b +} + +func (b *CookieBannerBuilder) WithConsentExpiryDays(days int) *CookieBannerBuilder { + b.attrs["consentExpiryDays"] = days + return b +} + +func (b *CookieBannerBuilder) WithConsentMode(mode string) *CookieBannerBuilder { + b.attrs["consentMode"] = mode + return b +} + +func (b *CookieBannerBuilder) Create() string { + return CreateCookieBanner(b.client, b.attrs) +} + +func CreateCookieCategory(c *testutil.Client, bannerID string, attrs ...Attrs) string { + c.T.Helper() + + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + + const query = ` + mutation($input: CreateCookieCategoryInput!) { + createCookieCategory(input: $input) { + cookieCategoryEdge { + node { id } + } + } + } + ` + + input := map[string]any{ + "cookieBannerId": bannerID, + "name": a.getString("name", SafeName("Category")), + "slug": a.getString("slug", strings.ToLower(gofakeit.LetterN(8))), + "description": a.getString("description", "Test cookie category"), + "rank": a.getInt("rank", 10), + } + + var result struct { + CreateCookieCategory struct { + CookieCategoryEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"cookieCategoryEdge"` + } `json:"createCookieCategory"` + } + + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createCookieCategory mutation failed") + + return result.CreateCookieCategory.CookieCategoryEdge.Node.ID +} + +func CreateCookiePattern(c *testutil.Client, categoryID string, attrs ...Attrs) string { + c.T.Helper() + + var a Attrs + if len(attrs) > 0 { + a = attrs[0] + } + + const query = ` + mutation($input: CreateCookiePatternInput!) { + createCookiePattern(input: $input) { + cookiePatternEdge { + node { id } + } + } + } + ` + + input := map[string]any{ + "cookieCategoryId": categoryID, + "pattern": a.getString("pattern", gofakeit.LetterN(8)+"_cookie"), + "matchType": a.getString("matchType", "EXACT"), + "displayName": a.getString("displayName", SafeName("Pattern")), + "description": a.getString("description", "Test cookie pattern"), + } + if maxAge := a.getStringPtr("maxAgeSeconds"); maxAge != nil { + input["maxAgeSeconds"] = a.getInt("maxAgeSeconds", 0) + } else if _, ok := a["maxAgeSeconds"]; ok { + input["maxAgeSeconds"] = a.getInt("maxAgeSeconds", 0) + } + + var result struct { + CreateCookiePattern struct { + CookiePatternEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"cookiePatternEdge"` + } `json:"createCookiePattern"` + } + + err := c.Execute(query, map[string]any{"input": input}, &result) + require.NoError(c.T, err, "createCookiePattern mutation failed") + + return result.CreateCookiePattern.CookiePatternEdge.Node.ID +}