New specs: archer-template-library, ccp-metrics-view-restructure, compliance-list-stale-after-sidebar-edit, compliance-metric-estimated-resolution-date, compliance-remediation-display-fix, flexible-jira-ticket-creation, forecast-burndown-chart, granite-loader-export, ivanti-queue-clear-completed-fix, multi-item-jira-ticket, queue-collapsible-sections, vendor-issue-type-dropdown New steering: archer-template-gen.md Updated: migration-registration-check hook, remediation-plan-history spec, gitlab-workflow, tech, versioning steering files
519 lines
21 KiB
Markdown
519 lines
21 KiB
Markdown
# Design Document: Archer Template Library
|
|
|
|
## Overview
|
|
|
|
The Archer Template Library adds a template management system to the STEAM Security Dashboard, allowing editors to store, browse, and reuse pre-filled content for Archer Risk Acceptance forms. Templates are organized by a Vendor > Platform > Model hierarchy and contain the static and semi-static content sections that map directly to the external Archer eGRC application's form fields.
|
|
|
|
The feature integrates into the existing Ivanti Todo Queue page where Archer workflow items are worked. When an editor selects an Archer queue item, they can pick a template, view its sections, and copy content to their clipboard for pasting into the external Archer application.
|
|
|
|
### Design Decisions
|
|
|
|
1. **Separate route module** (`routes/archerTemplates.js`) following the project's factory pattern — keeps template CRUD isolated from existing `archerTickets.js` which handles EXC number tracking.
|
|
2. **PostgreSQL table** with a composite unique index on `(LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)))` to enforce case-insensitive uniqueness at the database level.
|
|
3. **Single page component** (`ArcherTemplatePage.js`) for the Template Manager, plus a **reusable TemplateSelector component** embedded in the existing `IvantiTodoQueuePage.js` for the queue workflow integration.
|
|
4. **No separate search endpoint** — the list endpoint handles search/filter via query parameters, keeping the API surface small.
|
|
5. **Clipboard API** (`navigator.clipboard.writeText`) for copy operations — the dashboard already runs on HTTPS in production.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
graph TD
|
|
subgraph Frontend
|
|
TM[ArcherTemplatePage.js<br/>Template Manager]
|
|
TS[TemplateSelector.js<br/>Queue Integration]
|
|
IQ[IvantiTodoQueuePage.js]
|
|
end
|
|
|
|
subgraph Backend
|
|
R[routes/archerTemplates.js<br/>Express Router]
|
|
A[helpers/auditLog.js]
|
|
DB[(PostgreSQL<br/>archer_templates)]
|
|
end
|
|
|
|
TM -->|fetch /api/archer-templates| R
|
|
TS -->|fetch /api/archer-templates| R
|
|
IQ -->|embeds| TS
|
|
R -->|pool.query| DB
|
|
R -->|logAudit| A
|
|
A -->|fire-and-forget INSERT| DB
|
|
```
|
|
|
|
### Request Flow
|
|
|
|
1. Frontend component calls `/api/archer-templates/*` with session cookie
|
|
2. `requireAuth()` middleware validates session, attaches `req.user`
|
|
3. Write operations additionally pass through `requireGroup('Admin', 'Standard_User')`
|
|
4. Route handler validates input, executes PostgreSQL query via `pool`
|
|
5. On success of write operations, `logAudit()` is called fire-and-forget
|
|
6. Response returned to frontend
|
|
|
|
---
|
|
|
|
## Components and Interfaces
|
|
|
|
### Backend: `routes/archerTemplates.js`
|
|
|
|
Factory function signature:
|
|
|
|
```javascript
|
|
function createArcherTemplatesRouter() {
|
|
// Returns Express Router
|
|
// Imports pool from '../db', auth from '../middleware/auth', logAudit from '../helpers/auditLog'
|
|
}
|
|
```
|
|
|
|
#### API Endpoints
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| `GET` | `/api/archer-templates` | requireAuth | List/search/filter templates |
|
|
| `GET` | `/api/archer-templates/:id` | requireAuth | Get single template by ID |
|
|
| `POST` | `/api/archer-templates` | requireAuth + requireGroup | Create template |
|
|
| `PUT` | `/api/archer-templates/:id` | requireAuth + requireGroup | Update template |
|
|
| `DELETE` | `/api/archer-templates/:id` | requireAuth + requireGroup | Delete template |
|
|
| `POST` | `/api/archer-templates/:id/clone` | requireAuth + requireGroup | Clone template |
|
|
| `GET` | `/api/archer-templates/hierarchy/vendors` | requireAuth | Distinct vendors list |
|
|
| `GET` | `/api/archer-templates/hierarchy/platforms` | requireAuth | Distinct platforms for vendor |
|
|
| `GET` | `/api/archer-templates/hierarchy/models` | requireAuth | Distinct models for vendor+platform |
|
|
|
|
#### `GET /api/archer-templates`
|
|
|
|
**Query Parameters:**
|
|
|
|
| Param | Type | Description |
|
|
|-------|------|-------------|
|
|
| `search` | string | Substring match across vendor, platform, model (case-insensitive). Ignored if empty/whitespace-only. |
|
|
| `vendor` | string | Exact match filter on vendor (case-insensitive) |
|
|
| `platform` | string | Exact match filter on platform (case-insensitive) |
|
|
| `model` | string | Exact match filter on model (case-insensitive) |
|
|
|
|
**Response:** `200 OK`
|
|
```json
|
|
[
|
|
{
|
|
"id": 1,
|
|
"vendor": "Harmonic",
|
|
"platform": "vCMTS",
|
|
"model": "3.29.1",
|
|
"environment_overview": "...",
|
|
"segmentation": "...",
|
|
"mitigating_controls": "...",
|
|
"additional_info": "...",
|
|
"charter_network_banner": "...",
|
|
"data_classification": "...",
|
|
"charter_network": "...",
|
|
"additional_access_list": "...",
|
|
"created_by": 3,
|
|
"created_at": "2025-01-15T10:30:00Z",
|
|
"updated_at": "2025-01-15T10:30:00Z"
|
|
}
|
|
]
|
|
```
|
|
|
|
**Filtering logic:**
|
|
- `search` applies as `ILIKE '%value%'` across vendor OR platform OR model
|
|
- Field-specific filters apply as `LOWER(TRIM(field)) = LOWER(TRIM(value))`
|
|
- When both search and filters are present, they combine with AND logic
|
|
- Results always sorted by `vendor ASC, platform ASC, model ASC`
|
|
|
|
#### `POST /api/archer-templates`
|
|
|
|
**Request Body:**
|
|
```json
|
|
{
|
|
"vendor": "Harmonic",
|
|
"platform": "vCMTS",
|
|
"model": "3.29.1",
|
|
"environment_overview": "content...",
|
|
"segmentation": "content...",
|
|
"mitigating_controls": "",
|
|
"additional_info": "",
|
|
"charter_network_banner": "",
|
|
"data_classification": "",
|
|
"charter_network": "",
|
|
"additional_access_list": ""
|
|
}
|
|
```
|
|
|
|
**Validation:**
|
|
- `vendor`, `platform`, `model`: required, 1-100 chars after trim, non-empty after trim
|
|
- Section fields: optional, max 10,000 chars each, default to empty string
|
|
- Uniqueness: `LOWER(TRIM(vendor)) + LOWER(TRIM(platform)) + LOWER(TRIM(model))` must be unique
|
|
|
|
**Response:** `201 Created`
|
|
```json
|
|
{
|
|
"id": 1,
|
|
"vendor": "Harmonic",
|
|
"platform": "vCMTS",
|
|
"model": "3.29.1",
|
|
"environment_overview": "content...",
|
|
"...": "...",
|
|
"created_by": 3,
|
|
"created_at": "2025-01-15T10:30:00Z",
|
|
"updated_at": "2025-01-15T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- `400` — missing or invalid fields (includes field names in error message)
|
|
- `409` — duplicate vendor/platform/model combination
|
|
|
|
#### `PUT /api/archer-templates/:id`
|
|
|
|
**Request Body:** Partial — only provided fields are updated.
|
|
|
|
```json
|
|
{
|
|
"vendor": "Harmonic",
|
|
"mitigating_controls": "updated content..."
|
|
}
|
|
```
|
|
|
|
**Validation:** Same rules as create for any provided field. If vendor/platform/model change, uniqueness is re-checked against other templates (excluding self).
|
|
|
|
**Response:** `200 OK` — returns full updated template object.
|
|
|
|
**Error Responses:**
|
|
- `400` — invalid field values
|
|
- `404` — template ID not found
|
|
- `409` — new vendor/platform/model combination conflicts with another template
|
|
|
|
#### `DELETE /api/archer-templates/:id`
|
|
|
|
**Response:** `200 OK`
|
|
```json
|
|
{ "message": "Template deleted successfully" }
|
|
```
|
|
|
|
**Error Responses:**
|
|
- `404` — template ID not found
|
|
|
|
#### `POST /api/archer-templates/:id/clone`
|
|
|
|
**Request Body:**
|
|
```json
|
|
{
|
|
"vendor": "Harmonic",
|
|
"platform": "vCMTS",
|
|
"model": "3.30.0"
|
|
}
|
|
```
|
|
|
|
**Validation:**
|
|
- All three hierarchy fields required
|
|
- At least one must differ from source (enforced by uniqueness constraint)
|
|
- Same length/format validation as create
|
|
|
|
**Response:** `201 Created` — returns full new template object with copied section content.
|
|
|
|
**Error Responses:**
|
|
- `400` — missing/invalid hierarchy fields
|
|
- `404` — source template not found
|
|
- `409` — target combination already exists
|
|
|
|
#### `GET /api/archer-templates/hierarchy/vendors`
|
|
|
|
**Response:** `200 OK`
|
|
```json
|
|
["Adtran", "Harmonic", "Vecima"]
|
|
```
|
|
|
|
#### `GET /api/archer-templates/hierarchy/platforms?vendor=Harmonic`
|
|
|
|
**Query Parameters:** `vendor` (required)
|
|
|
|
**Response:** `200 OK`
|
|
```json
|
|
["RPD", "vCMTS"]
|
|
```
|
|
|
|
**Error:** `400` if `vendor` param missing.
|
|
|
|
#### `GET /api/archer-templates/hierarchy/models?vendor=Harmonic&platform=vCMTS`
|
|
|
|
**Query Parameters:** `vendor` (required), `platform` (required)
|
|
|
|
**Response:** `200 OK`
|
|
```json
|
|
["3.29.1", "3.30.0"]
|
|
```
|
|
|
|
**Error:** `400` if either param missing.
|
|
|
|
---
|
|
|
|
### Frontend: `ArcherTemplatePage.js`
|
|
|
|
Full-page Template Manager component at `frontend/src/components/pages/ArcherTemplatePage.js`.
|
|
|
|
**Component Structure:**
|
|
|
|
```
|
|
ArcherTemplatePage
|
|
├── Header (title + create button)
|
|
├── TemplateList (grouped by vendor > platform)
|
|
│ ├── VendorGroup (collapsible)
|
|
│ │ ├── PlatformSubgroup
|
|
│ │ │ └── TemplateRow (model, edit/clone/delete buttons)
|
|
│ │ └── ...
|
|
│ └── ...
|
|
├── TemplateFormModal (create/edit/clone)
|
|
│ ├── Hierarchy fields (vendor, platform, model)
|
|
│ └── Section textareas (8 sections with labels)
|
|
└── DeleteConfirmModal
|
|
```
|
|
|
|
**State Management:** Local `useState` — no global state needed. Template list fetched on mount and after mutations.
|
|
|
|
**Role-based rendering:** Uses `useAuth().canWrite()` to conditionally show action buttons. Viewers see the list but no create/edit/delete/clone controls.
|
|
|
|
---
|
|
|
|
### Frontend: `TemplateSelector.js`
|
|
|
|
Reusable component embedded in `IvantiTodoQueuePage.js` when viewing an Archer workflow item.
|
|
|
|
**Component Structure:**
|
|
|
|
```
|
|
TemplateSelector
|
|
├── SearchableDropdown
|
|
│ ├── Search input
|
|
│ └── Filtered template list
|
|
├── SectionPanel (shown after selection)
|
|
│ ├── StaticSections (Environment Overview, Segmentation, Mitigating Controls)
|
|
│ ├── SemiStaticSections (Additional Info, Charter Network Banner, etc.)
|
|
│ └── CopyAllButton
|
|
└── SectionBlock (repeated for each section)
|
|
├── Label
|
|
├── Content (or "No content stored" placeholder)
|
|
└── CopyButton (with confirmation state)
|
|
```
|
|
|
|
**Props:**
|
|
```javascript
|
|
// No props needed — fetches templates from API independently
|
|
// Appears as an expandable panel within the Archer queue item view
|
|
```
|
|
|
|
---
|
|
|
|
## Data Models
|
|
|
|
### Database Table: `archer_templates`
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS archer_templates (
|
|
id SERIAL PRIMARY KEY,
|
|
vendor VARCHAR(100) NOT NULL,
|
|
platform VARCHAR(100) NOT NULL,
|
|
model VARCHAR(100) NOT NULL,
|
|
environment_overview TEXT NOT NULL DEFAULT '',
|
|
segmentation TEXT NOT NULL DEFAULT '',
|
|
mitigating_controls TEXT NOT NULL DEFAULT '',
|
|
additional_info TEXT NOT NULL DEFAULT '',
|
|
charter_network_banner TEXT NOT NULL DEFAULT '',
|
|
data_classification TEXT NOT NULL DEFAULT '',
|
|
charter_network TEXT NOT NULL DEFAULT '',
|
|
additional_access_list TEXT NOT NULL DEFAULT '',
|
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Case-insensitive uniqueness on trimmed vendor/platform/model
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_archer_templates_unique_combo
|
|
ON archer_templates (LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)));
|
|
|
|
-- Index for list ordering and search
|
|
CREATE INDEX IF NOT EXISTS idx_archer_templates_vendor ON archer_templates(vendor);
|
|
CREATE INDEX IF NOT EXISTS idx_archer_templates_platform ON archer_templates(platform);
|
|
```
|
|
|
|
### Migration File: `backend/migrations/add_archer_templates_table.js`
|
|
|
|
Follows the project's migration pattern — idempotent, uses `IF NOT EXISTS`.
|
|
|
|
### Section Field Mapping
|
|
|
|
| DB Column | Archer Form Label | Type |
|
|
|-----------|------------------|------|
|
|
| `environment_overview` | Environment Overview | Static |
|
|
| `segmentation` | Segmentation | Static |
|
|
| `mitigating_controls` | Mitigating Controls | Static |
|
|
| `additional_info` | Additional Info/Background | Semi-Static |
|
|
| `charter_network_banner` | Charter Network Banner | Semi-Static |
|
|
| `data_classification` | Data Classification | Semi-Static |
|
|
| `charter_network` | Charter Network | Semi-Static |
|
|
| `additional_access_list` | Additional Access List | Semi-Static |
|
|
|
|
---
|
|
|
|
## Correctness Properties
|
|
|
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
|
|
|
### Property 1: Template data round-trip preservation
|
|
|
|
*For any* valid template with vendor (1-100 chars), platform (1-100 chars), model (1-100 chars), and section content (0-10,000 chars each), creating the template via the API and then retrieving it by ID SHALL return the exact same field values (after trimming vendor/platform/model whitespace).
|
|
|
|
**Validates: Requirements 1.1, 1.2, 1.5, 2.1**
|
|
|
|
### Property 2: Uniqueness enforcement across all write operations
|
|
|
|
*For any* two templates where `LOWER(TRIM(vendor))`, `LOWER(TRIM(platform))`, and `LOWER(TRIM(model))` are identical, the system SHALL reject the second operation (create, update, or clone) with a 409 status code, regardless of case or leading/trailing whitespace differences.
|
|
|
|
**Validates: Requirements 1.3, 2.2, 2.8, 3.2, 3.5**
|
|
|
|
### Property 3: Input validation rejects invalid hierarchy fields
|
|
|
|
*For any* template create or update request where vendor, platform, or model is empty after trimming, consists only of whitespace, or exceeds 100 characters, the API SHALL reject the request with a 400 status code and an error message identifying which field(s) failed validation.
|
|
|
|
**Validates: Requirements 1.6, 2.3**
|
|
|
|
### Property 4: List and search results are sorted
|
|
|
|
*For any* set of templates in the database and any combination of search/filter parameters, the API response SHALL return results ordered by vendor ascending, then platform ascending, then model ascending (lexicographic, case-insensitive).
|
|
|
|
**Validates: Requirements 2.4, 6.4**
|
|
|
|
### Property 5: Search and filter semantics with AND logic
|
|
|
|
*For any* search query (substring match on vendor/platform/model) combined with any field-specific exact-match filters (vendor, platform, model), the API SHALL return only templates that satisfy ALL conditions simultaneously: the search substring appears in at least one hierarchy field AND each provided filter exactly matches its respective field (case-insensitive).
|
|
|
|
**Validates: Requirements 2.5, 2.6, 6.1, 6.2, 6.3**
|
|
|
|
### Property 6: Partial update preserves unspecified fields
|
|
|
|
*For any* existing template and any subset of updatable fields provided in a PUT request, the API SHALL modify only the specified fields and leave all other fields unchanged, while setting `updated_at` to the current timestamp.
|
|
|
|
**Validates: Requirements 2.7**
|
|
|
|
### Property 7: Delete removes template permanently
|
|
|
|
*For any* existing template, after a successful DELETE request, a subsequent GET request for that template's ID SHALL return 404.
|
|
|
|
**Validates: Requirements 2.9**
|
|
|
|
### Property 8: Write operations require editor or admin role
|
|
|
|
*For any* user with viewer role (or no authenticated session), all write operations (POST create, PUT update, DELETE, POST clone) SHALL be rejected with 401 (unauthenticated) or 403 (insufficient permissions).
|
|
|
|
**Validates: Requirements 2.10, 4.13**
|
|
|
|
### Property 9: Non-existent template ID returns 404
|
|
|
|
*For any* template ID that does not exist in the database, GET, PUT, DELETE, and clone requests targeting that ID SHALL return a 404 status code.
|
|
|
|
**Validates: Requirements 2.12, 3.4**
|
|
|
|
### Property 10: Clone preserves all section content
|
|
|
|
*For any* existing template, cloning it with new vendor/platform/model values SHALL produce a new template whose 8 section content fields are byte-for-byte identical to the source template, with a different ID, new `created_at` timestamp, and `created_by` set to the requesting user.
|
|
|
|
**Validates: Requirements 3.1, 3.3**
|
|
|
|
### Property 11: Hierarchy endpoints return distinct sorted values
|
|
|
|
*For any* set of templates, the vendors endpoint SHALL return a deduplicated, alphabetically sorted array of vendor names; the platforms endpoint (given a vendor) SHALL return only distinct platforms from templates matching that vendor; and the models endpoint (given vendor+platform) SHALL return only distinct models from matching templates — all sorted alphabetically ascending.
|
|
|
|
**Validates: Requirements 7.1, 7.2, 7.3**
|
|
|
|
### Property 12: Copy All concatenation format
|
|
|
|
*For any* template with a mix of populated and empty sections, the "Copy All" operation SHALL produce a string that concatenates only non-empty sections, each preceded by its human-readable section header, in the order: static sections first (Environment Overview, Segmentation, Mitigating Controls) then semi-static sections (Additional Info/Background, Charter Network Banner, Data Classification, Charter Network, Additional Access List).
|
|
|
|
**Validates: Requirements 5.9**
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### Backend Error Strategy
|
|
|
|
| Scenario | HTTP Status | Error Response Format |
|
|
|----------|-------------|----------------------|
|
|
| Missing required field | 400 | `{ "error": "Vendor is required" }` or `{ "error": "Missing fields: vendor, model" }` |
|
|
| Field too long | 400 | `{ "error": "Vendor must be 100 characters or fewer" }` |
|
|
| Section too long | 400 | `{ "error": "environment_overview must be 10,000 characters or fewer" }` |
|
|
| Duplicate combination | 409 | `{ "error": "A template with this vendor/platform/model combination already exists" }` |
|
|
| Template not found | 404 | `{ "error": "Template not found" }` |
|
|
| Not authenticated | 401 | `{ "error": "Authentication required" }` |
|
|
| Insufficient permissions | 403 | `{ "error": "Insufficient permissions", "required": [...], "current": "..." }` |
|
|
| Database error | 500 | `{ "error": "Internal server error" }` |
|
|
|
|
### Frontend Error Handling
|
|
|
|
- **Network errors:** Caught in try/catch around `fetch()`, displayed as a banner message within the component.
|
|
- **Validation errors (400):** Displayed inline next to the relevant form field or as a general form error.
|
|
- **Conflict errors (409):** Displayed as a warning banner at the top of the form indicating the duplicate.
|
|
- **Auth errors (401/403):** Handled by AuthContext — 401 triggers redirect to login, 403 shows permission denied message.
|
|
- **Clipboard failures:** If `navigator.clipboard.writeText` rejects (e.g., permissions denied), display a fallback message suggesting manual copy.
|
|
|
|
### Audit Log Resilience
|
|
|
|
Per requirement 8.5, `logAudit()` is called fire-and-forget. If the audit insert fails, the error is logged to `console.error` but the main operation's response is unaffected. This matches the existing pattern in `helpers/auditLog.js`.
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Property-Based Tests (fast-check)
|
|
|
|
The project uses `fast-check` for property-based testing (visible in existing `__tests__/*.property.test.js` files). Each correctness property above maps to one property-based test with a minimum of 100 iterations.
|
|
|
|
**Test file:** `backend/__tests__/archer-template-library.property.test.js`
|
|
|
|
**Properties to implement:**
|
|
|
|
| Property | Test Description |
|
|
|----------|-----------------|
|
|
| 1 | Template data round-trip — create + GET preserves all fields |
|
|
| 2 | Uniqueness enforcement — duplicate combinations rejected across create/update/clone |
|
|
| 3 | Input validation — invalid hierarchy fields rejected with 400 |
|
|
| 4 | Sorted results — list/search always returns sorted by vendor/platform/model |
|
|
| 5 | Search + filter AND logic — combined criteria narrow results correctly |
|
|
| 6 | Partial update semantics — unspecified fields preserved |
|
|
| 7 | Delete permanence — deleted templates return 404 |
|
|
| 8 | Access control — viewer role cannot perform write operations |
|
|
| 9 | Non-existent ID — 404 for all operations on missing templates |
|
|
| 10 | Clone content preservation — cloned sections match source |
|
|
| 11 | Hierarchy distinct values — deduplicated and sorted |
|
|
| 12 | Copy All format — concatenation includes only non-empty sections in order |
|
|
|
|
**Tag format:** Each test tagged with `// Feature: archer-template-library, Property N: <property text>`
|
|
|
|
**Configuration:** 100 iterations minimum per property.
|
|
|
|
### Unit / Example-Based Tests
|
|
|
|
**Test file:** `backend/__tests__/archer-template-library.test.js`
|
|
|
|
- Template creation with all sections populated
|
|
- Template creation with no sections (defaults to empty strings)
|
|
- Timestamp metadata correctness (created_at, updated_at, created_by)
|
|
- Clone metadata (new created_at, new created_by)
|
|
- Hierarchy endpoint without required params returns 400
|
|
- Empty search results return 200 with empty array
|
|
- Whitespace-only search param is ignored
|
|
- Authentication required for read endpoints (401 without session)
|
|
|
|
### Integration Tests
|
|
|
|
**Test file:** `backend/__tests__/archer-template-library.integration.test.js`
|
|
|
|
- Audit log entries created for create/update/delete/clone
|
|
- Audit log failure does not block template operation
|
|
- Failed operations do not produce audit entries
|
|
- Full workflow: create → list → update → clone → delete
|
|
|
|
### Frontend Tests
|
|
|
|
- TemplateSelector copy-to-clipboard behavior
|
|
- TemplateFormModal validation prevents submission with empty required fields
|
|
- Viewer role sees no action buttons
|
|
- Template list grouped by vendor/platform renders correctly
|