diff --git a/config/default/core.date_format.fallback.yml b/config/default/core.date_format.fallback.yml index 0df090d7..17da8d2c 100644 --- a/config/default/core.date_format.fallback.yml +++ b/config/default/core.date_format.fallback.yml @@ -7,4 +7,4 @@ _core: id: fallback label: 'Fallback date format' locked: true -pattern: 'D, m/d/Y - H:i' +pattern: 'D, d/m/Y - H:i' diff --git a/config/default/core.date_format.long.yml b/config/default/core.date_format.long.yml index 0582db6d..d415d022 100644 --- a/config/default/core.date_format.long.yml +++ b/config/default/core.date_format.long.yml @@ -7,4 +7,4 @@ _core: id: long label: 'Default long date' locked: false -pattern: 'l, F j, Y - H:i' +pattern: 'l, j F Y - H:i' diff --git a/config/default/core.date_format.medium.yml b/config/default/core.date_format.medium.yml index cc85def4..6c83ec74 100644 --- a/config/default/core.date_format.medium.yml +++ b/config/default/core.date_format.medium.yml @@ -7,4 +7,4 @@ _core: id: medium label: 'Default medium date' locked: false -pattern: 'D, m/d/Y - H:i' +pattern: 'D, d/m/Y - H:i' diff --git a/config/default/core.date_format.short.yml b/config/default/core.date_format.short.yml index 8d1e8082..99d86158 100644 --- a/config/default/core.date_format.short.yml +++ b/config/default/core.date_format.short.yml @@ -7,4 +7,4 @@ _core: id: short label: 'Default short date' locked: false -pattern: 'm/d/Y - H:i' +pattern: 'd/m/Y - H:i' diff --git a/config/default/pathauto.pattern.project.yml b/config/default/pathauto.pattern.project.yml index 52675ee1..234e2858 100644 --- a/config/default/pathauto.pattern.project.yml +++ b/config/default/pathauto.pattern.project.yml @@ -9,7 +9,7 @@ dependencies: id: project label: Project type: 'canonical_entities:node' -pattern: '/projects/[node:title]' +pattern: '/work/[node:title]' selection_criteria: 4359d4f8-fcd8-4814-8ee7-2cd0ba4da65c: id: 'entity_bundle:node' diff --git a/config/default/system.date.yml b/config/default/system.date.yml index ba65e88f..0f0a9a4a 100644 --- a/config/default/system.date.yml +++ b/config/default/system.date.yml @@ -1,8 +1,8 @@ _core: default_config_hash: IVsFTD1mvR2NGBP_1myt9kFIFmGepH4PyaN5aQBYpno -first_day: 0 +first_day: 1 country: - default: null + default: AU timezone: default: Australia/Melbourne user: diff --git a/config/default/user.role.do_content_api.yml b/config/default/user.role.do_content_api.yml index 542edc80..7b54054d 100644 --- a/config/default/user.role.do_content_api.yml +++ b/config/default/user.role.do_content_api.yml @@ -28,6 +28,8 @@ weight: 4 is_admin: false permissions: - 'access content' + - 'access content overview' + - 'administer nodes' - 'administer redirects' - 'create blog content' - 'create civictheme_alert content' @@ -37,17 +39,34 @@ permissions: - 'create media' - 'create project content' - 'create url aliases' + - 'delete any blog content' + - 'delete any civictheme_alert content' + - 'delete any civictheme_event content' + - 'delete any civictheme_page content' + - 'delete any project content' + - 'edit any blog content' + - 'edit any civictheme_alert content' + - 'edit any civictheme_event content' + - 'edit any civictheme_page content' + - 'edit any project content' - 'edit own blog content' - 'edit own civictheme_alert content' - 'edit own civictheme_event content' - 'edit own civictheme_page content' - 'edit own project content' - 'issue subrequests' + - 'use civictheme_editorial transition archive' - 'use civictheme_editorial transition create_new_draft' - 'use civictheme_editorial transition needs_review' - 'use civictheme_editorial transition publish' + - 'use civictheme_editorial transition restore' + - 'use civictheme_editorial transition restore_to_draft' + - 'use civictheme_editorial transition restore_to_needs_review' + - 'use civictheme_editorial transition send_back_to_draft' - 'use content authoring api' - 'use key authentication' - 'use text format civictheme_rich_text' + - 'view any unpublished content' + - 'view latest version' - 'view own unpublished content' - 'view own unpublished media' diff --git a/docs/content-api.md b/docs/content-api.md index a361a696..c1a6f1ef 100644 --- a/docs/content-api.md +++ b/docs/content-api.md @@ -6,7 +6,9 @@ This document is the authoring contract: it describes the endpoints, the content ## Governance model (read this first) -Content created through the API is **never published automatically**. A human reviews and publishes it. +A page the API creates is **never published in the request that creates it**. It lands as a draft for a human to review. + +That guarantee covers creation, not the whole lifecycle. The service account holds every transition of the editorial workflow, so a client updating a node it created earlier - or one an editor authored - may move it to any state the workflow allows, including published. Treat the draft-first rule as a safeguard against an agent publishing in one shot, not as a guarantee that only humans ever publish. - **Nodes** of every content type are always created as **draft**. The server forces this: even if a request asks for `published`, the node is coerced to `draft`. - **Images** (`civictheme_image` media) are created **published** - they are assets, invisible until referenced by a published page. The server forces this too, so a client never has to set media moderation state. @@ -21,7 +23,11 @@ Send the API key in the `api-key` request header on every request: api-key: ``` -The key belongs to a dedicated, least-privilege service account (`do_content_api_service`) that may create and edit its own nodes of any content type, plus images and the supported components. It cannot delete anything, and cannot edit content authored by anyone else. Nodes it authors are forced to draft by the moderation policy - a human publishes them. +The key belongs to a dedicated service account (`do_content_api_service`) that fully manages content. It may create, edit and delete `blog`, `civictheme_alert`, `civictheme_event`, `civictheme_page` and `project` nodes regardless of who authored them, move them through every state of the editorial workflow, and read unpublished content and pending revisions. Any other content type is outside its reach until the role grants that bundle explicitly. + +Its media access is narrower than its node access: it can create images and the supported components, and read unpublished media, but cannot edit or delete media authored by anyone else. + +Treat the key as an administrative credential. It carries enough access to remove published pages, so store it in a secret manager, issue a separate key per environment, and revoke it at `/user//key-auth` the moment a client no longer needs it. Retrieve (or regenerate) the key as an administrator at `/user//key-auth` for the service account, or have a developer read it from the account. Always send it over HTTPS. On deployed environments the `shield` module may sit in front of the site; the API path must be allow-listed there or the client must also supply the Shield credentials. @@ -30,7 +36,7 @@ Retrieve (or regenerate) the key as an administrator at `/user//key-auth` f The feature ships as configuration, so it is enabled by a normal deployment: 1. Importing configuration enables `jsonapi` (with writes allowed), `key_auth`, `subrequests`, and the `do_content_api` module, and creates the `do_content_api` service role. -1. The `drush deploy` step runs a deploy hook that creates the `do_content_api_service` account (idempotent - it is skipped if the account already exists). The API key is generated automatically. +1. The `drush deploy` step runs a deploy hook that creates the `do_content_api_service` account, and the API key is generated automatically. Later runs reconcile an existing service account back to exactly the `do_content_api` role, leave a deliberately blocked account blocked, and refuse to touch an unrelated account that happens to share the username. 1. Retrieve the key at `/user//key-auth` and give it to the client. No keys are committed to the repository; each environment issues its own. @@ -50,7 +56,7 @@ A page is a `civictheme_page` node whose `field_c_n_components` is an **ordered | Field | Type | Notes | | --- | --- | --- | | `title` | attribute (string) | Required. | -| `moderation_state` | attribute | Always ends up `draft` (server-enforced). | +| `moderation_state` | attribute | Forced to `draft` on create; honoured on update. | | `field_c_n_components` | relationship (paragraphs) | Ordered list of component paragraphs. | | `field_c_n_summary` | attribute (string) | Optional teaser/summary. | @@ -137,5 +143,5 @@ The response is HTTP `207` with one entry per `requestId`. Each entry has its ow - **Order tokens correctly.** Use `drupal_internal__revision_id` from each paragraph's creation response. The numeric token is written quoted (`"target_revision_id":"{{...}}"`); the replacer strips the quotes. - **Alt text is required** on every image. - **Rich text needs a format**: `civictheme_rich_text`. -- **Page moderation state is ignored** - every page is created as `draft` regardless of the `moderation_state` sent; a human publishes it. +- **Page moderation state is ignored on create** - every page is created as `draft` regardless of the `moderation_state` sent. Updates honour the state sent. - **Cards (`civictheme_*_card`) are not placed directly on the page** - they live inside a `civictheme_manual_list` via `field_c_p_list_items`. diff --git a/tests/behat/features/project_content_type.feature b/tests/behat/features/project_content_type.feature index c601428a..42c216cc 100644 --- a/tests/behat/features/project_content_type.feature +++ b/tests/behat/features/project_content_type.feature @@ -72,12 +72,12 @@ Feature: Project content type Then the response status code should be 200 @api - Scenario: Project follows the editorial workflow and gets a /projects URL + Scenario: Project follows the editorial workflow and gets a /work URL Given the following "project" content: | title | moderation_state | field_do_n_year | field_do_n_status | field_c_n_banner_type | field_c_n_banner_theme | field_c_n_banner_blend_mode | field_c_n_vertical_spacing | | [TEST] Example project | published | 2025 | completed | large | inherit | normal | both | When I visit the "project" content page with the title "[TEST] Example project" - Then the path should be "/projects/test-example-project" + Then the path should be "/work/test-example-project" And I should see the text "[TEST] Example project" When I am logged in as a user with the "Site Administrator" role And I visit the "project" content edit page with the title "[TEST] Example project" @@ -91,7 +91,7 @@ Feature: Project content type | title | moderation_state | field_do_n_year | field_do_n_status | field_c_n_banner_type | field_c_n_banner_theme | field_c_n_banner_blend_mode | field_c_n_vertical_spacing | | [TEST] Draft project | draft | 2025 | ongoing | large | inherit | normal | both | And I am an anonymous user - When I go to "/projects/test-draft-project" + When I go to "/work/test-draft-project" Then the response status code should be 403 @api diff --git a/tests/behat/features/xmlsitemap.feature b/tests/behat/features/xmlsitemap.feature index d2067131..fbc43e08 100644 --- a/tests/behat/features/xmlsitemap.feature +++ b/tests/behat/features/xmlsitemap.feature @@ -49,5 +49,5 @@ Feature: XML sitemap And I am an anonymous user When I go to "sitemap.xml" Then the response status code should be 200 - And the response should contain "projects/test-sitemap-indexed-project" + And the response should contain "work/test-sitemap-indexed-project" And the response should not contain "sitemap-excluded-project" diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index 046cf144..e3103866 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -22,6 +22,7 @@ use Drupal\menu_link_content\MenuLinkContentInterface; use Drupal\node\NodeInterface; use Drupal\paragraphs\ParagraphInterface; +use Drupal\path_alias\PathAliasInterface; use Drupal\pathauto\PathautoState; use Drupal\search_api\Entity\Index; use Drupal\taxonomy\TermInterface; @@ -390,6 +391,25 @@ function do_base_deploy_populate_our_work_page(): string { return Helper::report(); } +/** + * Regenerates project aliases from the pathauto pattern. + * + * @param array|null $sandbox + * Batch sandbox, matching the nullable reference the batch helper takes. + * + * @return string|null + * Summary once every alias is regenerated, or NULL while batching. + */ +function do_base_deploy_move_projects_to_work(?array &$sandbox = NULL): ?string { + // The query doubles as the idempotency guard: an alias regenerated onto the + // new prefix no longer matches, so a repeat deployment finds nothing to do. + $query = \Drupal::entityQuery('path_alias')->condition('alias', '/projects/%', 'LIKE'); + + return Helper::entity($sandbox)->batchQuery($query, static function (PathAliasInterface $alias): void { + _do_base_project_realias($alias); + }, status: Reporter::UPDATED); +} + /** * Rebuilds the XML sitemap. */ @@ -730,6 +750,26 @@ function _do_base_menu_leading_weight(string $menu_name): int { return min($weights) - 1; } +/** + * Regenerates the alias of the project a path alias points at. + */ +function _do_base_project_realias(PathAliasInterface $alias): void { + if (!preg_match('#^/node/(\d+)$#', $alias->getPath(), $matches)) { + return; + } + + $node = \Drupal::entityTypeManager()->getStorage('node')->load($matches[1]); + + if (!$node instanceof NodeInterface || $node->bundle() !== 'project') { + return; + } + + // Regenerating rather than rewriting the alias string keeps the result + // defined by the pattern alone, and lets the redirect module record the + // superseded path so inbound links keep resolving. + \Drupal::service('pathauto.generator')->updateEntityAlias($node, 'update'); +} + /** * Loads the taxonomy term that defines which articles are blog articles. */ diff --git a/web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php new file mode 100644 index 00000000..0ea4ebad --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php @@ -0,0 +1,126 @@ +loadConfig('core.date_format.' . $config_name . '.yml')['pattern']; + $date = new \DateTimeImmutable(self::REFERENCE_DATE); + + // Act. + $rendered = $date->format($pattern); + + // Assert. + $this->assertSame($expected, $rendered, sprintf('Date format "%s" does not render an Australian date.', $config_name)); + } + + /** + * Data provider for testDateFormatPattern. + */ + public static function dataProviderDateFormatPattern(): \Iterator { + yield 'short' => ['short', '04/08/2026 - 14:30']; + yield 'medium' => ['medium', 'Tue, 04/08/2026 - 14:30']; + yield 'long' => ['long', 'Tuesday, 4 August 2026 - 14:30']; + yield 'fallback' => ['fallback', 'Tue, 04/08/2026 - 14:30']; + yield 'civictheme short date' => ['civictheme_short_date', '4 Aug 2026']; + yield 'civictheme short date and time' => ['civictheme_short_date_and_time', '4 Aug 2026 - 14:30']; + } + + /** + * Tests that the machine-readable formats stay ISO 8601. + * + * These populate the datetime attribute of time elements and HTML5 date + * input values, where the format is fixed by specification and localising + * them would emit invalid markup. + */ + #[DataProvider('dataProviderMachineReadableFormat')] + public function testMachineReadableFormat(string $config_name, string $expected): void { + // Prepare. + $pattern = $this->loadConfig('core.date_format.' . $config_name . '.yml')['pattern']; + + // Assert. + $this->assertSame($expected, $pattern); + } + + /** + * Data provider for testMachineReadableFormat. + */ + public static function dataProviderMachineReadableFormat(): \Iterator { + yield 'html date' => ['html_date', 'Y-m-d']; + yield 'html datetime' => ['html_datetime', 'Y-m-d\TH:i:sO']; + yield 'html month' => ['html_month', 'Y-m']; + yield 'html time' => ['html_time', 'H:i:s']; + yield 'html week' => ['html_week', 'Y-\WW']; + yield 'html year' => ['html_year', 'Y']; + yield 'html yearless date' => ['html_yearless_date', 'm-d']; + } + + /** + * Tests the regional settings that accompany the date formats. + */ + #[DataProvider('dataProviderRegionalSetting')] + public function testRegionalSetting(array $keys, mixed $expected): void { + // Prepare. + $value = $this->loadConfig('system.date.yml'); + + // Act. + foreach ($keys as $key) { + $value = $value[$key]; + } + + // Assert. + $this->assertSame($expected, $value); + } + + /** + * Data provider for testRegionalSetting. + */ + public static function dataProviderRegionalSetting(): \Iterator { + yield 'default timezone' => [['timezone', 'default'], 'Australia/Melbourne']; + yield 'default country' => [['country', 'default'], 'AU']; + yield 'week starts on Monday' => [['first_day'], 1]; + } + + /** + * Reads an exported configuration file from the default config directory. + */ + protected function loadConfig(string $file_name): array { + $path = dirname($this->root) . '/config/default/' . $file_name; + $this->assertFileExists($path); + + return Yaml::parseFile($path); + } + +} diff --git a/web/modules/custom/do_base/tests/src/Unit/PathautoPatternConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PathautoPatternConfigTest.php new file mode 100644 index 00000000..cfa48bd6 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/PathautoPatternConfigTest.php @@ -0,0 +1,81 @@ +loadConfig('pathauto.pattern.' . $pattern_id . '.yml')['pattern']; + + // Assert. + $this->assertSame($expected, $pattern); + } + + /** + * Data provider for testAliasPattern. + */ + public static function dataProviderAliasPattern(): \Iterator { + yield 'blog post' => ['blog', '/blog/[node:title]']; + yield 'event' => ['civictheme_event', '/events/[node:title]']; + yield 'project' => ['project', '/work/[node:title]']; + } + + /** + * Tests that each pattern is scoped to the bundle it is named for. + */ + #[DataProvider('dataProviderPatternIsScopedToItsBundle')] + public function testPatternIsScopedToItsBundle(string $pattern_id): void { + // Prepare. + $criteria = $this->loadConfig('pathauto.pattern.' . $pattern_id . '.yml')['selection_criteria']; + $bundles = []; + + foreach ($criteria as $criterion) { + $bundles = array_merge($bundles, array_keys($criterion['bundles'] ?? [])); + } + + // Assert. + $this->assertSame([$pattern_id], $bundles); + } + + /** + * Data provider for testPatternIsScopedToItsBundle. + */ + public static function dataProviderPatternIsScopedToItsBundle(): \Iterator { + yield 'blog post' => ['blog']; + yield 'event' => ['civictheme_event']; + yield 'project' => ['project']; + } + + /** + * Reads an exported configuration file from the default config directory. + */ + protected function loadConfig(string $file_name): array { + $path = dirname($this->root) . '/config/default/' . $file_name; + $this->assertFileExists($path); + + return Yaml::parseFile($path); + } + +} diff --git a/web/modules/custom/do_content_api/do_content_api.deploy.php b/web/modules/custom/do_content_api/do_content_api.deploy.php index ec537010..b13671e2 100644 --- a/web/modules/custom/do_content_api/do_content_api.deploy.php +++ b/web/modules/custom/do_content_api/do_content_api.deploy.php @@ -32,9 +32,10 @@ function do_content_api_deploy_service_account(): string { return sprintf('Account "%s" exists but is not the service account; left unchanged.', $username); } - // Reconcile to exactly the least-privilege role set, dropping any extra - // roles that would widen the externally authenticated account. Status is - // left untouched so a deliberately blocked account stays disabled. + // Reconcile to exactly the intended role set, dropping any extra roles that + // would widen the externally authenticated account beyond what the content + // API role grants. Status is left untouched so a deliberately blocked + // account stays disabled. if ($account->getRoles(TRUE) !== ['do_content_api']) { foreach ($account->getRoles(TRUE) as $role) { $account->removeRole($role); @@ -42,7 +43,7 @@ function do_content_api_deploy_service_account(): string { $account->addRole('do_content_api'); $account->save(); - return sprintf('Service account "%s" reconciled to least-privilege role set.', $username); + return sprintf('Service account "%s" reconciled to the content API role.', $username); } return sprintf('Service account "%s" already exists; skipped.', $username); diff --git a/web/modules/custom/do_content_api/tests/src/Unit/ServiceRolePermissionsTest.php b/web/modules/custom/do_content_api/tests/src/Unit/ServiceRolePermissionsTest.php new file mode 100644 index 00000000..5bab1d6f --- /dev/null +++ b/web/modules/custom/do_content_api/tests/src/Unit/ServiceRolePermissionsTest.php @@ -0,0 +1,209 @@ +assertSame(self::EXPECTED_PERMISSIONS, $this->loadRole()['permissions']); + } + + /** + * Tests that the role carries a content management permission. + */ + #[DataProvider('dataProviderManagementPermission')] + public function testManagementPermission(string $permission): void { + // Assert. + $this->assertContains($permission, $this->loadRole()['permissions']); + } + + /** + * Data provider for testManagementPermission. + */ + public static function dataProviderManagementPermission(): \Iterator { + yield 'administer content' => ['administer nodes']; + yield 'content overview' => ['access content overview']; + yield 'read unpublished content' => ['view any unpublished content']; + yield 'read pending revisions' => ['view latest version']; + yield 'authoring api gate' => ['use content authoring api']; + yield 'key authentication' => ['use key authentication']; + } + + /** + * Tests that every creatable content type is also fully manageable. + * + * Adding a content type to the API means adding a create permission, which + * is easy to do without the matching edit and delete grants. This pins the + * three together so a partially wired bundle fails here. + */ + public function testCreatableBundlesAreManageable(): void { + // Prepare. + $permissions = $this->loadRole()['permissions']; + $bundles = []; + + foreach ($permissions as $permission) { + if (preg_match('/^create (\w+) content$/', (string) $permission, $matches)) { + $bundles[] = $matches[1]; + } + } + + // Assert. + $this->assertNotEmpty($bundles, 'The role grants no content creation at all.'); + + foreach ($bundles as $bundle) { + $this->assertContains('edit any ' . $bundle . ' content', $permissions); + $this->assertContains('delete any ' . $bundle . ' content', $permissions); + } + } + + /** + * Tests that the role can use every transition of the editorial workflow. + * + * Content moderation forbids an update outright when the account holds no + * transition out of the entity's current state, so a missing transition + * does not just block a state change - it makes content in that state + * uneditable while leaving it deletable. + */ + public function testAllEditorialTransitionsAreGranted(): void { + // Prepare. + $workflow = $this->loadConfig('workflows.workflow.civictheme_editorial.yml'); + $transitions = array_keys($workflow['type_settings']['transitions']); + $permissions = $this->loadRole()['permissions']; + + // Assert. + $this->assertNotEmpty($transitions, 'The editorial workflow defines no transitions.'); + + foreach ($transitions as $transition) { + $this->assertContains('use civictheme_editorial transition ' . $transition, $permissions); + } + } + + /** + * Tests that the role is not flagged as an administrator role. + * + * The is_admin flag grants every permission on the site, including those of + * modules installed later, which would bypass the enumerated set entirely. + */ + public function testRoleIsNotAdmin(): void { + // Assert. + $this->assertFalse($this->loadRole()['is_admin']); + } + + /** + * Tests the media access the authoring contract documents. + */ + #[DataProvider('dataProviderMediaPermission')] + public function testMediaPermission(string $permission, bool $granted): void { + // Prepare. + $permissions = $this->loadRole()['permissions']; + + // Assert. + $granted + ? $this->assertContains($permission, $permissions) + : $this->assertNotContains($permission, $permissions); + } + + /** + * Data provider for testMediaPermission. + */ + public static function dataProviderMediaPermission(): \Iterator { + yield 'create images' => ['create civictheme_image media', TRUE]; + yield 'create media' => ['create media', TRUE]; + yield 'read own unpublished media' => ['view own unpublished media', TRUE]; + yield 'update any media' => ['update any media', FALSE]; + yield 'delete any media' => ['delete any media', FALSE]; + } + + /** + * Reads the exported service role configuration. + */ + protected function loadRole(): array { + return $this->loadConfig('user.role.do_content_api.yml'); + } + + /** + * Reads an exported configuration file from the default config directory. + */ + protected function loadConfig(string $file_name): array { + $path = dirname($this->root) . '/config/default/' . $file_name; + $this->assertFileExists($path); + + return Yaml::parseFile($path); + } + +}