diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9863cf..e5cb6e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # next version +- Show UCP settings as read-only to viewers, let UCP editors save without sales-channel edit access, and require UCP view access for Agentic Commerce Insights. - Keep disabled Agentic Files disabled when activating UCP or saving its settings. Existing custom content is preserved, and missing default files are created. - Serve `robots.txt` again on Shopware `6.7.1.0` to `6.7.4.x` instead of answering `500`. diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index a7362ee6..bfd779e0 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,5 +1,8 @@ # next version +- UCP-Einstellungen werden für Benutzer mit Leserechten schreibgeschützt angezeigt. + Benutzer mit UCP-Bearbeitungsrechten können sie ohne Bearbeitungsrechte für Verkaufskanäle speichern. + Agentic-Commerce-Insights erfordern UCP-Leserechte. - Deaktivierte Agentic Files bleiben beim Aktivieren von UCP oder Speichern der UCP-Einstellungen deaktiviert. Bestehende eigene Inhalte bleiben erhalten, und fehlende Standarddateien werden angelegt. - Unter Shopware `6.7.1.0` bis `6.7.4.x` liefert der Shop `robots.txt` wieder aus, statt mit `500` zu antworten. diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 84e29953..0b5c58c4 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -256,11 +256,14 @@ Expected result: - `SwagAgenticCommerce` administration assets exist under `public` - `/admin` still renders after the build -The CI admin matrix runs Playwright automatically when `CI_ADMIN_BROWSER_VALIDATE=1` is set, -covering UCP module render, overview, detail, the authenticated admin API -list/detail/config/preview, and the signing-key lifecycle. The Playwright suite is the -release-confidence check for those flows; the manual browser steps below are for exploratory UX -review and for the ACL/UX matrix that CI cannot run (CI runs as a privileged user only). +The CI admin matrix runs the legacy `test:e2e:admin` Playwright suite when +`CI_ADMIN_BROWSER_VALIDATE=1` is set. It does not run the acceptance suite or the `@UcpAcl` +permission matrix. Run the gating acceptance suite, including the `UcpAcl` project, with +`npm run acceptance` from the plugin root after completing the acceptance setup described in +[the acceptance README](../tests/acceptance/README.md). + +Use the manual browser steps below for exploratory UX review across the supported Administration +builds. Install the local browser QA dependency before running it manually: @@ -316,12 +319,14 @@ If needed, reset inside the lane: bin/console user:change-password admin --password 'shopware' ``` -The module render, overview, detail, save, signing-key actions, and profile preview are asserted by -the Playwright admin suite. Use the manual open for exploratory UX review: +The sales-channel detail, UCP configuration, and profile preview are asserted by the Playwright +Administration suite. Signing-key commands are verified through the console. Use the manual open for +exploratory UX review: -- settings page shows the UCP entry in the expected settings group for the lane -- direct route opens: `#/sw/settings/ucp/index` -- overview renders real sales channels and the native sales-channel shortcut +- Storefront and Headless channels show the UCP tab to users with `ucp.viewer` +- users with `ucp.viewer` but without `ucp.editor` see a read-only banner and disabled controls +- users with `ucp.editor` can save UCP configuration without `sales_channel.editor` +- Agentic Commerce Insights remain hidden without `ucp.viewer`, including direct navigation - profile preview renders the lane-aware transports - browser console has no UCP runtime error - a Product Comparison and an Agentic Commerce channel show no UCP card, and saving their feed @@ -331,6 +336,8 @@ the Playwright admin suite. Use the manual open for exploratory UX review: is accepted - `ucp:channels` lists no feed channel, and `ucp:config:set --sales-channel --active 1` exits non-zero +- signing keys are managed with `ucp:signing-keys:{generate,list,show-public,retire,delete}` through + `bin/console`; there is no Administration signing-key interface ## Storefront Build Validation @@ -494,15 +501,18 @@ Steps: ### 4. Admin ACL / UX -CI runs the admin suite as a privileged user only. Verify the role matrix by logging in as users -that hold exactly one of the UCP ACL roles. - -- `ucp.viewer`: save and signing-key actions are disabled; a read-only banner is shown. -- `ucp.key_rotator`: can create, retire, and delete signing keys, but cannot save config. -- `ucp.editor`: can save config. +The `UcpAcl` acceptance project verifies these role-specific behaviors. The gating acceptance suite +runs with `npm run acceptance` from the plugin root; `CI_ADMIN_BROWSER_VALIDATE` runs only the +legacy admin Playwright suite. Use manual accounts with the same role assignments when exploring the +UI: -Also visually verify the signing-key management UX: the algorithm select, the `kid` input, and the -retire/delete confirmation dialogs. +- `ucp.viewer` and `ucp.key_rotator`: configuration controls and Save are disabled, and the + read-only banner is shown. +- `ucp.editor` without `sales_channel.editor`: Save on the UCP tab persists only UCP configuration. +- Users without a UCP role do not see the UCP tab or list column, and cannot read or write UCP + configuration. +- Agentic Commerce Insights require `ucp.viewer`; product-comparison Insights keep their core + permission behavior. Verify that Save applies pending changes across sales channel tabs: @@ -601,7 +611,8 @@ A lane passes manual validation when all are true: - plugin installs through Composer and activates - admin build for the lane succeeds -- UCP administration module renders in browser (exploratory) +- Agentic Commerce sales-channel UCP settings render in browser (exploratory) +- Storefront and Headless channels show the UCP tab to users with `ucp.viewer` - storefront build for the lane succeeds and the storefront UI renders - real-browser cross-origin iframe enforcement behaves correctly: embed renders - bridge works on an allowlisted origin, browser blocks the frame with a CSP violation on a @@ -610,8 +621,7 @@ A lane passes manual validation when all are true: (scenario 2) - the remote-profile allowlist accepts an allowlisted external profile host and rejects a non-allowlisted one (scenario 3) -- admin ACL/UX matrix is correct for `ucp.viewer`, `ucp.editor`, and `ucp.key_rotator`, and the - signing-key UX renders (scenario 4) +- admin ACL/UX matrix is correct for `ucp.viewer`, `ucp.editor`, and `ucp.key_rotator` (scenario 4) - with a real tokenizing handler installed, the profile advertises it, `tokenize` returns a token, and a paid order completes (scenario 5) - concurrent `/complete` requests produce exactly one order with no duplicate and no `500` diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js b/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js index fbec29eb..142121c3 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js @@ -32,10 +32,9 @@ Shopware.Service('privileges').addPrivilegeMappingEntry({ }, }); -// UCP privileges (moved here from the removed sw-settings-ucp module). The -// Agentic Commerce tab gates editing on `ucp.editor` and signing-key actions on -// `ucp.key_rotator`; the parent is null since UCP config now lives on the sales -// channel rather than a dedicated Settings module. +// UCP config privileges. `ucp.key_rotator` currently adds only the viewer +// dependency; signing keys are managed through console commands, not the UI. +// The parent is null since UCP config lives on the sales channel. Shopware.Service('privileges').addPrivilegeMappingEntry({ category: 'permissions', parent: null, diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/index.js b/src/Resources/app/administration/src/extension/sw-sales-channel/index.js index 6230c5c3..deea6e34 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/index.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/index.js @@ -10,5 +10,6 @@ import './view/sw-sales-channel-create-base'; import './view/sw-sales-channel-detail-agentic-commerce'; import './view/sw-sales-channel-detail-agentic-commerce-integration'; import './view/sw-sales-channel-detail-agentic-commerce-statistics'; +import './view/sw-sales-channel-detail-product-export-insights'; import './view/sw-sales-channel-detail-product-comparison'; import './routes.init'; diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/index.js b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/index.js index 897edf28..da09eee2 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/index.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/index.js @@ -2,6 +2,7 @@ import template from './sw-sales-channel-detail.html.twig'; import './sw-sales-channel-detail.scss'; import { coreShipsAgenticCommerce } from '../../../../core-feature'; import { mergeAgenticFileDrafts, saveAgenticFileConfigurations } from '../../agentic-file-persistence'; +import { currentAdminVersion, isVersionAtLeast, useMtComponents } from '../../agentic-commerce/admin-version'; import { defaultForm as ucpDefaultForm, normalizeConfig as ucpNormalizeConfig, @@ -103,6 +104,19 @@ export const swSalesChannelDetailOverride = { : 'sw.sales.channel.detail.agenticCommerceStatistics'; }, + canViewUcpInsights() { + return this.acl.can('ucp.viewer'); + }, + + salesChannelDetailTabs() { + const tabs = this.$super('salesChannelDetailTabs'); + if (!Array.isArray(tabs) || !this.isAgenticCommerce || this.canViewUcpInsights) { + return tabs; + } + + return tabs.filter((tab) => tab.name !== 'sw.sales.channel.detail.productExportInsights'); + }, + shouldRenderAgenticCommerceTab() { const typeId = this.salesChannel?.typeId ?? this.$route.params.typeId; @@ -110,6 +124,34 @@ export const swSalesChannelDetailOverride = { && (isTransactionalSalesChannelType(typeId) || this.ucpState.transactional); }, + isUcpConfigRoute() { + return this.$route.name === 'sw.sales.channel.detail.agenticCommerce'; + }, + + usesMeteorComponents() { + return useMtComponents(); + }, + + supportsVueRouterViewSlot() { + // Vue Router 4 (Shopware 6.6+) supports the scoped router-view slot. + return isVersionAtLeast(currentAdminVersion(), 6, 6); + }, + + shouldRenderAgenticCommerceContent() { + return (!this.isAgenticCommerce + || this.$route.name !== 'sw.sales.channel.detail.productExportInsights' + || this.canViewUcpInsights) + && (this.$route.name !== 'sw.sales.channel.detail.agenticCommerce' || this.shouldRenderAgenticCommerceTab); + }, + + allowSaving() { + if (this.isUcpConfigRoute) { + return this.acl.can('ucp.editor') || this.acl.can('sales_channel.editor'); + } + + return this.$super('allowSaving'); + }, + // Widened to include AC channels so they reuse the product-export blocks. // $super() is skipped — it is unreliable during the create flow in 6.5/6.6. isProductComparison() { @@ -289,7 +331,7 @@ export const swSalesChannelDetailOverride = { // the page before this write, which replaces `ucpState.form` with the stored, // not yet updated config. async saveUcpState(salesChannelId, form) { - if (!form || !salesChannelId) { + if (!form || !salesChannelId || !this.acl.can('ucp.editor')) { return true; } @@ -421,7 +463,8 @@ export const swSalesChannelDetailOverride = { } if (this.pendingAgenticFilesReload - && this.pendingAgenticFilesReload === this.salesChannel?.id) { + && this.pendingAgenticFilesReload === this.salesChannel?.id + && this.acl.can('sales_channel.editor')) { this.isLoading = true; this.isSaveSuccessful = false; try { @@ -434,10 +477,40 @@ export const swSalesChannelDetailOverride = { } } + if (this.isUcpConfigRoute && !this.acl.can('sales_channel.editor')) { + if (!this.acl.can('ucp.editor') || !this.ucpState.loaded) { + return; + } + + const salesChannelId = this.salesChannel?.id; + const form = ucpNormalizeConfig(this.ucpState.form); + const hasUcpChanges = JSON.stringify(form) !== JSON.stringify(ucpNormalizeConfig(this.ucpState.savedForm)); + this.isSavingUcp = true; + this.isLoading = true; + this.isSaveSuccessful = false; + + try { + if (hasUcpChanges && !await this.saveUcpState(salesChannelId, form)) { + return; + } + + this.isSaveSuccessful = true; + if (salesChannelId && hasUcpChanges) { + this.refreshUcpPreview(salesChannelId); + } + } finally { + this.isSavingUcp = false; + this.isLoading = false; + } + + return; + } + if (!this.isAgenticCommerce) { const salesChannelId = this.salesChannel?.id; const form = this.ucpState.loaded ? ucpNormalizeConfig(this.ucpState.form) : null; const hasUcpChanges = form + && this.acl.can('ucp.editor') && JSON.stringify(form) !== JSON.stringify(ucpNormalizeConfig(this.ucpState.savedForm)); // Keep core validation and Storefront theme assignment in the save chain. diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-agentic-commerce-statistics-route.spec.js b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-agentic-commerce-statistics-route.spec.js index 33d651dd..45746066 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-agentic-commerce-statistics-route.spec.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-agentic-commerce-statistics-route.spec.js @@ -48,3 +48,42 @@ describe("sw-sales-channel-detail agenticCommerceStatisticsRoute", () => { ); }); }); + +describe("sw-sales-channel-detail Insights ACL", () => { + const insight = { name: "sw.sales.channel.detail.productExportInsights" }; + const base = { name: "sw.sales.channel.detail.base" }; + const tabs = [base, insight]; + + it("hides core Insights for Agentic Commerce without ucp.viewer", () => { + const computed = loadComputed(true); + const actual = computed.salesChannelDetailTabs.call({ + $super: () => tabs, + isAgenticCommerce: true, + canViewUcpInsights: false, + }); + + expect(actual).toEqual([base]); + }); + + it("preserves core Insights for Agentic Commerce with ucp.viewer", () => { + const computed = loadComputed(true); + const actual = computed.salesChannelDetailTabs.call({ + $super: () => tabs, + isAgenticCommerce: true, + canViewUcpInsights: true, + }); + + expect(actual).toEqual(tabs); + }); + + it("leaves product-comparison Insights unchanged", () => { + const computed = loadComputed(true); + const actual = computed.salesChannelDetailTabs.call({ + $super: () => tabs, + isAgenticCommerce: false, + canViewUcpInsights: false, + }); + + expect(actual).toBe(tabs); + }); +}); diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-on-save.spec.js b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-on-save.spec.js index f178aca9..ca341cf4 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-on-save.spec.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-on-save.spec.js @@ -113,6 +113,7 @@ function buildContext({ saveSalesChannel, isSaveSuccessful = true } = {}) { isLoading: false, isAgenticCommerce: true, allowSaving: true, + acl: { can: jest.fn(() => true) }, systemConfigApiService: { batchSave: jest.fn(() => Promise.resolve()) }, validateAgenticCommerceExportConfig: jest.fn(() => true), ucpState: { loaded: false, form: {}, savedForm: {}, generation: 0 }, @@ -241,6 +242,34 @@ describe('sw-sales-channel-detail onSave — export configuration validation', ( describe('sw-sales-channel-detail onSave — UCP config', () => { const UCP_FORM = { active: true, profileDomain: null, enabledCapabilities: ['cart', 'catalog'], enabledTransports: ['rest'] }; + it('allows UCP editors to save on the UCP route without sales_channel.editor', () => { + expect(swSalesChannelDetailOverride.computed.allowSaving.call({ + isUcpConfigRoute: true, + acl: { can: (privilege) => privilege === 'ucp.editor' }, + })).toBe(true); + }); + + it('allows sales-channel editors to save file changes on the UCP route', () => { + expect(swSalesChannelDetailOverride.computed.allowSaving.call({ + isUcpConfigRoute: true, + acl: { can: (privilege) => privilege === 'sales_channel.editor' }, + })).toBe(true); + }); + + it.each(['ucp.viewer', 'ucp.key_rotator'])('does not allow %s to save without sales-channel edit permission', (role) => { + expect(swSalesChannelDetailOverride.computed.allowSaving.call({ + isUcpConfigRoute: true, + acl: { can: (privilege) => privilege === role }, + })).toBe(false); + }); + + it('uses the core save permission on other routes', () => { + const superMethod = jest.fn(() => false); + + expect(swSalesChannelDetailOverride.computed.allowSaving.call({ isUcpConfigRoute: false, $super: superMethod })).toBe(false); + expect(superMethod).toHaveBeenCalledWith('allowSaving'); + }); + it('writes the UCP form as it was when Save was clicked, although the legacy reload replaced it', async () => { const ctx = buildContext({ isSaveSuccessful: true }); ctx.ucpState = { loaded: true, form: { ...UCP_FORM }, savedForm: {}, generation: 0 }; @@ -285,6 +314,7 @@ describe('sw-sales-channel-detail onSave — UCP config', () => { const ctx = { ucpState: { loaded: true, form: { active: false }, savedForm: { active: false }, generation: 3 }, ucpAdminApiService: { saveConfig }, + acl: { can: jest.fn(() => true) }, createNotificationError: jest.fn(), }; ctx.reloadAgenticFiles = swSalesChannelDetailOverride.methods.reloadAgenticFiles.bind(ctx); @@ -309,6 +339,7 @@ describe('sw-sales-channel-detail onSave — UCP config', () => { generation: 3, }, ucpAdminApiService: { saveConfig }, + acl: { can: jest.fn(() => true) }, createNotificationError: jest.fn(), reloadAgenticFiles, }; @@ -580,6 +611,7 @@ describe('sw-sales-channel-detail onSave — UCP config', () => { const ctx = { ucpState: { loaded: true, form: { ...storedForm }, savedForm: { ...storedForm }, generation: 3 }, ucpAdminApiService: { saveConfig }, + acl: { can: jest.fn(() => true) }, createNotificationError: jest.fn(), }; @@ -592,6 +624,38 @@ describe('sw-sales-channel-detail onSave — UCP config', () => { expect(ctx.ucpState.generation).toBe(4); }); + it('lets a UCP viewer with sales-channel edit access save Agentic Files without writing UCP settings', async () => { + const file = createFileEntity('new-file', { fileName: 'agents.md' }, true); + const { repository: fileRepository } = createFileRepository([ + { id: 'new-file', data: { fileName: 'agents.md' } }, + ]); + const ctx = buildUcpContext(); + ctx.acl.can.mockImplementation((privilege) => privilege === 'sales_channel.editor' || privilege === 'ucp.viewer'); + ctx.ucpState = { loaded: true, form: { ...UCP_FORM }, savedForm: {}, generation: 0 }; + ctx.salesChannel.salesChannelFiles = [file]; + ctx.repositoryFactory = { create: jest.fn(() => fileRepository) }; + ctx.ucpAdminApiService = { getProfilePreview: jest.fn(() => Promise.resolve({ data: { data: null } })) }; + + await swSalesChannelDetailOverride.methods.onSave.call(ctx); + + expect(fileRepository.save).toHaveBeenCalledWith(file, Shopware.Context.api); + expect(ctx.saveUcpState).not.toHaveBeenCalled(); + expect(ctx.isSaveSuccessful).toBe(true); + }); + + it('does not write UCP configuration from other tabs when the user lacks ucp.editor', async () => { + const ctx = { + ucpState: { loaded: true, form: { active: true }, savedForm: {}, generation: 0 }, + acl: { can: jest.fn(() => false) }, + ucpAdminApiService: { saveConfig: jest.fn() }, + }; + + const saved = await swSalesChannelDetailOverride.methods.saveUcpState.call(ctx, 'channel-id', { active: true }); + + expect(saved).toBe(true); + expect(ctx.ucpAdminApiService.saveConfig).not.toHaveBeenCalled(); + }); + it('saves the sales channel from the UCP tab before writing UCP and refreshing the preview', async () => { const draft = { ...UCP_FORM }; const preview = { data: { data: { ucp: { version: '1.0' } } } }; @@ -667,6 +731,68 @@ describe('sw-sales-channel-detail onSave — UCP config', () => { expect(ctx.isSaveSuccessful).toBe(true); }); + it('does not save Agentic Files without sales-channel edit permission', async () => { + const file = createFileEntity('draft-file', { fileName: 'agents.md' }, true); + const ctx = buildContext(); + ctx.isUcpConfigRoute = true; + ctx.acl.can.mockImplementation((privilege) => privilege === 'ucp.editor'); + ctx.ucpState = { loaded: true, form: { ...UCP_FORM }, savedForm: {}, generation: 0 }; + ctx.pendingAgenticFilesReload = 'channel-id'; + ctx.reloadAgenticFiles = jest.fn(); + ctx.salesChannel.salesChannelFiles = [file]; + ctx.saveAgenticFiles = jest.fn(() => Promise.resolve(true)); + ctx.ucpAdminApiService = { getProfilePreview: jest.fn(() => Promise.resolve({ data: { data: null } })) }; + + await swSalesChannelDetailOverride.methods.onSave.call(ctx); + + expect(ctx.saveUcpState).toHaveBeenCalledWith('channel-id', ctx.ucpState.form); + expect(ctx.$super).not.toHaveBeenCalled(); + expect(ctx.saveAgenticCommerceExportConfig).not.toHaveBeenCalled(); + expect(ctx.saveAgenticFiles).not.toHaveBeenCalled(); + expect(ctx.reloadAgenticFiles).not.toHaveBeenCalled(); + expect(ctx.isSaveSuccessful).toBe(true); + }); + + it('skips unchanged UCP settings without saving the sales channel for a UCP-only editor', async () => { + const ctx = buildUcpContext(); + ctx.isUcpConfigRoute = true; + ctx.acl.can.mockImplementation((privilege) => privilege === 'ucp.editor'); + ctx.ucpState = { loaded: true, form: { ...UCP_FORM }, savedForm: { ...UCP_FORM }, generation: 0 }; + ctx.saveAgenticFiles = jest.fn(); + ctx.ucpAdminApiService = { getProfilePreview: jest.fn() }; + + await swSalesChannelDetailOverride.methods.onSave.call(ctx); + + expect(ctx.$super).not.toHaveBeenCalled(); + expect(ctx.saveAgenticFiles).not.toHaveBeenCalled(); + expect(ctx.saveUcpState).not.toHaveBeenCalled(); + expect(ctx.ucpAdminApiService.getProfilePreview).not.toHaveBeenCalled(); + expect(ctx.isSavingUcp).toBe(false); + expect(ctx.isSaveSuccessful).toBe(true); + }); + + it('releases the UCP-only save guard after a failed write so the draft can be retried', async () => { + const ctx = buildUcpContext(); + ctx.isUcpConfigRoute = true; + ctx.acl.can.mockImplementation((privilege) => privilege === 'ucp.editor'); + ctx.ucpState = { loaded: true, form: { ...UCP_FORM }, savedForm: {}, generation: 0 }; + ctx.saveUcpState.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + ctx.ucpAdminApiService = { getProfilePreview: jest.fn(async () => ({ data: { data: null } })) }; + + await swSalesChannelDetailOverride.methods.onSave.call(ctx); + + expect(ctx.isSavingUcp).toBe(false); + expect(ctx.isLoading).toBe(false); + expect(ctx.isSaveSuccessful).toBe(false); + expect(ctx.ucpState.form).toEqual(UCP_FORM); + + await swSalesChannelDetailOverride.methods.onSave.call(ctx); + + expect(ctx.$super).not.toHaveBeenCalled(); + expect(ctx.saveUcpState).toHaveBeenCalledTimes(2); + expect(ctx.isSaveSuccessful).toBe(true); + }); + it('does not create the same Agentic File again on a repeated save', async () => { const { repository: fileRepository } = createFileRepository(); const ctx = buildContext(); diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-save-ordinary.spec.js b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-save-ordinary.spec.js index fa118c3e..f4d38c2b 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-save-ordinary.spec.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-save-ordinary.spec.js @@ -196,6 +196,18 @@ describe('ordinary sales-channel saves', () => { expect(ctx.saveUcpState).not.toHaveBeenCalled(); }); + it('does not write UCP settings without UCP edit permission', async () => { + const ctx = createContext(); + ctx.ucpState.form.active = true; + ctx.acl.can.mockReturnValue(false); + + await swSalesChannelDetailOverride.methods.onSave.call(ctx); + + expect(ctx.$super).toHaveBeenCalledWith('onSave'); + expect(ctx.saveUcpState).not.toHaveBeenCalled(); + expect(ctx.ucpAdminApiService.getProfilePreview).not.toHaveBeenCalled(); + }); + it('does not write UCP settings that have not loaded', async () => { const ctx = createContext(); ctx.ucpState.loaded = false; diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-template.spec.js b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-template.spec.js index 9fb72561..4e85a1e8 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-template.spec.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-detail/sw-sales-channel-detail-template.spec.js @@ -5,14 +5,132 @@ const fs = require('fs'); const path = require('path'); +global.Shopware = { + Component: { override: jest.fn() }, + Service: jest.fn(() => ({})), + Utils: { object: {} }, + Classes: { ShopwareError: class ShopwareError extends Error {} }, + Context: { api: {} }, + Defaults: { + productComparisonTypeId: 'product-comparison-type-id', + storefrontSalesChannelTypeId: 'storefront-type-id', + apiSalesChannelTypeId: 'api-type-id', + agenticCommerceTypeId: 'agentic-commerce-type-id', + }, +}; + +const { swSalesChannelDetailOverride } = require('Resources/extension/sw-sales-channel/page/sw-sales-channel-detail'); + const template = fs.readFileSync( path.resolve(__dirname, './sw-sales-channel-detail.html.twig'), 'utf8', ); describe('sw-sales-channel-detail template override', () => { - // Replacing this block drops the props other plugins pass to the routed view, e.g. Social Shopping's network form. - it('leaves the routed content view to core', () => { - expect(template).not.toContain('{% block sw_sales_channel_detail_content_view %}'); + it('keeps core routed-view props while hiding protected direct routes', () => { + expect(template).toContain('{% block sw_sales_channel_detail_content_view %}'); + expect(template).toContain('v-if="shouldRenderAgenticCommerceContent"'); + expect(template).toContain('{% parent %}'); + expect(template).toContain(':sales-channel="salesChannel"'); + expect(template).toContain(':product-export="productExport"'); + expect(template).toContain(':agentic-commerce-export-config="agenticCommerceExportConfig"'); + expect(template).toContain(':storefront-sales-channel-criteria="storefrontSalesChannelCriteria"'); + expect(template).toContain(':custom-field-sets="customFieldSets"'); + expect(template).toContain(':template-options="productComparison.templateOptions"'); + expect(template).toContain('@template-modal-confirm="onTemplateModalConfirm"'); + }); + + it('uses Vue Router 3 attribute forwarding on Shopware 6.5 and the scoped slot on 6.6+', () => { + expect(template).toContain('