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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ jobs:
- run: npm ci
- run: npm run type-check

frontend-typed-router-determinism:
name: Frontend Typed Router Determinism
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Setup dummy backend .env
run: cp ../.env.precommit ../.env
- run: npm run check:typed-router:build

frontend-unit-tests:
name: Frontend Unit Tests
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ name: Close stale PRs

on:
schedule:
- cron: "0 9 * * 1"
- cron: '0 9 * * 1'

jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
stale-pr-message: "This PR has been inactive for 30 days. It will be closed in 7 days if there is no further activity."
stale-pr-message: 'This PR has been inactive for 30 days. It will be closed in 7 days if there is no further activity.'
days-before-pr-stale: 30
days-before-pr-close: 7
days-before-issue-stale: -1
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ backups/

# Environments
.env
.env.tmp.*
.venv
env/
venv/
Expand Down
11 changes: 9 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,16 @@ repos:
files: '^frontend/.*\.(js|ts|vue|json|md|yml|css)$'
pass_filenames: false

- id: frontend-workflow-format
name: Check GitHub workflow formatting
entry: bash -c 'cd frontend && npm run check:workflow-format'
language: system
files: '^\.github/workflows/.*\.yml$'
pass_filenames: false

- id: codesight-requirements
name: Generate requirements.txt for codesight
entry: bash -c 'set -euo pipefail; echo "# autogenerated from poetry.lock via poetry export" > requirements.txt && poetry export --without-hashes --without-urls --all-groups --all-extras -f requirements.txt >> requirements.txt && git add requirements.txt'
name: Check requirements.txt for codesight
entry: bash scripts/check_requirements.sh
language: system
pass_filenames: false
always_run: true
Expand Down
198 changes: 198 additions & 0 deletions apps/workflow/tests/test_xero_instance_templates.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
import subprocess
import tempfile
from pathlib import Path

from django.test import SimpleTestCase
Expand All @@ -11,12 +13,19 @@
REPO_ROOT / "scripts" / "server" / "templates" / "xero-apps.json.template"
)
INSTANCE_SCRIPT = REPO_ROOT / "scripts" / "server" / "instance.sh"
DEPLOY_SCRIPT = REPO_ROOT / "scripts" / "server" / "deploy.sh"
COMMON_SCRIPT = REPO_ROOT / "scripts" / "server" / "common.sh"
SERVER_SETUP_SCRIPT = REPO_ROOT / "scripts" / "server" / "server-setup.sh"
SERVER_README = REPO_ROOT / "scripts" / "server" / "README.md"
PRODUCTION_SETUP_DOC = REPO_ROOT / "docs" / "instance-setup-production.md"
DEMO_SETUP_DOC = REPO_ROOT / "docs" / "instance-setup-demo.md"


class XeroInstanceTemplateTests(SimpleTestCase):
def test_credentials_template_includes_xero_oauth_env_vars(self):
content = CREDENTIALS_TEMPLATE.read_text()

self.assertIn("XERO_DEFAULT_USER_ID=", content)
self.assertIn("XERO_CLIENT_ID=", content)
self.assertIn("XERO_CLIENT_SECRET=", content)
self.assertIn("XERO_WEBHOOK_KEY=", content)
Expand Down Expand Up @@ -72,3 +81,192 @@ def test_instance_script_requires_and_loads_xero_app_fixture(self):
self.assertNotIn(
'rm -f "$INSTANCE_DIR/apps/workflow/fixtures/xero_apps.json"', content
)

def test_instance_script_requires_xero_default_user_id(self) -> None:
content = INSTANCE_SCRIPT.read_text()

self.assertIn('[[ -z "${XERO_DEFAULT_USER_ID:-}" ]]', content)
self.assertIn('MISSING+=("XERO_DEFAULT_USER_ID")', content)
self.assertNotIn("UNCONFIGURED_XERO_DEFAULT_USER_ID", content)

def test_instance_script_exposes_reconfigure_as_convergent_command(self) -> None:
content = INSTANCE_SCRIPT.read_text()

self.assertIn("instance.sh reconfigure <client> <env>", content)
self.assertIn("do_reconfigure()", content)
self.assertIn("do_configure false reconfigure", content)
self.assertIn("reconfigure) do_reconfigure", content)

def test_instance_script_rerenders_env_preserving_generated_values(self) -> None:
content = INSTANCE_SCRIPT.read_text()

self.assertIn("render_instance_env()", content)
self.assertIn(
'db_password="$(read_env_value "$env_file" DB_PASSWORD)"', content
)
self.assertIn(
'test_db_password="$(read_env_value "$env_file" TEST_DB_PASSWORD)"',
content,
)
self.assertIn('secret_key="$(read_env_value "$env_file" SECRET_KEY)"', content)
self.assertIn(
'bearer_secret="$(read_env_value "$env_file" BEARER_SECRET)"',
content,
)
self.assertIn('tmp_env="$(mktemp "$instance_dir/.env.tmp.XXXXXX")"', content)
self.assertIn('mv "$tmp_env" "$env_file"', content)
self.assertNotIn(".env already exists — skipping", content)
self.assertIn(
'DB_PASSWORD="$(read_env_value "$INSTANCE_DIR/.env" DB_PASSWORD)"',
content,
)
self.assertNotIn('DB_PASSWORD="$(. "$INSTANCE_DIR/.env"', content)

def test_instance_script_only_seeds_missing_db_config(self) -> None:
content = INSTANCE_SCRIPT.read_text()

self.assertIn(
"AIProvider already configured; skipping ai_providers.json load", content
)
self.assertIn("if AIProvider.objects.exists()", content)
self.assertIn(
"XeroApp already configured; skipping xero_apps.json load", content
)
self.assertIn("if XeroApp.objects.exists()", content)
self.assertNotIn(
"python manage.py loaddata apps/workflow/fixtures/ai_providers.json",
content,
)

def test_instance_script_rejects_seed_for_existing_checkout(self) -> None:
content = INSTANCE_SCRIPT.read_text()

self.assertIn('[[ -d "$INSTANCE_DIR/.git" && "$SEED" == "true" ]]', content)
self.assertIn("--seed is only valid when creating a new instance", content)

def test_credentials_file_stays_root_owned_before_root_source(self) -> None:
common_content = COMMON_SCRIPT.read_text()
instance_content = INSTANCE_SCRIPT.read_text()
deploy_content = DEPLOY_SCRIPT.read_text()
server_setup_content = SERVER_SETUP_SCRIPT.read_text()

self.assertIn("require_root_owned_credentials_file()", common_content)
self.assertIn("stat -c '%u:%g:%a' \"$creds_file\"", common_content)
self.assertIn('"0:0:600"', common_content)
self.assertIn('[[ -L "$creds_file" ]]', common_content)
self.assertIn("ensure_config_dir()", common_content)
self.assertIn("stat -c '%u:%g:%a' \"$config_dir\"", common_content)
self.assertIn('"0:0:755"', common_content)
self.assertIn('[[ -L "$CONFIG_DIR" ]]', common_content)
self.assertIn('[[ -L "$config_dir" ]]', common_content)

self.assertIn('chown root:root "$CREDS_FILE"', instance_content)
self.assertIn(
'require_root_owned_credentials_file "$creds_file"',
instance_content,
)
self.assertIn(
'require_root_owned_credentials_file "$CREDS_FILE"',
instance_content,
)
self.assertNotIn(
'chown "$INSTANCE_USER:$INSTANCE_USER" "$CREDS_FILE"',
instance_content,
)

self.assertIn(
'require_root_owned_credentials_file "$creds_file"',
deploy_content,
)
self.assertIn("chown root:root /opt/docketworks/config", server_setup_content)
self.assertIn("chmod 755 /opt/docketworks/config", server_setup_content)

def test_node_major_parsing_accepts_patch_versions(self) -> None:
common_content = COMMON_SCRIPT.read_text()
deploy_content = DEPLOY_SCRIPT.read_text()
server_setup_content = SERVER_SETUP_SCRIPT.read_text()

self.assertIn("node_major_from_nvmrc()", common_content)
self.assertIn("node_major_from_nvmrc()", server_setup_content)
self.assertIn(
"sed -nE 's/^[[:space:]]*v?([0-9]+).*/\\1/p'",
common_content,
)
self.assertIn(
'REQUIRED_NODE_MAJOR="$(node_major_from_nvmrc '
'"$LOCAL_REPO/frontend/.nvmrc")',
deploy_content,
)
self.assertIn(
'REQUIRED_NODE_MAJOR="$(node_major_from_nvmrc '
'"$LOCAL_REPO/frontend/.nvmrc")',
server_setup_content,
)
self.assertNotIn("tr -d 'v[:space:]'", deploy_content)
self.assertNotIn("tr -d 'v[:space:]'", server_setup_content)

for nvmrc_value in ["18", "v18", "18.2.0", "v18.2.0", " v18.2.0"]:
with tempfile.NamedTemporaryFile("w", encoding="utf-8") as nvmrc:
nvmrc.write(nvmrc_value)
nvmrc.flush()

result = subprocess.run(
[
"bash",
"-c",
'source "$1"; node_major_from_nvmrc "$2"',
"_",
str(COMMON_SCRIPT),
nvmrc.name,
],
check=True,
capture_output=True,
text=True,
)

self.assertEqual(result.stdout.strip(), "18")

def test_instance_mediafiles_are_owned_for_app_writes_and_nginx_reads(
self,
) -> None:
content = INSTANCE_SCRIPT.read_text()

self.assertIn(
'chown "$INSTANCE_USER:www-data" "$INSTANCE_DIR/mediafiles"',
content,
)
self.assertIn('chmod 750 "$INSTANCE_DIR/mediafiles"', content)

def test_xero_default_user_id_docs_match_required_create_time_workflow(
self,
) -> None:
docs = "\n".join(
[
CREDENTIALS_TEMPLATE.read_text(),
SERVER_README.read_text(),
PRODUCTION_SETUP_DOC.read_text(),
DEMO_SETUP_DOC.read_text(),
]
)

self.assertIn("XERO_DEFAULT_USER_ID must be present", docs)
self.assertIn("required before `instance.sh create`", docs)
self.assertNotIn("leave blank for now", docs)
self.assertNotIn("Create the instance first", docs)
self.assertNotIn("copy that UUID", docs)
self.assertNotIn("Copy the relevant user ID into credentials.env", docs)
self.assertNotIn("then run `instance.sh reconfigure`", docs)

def test_deploy_restores_typed_router_after_drift_detection(self) -> None:
content = DEPLOY_SCRIPT.read_text()

self.assertIn(
"server generated a different frontend/src/typed-router.d.ts",
content,
)
self.assertIn(
'git -C "$instance_dir" restore --source=HEAD -- '
"frontend/src/typed-router.d.ts",
content,
)
self.assertIn('FAILED_INSTANCES+=("$instance")', content)
12 changes: 6 additions & 6 deletions docs/client_onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ The client needs a Xero subscription. DocketWorks handles jobs and delegates inv
6. Create the app, copy **Client ID** and **Client Secret**
7. Under Webhooks, create a subscription, copy the **Webhook Key**

These go into the instance's `credentials.env`.
These go into the instance's root-owned `credentials.env`.

---

Expand Down Expand Up @@ -216,7 +216,7 @@ Follow `uat_setup.md` (Part C) or the production deployment process.
```bash
# UAT
sudo scripts/server/instance.sh prepare-config <client> <env>
# Fill credentials.env with Xero values
sudoedit /opt/docketworks/config/<client>-<env>.credentials.env
sudo scripts/server/instance.sh create <client> <env>
```

Expand Down Expand Up @@ -298,10 +298,10 @@ Upload the company logo and wide/letterhead logo via Admin > Settings > Company

| Information | Destination |
|------------|-------------|
| Xero Client ID / Secret / Webhook Key | `credentials.env` |
| GCP service account JSON key path | `.env` (`GCP_CREDENTIALS`) |
| Google Maps API key | `.env` or `shared.env` (`GOOGLE_MAPS_API_KEY`) |
| Email SMTP credentials | `.env` or `shared.env` |
| Xero Client ID / Secret / Webhook Key | root-owned `credentials.env` |
| GCP service account JSON key path | root-owned `credentials.env` (`GCP_CREDENTIALS`) |
| Google Maps API key | `shared.env` (`GOOGLE_MAPS_API_KEY`) |
| Email SMTP credentials | root-owned `credentials.env` |
| Supplier credentials (Steel & Tube) | `.env` |
| Company details, rates, markups, hours | CompanyDefaults (Admin > Settings) |
| Google Drive folder IDs | CompanyDefaults (Admin > Settings) |
Expand Down
32 changes: 14 additions & 18 deletions docs/instance-setup-demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,22 @@ Onboard a prospect for a paid trial of DocketWorks. Uses dummy staff but the pro
sudo scripts/server/instance.sh prepare-config <client> uat
```

Fill in `/opt/docketworks/instances/<client>-uat/credentials.env`:
Edit the root-owned credentials file:

```bash
sudoedit /opt/docketworks/config/<client>-uat.credentials.env
```

Fill in:
- XERO_DEFAULT_USER_ID — the existing Xero Demo Company login/user ID that will own time entries
- GCP_CREDENTIALS — shared dev service account key
- EMAIL credentials

(The Xero Client ID, Client Secret, and Webhook Key for the **Xero Demo Company** app go into the `xero_apps.json` fixture in Step 3.5, not `credentials.env`.)
XERO_DEFAULT_USER_ID must be present before `instance.sh create` runs.

Also fill in the Xero Client ID, Client Secret, Webhook Key, and Redirect URI
for the **Xero Demo Company** app. `instance.sh create` uses these values to
render and load the initial XeroApp fixture.

## Step 2: Create Instance

Expand All @@ -47,23 +58,8 @@ scripts/server/dw-run.sh <client>-uat python manage.py loaddata apps/workflow/fi
scripts/server/dw-run.sh <client>-uat python scripts/restore_checks/check_company_defaults.py
```

## Step 3.5: Load Xero App Credentials
## Step 3.5: Check Xero App Credentials

Copy the example fixture and fill in the **Xero Demo Company** app's Client ID, Client Secret, Redirect URI, and Webhook Key. Set `label` to something identifiable like `<client>-uat xero`.

```bash
# instance.sh creates the checkout directly at /opt/docketworks/instances/<INSTANCE>/
# (no /docketworks suffix) and the OS user as dw_<client>_<env> (underscores —
# matches the DB role; see scripts/server/common.sh:instance_user).
INSTANCE_DIR=/opt/docketworks/instances/<client>-uat
sudo -u dw_<client>_uat cp \
$INSTANCE_DIR/apps/workflow/fixtures/xero_apps.json.example \
$INSTANCE_DIR/apps/workflow/fixtures/xero_apps.json
sudo -u dw_<client>_uat $EDITOR $INSTANCE_DIR/apps/workflow/fixtures/xero_apps.json
scripts/server/dw-run.sh <client>-uat python manage.py loaddata apps/workflow/fixtures/xero_apps.json
```

**Check:**
```bash
scripts/server/dw-run.sh <client>-uat python scripts/restore_checks/check_xero_app.py
```
Expand Down
Loading
Loading