Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/default/core.date_format.fallback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion config/default/core.date_format.long.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion config/default/core.date_format.medium.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion config/default/core.date_format.short.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion config/default/pathauto.pattern.project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions config/default/system.date.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
19 changes: 19 additions & 0 deletions config/default/user.role.do_content_api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
16 changes: 11 additions & 5 deletions docs/content-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -21,7 +23,11 @@ Send the API key in the `api-key` request header on every request:
api-key: <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/<uid>/key-auth` the moment a client no longer needs it.

Retrieve (or regenerate) the key as an administrator at `/user/<uid>/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.

Expand All @@ -30,7 +36,7 @@ Retrieve (or regenerate) the key as an administrator at `/user/<uid>/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/<uid>/key-auth` and give it to the client.

No keys are committed to the repository; each environment issues its own.
Expand All @@ -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. |

Expand Down Expand Up @@ -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`.
6 changes: 3 additions & 3 deletions tests/behat/features/project_content_type.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/behat/features/xmlsitemap.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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"
40 changes: 40 additions & 0 deletions web/modules/custom/do_base/do_base.deploy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
126 changes: 126 additions & 0 deletions web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace Drupal\Tests\do_base\Unit;

use Drupal\Tests\UnitTestCase;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\Yaml\Yaml;

/**
* Tests that the exported date configuration renders Australian dates.
*
* Regional settings are routinely changed through the admin UI and re-exported,
* which is how a US-defaulted pattern reaches the repository unnoticed. These
* assertions run the shipped patterns against a reference date so a day-second
* format fails the build rather than the reader.
*/
#[CoversNothing]
#[Group('do_base')]
class DateFormatConfigTest extends UnitTestCase {

/**
* Reference date rendered through each pattern: 4 August 2026, 2:30pm.
*
* Day and month are both unambiguous and differ from each other, so a
* month-first pattern cannot render the same string as a day-first one.
*/
protected const string REFERENCE_DATE = '2026-08-04 14:30:00';

/**
* Tests that a shipped date format renders in Australian order.
*/
#[DataProvider('dataProviderDateFormatPattern')]
public function testDateFormatPattern(string $config_name, string $expected): void {
// Prepare.
$pattern = $this->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);
}

}
Loading
Loading