Compare commits
8
Commits
c52edfa9b1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18333600b5 | ||
|
|
6adaf7bf25 | ||
|
|
5f8165c833 | ||
|
|
dad21db65f | ||
|
|
8fb0818092 | ||
|
|
f5390ac7cf | ||
|
|
1cf37d94e7 | ||
|
|
a5246e2aa5 |
+8
-1
@@ -6,6 +6,7 @@ All notable changes to this project are recorded here. The format follows [Keep
|
||||
|
||||
### Decided
|
||||
|
||||
- Gitea issue 57: Spencer chose the stricter archive rule (2026-11-03, open question 53). The archive copy must succeed before a packet counts as filed. A failed copy keeps the packet where it was and tells Admin. See Changed.
|
||||
- Gitea issue 9: SethD kept the drawing-origin change order for a same-rev re-release on an in-shop packet (2026-10-29), so the shop confirms the new pages. A byte-identical file is still ignored. Nothing changes in the build.
|
||||
- Gitea issue 4: SethD wants the order a packet shows to follow the shop schedule, which is Presley's Production Planner (2026-10-29). Asked on the issue on 2026-10-30: what it is, where it lives, what a row holds and how often it changes. Packet Station would only read it. The order rule stays as built until then.
|
||||
- Gitea issue 31: two follow-up questions asked on 2026-10-30. A work order deleted from VISUAL while open blocks filing for good: count a leg gone for 2 runs as closed, or let an Admin close it with a reason? And should Shipping packets also wait for the VISUAL close? Both stay as built until answered.
|
||||
@@ -23,8 +24,13 @@ All notable changes to this project are recorded here. The format follows [Keep
|
||||
- Gitea issue 20: the storage alert stays at 85 percent. Phase 9 mails it to PS-Admins.
|
||||
- Gitea issue 38: the test stack's files stay under `~/packetstation/test` on docker-1. The test env example, the compose header and the docs now give that path.
|
||||
|
||||
### Removed
|
||||
|
||||
- `scripts/Request-VsqlServerCertificate.ps1`, its Pester tests and its DEPLOY.md procedure. IT issues and binds the vsql certificate by hand. Gitea issue 5 still tracks the switch to `VISUAL_ENCRYPT=yes`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The hand drawing, hand BOM and rev approval lines on a VISUAL draft showed the person's user id instead of their name.
|
||||
- The MTR viewer showed PDF certificates in a frame the server's own headers refuse, so Chrome showed nothing. It now draws them with the drawing viewer.
|
||||
|
||||
- Gitea issue 42: work-order text on the traveler stays inside its own box. Each line shrinks down to 70 percent of its size, then is cut with "...", so a long work order, operation or drawing no longer runs over the next QR code. The gap between work-order boxes is 18 pt, a quiet zone for the kiosk scanner. On the Work orders tab the card text wraps inside the card, the work order is 22 px and cards are at least 340 px wide, one per row on a narrow screen.
|
||||
@@ -33,6 +39,7 @@ All notable changes to this project are recorded here. The format follows [Keep
|
||||
|
||||
### Changed
|
||||
|
||||
- Filing copies the packet to the archive inside the filing transaction (Gitea issue 57). If the folder check or the copy fails, nothing is filed: the packet stays in its previous stage, the person filing sees why (503 `archive_failed`), and Admin gets the critical `archive_failed` alert for that packet with who tried, the folder and the error, plus an `archive.filing_blocked` audit row. Filing it again once the archive works files it and clears the alert. The `archive-copy` worker job now only catches up filings made while the archive was off. With the archive off or no folder saved, filing is unchanged.
|
||||
- Phase 10: the content security policy is final. Styles no longer need `'unsafe-inline'`, `data:` images and `blob:` workers are gone, and `frame-src` and `object-src` are `'none'` (a PDF opened in its own tab gets `object-src 'self'` so the browser can show it). The print area's rules moved into the theme stylesheet.
|
||||
- Phase 10: backups and the restore run on the VM with `pg_dump` and `pg_restore` as the postgres user, with no `docker exec`. Same markers, 7 dumps and the 02:00 timer.
|
||||
- Phase 10: security fixes in the pinned dependencies found by the CI scan: `pypdf` 6.21.2 to 6.21.4, `pyasn1` 0.6.4 to 0.6.5, and `certifi` to 2026.10.23.
|
||||
@@ -62,7 +69,7 @@ All notable changes to this project are recorded here. The format follows [Keep
|
||||
- Phase 10: archive location (Gitea issue 7). With `ARCHIVE_ROOT` set, an Admin picks the archive folder under Admin, Storage, File locations. The folder must be under the root: `..`, absolute paths elsewhere and links that leave the root are refused, and nothing creates folders. Check writes, reads back and removes a probe file. Save runs the same check first. Each filing is copied to `<folder>/<project>/<unit>/<data book>-seal<id>/`: the sealed data book, the drawing pages, the packet documents, the unit's and project's Q.C. folder documents, the MTRs and a `manifest.json` with every file's sha256. The new `archive-copy` job runs every 15 minutes and right after each filing. A failed copy never blocks filing. It is retried after 5, 15 and 60 minutes, then hourly, and raises the critical `archive_failed` notification after 3 tries; "Retry now" asks for a run. Turning the archive on later also copies earlier filings. Nothing in the archive is overwritten or deleted. Every setting change, check and copy is audited. `packetstation doctor` checks the folder. Migration 0012 adds `archive_copies`.
|
||||
- Phase 10: a banner tells everyone when the server was updated ("Packet Station was updated. Reload to get the new version.") with a Reload button. It checks on focus, on return to the tab, on an event-stream reconnect and every 5 minutes. It never reloads by itself.
|
||||
- Phase 10: native VM install kit in `deploy/vm/` for production and test, each on its own Linux VM without Docker: systemd units for the app, the worker, the migration and a firewall that lets only NPM reach port 8000; a launcher that hands each unit only its own secrets; `install.sh` (PostgreSQL 16, ODBC Driver 18, the PESCO CA, the service user and folders) and `upgrade.sh` with rollback; `ps-run`, `ps-doctor`, env examples with explicit `VISUAL_SERVER` and `VISUAL_DATABASE`. DEPLOY.md has the steps, the NPM proxy hosts for the VMs and the archive NFS mount.
|
||||
- Phase 10: regression bench. The old build's 38 scenarios are mapped to Playwright specs in `tests/e2e/BENCH.md`; the missing ones are new specs on synthetic fixtures, and every scenario fails on a CSP violation.
|
||||
- Phase 10: regression bench. The old build's 38 scenarios are mapped to Playwright specs in `tests/e2e/BENCH.md`; the missing ones are new specs on synthetic fixtures (111 Playwright tests, 4.5 minutes), and every test fails on a CSP violation.
|
||||
- Phase 10: GO-LIVE.md (date and feed boundary, groups, tablets, URL, cut-over, retiring M: as a read-only archive, production start, checklist, incident notes) and PAPER-FALLBACK.md (what each department does while the server is down and how paper is entered back).
|
||||
|
||||
- Gitea issue 18 (change C7): the department supervisor approves every received short, one receipt at a time, with a signature ("Approve a received short"), after checking the part and its heat number against what the warehouse issued. A lead is not enough. The Materials tab lists each receipt with who received it and who approved it, and an Approve button for the supervisor. A packet cannot be accepted as complete, or filed, until every short is received and approved ("Every short must be received and approved by the supervisor first."). Accept and route to another department does not wait. `POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve`; the packet read gains `shorts` and `shorts_unapproved`. Migration 0011 adds the append-only `short_approvals` table.
|
||||
|
||||
+1
-59
@@ -179,65 +179,7 @@ Docker's default address pool on docker-1 holds fifteen /16 bridge networks (172
|
||||
|
||||
## Known issue: vsql serves a weak self-signed certificate (found 2026-10-18)
|
||||
|
||||
`vsql.pesco.com\DEV` serves TLS (`VISUAL_ENCRYPT=trust` connects), but with `VISUAL_ENCRYPT=yes` OpenSSL in the image refuses it with "EE certificate key too weak": the server certificate's RSA key is under 2048 bits, which is what SQL Server's auto-generated self-signed certificate looks like. IT needs to issue vsql a 2048-bit, SHA-256 certificate from PESCO-PSYLOCKE-CA with `vsql.pesco.com` in the subject alternative names, install it on the DEV and production instances in SQL Server Configuration Manager and restart them. The image then needs the PESCO CA in its OpenSSL trust store for the ODBC driver (the entrypoint can concatenate the system bundle and /etc/ssl/pesco/ca.pem into /tmp and export `SSL_CERT_FILE`), and the stacks switch to `VISUAL_ENCRYPT=yes`. Tracked as Gitea issue 5.
|
||||
|
||||
### Installing the vsql certificate
|
||||
|
||||
`scripts/Request-VsqlServerCertificate.ps1` does the request, the checks and the binding. Spencer runs it on the SQL Server host, in an elevated PowerShell, as an account that may enroll from the WebServer template. Every step there is a real change, so preview first. Copy only the script to `C:\ProgramData\PacketStation\` and work from there.
|
||||
|
||||
1. Preflight. Nothing changes. Check the instance names, the service accounts, the INF and the list of steps.
|
||||
|
||||
```powershell
|
||||
cd C:\ProgramData\PacketStation
|
||||
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Instances DEV -WhatIf
|
||||
```
|
||||
|
||||
2. DEV first. This enrolls, validates, grants the DEV service account read on the key, saves a snapshot and binds DEV. Production is untouched.
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Instances DEV
|
||||
```
|
||||
|
||||
If the CA holds the request as pending, the script stops with the request id. Issue it on the CA (certsrv.msc, Pending Requests), then run the `certreq -retrieve` line it printed and continue with `-CerFile <that .cer> -Instances DEV`. If validation fails, nothing is bound. The usual cause is a template that forces a Key Storage Provider.
|
||||
|
||||
3. Restart DEV and check the log. `Restart-Service -Name 'MSSQL$DEV' -Force`, then start `SQLAgent$DEV` again if it ran. Or rerun with `-Thumbprint <thumbprint> -Instances DEV -RestartInstances`, which leaves the binding alone and does both. The instance's ERRORLOG should read `The certificate [Cn = vsql.pesco.com] was successfully loaded for encryption`. If the instance does not start, roll back (step 6).
|
||||
|
||||
4. Check from docker-1. Export the certificate (`Export-Certificate -Cert Cert:\LocalMachine\My\<thumbprint> -FilePath vsql.cer`) and copy it to docker-1. OpenSSL cannot speak TDS, so `openssl s_client` does not work against SQL Server. Verify the file at the image's security level instead:
|
||||
|
||||
```bash
|
||||
openssl x509 -inform DER -in vsql.cer -out vsql.pem
|
||||
openssl x509 -in vsql.pem -noout -subject -dates -ext subjectAltName,keyUsage,extendedKeyUsage
|
||||
openssl verify -auth_level 2 -purpose sslserver -CAfile ~/packetstation/test/ca/pesco-ca.pem vsql.pem
|
||||
```
|
||||
|
||||
Then prove the live connection with the doctor and `VISUAL_ENCRYPT=yes`. The image does not yet add the PESCO CA to OpenSSL's trust store for the ODBC driver (the issue 5 follow-up), so this one-off check builds a bundle in `/tmp`. It skips the entrypoint, so the M-Files password line fails. Read the VISUAL line.
|
||||
|
||||
```bash
|
||||
docker compose -p packetstation-test --env-file ~/packetstation/test/stack.env \
|
||||
-f deploy/compose.yaml -f deploy/compose.proxy.yaml run --rm --no-deps \
|
||||
-e VISUAL_ENCRYPT=yes --entrypoint sh app -c \
|
||||
'cat /etc/ssl/certs/ca-certificates.crt /etc/ssl/pesco/ca.pem > /tmp/ca.pem && SSL_CERT_FILE=/tmp/ca.pem exec packetstation doctor'
|
||||
```
|
||||
|
||||
That the ODBC driver honours `SSL_CERT_FILE` is untested. If the VISUAL line fails with an issuer error while `openssl verify` passed, the trust step is the gap, not the server.
|
||||
|
||||
5. Production, off-hours. Production (`vsql`, `PESCOINC`) is the default instance, `MSSQLSERVER`. Bind the same certificate. VISUAL users lose their connection during the restart.
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Thumbprint <thumbprint> -Instances MSSQLSERVER -WhatIf
|
||||
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Thumbprint <thumbprint> -Instances MSSQLSERVER -RestartInstances
|
||||
```
|
||||
|
||||
Check the ERRORLOG line again. The production stack's own doctor run is Spencer's, with its own approval.
|
||||
|
||||
6. Rollback. Each deployment saved `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert`. `-Rollback` restores the newest one not yet rolled back. It restores each instance on its own, reads each value back and lists any failure. If a restart fails, fix the cause and run the same command again: it retries only the instances not yet restored or restarted, and starts SQL Server Agent if it ran before the failed attempt. Preview it, then run it:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Rollback -Instances DEV -WhatIf
|
||||
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Rollback -Instances DEV -RestartInstances
|
||||
```
|
||||
|
||||
`-Snapshot deploy-<time>.json` restores a specific one. The rollback leaves the certificate and the key grant in place. Removing them is a manual step once nothing is bound to them.
|
||||
`vsql.pesco.com\DEV` serves TLS (`VISUAL_ENCRYPT=trust` connects), but with `VISUAL_ENCRYPT=yes` OpenSSL in the image refuses it with "EE certificate key too weak": the server certificate's RSA key is under 2048 bits, which is what SQL Server's auto-generated self-signed certificate looks like. IT needs to issue vsql a 2048-bit, SHA-256 certificate from PESCO-PSYLOCKE-CA with `vsql.pesco.com` in the subject alternative names, install it on the DEV and production instances in SQL Server Configuration Manager and restart them. The image then needs the PESCO CA in its OpenSSL trust store for the ODBC driver (the entrypoint can concatenate the system bundle and /etc/ssl/pesco/ca.pem into /tmp and export `SSL_CERT_FILE`), and the stacks switch to `VISUAL_ENCRYPT=yes`. Tracked as Gitea issue 5. IT issues and binds the certificate by hand; the repo has no script for it.
|
||||
|
||||
## Doctor
|
||||
|
||||
|
||||
+2
-2
@@ -144,9 +144,9 @@ For each rule whose digest time (site time) has passed today and that has outbox
|
||||
|
||||
## archive-copy (every 15 minutes, phase 10)
|
||||
|
||||
Gitea issue 7. Not pausable. Off when `ARCHIVE_ROOT` is unset or blank: the run does nothing (counts `enabled: false`) and FILE asks for nothing. With the root set but no folder saved on Admin, File locations, the run idles (`skipped: no archive folder saved`).
|
||||
Gitea issue 7. Not pausable. Off when `ARCHIVE_ROOT` is unset or blank: the run does nothing (counts `enabled: false`). With the root set but no folder saved on Admin, File locations, the run idles (`skipped: no archive folder saved`).
|
||||
|
||||
FILE inserts a `job_requests` row for `archive-copy` and NOTIFYs `ps_job_request` inside its own transaction when the archive is on and a folder is saved, so the copy starts right after the filing commits. The request is made in a savepoint: if it fails, the filing still commits and the next scheduled run copies the packet. A failed copy never blocks filing and never touches the packet. Saving a folder and "Retry now" also request a run.
|
||||
FILE makes its own copy inside the filing transaction and refuses to file when it fails (Gitea issue 57, open question 53), so this job does not copy new filings. It catches up the filings made while the archive was off. Those packets are already filed, so a failed copy here never touches the packet. Saving a folder and "Retry now" request a run.
|
||||
|
||||
Each run:
|
||||
|
||||
|
||||
+4
-2
@@ -121,8 +121,9 @@ Each phase ends with passing tests, docs updated (README, CHANGELOG, SECURITY.md
|
||||
- Rewrite the 38 scenarios against the new UI with synthetic fixtures; run in Gitea Actions against compose.ci.yml. Policy matrix, merge rules and packet-rule tests already run from phase 2 and 3.
|
||||
- CSP final pass, dependency scan, build-version banner and reload, DEPLOY.md with the NPM proxy host settings, SECURITY.md rewritten with the ID table, GO-LIVE.md (date, boundary, groups, tablets, URL, M: retirement as read-only archive), PAPER-FALLBACK.md, incident notes.
|
||||
- Production and test each on their own Linux VM, natively, with no Docker (Spencer, 2026-10-27, Gitea issue 37; see "Host layout"). The test VM is built first from the same steps, so the production install, backups, restore and doctor are rehearsed before go-live. The app and the worker run as systemd services from a venv built from the same `server/requirements.txt` and `server/requirements-pesco.txt` pins as the image, with PostgreSQL 16, ODBC Driver 18 and the PESCO CA installed on the VM. Settings from an env file, secrets as root-owned files read through the same `*_PASSWORD_FILE` settings, VISUAL_SERVER and VISUAL_DATABASE set explicitly; first start is Spencer's approval. NPM on docker-1 proxies `packetstation.pescoinc.biz` and `packetstation-test.pescoinc.biz` to their VMs over the LAN. Backups, the doctor and the restore are rewritten for the VMs (no `docker exec`). DEPLOY.md gets a VM section; the compose files stay for CI. The docker-1 test stack keeps running until the test VM is ready, then it is stopped.
|
||||
- Archive location (Gitea issue 7, Spencer 2026-10-27). Spencer creates an NFS share on the QNAP and mounts it on docker-1 (root, steps in DEPLOY.md), with write access for uid 10001. Compose bind-mounts it into the app and the worker at a fixed root, `ARCHIVE_ROOT` (unset turns the feature off). The Admin page gets a File locations section with one setting for now, the archive folder, as a path under `ARCHIVE_ROOT`. The server refuses a path that leaves the root (`..`, absolute paths elsewhere, symlinks out). "Check" writes a probe file, reads it back, removes it and shows the result, and saving runs the same check first. The setting change and every check are audited. `doctor` runs the check too. At filing, the sealed data book, each of the packet's documents and a manifest are written to the archive folder (SethD, 2026-10-28, issue 7). Whether a failed copy blocks the filing or is retried and raised to Admin is settled in the phase 10 design.
|
||||
- Archive location (Gitea issue 7, Spencer 2026-10-27). Spencer creates an NFS share on the QNAP and mounts it on docker-1 (root, steps in DEPLOY.md), with write access for uid 10001. Compose bind-mounts it into the app and the worker at a fixed root, `ARCHIVE_ROOT` (unset turns the feature off). The Admin page gets a File locations section with one setting for now, the archive folder, as a path under `ARCHIVE_ROOT`. The server refuses a path that leaves the root (`..`, absolute paths elsewhere, symlinks out). "Check" writes a probe file, reads it back, removes it and shows the result, and saving runs the same check first. The setting change and every check are audited. `doctor` runs the check too. At filing, the sealed data book, each of the packet's documents and a manifest are written to the archive folder (SethD, 2026-10-28, issue 7). A failed copy blocks the filing and is raised to Admin (Spencer, 2026-11-03, Gitea issue 57; open question 53).
|
||||
- Closes: O3, O4; S4 is IT's pentest, deferred.
|
||||
- Done 2026-10-30. The bench maps all 38 old scenarios (tests/e2e/BENCH.md; trel is open question 54) and runs 111 Playwright tests, each failing on a CSP violation. The CSP dropped `'unsafe-inline'`. pip-audit found pypdf, pyasn1 and certifi to bump. The archive copy, the version banner, the VM kit in `deploy/vm/` with VM backups and restore, and the go-live, paper-fallback and security docs are built. The VMs, the NFS share, the timers and the go-live date are Spencer's and Seth's (open question 53 records the archive copy policy).
|
||||
|
||||
## SECURITY.md Part 3: which phase closes each ID
|
||||
|
||||
@@ -291,7 +292,8 @@ From phase 9 (2026-10-21), for Seth or Spencer to veto:
|
||||
50. The profile CSV `stations` cell: default the cell is the person's whole list of station limits across departments and replaces every limit they had; `-` clears them all; an empty cell leaves them. Phase 9 (2026-10-21) applied the default.
|
||||
51. A direct welder assign: default it does not check `profile_active`, so an Admin's inactive switch hides a person from the picker but a typed assign still works. Phase 9 (2026-10-21) applied the default; say if an inactive profile should be refused there too.
|
||||
52. A checklist item added while a stage is open: default it applies to that open stage too, because completeness reads the live template, so the item must be ticked before submit. Phase 9 (2026-10-21) applied the default. Replaced by change C8: a template change applies to new packets only. Built on 2026-10-30: a stage freezes its list at START, so a change reaches only stages not yet started.
|
||||
53. A failed archive copy at filing (phase 10, Gitea issue 7): default it never blocks the filing. The FILE transaction asks the worker for an `archive-copy` run; the job retries a failed copy after 5, 15 and 60 minutes, then hourly, and raises the `archive_failed` condition to Admin after 3 failed tries or a failed folder check. Admin, Storage, File locations shows the failures and has "Retry now". Turning the archive on later also copies the earlier filings. Seth or Spencer may ask for the stricter rule (the copy must succeed before the packet counts as filed) instead.
|
||||
53. A failed archive copy at filing (phase 10, Gitea issues 7 and 57). Answered by Spencer on 2026-11-03 (issue 57): the copy must succeed before the packet counts as filed. FILE checks the folder and copies the packet inside the filing transaction. If the check or the copy fails, the whole filing rolls back, so the packet stays in its previous stage and can be filed again. The person filing gets 503 `archive_failed` with the reason. In a transaction of its own the server writes an `archive.filing_blocked` audit row and raises the `archive_failed` condition to Admin (subject `filing:<packet id>`, critical) with the packet, who tried, the archive folder and the error. A retry that fails again only counts on the open condition, and the next successful filing of that packet clears it. With `ARCHIVE_ROOT` unset or no folder saved, the archive is not in use and filing goes ahead. The worker job still copies the filings made while the archive was off, with its retries and its own `archive_failed` condition (subject `archive`). The earlier default (never block, retry in the worker) is replaced.
|
||||
54. The old Clerical "Released" section (every released packet by place, with overdue and 7+ day counts; old bench scenario trel) is not in the new build and not on the Drop list (phase 10, tests/e2e/BENCH.md). Default: not built; the boards and the find bar's Mine, Board and List views show where each packet is. Seth decides whether it is needed for parity.
|
||||
|
||||
Changes decided by SethD on 2026-10-26. Each replaces a default above. All five were built on 2026-10-30 (branch `seth-changes`):
|
||||
|
||||
|
||||
+4
-4
@@ -73,7 +73,7 @@ The brief's wording for the identity IDs, which the table follows: I4 and I7 are
|
||||
|
||||
| ID | Requirement | Status | Owner |
|
||||
|---|---|---|---|
|
||||
| S1 | CSP, X-Frame-Options and related headers | Closed, phase 0, tuned phase 10 (the CSP pass; see "Headers"). | Dev |
|
||||
| S1 | CSP, X-Frame-Options and related headers | Closed, phase 0, tuned phase 10 (the CSP pass; see "Headers"; every e2e test fails on a CSP violation and one checks the served policy exactly). | Dev |
|
||||
| S2 | CSRF protection on every state-changing request | Closed, phase 1 (SameSite=Strict cookie, the Origin check, the `X-Requested-With` header). | Dev |
|
||||
| S3 | Dependency update and vulnerability scan | Closed, phase 9 (CI scanning on every run: `pip-audit` over every pin in `server/requirements.txt` fails on any known vulnerability; `npm audit --audit-level=high` fails on a high or critical one). Phase 10 bumped pypdf to 6.21.4, certifi to 2026.10.23 and pyasn1 to 0.6.5, and the scan is clean. | Dev |
|
||||
| S4 | Security review or penetration test before go-live | Deferred to IT: a penetration test of the production VM and the proxy before go-live, by IT or an outside firm. Who does it and when is TBD (Spencer decides). It is on the go-live checklist. If go-live goes ahead without it, Spencer records that decision on the checklist. | IT |
|
||||
@@ -195,10 +195,10 @@ The archive closes D6 (Gitea issue 7). It is a copy of every filed packet on the
|
||||
- An Admin picks the archive folder in the File locations section of the Admin page (setting `files.archive_dir`, versioned with compare and set like the security settings). The folder is a path under `ARCHIVE_ROOT`. The server refuses an empty path, `..`, an absolute path outside the root, a NUL, a path whose real location is outside the real root (a symlink out), and a folder that does not exist. It never creates folders.
|
||||
- "Check" writes a probe file in the folder, syncs it to disk, reads it back, compares it and deletes it. It shows the result, the resolved path and the free space. A save runs the same check first and is refused when it fails. `doctor` runs the check on the saved folder.
|
||||
- Every setting change (old and new value), every check (passed or not) and every copy (done or failed) writes an audit row. Admin writes need a password session and pass the Origin check, as every Admin write does.
|
||||
- At filing, the worker copies the sealed data book, every document of the packet and a `manifest.json` into `<project>/<unit>/<data book name>-seal<seal id>/`. The manifest names the packet, project, unit, seal id, seal hash, key id, who sealed and filed it and when, the build, and for every file its name, kind, sha256, size and source asset. Anyone can check the copy against the manifest without Packet Station.
|
||||
- At filing, FILE copies the sealed data book, every document of the packet and a `manifest.json` into `<project>/<unit>/<data book name>-seal<seal id>/`. The manifest names the packet, project, unit, seal id, seal hash, key id, who sealed and filed it and when, the build, and for every file its name, kind, sha256, size and source asset. Anyone can check the copy against the manifest without Packet Station.
|
||||
- Each file is written under a temporary name, synced and renamed. A different file is never overwritten; an identical file counts as done. Nothing in the archive is ever deleted by Packet Station. A refile after a reopen gets a new seal and a new folder, so both copies stay.
|
||||
- A failed copy never blocks filing (decided in the phase 10 design; recorded as an open question in [PLAN.md](PLAN.md)). The worker job `archive-copy` runs every 15 minutes and right after each filing. It retries a failed copy after 5, 15 and 60 minutes, then hourly. When a copy has failed 3 times, or the folder check fails, it raises the Admin alert `archive_failed`, cleared when nothing is failed. "Retry now" on the File locations section asks for a run at once.
|
||||
- When the archive is turned on later, the job also copies every earlier filing that has no copy yet.
|
||||
- The copy must succeed before the packet counts as filed (Spencer, 2026-11-03, Gitea issue 57; open question 53 in [PLAN.md](PLAN.md)). FILE checks the folder and copies inside the filing transaction. A failed check or copy rolls the filing back, so the packet stays in its previous stage. The server then writes an `archive.filing_blocked` audit row and raises the critical Admin alert `archive_failed` for that packet, with who tried, the folder and the error. Filing it again once the archive works clears the alert.
|
||||
- When the archive is turned on later, the worker job `archive-copy` copies every earlier filing that has no copy yet. It runs every 15 minutes, retries a failed copy after 5, 15 and 60 minutes, then hourly, and raises `archive_failed` when a copy has failed 3 times or the folder check fails. "Retry now" on the File locations section asks for a run at once.
|
||||
- The archive is a plain copy outside the app's access control. The files are not encrypted by the app (D1 is deferred). Who can read or change the share, the NFS export rule (the production VM only), snapshots and the share's own backup are IT's.
|
||||
|
||||
### Warehouse rules (phase 4)
|
||||
|
||||
@@ -927,6 +927,7 @@ export interface HandUpload {
|
||||
name?: string | null;
|
||||
reason?: string | null;
|
||||
by?: WhoRef | string | null;
|
||||
by_name?: string | null;
|
||||
at?: string | null;
|
||||
vis_rev?: string | null;
|
||||
lines?: number;
|
||||
@@ -974,6 +975,7 @@ export interface VisualInfo {
|
||||
rev: string | number;
|
||||
vis_rev?: string | null;
|
||||
by?: WhoRef | string | null;
|
||||
by_name?: string | null;
|
||||
at?: string | null;
|
||||
reason?: string | null;
|
||||
/** "called": Clerical kept the rev VISUAL calls over M-Files' Effective rev (`latest`). */
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("Packet page, VISUAL box", () => {
|
||||
it("uploads a hand BOM as multipart with the signature fields", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base(visualDraft()),
|
||||
"POST /api/packets/1/hand/bom": { body: visualDraft({ version: 4, visual: { manual_bom: { name: "bom.csv", reason: "No materials yet", lines: 3 } } }) },
|
||||
"POST /api/packets/1/hand/bom": { body: visualDraft({ version: 4, visual: { manual_bom: { name: "bom.csv", reason: "No materials yet", lines: 3, by: "cf284ce9-e7a6-45d0-bba6-d46b752d553d", by_name: "Clara Clerk" } } }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Upload BOM" }));
|
||||
@@ -165,6 +165,9 @@ describe("Packet page, VISUAL box", () => {
|
||||
expect((fd.get("file") as File).name).toBe("bom.csv");
|
||||
expect(await screen.findByRole("button", { name: "Use VISUAL's BOM again" })).toBeInTheDocument();
|
||||
expect(screen.getByText("BOM uploaded by hand:", { exact: false })).toBeInTheDocument();
|
||||
// The line names the person, never the stored user id.
|
||||
expect(screen.getByText(/BOM uploaded by hand:.*Clara Clerk/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/cf284ce9/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uploads a hand drawing: the asset first, then the attach with reason and sign", async () => {
|
||||
|
||||
@@ -21,9 +21,11 @@ type AskReason = (opts: ReasonOptions) => Promise<string | null>;
|
||||
const isCl = (roles: readonly string[]) => roles.some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin");
|
||||
const isClSup = (roles: readonly string[]) => roles.some((r) => r === "clerical_supervisor" || r === "admin");
|
||||
|
||||
function whoName(by: WhoRef | string | null | undefined): string {
|
||||
if (!by) return "";
|
||||
return typeof by === "string" ? by : by.display_name;
|
||||
/** The server stores `by` as the user id and the display name beside it in `by_name`. */
|
||||
function whoName(m: { by?: WhoRef | string | null; by_name?: string | null }): string {
|
||||
if (m.by_name) return m.by_name;
|
||||
if (!m.by) return "";
|
||||
return typeof m.by === "string" ? m.by : m.by.display_name;
|
||||
}
|
||||
|
||||
function foldedText(f: FoldedOp): string {
|
||||
@@ -126,7 +128,7 @@ function HandLine({ what, m }: { what: string; m: HandUpload }) {
|
||||
return (
|
||||
<div className="small">
|
||||
{what} uploaded by hand: <span className="mono">{m.name ?? ""}</span>
|
||||
{m.by ? ` - ${whoName(m.by)}` : ""} {m.at ? fmtTime(m.at) : ""}
|
||||
{m.by ? ` - ${whoName(m)}` : ""} {m.at ? fmtTime(m.at) : ""}
|
||||
{m.reason ? ` · ${m.reason}` : ""}
|
||||
{typeof m.lines === "number" ? ` · ${m.lines} line${m.lines === 1 ? "" : "s"}` : ""}
|
||||
</div>
|
||||
@@ -237,7 +239,7 @@ export function VisualBox({
|
||||
<b>Rev {String(v.rev_ok.rev)} approved by hand</b> in place of VISUAL rev {v.rev_ok.vis_rev || "-"}
|
||||
</>
|
||||
)}
|
||||
{v.rev_ok.by ? ` - ${whoName(v.rev_ok.by)}` : ""} {v.rev_ok.at ? fmtTime(v.rev_ok.at) : ""}
|
||||
{v.rev_ok.by ? ` - ${whoName(v.rev_ok)}` : ""} {v.rev_ok.at ? fmtTime(v.rev_ok.at) : ""}
|
||||
{v.rev_ok.reason ? `: ${v.rev_ok.reason}` : ""}.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -7,22 +7,3 @@ The nightly `pg_dump` backup with its systemd timer, and the restore script, for
|
||||
## Export-PescoCaBundle.ps1
|
||||
|
||||
Run on a domain-joined PC. Exports every unexpired PESCO CA certificate from the Windows stores to one PEM bundle and copies it to docker-1 with scp (default target `~/packetstation/test/ca/pesco-ca.pem`). Use `-Remote` for another path and `-NoCopy` to only write the file. It warns on SHA-1 signed certificates; see docs/DEPLOY.md.
|
||||
|
||||
## Request-VsqlServerCertificate.ps1
|
||||
|
||||
Run elevated on the SQL Server host behind vsql, in Windows PowerShell 5.1 or PowerShell 7. Requests a TLS certificate from PESCO-PSYLOCKE-CA with certreq the way SQL Server requires it (legacy Microsoft RSA SChannel Cryptographic Provider, KeySpec AT_KEYEXCHANGE, RSA 2048, SHA-256, Server Authentication, key usage 0xA0, SANs vsql.pesco.com, vsql and the host FQDN and short name). It installs it and validates it before anything is bound. The validation checks the key provider and KeySpec from the certificate's own key property, the key size, the signature, the dates, the EKU, and the SAN DNS names as whole names. It grants only the selected instances' service accounts read on the private key, then binds the thumbprint and reads it back.
|
||||
|
||||
- `-WhatIf` previews every step and changes nothing. `-Confirm` asks before each change. Declining enrollment or a key grant cancels the run with nothing bound (exit code 2).
|
||||
- `-CerFile` continues a request that was pending. `-Thumbprint` binds a certificate already installed. `-Instances` limits the run (`DEV`, `MSSQLSERVER`). `-RestartInstances` restarts them and starts SQL Server Agent again if it ran. The running dependents are written to `dependents-<service>.json` before the stop and the file is removed once they run again, so after a failed restart the next restart of that service starts them too.
|
||||
- Each deployment writes a new `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert` with the values it replaced. Instances already bound to the thumbprint are left alone and get no snapshot, so a rerun never hides the useful one. A pending request writes `request-<time>.json` with its request id. A registry value that cannot be read stops the run; only a value confirmed absent is saved as absent.
|
||||
- `-Rollback` restores the newest snapshot not yet rolled back, each instance on its own, verified by reading back, and records it in `rollback-<time>.json` with which restarts succeeded. A snapshot stays open while an instance is unrestored or its restart failed, so `-Rollback -RestartInstances` again retries exactly those. `-Snapshot` picks another. Failures are listed and the run exits with an error.
|
||||
- Instances are read from the value names of `Instance Names\SQL`, so a name starting with `PS` (for example `PSREPORTING`) is found.
|
||||
|
||||
Tests: `scripts/tests/Request-VsqlServerCertificate.Tests.ps1` (Pester 5) fakes the host (instances, services, registry, machine store, certreq, icacls) and uses real throwaway certificates in `Cert:\CurrentUser\My`. Run it on both PowerShell versions:
|
||||
|
||||
```powershell
|
||||
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -SkipPublisherCheck
|
||||
Invoke-Pester -Path scripts ests -Output Detailed
|
||||
```
|
||||
|
||||
The run procedure is in docs/DEPLOY.md, under the vsql known issue. Gitea issue 5 tracks the switch to `VISUAL_ENCRYPT=yes`.
|
||||
|
||||
@@ -1,795 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Requests a SQL Server TLS certificate for vsql from PESCO-PSYLOCKE-CA and binds it to the instances.
|
||||
|
||||
.DESCRIPTION
|
||||
Run on the SQL Server host (the machine behind vsql.pesco.com) in an elevated PowerShell, as a
|
||||
domain account that may enroll from the chosen template. Works on Windows PowerShell 5.1 and
|
||||
PowerShell 7. Steps:
|
||||
|
||||
1. Resolve the SQL Server instances to configure and their service accounts. Unknown names
|
||||
in -Instances are an error, nothing is touched.
|
||||
2. Write a certreq INF with what SQL Server requires (Microsoft certificate requirements):
|
||||
a legacy CryptoAPI provider, Microsoft RSA SChannel Cryptographic Provider, with KeySpec
|
||||
AT_KEYEXCHANGE; RSA 2048; SHA-256; Server Authentication EKU; key usage digital signature
|
||||
and key encipherment; subject CN=vsql.pesco.com; subject alternative names vsql.pesco.com,
|
||||
vsql, the host FQDN and short name; machine store; exportable.
|
||||
3. certreq -new, -submit and -accept, each confirmed through ShouldProcess. A pending request
|
||||
is saved in its own request-<time>.json; issue it on the CA, retrieve it, then rerun with
|
||||
-CerFile (or -Thumbprint once installed).
|
||||
4. Validate the installed certificate before anything is bound: a private key from a CSP
|
||||
(provider type 12) with KeySpec AT_KEYEXCHANGE, read from the certificate's own key
|
||||
provider property; 2048 bits or more; SHA-256 family signature; valid for 30 more days;
|
||||
Server Authentication EKU; digital signature and key encipherment if key usage is set;
|
||||
every required DNS name, decoded from the SAN extension and compared as whole names.
|
||||
5. Grant the selected instances' service accounts read on the private key. A declined or
|
||||
failed grant cancels the run before any instance is changed.
|
||||
6. Instances already bound to this thumbprint are left alone. For the others, save their
|
||||
current Certificate values in a new deploy-<time>.json (never overwritten), then bind the
|
||||
thumbprint (the registry value SQL Server Configuration Manager sets) and read it back.
|
||||
ForceEncryption is left as it is. A failure restores every instance changed in this run,
|
||||
each on its own, verified.
|
||||
7. Print the restart commands, or restart with -RestartInstances (a short VISUAL outage).
|
||||
Services that depend on the instance (SQL Server Agent) are started again if they ran.
|
||||
|
||||
-WhatIf previews every step without changing anything. -Confirm asks before each change.
|
||||
-Rollback restores a saved deployment snapshot.
|
||||
|
||||
.PARAMETER CertificateAuthority
|
||||
CA config string host\name. Default DC-02.PESCO.COM\PESCO-PSYLOCKE-CA.
|
||||
|
||||
.PARAMETER Template
|
||||
Certificate template name. Default WebServer. The template must allow Server Authentication
|
||||
and must not force a Key Storage Provider.
|
||||
|
||||
.PARAMETER DnsNames
|
||||
Names the certificate must carry. The first is the subject CN. The host FQDN and short name
|
||||
are always added.
|
||||
|
||||
.PARAMETER Instances
|
||||
Instance names to configure (MSSQLSERVER for the default instance, DEV for vsql\DEV).
|
||||
Default: every instance on this host.
|
||||
|
||||
.PARAMETER CerFile
|
||||
Continue from an issued certificate file (after a pending request was issued and retrieved).
|
||||
|
||||
.PARAMETER Thumbprint
|
||||
Continue from a certificate already in the machine store; skips the request.
|
||||
|
||||
.PARAMETER RestartInstances
|
||||
Restart each configured instance so the certificate (or the rollback) takes effect.
|
||||
|
||||
.PARAMETER Rollback
|
||||
Restore the Certificate values saved by a deployment snapshot and stop. Each instance is
|
||||
restored on its own and read back; only verified instances count as restored. The default
|
||||
snapshot is the newest one with instances not yet restored (a rollback-<time>.json records
|
||||
what was restored), so each -Rollback steps back one deployment. Run it with -WhatIf first
|
||||
to see which one it picks.
|
||||
|
||||
.PARAMETER Snapshot
|
||||
With -Rollback: the snapshot file (name or path) to restore instead of the default.
|
||||
|
||||
.EXAMPLE
|
||||
.\Request-VsqlServerCertificate.ps1 -Instances DEV -WhatIf
|
||||
.EXAMPLE
|
||||
.\Request-VsqlServerCertificate.ps1 -Instances DEV
|
||||
.EXAMPLE
|
||||
.\Request-VsqlServerCertificate.ps1 -CerFile C:\ProgramData\PacketStation\vsql-cert\vsql-20261018-090000-000.cer -Instances DEV -RestartInstances
|
||||
.EXAMPLE
|
||||
.\Request-VsqlServerCertificate.ps1 -Rollback -WhatIf
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Request')]
|
||||
param(
|
||||
[string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA',
|
||||
[string]$Template = 'WebServer',
|
||||
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
|
||||
[string[]]$Instances,
|
||||
[Parameter(ParameterSetName = 'Cer', Mandatory)][string]$CerFile,
|
||||
[Parameter(ParameterSetName = 'Thumb', Mandatory)][string]$Thumbprint,
|
||||
[switch]$RestartInstances,
|
||||
[Parameter(ParameterSetName = 'Rollback', Mandatory)][switch]$Rollback,
|
||||
[Parameter(ParameterSetName = 'Rollback')][string]$Snapshot
|
||||
)
|
||||
|
||||
$script:AcceptedSignatureOids = @{
|
||||
'1.2.840.113549.1.1.11' = 'sha256RSA'
|
||||
'1.2.840.113549.1.1.12' = 'sha384RSA'
|
||||
'1.2.840.113549.1.1.13' = 'sha512RSA'
|
||||
}
|
||||
$script:SchannelProvider = 'Microsoft RSA SChannel Cryptographic Provider'
|
||||
|
||||
# --- helpers that touch nothing --------------------------------------------------------------
|
||||
|
||||
function Test-SameValue($A, $B) {
|
||||
# Registry values: $null means absent, '' means present and empty. Thumbprints ignore case.
|
||||
# Untyped on purpose: a [string] parameter turns $null into ''.
|
||||
if ($null -eq $A -or $null -eq $B) { return ($null -eq $A -and $null -eq $B) }
|
||||
return [string]::Equals([string]$A, [string]$B, [StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Get-UniqueNames([string[]]$Names) {
|
||||
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
$out = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($n in $Names) { if ($n -and $seen.Add($n)) { $out.Add($n) } }
|
||||
return $out.ToArray()
|
||||
}
|
||||
|
||||
function Get-SqlServiceName([string]$Instance) {
|
||||
if ($Instance -eq 'MSSQLSERVER') { return 'MSSQLSERVER' }
|
||||
return 'MSSQL$' + $Instance
|
||||
}
|
||||
|
||||
function Get-ServiceFilter([string]$ServiceName) {
|
||||
# WQL string literal. `$` needs no escape; a quote or backslash never occurs in a service name.
|
||||
if ($ServiceName -match "['\\]") { throw "Unexpected character in service name '$ServiceName'." }
|
||||
return "Name='$ServiceName'"
|
||||
}
|
||||
|
||||
function Read-DerLength([byte[]]$Bytes, [ref]$Position) {
|
||||
if ($Position.Value -ge $Bytes.Length) { throw 'SAN extension is truncated.' }
|
||||
$first = [int]$Bytes[$Position.Value]; $Position.Value++
|
||||
if ($first -lt 0x80) { return $first }
|
||||
$count = $first -band 0x7F
|
||||
if ($count -lt 1 -or $count -gt 3) { throw 'SAN extension has an unsupported length encoding.' }
|
||||
$length = 0
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
if ($Position.Value -ge $Bytes.Length) { throw 'SAN extension is truncated.' }
|
||||
$length = ($length * 256) + [int]$Bytes[$Position.Value]; $Position.Value++
|
||||
}
|
||||
return $length
|
||||
}
|
||||
|
||||
function Get-SanDnsNames([byte[]]$RawData) {
|
||||
# SubjectAltName ::= SEQUENCE OF GeneralName; dNSName is [2] IMPLICIT IA5String (tag 0x82).
|
||||
# A plain DER walk, the same on Windows PowerShell 5.1 and PowerShell 7, independent of
|
||||
# display formatting and language.
|
||||
if (-not $RawData -or $RawData.Length -lt 2 -or $RawData[0] -ne 0x30) { throw 'SAN extension is not a DER SEQUENCE.' }
|
||||
$pos = 1
|
||||
$length = Read-DerLength $RawData ([ref]$pos)
|
||||
$end = $pos + $length
|
||||
if ($end -ne $RawData.Length) { throw 'SAN extension length does not match its content.' }
|
||||
$names = New-Object System.Collections.Generic.List[string]
|
||||
while ($pos -lt $end) {
|
||||
$tag = [int]$RawData[$pos]; $pos++
|
||||
if (($tag -band 0x1F) -eq 0x1F) { throw 'SAN extension uses a multi-byte tag.' }
|
||||
$len = Read-DerLength $RawData ([ref]$pos)
|
||||
if ($pos + $len -gt $end) { throw 'SAN extension is truncated.' }
|
||||
if ($tag -eq 0x82) { $names.Add([System.Text.Encoding]::ASCII.GetString($RawData, $pos, $len)) }
|
||||
$pos += $len
|
||||
}
|
||||
return $names.ToArray()
|
||||
}
|
||||
|
||||
function Get-CertificateKeyInfo([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) {
|
||||
# The certificate's CERT_KEY_PROV_INFO property, which is what SQL Server uses to find the key.
|
||||
# $cert.PrivateKey is not usable for this: PowerShell 7 (.NET 5 and later) returns RSACng
|
||||
# even for a CSP key, Windows PowerShell 5.1 returns RSACryptoServiceProvider.
|
||||
if (-not ('PacketStation.KeyProvInfo' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
namespace PacketStation {
|
||||
public static class KeyProvInfo {
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
struct CRYPT_KEY_PROV_INFO {
|
||||
public string pwszContainerName;
|
||||
public string pwszProvName;
|
||||
public uint dwProvType;
|
||||
public uint dwFlags;
|
||||
public uint cProvParam;
|
||||
public IntPtr rgProvParam;
|
||||
public uint dwKeySpec;
|
||||
}
|
||||
[DllImport("crypt32.dll", SetLastError = true)]
|
||||
static extern bool CertGetCertificateContextProperty(IntPtr pCertContext, uint dwPropId, IntPtr pvData, ref uint pcbData);
|
||||
public static object[] Get(IntPtr certContext) {
|
||||
uint size = 0;
|
||||
if (!CertGetCertificateContextProperty(certContext, 2, IntPtr.Zero, ref size)) return null;
|
||||
IntPtr buffer = Marshal.AllocHGlobal((int)size);
|
||||
try {
|
||||
if (!CertGetCertificateContextProperty(certContext, 2, buffer, ref size)) return null;
|
||||
CRYPT_KEY_PROV_INFO info = (CRYPT_KEY_PROV_INFO)Marshal.PtrToStructure(buffer, typeof(CRYPT_KEY_PROV_INFO));
|
||||
return new object[] { info.pwszContainerName, info.pwszProvName, (int)info.dwProvType, (int)info.dwFlags, (int)info.dwKeySpec };
|
||||
} finally { Marshal.FreeHGlobal(buffer); }
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
$raw = [PacketStation.KeyProvInfo]::Get($Certificate.Handle)
|
||||
if (-not $raw) { return $null }
|
||||
$info = [pscustomobject]@{
|
||||
Container = [string]$raw[0]
|
||||
Provider = [string]$raw[1]
|
||||
ProviderType = [int]$raw[2]
|
||||
Machine = (([int]$raw[3]) -band 0x20) -ne 0 # CRYPT_MACHINE_KEYSET
|
||||
KeySpec = [int]$raw[4]
|
||||
UniqueName = $null
|
||||
Error = $null
|
||||
}
|
||||
if ($info.ProviderType -ne 0) {
|
||||
try {
|
||||
$cp = New-Object System.Security.Cryptography.CspParameters $info.ProviderType, $info.Provider, $info.Container
|
||||
$cp.KeyNumber = $info.KeySpec
|
||||
$cp.Flags = [System.Security.Cryptography.CspProviderFlags]::UseExistingKey
|
||||
if ($info.Machine) { $cp.Flags = $cp.Flags -bor [System.Security.Cryptography.CspProviderFlags]::UseMachineKeyStore }
|
||||
$rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider $cp
|
||||
try { $info.UniqueName = $rsa.CspKeyContainerInfo.UniqueKeyContainerName } finally { $rsa.Dispose() }
|
||||
}
|
||||
catch { $info.Error = $_.Exception.Message }
|
||||
}
|
||||
return $info
|
||||
}
|
||||
|
||||
function Test-ServerCertificate {
|
||||
param(
|
||||
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
|
||||
[string[]]$RequiredNames,
|
||||
[datetime]$Now = (Get-Date),
|
||||
[object]$KeyInfo = $(if ($Certificate.HasPrivateKey) { Get-CertificateKeyInfo $Certificate })
|
||||
)
|
||||
$problems = New-Object System.Collections.Generic.List[string]
|
||||
if (-not $Certificate.HasPrivateKey) { $problems.Add('no private key') }
|
||||
elseif (-not $KeyInfo) { $problems.Add('no key provider information on the certificate') }
|
||||
elseif ($KeyInfo.ProviderType -eq 0) { $problems.Add("the private key is in a Key Storage Provider ($($KeyInfo.Provider)); SQL Server needs a CryptoAPI (CSP) key") }
|
||||
else {
|
||||
if ($KeyInfo.ProviderType -ne 12) { $problems.Add("provider type $($KeyInfo.ProviderType) ($($KeyInfo.Provider)); expected 12, $script:SchannelProvider") }
|
||||
if ($KeyInfo.KeySpec -ne 1) { $problems.Add("KeySpec is $($KeyInfo.KeySpec); SQL Server needs AT_KEYEXCHANGE (1)") }
|
||||
if ($KeyInfo.Error) { $problems.Add("the private key container cannot be opened: $($KeyInfo.Error)") }
|
||||
}
|
||||
$rsaPublic = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Certificate)
|
||||
$keySize = if ($rsaPublic) { $rsaPublic.KeySize } else { 0 }
|
||||
if (-not $rsaPublic) { $problems.Add('the key is not RSA') }
|
||||
elseif ($keySize -lt 2048) { $problems.Add("key size $keySize is below 2048") }
|
||||
$sigOid = $Certificate.SignatureAlgorithm.Value
|
||||
$sig = if ($script:AcceptedSignatureOids.ContainsKey($sigOid)) { $script:AcceptedSignatureOids[$sigOid] } else { "$($Certificate.SignatureAlgorithm.FriendlyName) ($sigOid)" }
|
||||
if (-not $script:AcceptedSignatureOids.ContainsKey($sigOid)) { $problems.Add("signature algorithm $sig is not SHA-256, SHA-384 or SHA-512 with RSA") }
|
||||
if ($Certificate.NotBefore -gt $Now -or $Certificate.NotAfter -lt $Now) { $problems.Add("not valid now (valid $($Certificate.NotBefore.ToString('yyyy-MM-dd')) to $($Certificate.NotAfter.ToString('yyyy-MM-dd')))") }
|
||||
elseif ($Certificate.NotAfter -lt $Now.AddDays(30)) { $problems.Add("expires $($Certificate.NotAfter.ToString('yyyy-MM-dd')), within 30 days") }
|
||||
$ekuExt = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } | Select-Object -First 1
|
||||
$ekus = @()
|
||||
if ($ekuExt) { $ekus = @((New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension $ekuExt, $ekuExt.Critical).EnhancedKeyUsages | ForEach-Object { $_.Value }) }
|
||||
if ($ekus -notcontains '1.3.6.1.5.5.7.3.1') { $problems.Add('Server Authentication EKU missing') }
|
||||
$kuExt = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.15' } | Select-Object -First 1
|
||||
if ($kuExt) {
|
||||
$ku = (New-Object System.Security.Cryptography.X509Certificates.X509KeyUsageExtension $kuExt, $kuExt.Critical).KeyUsages
|
||||
$flags = [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]
|
||||
if (-not ($ku -band $flags::KeyEncipherment)) { $problems.Add('key usage lacks key encipherment') }
|
||||
if (-not ($ku -band $flags::DigitalSignature)) { $problems.Add('key usage lacks digital signature') }
|
||||
}
|
||||
$dns = @()
|
||||
$sanExt = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } | Select-Object -First 1
|
||||
if (-not $sanExt) { $problems.Add('no subject alternative name extension') }
|
||||
else {
|
||||
try { $dns = @(Get-SanDnsNames $sanExt.RawData) } catch { $problems.Add("subject alternative names unreadable: $($_.Exception.Message)") }
|
||||
}
|
||||
foreach ($n in $RequiredNames) {
|
||||
$hit = @($dns | Where-Object { [string]::Equals($_, $n, [StringComparison]::OrdinalIgnoreCase) })
|
||||
if (-not $hit) { $problems.Add("subject alternative name $n missing") }
|
||||
}
|
||||
return [pscustomobject]@{ Problems = $problems; KeyInfo = $KeyInfo; KeySize = $keySize; Signature = $sig; DnsNames = $dns }
|
||||
}
|
||||
|
||||
function New-CertRequestInf([string]$Primary, [string[]]$Names, [string]$Template, [string]$Stamp) {
|
||||
$sanLines = ($Names | ForEach-Object { "_continue_ = `"dns=$_&`"" }) -join "`r`n"
|
||||
return @"
|
||||
[Version]
|
||||
Signature = "`$Windows NT`$"
|
||||
|
||||
[NewRequest]
|
||||
Subject = "CN=$Primary"
|
||||
KeyLength = 2048
|
||||
KeyAlgorithm = RSA
|
||||
HashAlgorithm = SHA256
|
||||
; SQL Server needs a legacy CryptoAPI provider and an exchange key (KeySpec = AT_KEYEXCHANGE).
|
||||
ProviderName = "$script:SchannelProvider"
|
||||
ProviderType = 12
|
||||
KeySpec = 1
|
||||
; digital signature + key encipherment
|
||||
KeyUsage = 0xA0
|
||||
Exportable = TRUE
|
||||
MachineKeySet = TRUE
|
||||
RequestType = PKCS10
|
||||
FriendlyName = "SQL Server TLS $Primary ($Stamp)"
|
||||
|
||||
[EnhancedKeyUsageExtension]
|
||||
OID = 1.3.6.1.5.5.7.3.1
|
||||
|
||||
[Extensions]
|
||||
2.5.29.17 = "{text}"
|
||||
$sanLines
|
||||
|
||||
[RequestAttributes]
|
||||
CertificateTemplate = $Template
|
||||
"@
|
||||
}
|
||||
|
||||
function Invoke-NativeCommand([string]$FilePath, [string[]]$Arguments) {
|
||||
# Windows PowerShell 5.1 turns redirected stderr into a terminating error under 'Stop'.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$out = @(& $FilePath @Arguments 2>&1 | ForEach-Object { "$_" })
|
||||
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $out }
|
||||
}
|
||||
|
||||
function Resolve-AccountSid([string]$Account) {
|
||||
switch -Regex ($Account) {
|
||||
'^(LocalSystem|NT AUTHORITY\\SYSTEM)$' { return 'S-1-5-18' }
|
||||
'^NT AUTHORITY\\(LocalService|LOCAL SERVICE)$' { return 'S-1-5-19' }
|
||||
'^NT AUTHORITY\\(NetworkService|NETWORK SERVICE)$' { return 'S-1-5-20' }
|
||||
}
|
||||
$name = if ($Account -like '.\*') { "$env:COMPUTERNAME\$($Account.Substring(2))" } else { $Account }
|
||||
return (New-Object System.Security.Principal.NTAccount $name).Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
}
|
||||
|
||||
# --- the host, as replaceable operations ------------------------------------------------------
|
||||
|
||||
function Get-VsqlDefaultProvider {
|
||||
@{
|
||||
WorkDir = Join-Path $env:ProgramData 'PacketStation\vsql-cert'
|
||||
InstanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
|
||||
IsAdmin = { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
|
||||
GetInstanceMap = {
|
||||
# The key's own value names. Get-ItemProperty mixes in PSPath and friends, and
|
||||
# filtering those by 'PS*' would drop a real instance such as PSREPORTING.
|
||||
param($Root)
|
||||
$path = "$Root\Instance Names\SQL"
|
||||
$map = [ordered]@{}
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$key = Get-Item -LiteralPath $path -ErrorAction Stop
|
||||
foreach ($n in $key.GetValueNames()) { if ($n) { $map[$n] = [string]$key.GetValue($n) } }
|
||||
}
|
||||
$map
|
||||
}
|
||||
GetService = { param($Name) Get-CimInstance Win32_Service -Filter (Get-ServiceFilter $Name) -ErrorAction Stop }
|
||||
TestRegKey = { param($Key) Test-Path -LiteralPath $Key }
|
||||
GetRegValue = {
|
||||
# $null only for a value confirmed absent. A missing key or a read error throws, so it
|
||||
# is never saved in a snapshot, or verified after a restore, as "absent".
|
||||
param($Key, $Name)
|
||||
$item = Get-Item -LiteralPath $Key -ErrorAction Stop
|
||||
if (@($item.GetValueNames()) -notcontains $Name) { return $null }
|
||||
[string]$item.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
}
|
||||
SetRegValue = { param($Key, $Name, $Value) Set-ItemProperty -LiteralPath $Key -Name $Name -Value $Value -ErrorAction Stop -Confirm:$false }
|
||||
RemoveRegValue = { param($Key, $Name) Remove-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop -Confirm:$false }
|
||||
GetHostNames = { @([System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName, $env:COMPUTERNAME) }
|
||||
GetMachineCert = { param($Thumb) Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $Thumb } | Select-Object -First 1 }
|
||||
Certreq = { param([string[]]$Arguments) Invoke-NativeCommand 'certreq.exe' $Arguments }
|
||||
Icacls = { param([string[]]$Arguments) Invoke-NativeCommand 'icacls.exe' $Arguments }
|
||||
ResolveSid = { param($Account) Resolve-AccountSid $Account }
|
||||
GetKeyInfo = { param($Cert) Get-CertificateKeyInfo $Cert }
|
||||
KeyFilePath = { param($KeyInfo) Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$($KeyInfo.UniqueName)" }
|
||||
TestFile = { param($Path) Test-Path -LiteralPath $Path -PathType Leaf }
|
||||
GetRunningDependents = { param($Name) @((Get-Service -Name $Name -ErrorAction Stop).DependentServices | Where-Object { $_.Status -eq 'Running' } | ForEach-Object { $_.Name }) }
|
||||
RestartService = { param($Name) Restart-Service -Name $Name -Force -ErrorAction Stop -Confirm:$false }
|
||||
StartService = { param($Name) Start-Service -Name $Name -ErrorAction Stop -Confirm:$false }
|
||||
Now = { Get-Date }
|
||||
Log = { param($Message) Write-Host $Message }
|
||||
ShouldProcess = $null # $null: the cmdlet's own ShouldProcess (-WhatIf, -Confirm)
|
||||
}
|
||||
}
|
||||
|
||||
# --- steps ------------------------------------------------------------------------------------
|
||||
|
||||
function Get-SqlInstances($P, [string[]]$Wanted) {
|
||||
$map = & $P.GetInstanceMap $P.InstanceRoot
|
||||
if (-not $map -or $map.Count -eq 0) { throw 'No SQL Server Database Engine instance is registered on this host. Is this the vsql machine?' }
|
||||
$found = @($map.Keys)
|
||||
if ($Wanted) {
|
||||
$unknown = @($Wanted | Where-Object { $found -notcontains $_ })
|
||||
if ($unknown) { throw "Unknown instance(s): $($unknown -join ', '). Found on this host: $($found -join ', '). Nothing was changed." }
|
||||
$found = @($found | Where-Object { $Wanted -contains $_ })
|
||||
}
|
||||
$targets = foreach ($name in $found) {
|
||||
$svc = Get-SqlServiceName $name
|
||||
$service = & $P.GetService $svc
|
||||
if (-not $service) { throw "Service $svc for instance $name was not found." }
|
||||
if (-not $service.StartName) { throw "Service $svc for instance $name has no service account." }
|
||||
$regKey = "$($P.InstanceRoot)\$($map[$name])\MSSQLServer\SuperSocketNetLib"
|
||||
if (-not (& $P.TestRegKey $regKey)) { throw "Registry key for instance $name not found: $regKey" }
|
||||
[pscustomobject]@{ Name = $name; Id = $map[$name]; Service = $svc; Account = [string]$service.StartName; RegKey = $regKey }
|
||||
}
|
||||
return @($targets)
|
||||
}
|
||||
|
||||
function Write-NewJsonFile([string]$Path, [object]$Value) {
|
||||
# Snapshots and request records are immutable: a file is written once and never replaced.
|
||||
$dir = Split-Path -Parent $Path
|
||||
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir -Confirm:$false | Out-Null }
|
||||
$json = ConvertTo-Json -InputObject $Value -Depth 6
|
||||
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)
|
||||
try {
|
||||
$writer = New-Object System.IO.StreamWriter $stream, (New-Object System.Text.UTF8Encoding $false)
|
||||
$writer.Write($json); $writer.Flush()
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
}
|
||||
|
||||
function Restore-InstanceBinding($P, [string]$RegKey, $Previous) {
|
||||
# One instance, on its own. Returns $null when the value reads back as saved, else the problem.
|
||||
try {
|
||||
if ($null -eq $Previous) {
|
||||
if ($null -ne (& $P.GetRegValue $RegKey 'Certificate')) { & $P.RemoveRegValue $RegKey 'Certificate' }
|
||||
}
|
||||
else { & $P.SetRegValue $RegKey 'Certificate' $Previous }
|
||||
$now = & $P.GetRegValue $RegKey 'Certificate'
|
||||
if (Test-SameValue $now $Previous) { return $null }
|
||||
return "reads '$now' after restoring '$Previous'"
|
||||
}
|
||||
catch { return $_.Exception.Message }
|
||||
}
|
||||
|
||||
function Format-Value($Value) {
|
||||
if ($null -eq $Value) { return '(absent)' }
|
||||
if ($Value -eq '') { return "''" }
|
||||
return $Value
|
||||
}
|
||||
|
||||
function Get-DependentsFile($P, [string]$Service) {
|
||||
return Join-Path $P.WorkDir ('dependents-' + ($Service -replace '[^A-Za-z0-9]', '_') + '.json')
|
||||
}
|
||||
|
||||
function Invoke-InstanceRestart($P, $Confirm, [object[]]$Targets) {
|
||||
# Restart-Service -Force stops dependents (SQL Server Agent) and does not start them again.
|
||||
# The running ones are written to dependents-<service>.json before the stop, and the file is
|
||||
# removed only once they run again. A failed restart therefore does not lose them: the next
|
||||
# restart of that service, by a rerun or a rollback, starts them too.
|
||||
$failed = New-Object System.Collections.Generic.List[string]
|
||||
$restarted = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($t in $Targets) {
|
||||
if (-not (& $Confirm $t.Service 'restart service (and start its running dependents again)')) { continue }
|
||||
try {
|
||||
$file = Get-DependentsFile $P $t.Service
|
||||
$saved = @()
|
||||
if (Test-Path -LiteralPath $file) { $saved = @((Get-Content -LiteralPath $file -Raw | ConvertFrom-Json).services) }
|
||||
$deps = @(Get-UniqueNames (@($saved) + @(& $P.GetRunningDependents $t.Service)))
|
||||
if ($deps.Count) {
|
||||
if (-not (Test-Path -LiteralPath $P.WorkDir)) { New-Item -ItemType Directory -Force -Path $P.WorkDir -Confirm:$false | Out-Null }
|
||||
$json = ConvertTo-Json -InputObject ([ordered]@{ service = $t.Service; services = $deps; at = (& $P.Now).ToString('s') })
|
||||
[System.IO.File]::WriteAllText($file, $json, (New-Object System.Text.UTF8Encoding $false))
|
||||
}
|
||||
& $P.RestartService $t.Service
|
||||
foreach ($d in $deps) { & $P.StartService $d }
|
||||
if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Confirm:$false }
|
||||
$restarted.Add($t.Name)
|
||||
& $P.Log "Restarted $($t.Service)$(if ($deps.Count) { "; started again: $($deps -join ', ')" })"
|
||||
}
|
||||
catch { $failed.Add("$($t.Service): $($_.Exception.Message)") }
|
||||
}
|
||||
return [pscustomobject]@{ Restarted = $restarted.ToArray(); Failed = $failed.ToArray() }
|
||||
}
|
||||
|
||||
function New-RecordPath($P, [string]$Prefix) {
|
||||
# deploy-, request- and rollback- records sort by name in time order and are never reused.
|
||||
for ($i = 0; $i -lt 50; $i++) {
|
||||
$path = Join-Path $P.WorkDir ("$Prefix-" + (& $P.Now).ToString('yyyyMMdd-HHmmss-fff') + '.json')
|
||||
if (-not (Test-Path -LiteralPath $path)) { return $path }
|
||||
Start-Sleep -Milliseconds 5
|
||||
}
|
||||
throw "No free $Prefix record name in $($P.WorkDir)."
|
||||
}
|
||||
|
||||
function Get-RollbackState($P) {
|
||||
# Snapshot file name -> what the rollback records say: instances whose registry value was
|
||||
# restored and verified, and instances whose restart was asked for but has not succeeded.
|
||||
# Records are read in time order, so a later successful restart clears an earlier failure.
|
||||
$state = @{}
|
||||
foreach ($f in @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'rollback-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
||||
$r = Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json
|
||||
if (-not $state.ContainsKey($r.snapshot)) {
|
||||
$state[$r.snapshot] = [pscustomobject]@{
|
||||
Restored = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
RestartPending = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
}
|
||||
}
|
||||
$s = $state[$r.snapshot]
|
||||
foreach ($n in @($r.instances)) { if ($n) { [void]$s.Restored.Add($n) } }
|
||||
foreach ($n in @($r.restarted)) { if ($n) { [void]$s.RestartPending.Remove($n) } }
|
||||
foreach ($n in @($r.restartPending)) { if ($n) { [void]$s.RestartPending.Add($n) } }
|
||||
}
|
||||
return $state
|
||||
}
|
||||
|
||||
function Invoke-VsqlRollback($P, $Confirm, [bool]$WhatIf, [string[]]$Instances, [string]$Snapshot, [bool]$RestartInstances) {
|
||||
# Default: the newest snapshot with an instance not yet restored, or restored with a restart
|
||||
# that did not succeed. Each -Rollback steps back one deployment, a retry finishes the one
|
||||
# that failed, and a later run never re-applies a newer snapshot. -Snapshot picks one.
|
||||
$files = @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'deploy-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
|
||||
if (-not $files) { throw "No deployment snapshots in $($P.WorkDir)." }
|
||||
$load = { param($f) Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json }
|
||||
$pick = { param($s) @($s.instances | Where-Object { -not $Instances -or $Instances -contains $_.name }) }
|
||||
if ($Instances -and -not $Snapshot) {
|
||||
$known = @($files | ForEach-Object { (& $load $_).instances | ForEach-Object { $_.name } })
|
||||
$unknown = @($Instances | Where-Object { $known -notcontains $_ })
|
||||
if ($unknown) { throw "Instance(s) $($unknown -join ', ') are not in any snapshot. Nothing was changed." }
|
||||
}
|
||||
$chosen = $null
|
||||
$entries = @()
|
||||
if ($Snapshot) {
|
||||
$file = $files | Where-Object { $_.Name -eq $Snapshot -or $_.FullName -eq $Snapshot -or $_.BaseName -eq $Snapshot } | Select-Object -First 1
|
||||
if (-not $file) { throw "Snapshot $Snapshot not found. Snapshots: $(($files | ForEach-Object { $_.Name }) -join ', ')" }
|
||||
$chosen = [pscustomobject]@{ File = $file; Data = (& $load $file) }
|
||||
$entries = @(& $pick $chosen.Data)
|
||||
}
|
||||
else {
|
||||
$state = Get-RollbackState $P
|
||||
foreach ($f in $files) {
|
||||
$s = & $load $f
|
||||
$st = $state[$f.Name]
|
||||
$open = @(& $pick $s | Where-Object { -not $st -or -not $st.Restored.Contains($_.name) -or $st.RestartPending.Contains($_.name) })
|
||||
if ($open) { $chosen = [pscustomobject]@{ File = $f; Data = $s }; $entries = $open; break }
|
||||
}
|
||||
if (-not $chosen) {
|
||||
& $P.Log 'Every snapshot has been rolled back already. Nothing to do. Use -Snapshot to restore a specific one.'
|
||||
return [pscustomobject]@{ Snapshot = $null; Restored = @(); Failed = @(); Restarted = @() }
|
||||
}
|
||||
}
|
||||
if ($Instances) {
|
||||
$unknown = @($Instances | Where-Object { @($chosen.Data.instances | ForEach-Object { $_.name }) -notcontains $_ })
|
||||
if ($unknown) { throw "Instance(s) $($unknown -join ', ') are not in snapshot $($chosen.File.Name). Nothing was changed." }
|
||||
}
|
||||
& $P.Log "Snapshot $($chosen.File.Name): saved $($chosen.Data.at) before binding $($chosen.Data.thumbprint)."
|
||||
$restored = New-Object System.Collections.Generic.List[object]
|
||||
$failed = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($e in $entries) {
|
||||
$prev = if ($null -eq $e.previous) { $null } else { [string]$e.previous }
|
||||
if (-not (& $Confirm "instance $($e.name)" "restore Certificate to $(Format-Value $prev)")) { continue }
|
||||
$problem = Restore-InstanceBinding $P $e.regKey $prev
|
||||
if ($problem) { $failed.Add("$($e.name): $problem"); & $P.Log "FAILED to restore $($e.name): $problem" }
|
||||
else {
|
||||
$restored.Add([pscustomobject]@{ Name = $e.name; Service = $e.service; RegKey = $e.regKey })
|
||||
& $P.Log "Restored $($e.name) to $(Format-Value $prev) (verified)"
|
||||
}
|
||||
}
|
||||
$restart = [pscustomobject]@{ Restarted = @(); Failed = @() }
|
||||
if ($restored.Count) {
|
||||
$names = @($restored | ForEach-Object { $_.Name })
|
||||
if ($RestartInstances) {
|
||||
try { $restart = Invoke-InstanceRestart $P $Confirm $restored.ToArray() }
|
||||
finally {
|
||||
# Written after the restarts and even if they throw. A restored instance that was not
|
||||
# restarted stays open, so the next -Rollback -RestartInstances retries it.
|
||||
$done = @($restart.Restarted)
|
||||
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{
|
||||
at = (& $P.Now).ToString('s')
|
||||
snapshot = $chosen.File.Name
|
||||
instances = $names
|
||||
restarted = $done
|
||||
restartPending = @($names | Where-Object { $done -notcontains $_ })
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{ at = (& $P.Now).ToString('s'); snapshot = $chosen.File.Name; instances = $names; restarted = @(); restartPending = @() })
|
||||
& $P.Log 'Restart the restored instances for the rollback to take effect:'
|
||||
foreach ($r in $restored) { & $P.Log " Restart-Service -Name '$($r.Service)' -Force" }
|
||||
}
|
||||
}
|
||||
$all = @($failed) + @($restart.Failed)
|
||||
if ($all.Count) { throw "Rollback incomplete:`n - $($all -join "`n - ")" }
|
||||
return [pscustomobject]@{ Snapshot = $chosen.File.FullName; Restored = @($restored | ForEach-Object { $_.Name }); Failed = @(); Restarted = $restart.Restarted }
|
||||
}
|
||||
|
||||
function Invoke-VsqlCertificateDeployment {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)][hashtable]$Provider,
|
||||
[string]$CertificateAuthority = 'DC-02.PESCO.COM\PESCO-PSYLOCKE-CA',
|
||||
[string]$Template = 'WebServer',
|
||||
[string[]]$DnsNames = @('vsql.pesco.com', 'vsql'),
|
||||
[string[]]$Instances,
|
||||
[string]$CerFile,
|
||||
[string]$Thumbprint,
|
||||
[switch]$RestartInstances,
|
||||
[switch]$Rollback,
|
||||
[string]$Snapshot
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$P = $Provider
|
||||
$whatIf = [bool]$WhatIfPreference
|
||||
$cmdlet = $PSCmdlet
|
||||
$confirm = if ($P.ShouldProcess) { $P.ShouldProcess } else { { param($Target, $Action) $cmdlet.ShouldProcess($Target, $Action) }.GetNewClosure() }
|
||||
# A prerequisite (enrollment, key access) that is declined cancels the run; under -WhatIf the preview goes on.
|
||||
$gate = {
|
||||
param($Target, $Action)
|
||||
if (& $confirm $Target $Action) { return $true }
|
||||
if (-not $whatIf) { throw (New-Object System.OperationCanceledException "Cancelled: $Action on $Target was declined. Nothing was bound.") }
|
||||
return $false
|
||||
}.GetNewClosure()
|
||||
|
||||
if (-not (& $P.IsAdmin)) { throw 'Run this in an elevated PowerShell on the SQL Server host.' }
|
||||
if ($CerFile -and $Thumbprint) { throw 'Use -CerFile or -Thumbprint, not both.' }
|
||||
if ($Rollback) { return Invoke-VsqlRollback $P $confirm $whatIf $Instances $Snapshot $RestartInstances.IsPresent }
|
||||
|
||||
try {
|
||||
# --- 1. instances ----------------------------------------------------------------------
|
||||
$targets = @(Get-SqlInstances $P $Instances)
|
||||
$names = @(Get-UniqueNames (@($DnsNames) + @(& $P.GetHostNames)))
|
||||
$primary = $DnsNames[0]
|
||||
& $P.Log "Instances: $(($targets | ForEach-Object { "$($_.Name) [$($_.Account)]" }) -join ', ')"
|
||||
& $P.Log "Certificate CN=$primary, SANs: $($names -join ', ')"
|
||||
$stamp = (& $P.Now).ToString('yyyyMMdd-HHmmss-fff')
|
||||
$cert = $null
|
||||
|
||||
# --- 2 and 3. the certificate ----------------------------------------------------------
|
||||
if ($Thumbprint) {
|
||||
$t = ($Thumbprint -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
|
||||
if ($t.Length -ne 40) { throw "Thumbprint '$Thumbprint' is not 40 hex digits." }
|
||||
$cert = & $P.GetMachineCert $t
|
||||
if (-not $cert) { throw "Certificate $t is not in the machine store." }
|
||||
}
|
||||
elseif ($CerFile) {
|
||||
if (-not (& $P.TestFile $CerFile)) { throw "Certificate file not found: $CerFile" }
|
||||
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, [System.IO.File]::ReadAllBytes($CerFile))
|
||||
if (& $gate $CerFile 'certreq -accept -machine (install the issued certificate with its pending key)') {
|
||||
$r = & $P.Certreq @('-accept', '-machine', '-q', $CerFile)
|
||||
$r.Output | ForEach-Object { & $P.Log $_ }
|
||||
if ($r.ExitCode -ne 0) { throw "certreq -accept failed ($($r.ExitCode)). The request that made this certificate must be pending on this host; if it is already installed, rerun with -Thumbprint $($issued.Thumbprint)." }
|
||||
}
|
||||
$cert = & $P.GetMachineCert $issued.Thumbprint
|
||||
if (-not $cert -and -not $whatIf) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store after accept." }
|
||||
}
|
||||
else {
|
||||
$work = $P.WorkDir
|
||||
$inf = Join-Path $work "vsql-$stamp.inf"
|
||||
$csr = Join-Path $work "vsql-$stamp.req"
|
||||
$cer = Join-Path $work "vsql-$stamp.cer"
|
||||
$infText = New-CertRequestInf $primary $names $Template $stamp
|
||||
& $P.Log "INF: $inf"
|
||||
if ($whatIf) { & $P.Log $infText }
|
||||
if (& $gate $inf 'write the request INF and run certreq -new (new RSA 2048 key in the machine store)') {
|
||||
if (-not (Test-Path -LiteralPath $work)) { New-Item -ItemType Directory -Force -Path $work -Confirm:$false | Out-Null }
|
||||
[System.IO.File]::WriteAllText($inf, $infText, [System.Text.Encoding]::ASCII)
|
||||
$r = & $P.Certreq @('-new', '-q', $inf, $csr)
|
||||
$r.Output | ForEach-Object { & $P.Log $_ }
|
||||
if ($r.ExitCode -ne 0) { throw "certreq -new failed ($($r.ExitCode))." }
|
||||
}
|
||||
if (& $gate $CertificateAuthority "certreq -submit (template $Template)") {
|
||||
$r = & $P.Certreq @('-submit', '-q', '-config', $CertificateAuthority, $csr, $cer)
|
||||
$r.Output | ForEach-Object { & $P.Log $_ }
|
||||
$reqId = $r.Output | Select-String -Pattern 'RequestId:\s*"?(\d+)' | ForEach-Object { $_.Matches[0].Groups[1].Value } | Select-Object -First 1
|
||||
$requestFile = New-RecordPath $P 'request'
|
||||
Write-NewJsonFile $requestFile ([ordered]@{ id = $reqId; ca = $CertificateAuthority; template = $Template; inf = $inf; req = $csr; cer = $cer; instances = @($targets | ForEach-Object { $_.Name }); at = (& $P.Now).ToString('s'); exitCode = $r.ExitCode })
|
||||
$resume = "-CerFile `"$cer`"$(if ($Instances) { " -Instances $($Instances -join ',')" })"
|
||||
if (-not (& $P.TestFile $cer)) {
|
||||
if ($r.ExitCode -eq 0 -or ($r.Output -join ' ') -match 'pending|Taken Under Submission') {
|
||||
throw "The request is pending on the CA (request id $reqId, saved in $requestFile). Have the CA admin issue it (certsrv.msc, Pending Requests, Issue). Then on this host run: certreq -retrieve -config `"$CertificateAuthority`" $reqId `"$cer`" and rerun this script with $resume. Nothing was bound."
|
||||
}
|
||||
throw "The CA did not issue the certificate (certreq exit $($r.ExitCode), request id $reqId, saved in $requestFile). Nothing was bound."
|
||||
}
|
||||
}
|
||||
if (& $gate $cer 'certreq -accept -machine (install the issued certificate)') {
|
||||
$r = & $P.Certreq @('-accept', '-machine', '-q', $cer)
|
||||
$r.Output | ForEach-Object { & $P.Log $_ }
|
||||
if ($r.ExitCode -ne 0) { throw "certreq -accept failed ($($r.ExitCode))." }
|
||||
$issued = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, [System.IO.File]::ReadAllBytes($cer))
|
||||
$cert = & $P.GetMachineCert $issued.Thumbprint
|
||||
if (-not $cert) { throw "Issued certificate $($issued.Thumbprint) not found in the machine store." }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 4. validate -----------------------------------------------------------------------
|
||||
$thumb = $null
|
||||
$keyFile = 'the new private key'
|
||||
if ($cert) {
|
||||
$thumb = $cert.Thumbprint.ToLowerInvariant()
|
||||
$keyInfo = & $P.GetKeyInfo $cert
|
||||
$check = Test-ServerCertificate -Certificate $cert -RequiredNames $names -Now (& $P.Now) -KeyInfo $keyInfo
|
||||
& $P.Log "Certificate $($cert.Subject) thumbprint $thumb, $($check.KeySize)-bit, $($check.Signature), expires $($cert.NotAfter.ToString('yyyy-MM-dd')), SANs $($check.DnsNames -join ', ')"
|
||||
if ($keyInfo -and $keyInfo.ProviderType -ne 0 -and -not $keyInfo.Machine) { $check.Problems.Add('the private key is not in the machine key store') }
|
||||
if ($check.Problems.Count) {
|
||||
throw "The certificate is not usable by SQL Server:`n - $($check.Problems -join "`n - ")`nNothing was bound. Check the template ($Template) on the CA; it must issue through a legacy CSP with an exchange key."
|
||||
}
|
||||
$keyFile = & $P.KeyFilePath $keyInfo
|
||||
if (-not (& $P.TestFile $keyFile)) { throw "Private key container file not found: $keyFile. Nothing was bound." }
|
||||
}
|
||||
else { & $P.Log 'WhatIf: the issued certificate would be validated here before anything is bound.' }
|
||||
|
||||
# --- 5. private key access, per selected instance account -----------------------------
|
||||
foreach ($acct in (Get-UniqueNames @($targets | ForEach-Object { $_.Account }))) {
|
||||
$sid = & $P.ResolveSid $acct
|
||||
if (& $gate $keyFile "grant read to $acct ($sid)") {
|
||||
$r = & $P.Icacls @($keyFile, '/grant', "*${sid}:(R)")
|
||||
if ($r.ExitCode -ne 0) { throw "Could not grant $acct read on the private key ($($r.Output -join ' ')). Nothing was bound." }
|
||||
& $P.Log "Private key read granted to $acct"
|
||||
}
|
||||
}
|
||||
|
||||
# --- 6. bind, with an immutable snapshot of what it replaces --------------------------
|
||||
$already = New-Object System.Collections.Generic.List[object]
|
||||
$plan = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($i in $targets) {
|
||||
$cur = & $P.GetRegValue $i.RegKey 'Certificate'
|
||||
if ($thumb -and (Test-SameValue $cur $thumb)) { $already.Add($i); & $P.Log "Instance $($i.Name): already bound to $thumb; left alone, no snapshot."; continue }
|
||||
if (& $confirm "instance $($i.Name)" "set Certificate=$(if ($thumb) { $thumb } else { '<new thumbprint>' }) (now $(Format-Value $cur))") {
|
||||
$plan.Add([pscustomobject]@{ Target = $i; Previous = $cur })
|
||||
}
|
||||
}
|
||||
$snapshotFile = $null
|
||||
$bound = New-Object System.Collections.Generic.List[object]
|
||||
if ($plan.Count) {
|
||||
$snapshotFile = New-RecordPath $P 'deploy'
|
||||
Write-NewJsonFile $snapshotFile ([ordered]@{
|
||||
at = (& $P.Now).ToString('s')
|
||||
thumbprint = $thumb
|
||||
instances = @($plan | ForEach-Object { [ordered]@{ name = $_.Target.Name; service = $_.Target.Service; regKey = $_.Target.RegKey; previous = $_.Previous } })
|
||||
})
|
||||
& $P.Log "Previous bindings saved to $snapshotFile (rollback: .\Request-VsqlServerCertificate.ps1 -Rollback)"
|
||||
$attempted = New-Object System.Collections.Generic.List[object]
|
||||
try {
|
||||
foreach ($item in $plan) {
|
||||
$attempted.Add($item)
|
||||
& $P.SetRegValue $item.Target.RegKey 'Certificate' $thumb
|
||||
$readBack = & $P.GetRegValue $item.Target.RegKey 'Certificate'
|
||||
if (-not (Test-SameValue $readBack $thumb)) { throw "instance $($item.Target.Name) reads '$readBack' after binding $thumb" }
|
||||
$bound.Add($item.Target)
|
||||
$force = & $P.GetRegValue $item.Target.RegKey 'ForceEncryption'
|
||||
& $P.Log "Instance $($item.Target.Name): Certificate bound (ForceEncryption=$force, unchanged)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$original = $_.Exception.Message
|
||||
$restoreFailures = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($item in $attempted) {
|
||||
$problem = Restore-InstanceBinding $P $item.Target.RegKey $item.Previous
|
||||
if ($problem) { $restoreFailures.Add("$($item.Target.Name): $problem") }
|
||||
else { & $P.Log "Restored $($item.Target.Name) to $(Format-Value $item.Previous) (verified)" }
|
||||
}
|
||||
$msg = "Binding failed: $original."
|
||||
if ($restoreFailures.Count) { $msg += " Automatic restore FAILED for:`n - $($restoreFailures -join "`n - ")`nRun -Rollback -Snapshot $(Split-Path -Leaf $snapshotFile)." }
|
||||
else { $msg += ' Every instance changed in this run was restored and verified.' }
|
||||
throw $msg
|
||||
}
|
||||
}
|
||||
|
||||
# --- 7. restart -----------------------------------------------------------------------
|
||||
$live = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($i in $bound) { $live.Add($i) }
|
||||
foreach ($i in $already) { $live.Add($i) }
|
||||
$restart = [pscustomobject]@{ Restarted = @(); Failed = @() }
|
||||
if ($RestartInstances) { $restart = Invoke-InstanceRestart $P $confirm $(if ($whatIf) { $targets } else { $live.ToArray() }) }
|
||||
elseif ($live.Count) {
|
||||
& $P.Log ''
|
||||
& $P.Log 'Restart the instances for the certificate to take effect (off-hours; VISUAL users lose their connection):'
|
||||
foreach ($i in $live) { & $P.Log " Restart-Service -Name '$($i.Service)' -Force" }
|
||||
}
|
||||
if ($restart.Failed.Count) { throw "Restart failed:`n - $($restart.Failed -join "`n - ")`nIf an instance does not start, run this script with -Rollback -RestartInstances." }
|
||||
if ($thumb) {
|
||||
& $P.Log ''
|
||||
& $P.Log "Done. Thumbprint $thumb. After the restart the SQL Server error log should read 'The certificate [Cn = $primary] was successfully loaded for encryption'."
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
Cancelled = $false
|
||||
Thumbprint = $thumb
|
||||
Bound = @($bound | ForEach-Object { $_.Name })
|
||||
AlreadyBound = @($already | ForEach-Object { $_.Name })
|
||||
Snapshot = $snapshotFile
|
||||
Restarted = $restart.Restarted
|
||||
}
|
||||
}
|
||||
catch [System.OperationCanceledException] {
|
||||
Write-Warning $_.Exception.Message
|
||||
return [pscustomobject]@{ Cancelled = $true; Thumbprint = $null; Bound = @(); AlreadyBound = @(); Snapshot = $null; Restarted = @() }
|
||||
}
|
||||
}
|
||||
|
||||
# --- entry -------------------------------------------------------------------------------------
|
||||
# Dot-sourced (by the Pester tests): define the functions only.
|
||||
if ($MyInvocation.InvocationName -eq '.') { return }
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$run = @{
|
||||
Provider = Get-VsqlDefaultProvider
|
||||
CertificateAuthority = $CertificateAuthority
|
||||
Template = $Template
|
||||
DnsNames = $DnsNames
|
||||
Instances = $Instances
|
||||
CerFile = $CerFile
|
||||
Thumbprint = $Thumbprint
|
||||
RestartInstances = $RestartInstances
|
||||
Rollback = $Rollback
|
||||
Snapshot = $Snapshot
|
||||
}
|
||||
$result = Invoke-VsqlCertificateDeployment @run
|
||||
if ($result.Cancelled) { exit 2 }
|
||||
if (-not $Rollback -and $result.Thumbprint) { Write-Host 'Then tell Packet Station: the stacks switch to VISUAL_ENCRYPT=yes (Gitea issue 5).' }
|
||||
@@ -1,716 +0,0 @@
|
||||
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' }
|
||||
# Pester 5 tests for Request-VsqlServerCertificate.ps1. Run on Windows PowerShell 5.1 and PowerShell 7:
|
||||
# Invoke-Pester -Path scripts\tests -Output Detailed
|
||||
# The host (registry, services, certreq, icacls, machine store) is a fake. The certificates are
|
||||
# real, made with New-SelfSignedCertificate in Cert:\CurrentUser\My under the friendly name
|
||||
# below, and removed in AfterAll. Nothing touches the machine store, the registry or a CA.
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Request-VsqlServerCertificate.ps1')
|
||||
|
||||
$script:TestFriendlyName = 'PS2-VSQL-CERT-TEST'
|
||||
$script:Root = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
|
||||
$script:KeyDefault = "$Root\MSSQL16.MSSQLSERVER\MSSQLServer\SuperSocketNetLib"
|
||||
$script:KeyDev = "$Root\MSSQL16.DEV\MSSQLServer\SuperSocketNetLib"
|
||||
$script:HostSans = 'DNS=vsql.pesco.com&DNS=vsql&DNS=sqlhost.pesco.com&DNS=SQLHOST'
|
||||
$script:Required = @('vsql.pesco.com', 'vsql', 'sqlhost.pesco.com', 'SQLHOST')
|
||||
|
||||
Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -eq $TestFriendlyName } | ForEach-Object { Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($_.Thumbprint)" -DeleteKey }
|
||||
|
||||
function New-TestCert {
|
||||
param(
|
||||
[string]$Sans = $HostSans,
|
||||
[string]$Provider = 'Microsoft RSA SChannel Cryptographic Provider',
|
||||
[string]$KeySpec = 'KeyExchange',
|
||||
[int]$KeyLength = 2048,
|
||||
[string]$Hash = 'SHA256',
|
||||
[string]$Eku = '1.3.6.1.5.5.7.3.1',
|
||||
[string[]]$KeyUsage = @('DigitalSignature', 'KeyEncipherment'),
|
||||
[datetime]$NotBefore = (Get-Date).AddDays(-1),
|
||||
[datetime]$NotAfter = (Get-Date).AddYears(1)
|
||||
)
|
||||
$a = @{
|
||||
Subject = 'CN=vsql.pesco.com'
|
||||
CertStoreLocation = 'Cert:\CurrentUser\My'
|
||||
FriendlyName = $TestFriendlyName
|
||||
Provider = $Provider
|
||||
KeyLength = $KeyLength
|
||||
HashAlgorithm = $Hash
|
||||
KeyUsage = $KeyUsage
|
||||
NotBefore = $NotBefore
|
||||
NotAfter = $NotAfter
|
||||
TextExtension = @("2.5.29.17={text}$Sans", "2.5.29.37={text}$Eku")
|
||||
}
|
||||
if ($KeySpec) { $a.KeySpec = $KeySpec }
|
||||
$c = New-SelfSignedCertificate @a
|
||||
Get-Item "Cert:\CurrentUser\My\$($c.Thumbprint)"
|
||||
}
|
||||
|
||||
$script:Good = New-TestCert
|
||||
$script:Good2 = New-TestCert
|
||||
$script:Evil = New-TestCert -Sans 'DNS=vsql.pesco.com.evil.example&DNS=vsql.evil.example&DNS=sqlhost.pesco.com&DNS=SQLHOST'
|
||||
$script:Cased = New-TestCert -Sans 'DNS=VSQL.PESCO.COM&DNS=Vsql&DNS=SqlHost.Pesco.Com&DNS=sqlhost'
|
||||
$script:Ksp = New-TestCert -Provider 'Microsoft Software Key Storage Provider' -KeySpec ''
|
||||
# The SChannel CSP cannot hold a signature key, so these use the Enhanced RSA and AES CSP (type 24).
|
||||
$script:SigSpec = New-TestCert -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' -KeySpec 'Signature' -KeyUsage 'DigitalSignature'
|
||||
$script:AesCsp = New-TestCert -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider'
|
||||
$script:Weak = New-TestCert -KeyLength 1024
|
||||
$script:Sha1 = New-TestCert -Hash 'SHA1'
|
||||
$script:NoServerAuth = New-TestCert -Eku '1.3.6.1.5.5.7.3.2'
|
||||
$script:Expired = New-TestCert -NotBefore (Get-Date).AddDays(-60) -NotAfter (Get-Date).AddDays(-1)
|
||||
|
||||
$script:Mutating = '^(certreq -new|certreq -submit|certreq -accept|icacls|SetReg|RemoveReg|Restart)'
|
||||
|
||||
# A fake SQL host: default instance and DEV, service accounts, registry, machine store,
|
||||
# certreq and icacls. $Fake.Calls records every operation in order.
|
||||
function New-FakeHost {
|
||||
param([string]$Work)
|
||||
$fake = @{
|
||||
Admin = $true
|
||||
Calls = New-Object System.Collections.ArrayList
|
||||
Log = New-Object System.Collections.ArrayList
|
||||
Instances = [ordered]@{ MSSQLSERVER = 'MSSQL16.MSSQLSERVER'; DEV = 'MSSQL16.DEV' }
|
||||
Services = @{
|
||||
'MSSQLSERVER' = [pscustomobject]@{ Name = 'MSSQLSERVER'; StartName = 'NT Service\MSSQLSERVER' }
|
||||
'MSSQL$DEV' = [pscustomobject]@{ Name = 'MSSQL$DEV'; StartName = 'PESCO\svc-sqldev' }
|
||||
}
|
||||
Registry = @{
|
||||
$KeyDefault = @{ Certificate = ''; ForceEncryption = '0' }
|
||||
$KeyDev = @{ Certificate = ''; ForceEncryption = '0' }
|
||||
}
|
||||
Store = @{}
|
||||
Submit = 'Issued' # Issued, Pending, Failure
|
||||
IssueCert = $Good
|
||||
IcaclsFail = $false
|
||||
SetFails = @{} # regKey -> $true: Set-ItemProperty throws
|
||||
SetIgnored = @{} # regKey -> $true: the write does not stick
|
||||
ReadFails = @{} # regKey -> $true: reading throws (access denied)
|
||||
Dependents = @{ 'MSSQLSERVER' = @('SQLSERVERAGENT'); 'MSSQL$DEV' = @('SQLAgent$DEV') }
|
||||
Running = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
RestartFails = @{} # service -> $true: stops it and its dependents, then fails to start
|
||||
KeyFile = Join-Path $Work 'machinekey.bin'
|
||||
ShouldProcess = $null
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $Work | Out-Null
|
||||
Set-Content -Path $fake.KeyFile -Value 'key'
|
||||
$realKeyInfo = ${function:Get-CertificateKeyInfo}
|
||||
$p = @{
|
||||
WorkDir = $Work
|
||||
InstanceRoot = $Root
|
||||
IsAdmin = { [void]$fake.Calls.Add('IsAdmin'); $fake.Admin }.GetNewClosure()
|
||||
GetInstanceMap = { param($r) $fake.Instances }.GetNewClosure()
|
||||
GetService = { param($n) [void]$fake.Calls.Add("GetService $n"); $fake.Services[$n] }.GetNewClosure()
|
||||
TestRegKey = { param($k) $fake.Registry.ContainsKey($k) }.GetNewClosure()
|
||||
GetRegValue = {
|
||||
param($k, $n)
|
||||
if ($fake.ReadFails[$k]) { throw "Requested registry access is not allowed: $k" }
|
||||
$v = $fake.Registry[$k]; if ($v.ContainsKey($n)) { [string]$v[$n] } else { $null }
|
||||
}.GetNewClosure()
|
||||
SetRegValue = {
|
||||
param($k, $n, $v)
|
||||
[void]$fake.Calls.Add("SetReg $k $n=$v")
|
||||
if ($fake.SetFails[$k]) { throw "Access to $k is denied" }
|
||||
if (-not $fake.SetIgnored[$k]) { $fake.Registry[$k][$n] = $v }
|
||||
}.GetNewClosure()
|
||||
RemoveRegValue = { param($k, $n) [void]$fake.Calls.Add("RemoveReg $k $n"); if ($fake.SetFails[$k]) { throw "Access to $k is denied" }; $fake.Registry[$k].Remove($n) }.GetNewClosure()
|
||||
GetHostNames = { @('sqlhost.pesco.com', 'SQLHOST') }
|
||||
GetMachineCert = { param($t) $fake.Store[$t] }.GetNewClosure()
|
||||
Certreq = {
|
||||
param([string[]]$a)
|
||||
[void]$fake.Calls.Add("certreq $($a[0]) $($a[1..($a.Count - 1)] -join ' ')")
|
||||
switch ($a[0]) {
|
||||
'-new' { return [pscustomobject]@{ ExitCode = 0; Output = @('CertReq: Request Created') } }
|
||||
'-submit' {
|
||||
$cer = $a[-1]
|
||||
switch ($fake.Submit) {
|
||||
'Issued' { [IO.File]::WriteAllBytes($cer, $fake.IssueCert.Export('Cert')); return [pscustomobject]@{ ExitCode = 0; Output = @('RequestId: 42', 'RequestId: "42"', 'Certificate retrieved(Issued) Issued') } }
|
||||
'Pending' { return [pscustomobject]@{ ExitCode = 0; Output = @('RequestId: 43', 'RequestId: "43"', 'Certificate request is pending: Taken Under Submission (0)') } }
|
||||
default { return [pscustomobject]@{ ExitCode = -2146877436; Output = @('RequestId: 44', 'Certificate not issued (Denied) Denied by Policy Module') } }
|
||||
}
|
||||
}
|
||||
'-accept' {
|
||||
$c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, [IO.File]::ReadAllBytes($a[-1]))
|
||||
$fake.Store[$c.Thumbprint] = Get-Item "Cert:\CurrentUser\My\$($c.Thumbprint)"
|
||||
return [pscustomobject]@{ ExitCode = 0; Output = @('Installed Certificate') }
|
||||
}
|
||||
}
|
||||
}.GetNewClosure()
|
||||
Icacls = {
|
||||
param([string[]]$a)
|
||||
[void]$fake.Calls.Add("icacls $($a -join ' ')")
|
||||
if ($fake.IcaclsFail) { return [pscustomobject]@{ ExitCode = 5; Output = @('Access is denied.') } }
|
||||
[pscustomobject]@{ ExitCode = 0; Output = @('processed file') }
|
||||
}.GetNewClosure()
|
||||
ResolveSid = { param($acct) @{ 'NT Service\MSSQLSERVER' = 'S-1-5-80-1'; 'PESCO\svc-sqldev' = 'S-1-5-21-9-1105' }[$acct] }
|
||||
# The real key reader on the real test certificate; only the store location is faked,
|
||||
# since the test keys live in the user store and vsql's in the machine store.
|
||||
GetKeyInfo = { param($c) $k = & $realKeyInfo $c; if ($k) { $k.Machine = $true }; $k }.GetNewClosure()
|
||||
KeyFilePath = { param($k) $fake.KeyFile }.GetNewClosure()
|
||||
TestFile = { param($f) Test-Path -LiteralPath $f -PathType Leaf }
|
||||
GetRunningDependents = { param($n) @($fake.Dependents[$n] | Where-Object { $fake.Running.Contains($_) }) }.GetNewClosure()
|
||||
RestartService = {
|
||||
param($n)
|
||||
[void]$fake.Calls.Add("Restart $n")
|
||||
foreach ($d in $fake.Dependents[$n]) { [void]$fake.Running.Remove($d) }
|
||||
[void]$fake.Running.Remove($n)
|
||||
if ($fake.RestartFails[$n]) { throw "Service '$n' cannot be started" }
|
||||
[void]$fake.Running.Add($n)
|
||||
}.GetNewClosure()
|
||||
StartService = { param($n) [void]$fake.Calls.Add("Start $n"); [void]$fake.Running.Add($n) }.GetNewClosure()
|
||||
Now = { Get-Date }
|
||||
Log = { param($m) [void]$fake.Log.Add("$m") }.GetNewClosure()
|
||||
ShouldProcess = $null
|
||||
}
|
||||
foreach ($svc in 'MSSQLSERVER', 'MSSQL$DEV', 'SQLSERVERAGENT', 'SQLAgent$DEV') { [void]$fake.Running.Add($svc) }
|
||||
$fake.Provider = $p
|
||||
return $fake
|
||||
}
|
||||
|
||||
function Get-MutatingCalls($Fake) { @($Fake.Calls | Where-Object { $_ -match $Mutating }) }
|
||||
function Get-Snapshots($Fake) { @(Get-ChildItem -LiteralPath $Fake.Provider.WorkDir -Filter 'deploy-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name) }
|
||||
function Add-ToStore($Fake, $Cert) { $Fake.Store[$Cert.Thumbprint] = $Cert }
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -eq $TestFriendlyName } | ForEach-Object { Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($_.Thumbprint)" -DeleteKey }
|
||||
}
|
||||
|
||||
Describe 'Finding 1: administrator check' {
|
||||
BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())); Add-ToStore $f $Good }
|
||||
|
||||
It 'refuses an unelevated session before any lookup or change' {
|
||||
$f.Admin = $false
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint } | Should -Throw '*elevated*'
|
||||
$f.Calls | Should -Be @('IsAdmin')
|
||||
}
|
||||
It 'refuses an unelevated rollback too' {
|
||||
$f.Admin = $false
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*elevated*'
|
||||
}
|
||||
It 'lets an elevated session through' {
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint
|
||||
$r.Bound | Should -Be @('MSSQLSERVER', 'DEV')
|
||||
}
|
||||
It 'no longer calls Test-Admin with stray arguments' {
|
||||
$text = Get-Content (Join-Path $PSScriptRoot '..\Request-VsqlServerCertificate.ps1') -Raw
|
||||
$text | Should -Not -Match 'Test-Admin\s+-eq'
|
||||
$text | Should -Match 'if \(-not \(& \$P\.IsAdmin\)\)'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Finding 2: named instance service lookup' {
|
||||
It 'builds a WQL filter that keeps $ unescaped' {
|
||||
Get-ServiceFilter 'MSSQL$DEV' | Should -BeExactly "Name='MSSQL`$DEV'"
|
||||
Get-SqlServiceName 'DEV' | Should -BeExactly 'MSSQL$DEV'
|
||||
Get-SqlServiceName 'MSSQLSERVER' | Should -BeExactly 'MSSQLSERVER'
|
||||
}
|
||||
It 'runs that filter against real WMI without error (the old escaped form is an invalid query)' {
|
||||
{ Get-CimInstance Win32_Service -Filter (Get-ServiceFilter 'MSSQL$DEV') -ErrorAction Stop } | Should -Not -Throw
|
||||
{ Get-CimInstance Win32_Service -Filter "Name='MSSQL\`$DEV'" -ErrorAction Stop } | Should -Throw
|
||||
}
|
||||
It 'discovers the default and DEV instances with their accounts' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'disc')
|
||||
$t = Get-SqlInstances $f.Provider $null
|
||||
$t.Name | Should -Be @('MSSQLSERVER', 'DEV')
|
||||
($t | Where-Object Name -eq 'DEV').Service | Should -BeExactly 'MSSQL$DEV'
|
||||
($t | Where-Object Name -eq 'DEV').Account | Should -Be 'PESCO\svc-sqldev'
|
||||
($t | Where-Object Name -eq 'DEV').RegKey | Should -Be $KeyDev
|
||||
$f.Calls | Should -Contain 'GetService MSSQL$DEV'
|
||||
}
|
||||
It 'fails loudly when a service is missing' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'nosvc')
|
||||
$f.Services.Remove('MSSQL$DEV')
|
||||
{ Get-SqlInstances $f.Provider @('DEV') } | Should -Throw '*MSSQL$DEV*not found*'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Unknown instance names' {
|
||||
It 'rejects them before any change' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'unknown')
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances 'DEV', 'PROD' } | Should -Throw '*Unknown instance(s): PROD*'
|
||||
Get-MutatingCalls $f | Should -BeNullOrEmpty
|
||||
@($f.Calls | Where-Object { $_ -like 'certreq*' }) | Should -BeNullOrEmpty
|
||||
Test-Path (Join-Path $f.Provider.WorkDir '*.inf') | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Finding 3: SAN decoding and matching' {
|
||||
It 'decodes the DNS names from the raw extension' {
|
||||
$ext = $Good.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }
|
||||
Get-SanDnsNames $ext.RawData | Should -Be @('vsql.pesco.com', 'vsql', 'sqlhost.pesco.com', 'SQLHOST')
|
||||
}
|
||||
It 'agrees with .NET where .NET can decode SANs (PowerShell 7)' {
|
||||
$ext = $Good.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' }
|
||||
if (-not ($ext.PSObject.Methods.Name -contains 'EnumerateDnsNames')) { Set-ItResult -Skipped -Because 'this .NET has no X509SubjectAlternativeNameExtension'; return }
|
||||
Get-SanDnsNames $ext.RawData | Should -Be @($ext.EnumerateDnsNames())
|
||||
}
|
||||
It 'skips non-DNS entries and handles long-form lengths' {
|
||||
# SEQUENCE { [7] 4 bytes IP, [2] 130 bytes name }
|
||||
$name = 'a' * 126 + '.com'
|
||||
$dns = [byte[]](0x82, 0x81, 130) + [Text.Encoding]::ASCII.GetBytes($name)
|
||||
$ip = [byte[]](0x87, 4, 192, 168, 1, 1)
|
||||
$body = $ip + $dns
|
||||
$raw = [byte[]](0x30, 0x81, $body.Length) + $body
|
||||
Get-SanDnsNames $raw | Should -Be @($name)
|
||||
}
|
||||
It 'rejects malformed data' {
|
||||
{ Get-SanDnsNames ([byte[]](0x30, 0x05, 0x82, 0x09, 0x61)) } | Should -Throw
|
||||
{ Get-SanDnsNames ([byte[]](0x31, 0x00)) } | Should -Throw
|
||||
}
|
||||
It 'rejects vsql.pesco.com.evil.example for vsql.pesco.com and vsql' {
|
||||
$c = Test-ServerCertificate -Certificate $Evil -RequiredNames $Required
|
||||
$c.Problems | Should -Contain 'subject alternative name vsql.pesco.com missing'
|
||||
$c.Problems | Should -Contain 'subject alternative name vsql missing'
|
||||
@($c.Problems | Where-Object { $_ -like '*sqlhost*' -or $_ -like '*SQLHOST*' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'accepts case variants' {
|
||||
(Test-ServerCertificate -Certificate $Cased -RequiredNames $Required).Problems | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Certificate validation on real keys' {
|
||||
It 'accepts a CSP exchange key, RSA 2048, SHA-256, Server Authentication, all SANs' {
|
||||
$c = Test-ServerCertificate -Certificate $Good -RequiredNames $Required
|
||||
$c.Problems | Should -BeNullOrEmpty
|
||||
$c.KeyInfo.ProviderType | Should -Be 12
|
||||
$c.KeyInfo.KeySpec | Should -Be 1
|
||||
$c.KeyInfo.UniqueName | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
It 'rejects a KSP (CNG) key' {
|
||||
(Test-ServerCertificate -Certificate $Ksp -RequiredNames $Required).Problems -join " | " | Should -Match 'Key Storage Provider'
|
||||
}
|
||||
It 'rejects a CSP signature key' {
|
||||
$p = (Test-ServerCertificate -Certificate $SigSpec -RequiredNames $Required).Problems
|
||||
$p -join " | " | Should -Match 'AT_KEYEXCHANGE'
|
||||
$p | Should -Contain 'key usage lacks key encipherment'
|
||||
}
|
||||
It 'rejects a CSP other than the SChannel provider' {
|
||||
$p = (Test-ServerCertificate -Certificate $AesCsp -RequiredNames $Required).Problems
|
||||
@($p) | Should -HaveCount 1
|
||||
$p -join " | " | Should -Match 'provider type 24'
|
||||
}
|
||||
It 'rejects a key under 2048 bits' {
|
||||
(Test-ServerCertificate -Certificate $Weak -RequiredNames $Required).Problems -join " | " | Should -Match 'key size 1024'
|
||||
}
|
||||
It 'rejects SHA-1' {
|
||||
(Test-ServerCertificate -Certificate $Sha1 -RequiredNames $Required).Problems -join " | " | Should -Match 'not SHA-256'
|
||||
}
|
||||
It 'rejects a certificate without Server Authentication' {
|
||||
(Test-ServerCertificate -Certificate $NoServerAuth -RequiredNames $Required).Problems | Should -Contain 'Server Authentication EKU missing'
|
||||
}
|
||||
It 'rejects an expired certificate' {
|
||||
(Test-ServerCertificate -Certificate $Expired -RequiredNames $Required).Problems -join " | " | Should -Match 'not valid now'
|
||||
}
|
||||
It 'rejects one that expires within 30 days' {
|
||||
(Test-ServerCertificate -Certificate $Good -RequiredNames $Required -Now $Good.NotAfter.AddDays(-10)).Problems -join " | " | Should -Match 'within 30 days'
|
||||
}
|
||||
It 'binds nothing when the issued certificate fails validation' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'invalid')
|
||||
Add-ToStore $f $Ksp
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Ksp.Thumbprint } | Should -Throw '*not usable*'
|
||||
Get-MutatingCalls $f | Should -BeNullOrEmpty
|
||||
Get-Snapshots $f | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Certificate request INF' {
|
||||
It 'asks for exactly what SQL Server needs' {
|
||||
$inf = New-CertRequestInf 'vsql.pesco.com' $Required 'WebServer' '20261018-090000-000'
|
||||
$inf | Should -Match 'Subject = "CN=vsql.pesco.com"'
|
||||
$inf | Should -Match 'ProviderName = "Microsoft RSA SChannel Cryptographic Provider"'
|
||||
$inf | Should -Match 'ProviderType = 12'
|
||||
$inf | Should -Match 'KeySpec = 1'
|
||||
$inf | Should -Match 'KeyLength = 2048'
|
||||
$inf | Should -Match 'KeyAlgorithm = RSA'
|
||||
$inf | Should -Match 'HashAlgorithm = SHA256'
|
||||
$inf | Should -Match 'KeyUsage = 0xA0'
|
||||
$inf | Should -Match 'MachineKeySet = TRUE'
|
||||
$inf | Should -Match 'OID = 1\.3\.6\.1\.5\.5\.7\.3\.1'
|
||||
$inf | Should -Match 'CertificateTemplate = WebServer'
|
||||
$inf | Should -Match 'Signature = "\$Windows NT\$"'
|
||||
foreach ($n in $Required) { $inf | Should -Match ([regex]::Escape("_continue_ = `"dns=$n&`"")) }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'A full request, issued' {
|
||||
It 'requests, accepts with -machine, validates, grants each account, snapshots and binds' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'issued')
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider
|
||||
$r.Thumbprint | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
$r.Bound | Should -Be @('MSSQLSERVER', 'DEV')
|
||||
$order = @($f.Calls | Where-Object { $_ -match $Mutating } | ForEach-Object { ($_ -split ' ')[0..1] -join ' ' })
|
||||
$order[0..2] | Should -Be @('certreq -new', 'certreq -submit', 'certreq -accept')
|
||||
@($f.Calls | Where-Object { $_ -like 'certreq -accept*' })[0] | Should -Match '-machine -q'
|
||||
@($f.Calls | Where-Object { $_ -like 'icacls*' }) | Should -Be @("icacls $($f.KeyFile) /grant *S-1-5-80-1:(R)", "icacls $($f.KeyFile) /grant *S-1-5-21-9-1105:(R)")
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be $r.Thumbprint
|
||||
$f.Registry[$KeyDev].ForceEncryption | Should -Be '0'
|
||||
@(Get-ChildItem $f.Provider.WorkDir -Filter 'request-*.json').Count | Should -Be 1
|
||||
(Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json).instances.previous | Should -Be @('', '')
|
||||
@($f.Calls | Where-Object { $_ -like 'Restart*' }) | Should -BeNullOrEmpty
|
||||
$f.Log | Should -Contain " Restart-Service -Name 'MSSQL`$DEV' -Force"
|
||||
}
|
||||
It 'restarts only with -RestartInstances' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'restart')
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances DEV -RestartInstances
|
||||
$r.Bound | Should -Be @('DEV')
|
||||
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
|
||||
$f.Calls | Should -Not -Contain 'Restart MSSQLSERVER'
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be ''
|
||||
}
|
||||
It 'fails cleanly when the CA denies the request' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'denied')
|
||||
$f.Submit = 'Failure'
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider } | Should -Throw '*did not issue*request id 44*'
|
||||
@($f.Calls | Where-Object { $_ -match '^(icacls|SetReg|certreq -accept)' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Pending request' {
|
||||
It 'saves the request id, binds nothing, and resumes with -CerFile' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'pending')
|
||||
$f.Submit = 'Pending'
|
||||
$err = $null
|
||||
try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances DEV } catch { $err = $_.Exception.Message }
|
||||
$err | Should -Match 'pending on the CA \(request id 43'
|
||||
$err | Should -Match 'certreq -retrieve -config "DC-02.PESCO.COM\\PESCO-PSYLOCKE-CA" 43'
|
||||
$err | Should -Match '-Instances DEV'
|
||||
@($f.Calls | Where-Object { $_ -match '^(icacls|SetReg|certreq -accept)' }) | Should -BeNullOrEmpty
|
||||
$req = Get-Content (Get-ChildItem $f.Provider.WorkDir -Filter 'request-*.json')[0].FullName -Raw | ConvertFrom-Json
|
||||
$req.id | Should -Be '43'
|
||||
$req.instances | Should -Be @('DEV')
|
||||
|
||||
# The CA admin issues it; certreq -retrieve writes the .cer the record names.
|
||||
[IO.File]::WriteAllBytes($req.cer, $Good.Export('Cert'))
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -CerFile $req.cer -Instances DEV
|
||||
@($f.Calls | Where-Object { $_ -like 'certreq -accept*' })[0] | Should -Be "certreq -accept -machine -q $($req.cer)"
|
||||
$r.Bound | Should -Be @('DEV')
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Finding 5: declined or failed prerequisites' {
|
||||
BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())); Add-ToStore $f $Good }
|
||||
|
||||
It 'a declined ACL grant cancels the run and binds nothing' {
|
||||
$f.Provider.ShouldProcess = { param($t, $a) $a -notlike 'grant read*' }
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint 3>$null
|
||||
$r.Cancelled | Should -BeTrue
|
||||
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart|icacls)' }) | Should -BeNullOrEmpty
|
||||
Get-Snapshots $f | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'a declined second grant cancels after the first, still binding nothing' {
|
||||
$count = @{ n = 0 }
|
||||
$f.Provider.ShouldProcess = { param($t, $a) if ($a -like 'grant read*') { $count.n++; return ($count.n -lt 2) }; $true }.GetNewClosure()
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint 3>$null
|
||||
$r.Cancelled | Should -BeTrue
|
||||
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart)' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'a failed icacls stops the run and binds nothing' {
|
||||
$f.IcaclsFail = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint } | Should -Throw '*Could not grant*Nothing was bound*'
|
||||
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart)' }) | Should -BeNullOrEmpty
|
||||
Get-Snapshots $f | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'declining certreq -new, -submit or -accept cancels before the next step' {
|
||||
foreach ($step in 'certreq -new', 'certreq -submit', 'certreq -accept') {
|
||||
$g = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid()))
|
||||
$g.Provider.ShouldProcess = { param($t, $a) $a -notlike "*$step*" }.GetNewClosure()
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $g.Provider 3>$null
|
||||
$r.Cancelled | Should -BeTrue
|
||||
@($g.Calls | Where-Object { $_ -like "$step*" }) | Should -BeNullOrEmpty
|
||||
@($g.Calls | Where-Object { $_ -match '^(icacls|SetReg)' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
It 'declining one instance binding leaves that instance out of the snapshot' {
|
||||
$f.Provider.ShouldProcess = { param($t, $a) $t -ne 'instance MSSQLSERVER' }
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint
|
||||
$r.Bound | Should -Be @('DEV')
|
||||
(Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json).instances.name | Should -Be @('DEV')
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Finding 4: snapshots per deployment' {
|
||||
It 'keeps the useful snapshot when the bound certificate is run again, and rolls back to it' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'rerun')
|
||||
Add-ToStore $f $Good; Add-ToStore $f $Good2
|
||||
$a = $Good.Thumbprint.ToLowerInvariant(); $b = $Good2.Thumbprint.ToLowerInvariant()
|
||||
$f.Registry[$KeyDefault].Certificate = $a; $f.Registry[$KeyDev].Certificate = $a
|
||||
|
||||
$r1 = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint
|
||||
$r1.Bound | Should -Be @('MSSQLSERVER', 'DEV')
|
||||
$snap = Get-Snapshots $f
|
||||
$snap.Count | Should -Be 1
|
||||
$before = Get-FileHash $snap[0].FullName
|
||||
|
||||
Start-Sleep -Milliseconds 20
|
||||
$f.Calls.Clear()
|
||||
$r2 = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint -RestartInstances
|
||||
$r2.Bound | Should -BeNullOrEmpty
|
||||
$r2.AlreadyBound | Should -Be @('MSSQLSERVER', 'DEV')
|
||||
$r2.Snapshot | Should -BeNullOrEmpty
|
||||
@($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty
|
||||
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
|
||||
(Get-Snapshots $f).Count | Should -Be 1
|
||||
(Get-FileHash $snap[0].FullName).Hash | Should -Be $before.Hash
|
||||
|
||||
$rb = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback
|
||||
$rb.Restored | Should -Be @('MSSQLSERVER', 'DEV')
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be $a
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be $a
|
||||
$b | Should -Not -Be $a
|
||||
}
|
||||
It 'never replaces an existing snapshot file' {
|
||||
$path = Join-Path $TestDrive 'immutable.json'
|
||||
Write-NewJsonFile $path @{ a = 1 }
|
||||
{ Write-NewJsonFile $path @{ a = 2 } } | Should -Throw
|
||||
(Get-Content $path -Raw | ConvertFrom-Json).a | Should -Be 1
|
||||
}
|
||||
It 'records an absent value as null and an empty value as empty' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'nullempty')
|
||||
Add-ToStore $f $Good
|
||||
$f.Registry[$KeyDefault].Remove('Certificate')
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
|
||||
$s = Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json
|
||||
$null -eq ($s.instances | Where-Object name -eq 'MSSQLSERVER').previous | Should -BeTrue
|
||||
($s.instances | Where-Object name -eq 'DEV').previous | Should -BeExactly ''
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback | Out-Null
|
||||
$f.Registry[$KeyDefault].ContainsKey('Certificate') | Should -BeFalse
|
||||
$f.Registry[$KeyDev].Certificate | Should -BeExactly ''
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Finding 6: rollback restores each instance on its own and verifies' {
|
||||
BeforeEach {
|
||||
$f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid()))
|
||||
Add-ToStore $f $Good
|
||||
$f.Registry[$KeyDefault].Certificate = 'aaaa'; $f.Registry[$KeyDev].Certificate = 'bbbb'
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
|
||||
$f.Calls.Clear()
|
||||
}
|
||||
|
||||
It 'restores and verifies every instance' {
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback
|
||||
$r.Restored | Should -Be @('MSSQLSERVER', 'DEV')
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
$f.Log | Should -Contain 'Restored DEV to bbbb (verified)'
|
||||
}
|
||||
It 'keeps going past a failed instance and reports it' {
|
||||
$f.SetFails[$KeyDefault] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*MSSQLSERVER*denied*'
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
$f.Log | Should -Not -Contain 'Restored MSSQLSERVER to aaaa (verified)'
|
||||
}
|
||||
It 'reports a write that does not stick instead of claiming success' {
|
||||
$f.SetIgnored[$KeyDev] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw "*DEV: reads*after restoring 'bbbb'*"
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
|
||||
}
|
||||
It 'restores only the named instances and rejects names not in the snapshot' {
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Instances PROD } | Should -Throw '*not in any snapshot*'
|
||||
@($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Instances DEV -RestartInstances
|
||||
$r.Restored | Should -Be @('DEV')
|
||||
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
# The next default rollback finishes the same snapshot with the instance left.
|
||||
$f.Calls.Clear()
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Restored | Should -Be @('MSSQLSERVER')
|
||||
@($f.Calls | Where-Object { $_ -like "SetReg $KeyDev*" }) | Should -BeNullOrEmpty
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
|
||||
}
|
||||
It 'retries an instance whose restore failed' {
|
||||
$f.SetFails[$KeyDefault] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*'
|
||||
$f.SetFails.Clear()
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Restored | Should -Be @('MSSQLSERVER')
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa'
|
||||
}
|
||||
It 'defaults to the newest snapshot not yet rolled back; -Snapshot picks one' {
|
||||
Add-ToStore $f $Good2
|
||||
Start-Sleep -Milliseconds 20
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint | Out-Null
|
||||
$snaps = Get-Snapshots $f
|
||||
$snaps.Count | Should -Be 2
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -Be $snaps[1].FullName
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -Be $snaps[0].FullName
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
# Both rolled back: a third run does nothing, it does not re-apply the newer snapshot.
|
||||
$f.Calls.Clear()
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -BeNullOrEmpty
|
||||
@($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Snapshot $snaps[1].Name).Snapshot | Should -Be $snaps[1].FullName
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
}
|
||||
It 'automatic restore during binding restores each changed instance on its own' {
|
||||
Add-ToStore $f $Good2
|
||||
$f.SetFails[$KeyDev] = $true
|
||||
$err = $null
|
||||
try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } catch { $err = $_.Exception.Message }
|
||||
$err | Should -Match 'Binding failed'
|
||||
$err | Should -Match 'restore FAILED for:\s+- DEV'
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
$f.Log | Should -Contain "Restored MSSQLSERVER to $($Good.Thumbprint.ToLowerInvariant()) (verified)"
|
||||
}
|
||||
It 'automatic restore after a write that does not stick reports success only when verified' {
|
||||
Add-ToStore $f $Good2
|
||||
$f.SetIgnored[$KeyDev] = $true
|
||||
$err = $null
|
||||
try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } catch { $err = $_.Exception.Message }
|
||||
$err | Should -Match 'DEV reads'
|
||||
$err | Should -Match 'Every instance changed in this run was restored and verified'
|
||||
$f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Second review: recovery paths' {
|
||||
BeforeEach {
|
||||
$f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid()))
|
||||
Add-ToStore $f $Good
|
||||
$f.Registry[$KeyDefault].Certificate = 'aaaa'; $f.Registry[$KeyDev].Certificate = 'bbbb'
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
|
||||
$f.Calls.Clear()
|
||||
}
|
||||
|
||||
It 'a rollback whose restart failed is retried, not skipped and not stepped past' {
|
||||
$f.RestartFails['MSSQL$DEV'] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw '*Rollback incomplete*MSSQL$DEV*'
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
$f.RestartFails.Clear()
|
||||
$f.Calls.Clear()
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances
|
||||
$r.Snapshot | Should -Not -BeNullOrEmpty
|
||||
$r.Restarted | Should -Be @('DEV')
|
||||
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
|
||||
$f.Calls | Should -Not -Contain 'Restart MSSQLSERVER'
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
# Now complete: nothing left to do.
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances).Snapshot | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'a declined restart under -Confirm also stays open' {
|
||||
$f.Provider.ShouldProcess = { param($t, $a) $a -notlike 'restart service*' -or $t -ne 'MSSQL$DEV' }
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null
|
||||
$f.Provider.ShouldProcess = $null
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances
|
||||
$r.Restarted | Should -Be @('DEV')
|
||||
}
|
||||
It 'SQL Server Agent is started again after a failed restart is retried' {
|
||||
$f.RestartFails['MSSQL$DEV'] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw
|
||||
$f.Running.Contains('SQLAgent$DEV') | Should -BeFalse
|
||||
$depFile = Join-Path $f.Provider.WorkDir 'dependents-MSSQL_DEV.json'
|
||||
(Get-Content $depFile -Raw | ConvertFrom-Json).services | Should -Be @('SQLAgent$DEV')
|
||||
$f.RestartFails.Clear()
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null
|
||||
$f.Calls | Should -Contain 'Start SQLAgent$DEV'
|
||||
$f.Running.Contains('SQLAgent$DEV') | Should -BeTrue
|
||||
Test-Path $depFile | Should -BeFalse
|
||||
}
|
||||
It 'a successful restart starts the agent that ran and leaves no dependents file' {
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint -Instances MSSQLSERVER -RestartInstances | Out-Null
|
||||
$f.Calls | Should -Contain 'Start SQLSERVERAGENT'
|
||||
$f.Running.Contains('SQLSERVERAGENT') | Should -BeTrue
|
||||
@(Get-ChildItem $f.Provider.WorkDir -Filter 'dependents-*.json') | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'a registry read failure stops a deployment before any snapshot or change' {
|
||||
Add-ToStore $f $Good2
|
||||
$before = @(Get-Snapshots $f).Count
|
||||
$f.ReadFails[$KeyDev] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } | Should -Throw '*not allowed*'
|
||||
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg)' }) | Should -BeNullOrEmpty
|
||||
@(Get-Snapshots $f).Count | Should -Be $before
|
||||
}
|
||||
It 'a registry read failure during rollback is a failure, never verified as absent' {
|
||||
$f.ReadFails[$KeyDev] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*DEV*not allowed*'
|
||||
$f.Log | Should -Not -Contain 'Restored DEV to bbbb (verified)'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Real registry operations (HKCU test key)' {
|
||||
BeforeAll {
|
||||
$script:TestRoot = "HKCU:\Software\PS2-VSQL-CERT-TEST-$([guid]::NewGuid())"
|
||||
New-Item -Path "$TestRoot\Instance Names\SQL" -Force | Out-Null
|
||||
New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'PSREPORTING' -Value 'MSSQL16.PSREPORTING' | Out-Null
|
||||
New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'DEV' -Value 'MSSQL16.DEV' | Out-Null
|
||||
New-Item -Path "$TestRoot\Empty" -Force | Out-Null
|
||||
New-ItemProperty -Path "$TestRoot\Empty" -Name 'Certificate' -Value '' | Out-Null
|
||||
New-Item -Path "$TestRoot\Absent" -Force | Out-Null
|
||||
$script:Real = Get-VsqlDefaultProvider
|
||||
}
|
||||
AfterAll { Remove-Item -LiteralPath $TestRoot -Recurse -Force }
|
||||
|
||||
It 'lists every instance, including one whose name starts with PS' {
|
||||
$map = & $Real.GetInstanceMap $TestRoot
|
||||
@($map.Keys) | Should -Be @('PSREPORTING', 'DEV')
|
||||
$map['PSREPORTING'] | Should -Be 'MSSQL16.PSREPORTING'
|
||||
}
|
||||
It 'returns an empty map when no instance is registered' {
|
||||
(& $Real.GetInstanceMap "$TestRoot\Absent").Count | Should -Be 0
|
||||
}
|
||||
It 'tells an empty value from an absent one' {
|
||||
& $Real.GetRegValue "$TestRoot\Empty" 'Certificate' | Should -BeExactly ''
|
||||
$null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue
|
||||
}
|
||||
It 'throws, rather than reporting absent, when the key cannot be read' {
|
||||
{ & $Real.GetRegValue "$TestRoot\Missing" 'Certificate' } | Should -Throw
|
||||
}
|
||||
It 'writes, reads back and removes through the real operations' {
|
||||
& $Real.SetRegValue "$TestRoot\Absent" 'Certificate' 'abcd'
|
||||
& $Real.GetRegValue "$TestRoot\Absent" 'Certificate' | Should -Be 'abcd'
|
||||
& $Real.RemoveRegValue "$TestRoot\Absent" 'Certificate'
|
||||
$null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe '-WhatIf' {
|
||||
It 'changes nothing on the real ShouldProcess path' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'whatif-real')
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -RestartInstances -WhatIf 6>$null
|
||||
Get-MutatingCalls $f | Should -BeNullOrEmpty
|
||||
@(Get-ChildItem $f.Provider.WorkDir -File | Where-Object Name -ne 'machinekey.bin') | Should -BeNullOrEmpty
|
||||
$r.Bound | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'previews every step of a request' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'whatif-steps')
|
||||
$seen = New-Object System.Collections.ArrayList
|
||||
$f.Provider.ShouldProcess = { param($t, $a) [void]$seen.Add("$a | $t"); $false }.GetNewClosure()
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -RestartInstances -WhatIf | Out-Null
|
||||
Get-MutatingCalls $f | Should -BeNullOrEmpty
|
||||
$expect = 'certreq -new', 'certreq -submit', 'certreq -accept', 'grant read to NT Service\MSSQLSERVER', 'grant read to PESCO\svc-sqldev',
|
||||
'set Certificate=<new thumbprint>* | instance MSSQLSERVER', 'set Certificate=<new thumbprint>* | instance DEV', 'restart service* | MSSQLSERVER', 'restart service* | MSSQL$DEV'
|
||||
foreach ($e in $expect) { @($seen | Where-Object { $_ -like "*$e*" }).Count | Should -Be 1 -Because "the preview should include '$e'" }
|
||||
$f.Log -join "`n" | Should -Match 'ProviderType = 12'
|
||||
}
|
||||
It 'previews a rollback without changing it' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'whatif-rollback')
|
||||
Add-ToStore $f $Good
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
|
||||
$f.Calls.Clear()
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances -WhatIf 6>$null | Out-Null
|
||||
Get-MutatingCalls $f | Should -BeNullOrEmpty
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Account to SID' {
|
||||
It 'maps the built-in service accounts that icacls cannot name' {
|
||||
Resolve-AccountSid 'LocalSystem' | Should -Be 'S-1-5-18'
|
||||
Resolve-AccountSid 'NT AUTHORITY\NetworkService' | Should -Be 'S-1-5-20'
|
||||
Resolve-AccountSid 'NT AUTHORITY\LocalService' | Should -Be 'S-1-5-19'
|
||||
}
|
||||
It 'translates a real account' {
|
||||
Resolve-AccountSid "$env:USERDOMAIN\$env:USERNAME" | Should -Match '^S-1-5-21-'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Native commands' {
|
||||
It 'captures stderr and the exit code without throwing under Stop' {
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$r = Invoke-NativeCommand 'cmd.exe' @('/c', 'echo out & echo err 1>&2 & exit 3')
|
||||
$r.ExitCode | Should -Be 3
|
||||
$r.Output -join ' ' | Should -Match 'err'
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ from psycopg.types.json import Jsonb
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import audit, events, policy
|
||||
from ..domain import archive, packets, search, shop
|
||||
from ..domain import assets as asset_store
|
||||
from ..domain import ncr as ncrs
|
||||
from ..domain import packets, search, shop
|
||||
from ..domain import qc as qc_dom
|
||||
from ..domain.pipeline import (
|
||||
Expect,
|
||||
@@ -543,6 +543,10 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
||||
A.FILE: ("note",),
|
||||
A.ADMIN_REOPEN: ("reason",),
|
||||
}
|
||||
MSG_ARCHIVE = (
|
||||
"The packet could not be copied to the archive: {reason}. Nothing was filed, and Admin has been told."
|
||||
" File it again once the archive works."
|
||||
)
|
||||
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
|
||||
|
||||
|
||||
@@ -591,29 +595,34 @@ def transition(
|
||||
else:
|
||||
del extra["stamp"]
|
||||
content = {"action": str(action), "packet_id": packet_id, "expect": body.expect.model_dump(), **extra}
|
||||
with packet_write(
|
||||
d,
|
||||
p,
|
||||
packet_id,
|
||||
action,
|
||||
request=request,
|
||||
extra=extra,
|
||||
it_reason=body.it_reason,
|
||||
sign=body.sign,
|
||||
expect=body.expect,
|
||||
sign_content=content,
|
||||
policy_principal=pp,
|
||||
) as w:
|
||||
if action is A.ASSIGN:
|
||||
ok = conn.execute(
|
||||
"SELECT 1 FROM stations WHERE id = %s AND dept_code = %s AND active",
|
||||
(extra["station_id"], w.state.dept),
|
||||
).fetchone()
|
||||
if ok is None:
|
||||
raise ApiError(422, "invalid_station", "Choose a station in this department.")
|
||||
active_user(conn, extra["welder_id"], "welder")
|
||||
shop.welder_check(conn, w.state.dept, str(extra["welder_id"]), int(extra["station_id"]))
|
||||
apply_transition(w)
|
||||
try:
|
||||
with packet_write(
|
||||
d,
|
||||
p,
|
||||
packet_id,
|
||||
action,
|
||||
request=request,
|
||||
extra=extra,
|
||||
it_reason=body.it_reason,
|
||||
sign=body.sign,
|
||||
expect=body.expect,
|
||||
sign_content=content,
|
||||
policy_principal=pp,
|
||||
) as w:
|
||||
if action is A.ASSIGN:
|
||||
ok = conn.execute(
|
||||
"SELECT 1 FROM stations WHERE id = %s AND dept_code = %s AND active",
|
||||
(extra["station_id"], w.state.dept),
|
||||
).fetchone()
|
||||
if ok is None:
|
||||
raise ApiError(422, "invalid_station", "Choose a station in this department.")
|
||||
active_user(conn, extra["welder_id"], "welder")
|
||||
shop.welder_check(conn, w.state.dept, str(extra["welder_id"]), int(extra["station_id"]))
|
||||
apply_transition(w)
|
||||
except archive.FilingCopyFailed as e:
|
||||
# The filing rolled back (Gitea issue 57); record it and tell Admin in a transaction of its own.
|
||||
archive.record_filing_failure(conn, p, packet_id, e)
|
||||
raise ApiError(503, "archive_failed", MSG_ARCHIVE.format(reason=e.message.rstrip("."))) from None
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,14 @@ A path that leaves the root is refused: empty, `..`, an absolute path elsewhere,
|
||||
the root, or a folder that does not exist. The check writes a probe file, reads it back and removes it. Nothing here
|
||||
creates folders or deletes anything in the archive.
|
||||
|
||||
FILE inserts a job request for `archive-copy` in its own transaction. The worker job (worker/jobs/archive_copy.py)
|
||||
queues every unbroken seal with a data book that has no `archive_copies` row, then copies the pending and due failed
|
||||
rows. A failed copy never blocks filing: it is retried with a backoff and raised to Admin as `archive_failed`.
|
||||
FILE copies the packet into the archive inside its own transaction (Gitea issue 57, Spencer 2026-11-03): the copy
|
||||
must succeed before the packet counts as filed. A failed folder check or copy rolls the whole filing back, so the
|
||||
packet stays where it was and can be filed again. The failure is then recorded in its own transaction and raised to
|
||||
Admin as the `archive_failed` condition with subject `filing:<packet id>`, cleared when that packet files.
|
||||
|
||||
The worker job (worker/jobs/archive_copy.py) copies the seals filed while the archive was off: it queues every
|
||||
unbroken seal with a data book that has no `archive_copies` row, then copies the pending and due failed rows with a
|
||||
backoff, and raises `archive_failed` (subject `archive`) to Admin.
|
||||
|
||||
Folder layout under the archive folder: `<project>/<unit>/<data book name>-seal<seal id>/` with the data book PDF,
|
||||
the packet's files (drawing page files, packet documents, the Q.C. folder documents of its unit and project, MTRs)
|
||||
@@ -45,6 +50,8 @@ JOB = "archive-copy"
|
||||
JOB_CHANNEL = "ps_job_request"
|
||||
EVENT = "archive_failed"
|
||||
EVENT_SUBJECT = "archive"
|
||||
FILING_SUBJECT = "filing:{packet_id}"
|
||||
FILING_TITLE = "Filing blocked: archive copy failed"
|
||||
PROBE_PREFIX = ".packetstation-probe-"
|
||||
MANIFEST = "manifest.json"
|
||||
DIR_MAX = 400
|
||||
@@ -72,6 +79,16 @@ class CopyError(Exception):
|
||||
"""One copy failed. The message goes into archive_copies.last_error and the audit row."""
|
||||
|
||||
|
||||
class FilingCopyFailed(Exception):
|
||||
"""FILE could not copy the packet into the archive. The filing rolls back; the message is for the person filing
|
||||
and for Admin."""
|
||||
|
||||
def __init__(self, message: str, archive_dir: str | None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.archive_dir = archive_dir
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckResult:
|
||||
ok: bool
|
||||
@@ -320,20 +337,104 @@ def request_copy(conn: Any, user_id: Any = None, note: str | None = None) -> Non
|
||||
conn.execute("SELECT ps_notify(%s, %s)", (JOB_CHANNEL, json.dumps({"job": JOB}, separators=(",", ":"))))
|
||||
|
||||
|
||||
def on_filed(conn: Any, settings: Settings, user_id: Any, packet_id: int) -> bool:
|
||||
"""FILE's hook: request a prompt copy when the archive is on and a folder is saved. Never raises; a failure
|
||||
here only means the copy waits for the next scheduled run. Returns whether a request went in."""
|
||||
if root_of(settings) is None:
|
||||
return False
|
||||
def copy_at_filing(conn: Any, settings: Settings, packet_id: int, seal_id: int) -> dict[str, Any] | None:
|
||||
"""FILE's copy, in the filing transaction: check the folder, copy the seal, record the copy as done.
|
||||
Returns {folder, files}, or None when the archive is off (ARCHIVE_ROOT unset or no folder saved).
|
||||
Raises FilingCopyFailed, which rolls the filing back."""
|
||||
root = root_of(settings)
|
||||
if root is None:
|
||||
return None
|
||||
archive_dir = load(conn)[0]
|
||||
if archive_dir is None:
|
||||
return None
|
||||
rel, result = check(root, archive_dir)
|
||||
if not result.ok or rel is None:
|
||||
raise FilingCopyFailed(f"The archive folder check failed: {result.message}", archive_dir)
|
||||
base = root.joinpath(*rel.split("/"))
|
||||
try:
|
||||
folder, sha, files = copy_seal(conn, settings, base, rel, seal_id)
|
||||
except CopyError as e:
|
||||
raise FilingCopyFailed(str(e), archive_dir) from None
|
||||
except Exception as e: # anything unexpected blocks the filing the same way
|
||||
log.exception("archive copy of packet %s at filing failed", packet_id)
|
||||
raise FilingCopyFailed(f"{type(e).__name__}: {e}", archive_dir) from None
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO archive_copies (seal_id, packet_id, status, attempts, folder, manifest_sha256, done_at)"
|
||||
" VALUES (%s, %s, 'done', 1, %s, %s, now())",
|
||||
(seal_id, packet_id, folder, sha),
|
||||
)
|
||||
audit.append(
|
||||
conn,
|
||||
action="archive.copied",
|
||||
subject_type="seal",
|
||||
subject_id=str(seal_id),
|
||||
packet_id=int(packet_id),
|
||||
detail={"seal_id": seal_id, "folder": folder, "files": files, "manifest_sha256": sha, "source": "filing"},
|
||||
)
|
||||
from ..notify import resolve_event
|
||||
|
||||
resolve_event(
|
||||
conn, EVENT, subject=FILING_SUBJECT.format(packet_id=packet_id), body="The packet was filed and copied."
|
||||
)
|
||||
return {"folder": folder, "files": files}
|
||||
|
||||
|
||||
def record_filing_failure(conn: Any, who: Any, packet_id: int, failure: FilingCopyFailed) -> None:
|
||||
"""After the filing rolled back: one `archive.filing_blocked` audit row and the Admin alert, in their own
|
||||
transaction. Never raises; the person filing gets the refusal either way."""
|
||||
from ..notify import raise_event
|
||||
|
||||
try:
|
||||
with conn.transaction():
|
||||
if load(conn)[0] is None:
|
||||
return False
|
||||
request_copy(conn, user_id, f"filed packet {packet_id}")
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT pr.number, u.lot, p.drawing, p.status, p.dept_code FROM packets p"
|
||||
" JOIN units u ON u.id = p.unit_id JOIN projects pr ON pr.id = u.project_id WHERE p.id = %s",
|
||||
(packet_id,),
|
||||
).fetchone()
|
||||
label = packet_label(*row[:3]) if row else f"packet {packet_id}"
|
||||
user_id = getattr(who, "user_id", None)
|
||||
name = conn.execute("SELECT display_name FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||
by = name[0] if name else None
|
||||
status = str(row[3]) if row else None
|
||||
dept = row[4] if row else None
|
||||
detail = {
|
||||
"packet_id": packet_id,
|
||||
"packet_label": label,
|
||||
"status": status,
|
||||
"dept": dept,
|
||||
"archive_dir": failure.archive_dir,
|
||||
"error": failure.message,
|
||||
"by": by,
|
||||
}
|
||||
audit.append(
|
||||
conn,
|
||||
action="archive.filing_blocked",
|
||||
user_id=user_id,
|
||||
session_id=getattr(who, "session_id", None),
|
||||
subject_type="packet",
|
||||
subject_id=str(packet_id),
|
||||
packet_id=int(packet_id),
|
||||
detail=detail,
|
||||
ip=getattr(who, "ip", None),
|
||||
)
|
||||
where = f"{status or 'where it was'}{' in ' + dept if dept else ''}"
|
||||
raise_event(
|
||||
conn,
|
||||
EVENT,
|
||||
subject=FILING_SUBJECT.format(packet_id=packet_id),
|
||||
title=f"{FILING_TITLE}: {label}",
|
||||
body=(
|
||||
f"{by or 'Someone'} tried to file {label}, but the packet could not be copied to the archive,"
|
||||
f" so it was not filed. It stays {where} and can be filed again once the archive works.\n\n"
|
||||
f"Archive folder: {failure.archive_dir or '(none)'}\nError: {failure.message}"
|
||||
),
|
||||
detail=detail,
|
||||
severity="critical",
|
||||
condition=True,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("archive copy request for packet %s not made; the scheduled run picks it up", packet_id)
|
||||
return False
|
||||
log.exception("the blocked filing of packet %s was not recorded", packet_id)
|
||||
|
||||
|
||||
def reset_backoff(conn: Any) -> int:
|
||||
|
||||
@@ -833,7 +833,8 @@ def apply_transition(w: Write) -> None:
|
||||
def _file(w: Write) -> None:
|
||||
"""FILE: the data book date, the seal, and the data book built and stored on the seal, all in this transaction.
|
||||
|
||||
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back."""
|
||||
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back. So does an archive
|
||||
copy that fails (archive.FilingCopyFailed, answered 503 archive_failed by the transition endpoint)."""
|
||||
key = _seal_key(w)
|
||||
_set_packet(w, "databook_at = now()")
|
||||
row = packets.load_row(w.conn, w.id)
|
||||
@@ -850,11 +851,13 @@ def _file(w: Write) -> None:
|
||||
w.log_also("Data book filed", f"{pages} pages")
|
||||
w.detail["seal_id"] = int(seal_id)
|
||||
w.detail["databook_pages"] = pages
|
||||
# The archive copy runs in the worker after this commits. A failed copy never blocks filing.
|
||||
# The archive copy must succeed before the packet counts as filed (Gitea issue 57). A failure raises
|
||||
# archive.FilingCopyFailed and the whole filing rolls back; the transition endpoint records it for Admin.
|
||||
from . import archive
|
||||
|
||||
if archive.on_filed(w.conn, _settings(w), w.user_id, w.id):
|
||||
w.detail["archive_requested"] = True
|
||||
copied = archive.copy_at_filing(w.conn, _settings(w), w.id, int(seal_id))
|
||||
if copied is not None:
|
||||
w.detail["archive_folder"] = copied["folder"]
|
||||
|
||||
|
||||
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
|
||||
|
||||
@@ -75,8 +75,8 @@ _TYPES = (
|
||||
EventType(
|
||||
"archive_failed",
|
||||
"Archive copy failing",
|
||||
"A filed packet could not be copied to the archive folder after three tries, or the archive folder check"
|
||||
" failed.",
|
||||
"A packet could not be filed because its archive copy failed, an earlier filing could not be copied after"
|
||||
" three tries, or the archive folder check failed.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""The archive copy (Gitea issue 7), every 15 minutes and on request (FILE asks for a run when it commits).
|
||||
"""The archive copy (Gitea issue 7) for seals filed while the archive was off, every 15 minutes and on request.
|
||||
|
||||
Off when ARCHIVE_ROOT is unset, and idle until the Admin saves a folder. Each run checks the folder (the probe), queues
|
||||
every unbroken seal with a data book that has no `archive_copies` row (so turning the archive on later copies earlier
|
||||
filings too), then copies the pending rows and the failed rows that are due. A failed copy waits 5, 15, 60 minutes,
|
||||
then an hour between tries. The `archive_failed` condition (subject `archive`) is raised when a copy has failed three
|
||||
times or the folder check fails, and cleared when no copy is failed. A failed copy never touches the packet.
|
||||
FILE copies its own packet in the filing transaction and refuses to file when the copy fails (Gitea issue 57; see
|
||||
domain/archive.py). This job catches up the rest: off when ARCHIVE_ROOT is unset, and idle until the Admin saves a
|
||||
folder. Each run checks the folder (the probe), queues every unbroken seal with a data book that has no
|
||||
`archive_copies` row (so turning the archive on later copies earlier filings too), then copies the pending rows and
|
||||
the failed rows that are due. A failed copy waits 5, 15, 60 minutes, then an hour between tries. The `archive_failed`
|
||||
condition (subject `archive`) is raised when a copy has failed three times or the folder check fails, and cleared when
|
||||
no copy is failed. These packets are already filed, so a failed copy here never touches the packet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Regression bench: the old 38 scenarios on the new UI
|
||||
|
||||
The old build's bench is `tests/ps/*.js` in the old repo (read-only, fixtures local only). Its scripts logged values
|
||||
for a person to read; a script "passed" when it printed no page error. Here every scenario is a Playwright test with
|
||||
real assertions, on the synthetic fixtures (`tests/fixtures/generator.py`) and the throwaway Samba DC, run by the
|
||||
`e2e` job in `.gitea/workflows/ci.yml` against `deploy/compose.ci.yaml`.
|
||||
|
||||
Every spec imports `test` from `tests/helpers.ts`. Its CSP guard fails a test on any `securitypolicyviolation` event
|
||||
in any frame, or a Content Security Policy error on the console, in every browser context the test opens.
|
||||
|
||||
Spec and test names below are `file › test`. "Server" points at the pytest file that holds a rule the old script
|
||||
checked in the browser. "Dropped" gives the reason: the BRIEF.md "Drop" list, or a mechanism the server replaced.
|
||||
|
||||
| # | Old script | What it checked | New coverage |
|
||||
|---|---|---|---|
|
||||
| 1 | t8s | Data book PDF of a filed, sealed packet with a Q.C. reject in its history | close › "R1: the data book and the traceability CSV answer as files"; close › "R1: filed in the data book with the PIN; the seal holds and the filed data book downloads". Server: test_pdf_databook.py |
|
||||
| 2 | t9s | Departments end to end: assign a stall and welder, checklist, weld, submit; the lead cannot review; route to 09 Hydro; Q.C. accepts; Clerical closes and exports | shop › "tsec2: assign, work at the stall, submit, send to Q.C."; shop › "tcust: ... the lead's read-only review ..."; shop › "skip Q.C.: sup.wes routes a review packet to 09 Hydro"; qc › "accept: ..."; close › R1 tests. First-run admin setup and the Users sheet: dropped (roles are AD groups, Drop list) |
|
||||
| 3 | tauto | Clean VISUAL drafts go to the warehouse on their own; flagged ones stay; sub-assembly borrows the heat table; warehouse grouping by sent date | Server: test_feed_apply.py `test_auto_release`, `test_paused_stops_auto_release`; test_shop_api.py `test_heat_src_borrowing`. visual-feed › "Run now ..." accepts an auto-released packet. Warehouse grouping: warehouse › "warehouse board" |
|
||||
| 4 | tcl | Only supervisors of the departments a change order names get the banner and Verify | shop › "tcosup: a change order requiring 03 on the Supervisor screen, verified with a signature" (sup.wes gets it; multi.mia, 06, does not) |
|
||||
| 5 | tclose | Close in VISUAL, file; filing blocked before the close; move back undoes it | close › "R1: confirmed closed in VISUAL ..."; close › "R2: filing is blocked until the job is closed in VISUAL; move back undoes the close" |
|
||||
| 6 | tclsync4 | The feed makes packets, projects and units; a second pull updates drafts; the vault attaches the drawing; Sync now | visual-feed › "Run now makes project L900, its drafts and attaches the WELD-PART drawing". Server: test_feed_apply.py, test_feed_api.py. The browser-side apply, `rows.json`, `found.json`, `want.json` and the helper check-in: dropped (PowerShell sync in the Drop list; the worker runs the feed) |
|
||||
| 7 | tcoappr | VISUAL changes on in-shop packets open change orders for Clerical; staged drawing; approve, hold, reject; old sheets VOID; supervisors verify | visual-feed › "VISUAL moves the drawing to rev 2: a change order, staged, approved"; shop › "tcosup ..."; shop › "tsync: two sessions at once" (two verifies). Server: test_co_apply.py (hold, reject, no second change order for the same rows), test_fingerprint.py |
|
||||
| 8 | tcosup | Change-order banner, stall tag and rows on the Supervisor screen | shop › "tcosup: a change order requiring 03 on the Supervisor screen, verified with a signature" |
|
||||
| 9 | tcust | Custody: view only, no writes; send back with a reason; short-line exception; change-order verify exception | shop › "tcust: custody, view only, the lead's read-only review, multi.mia cannot assign 03"; clerical-draft › "wh.wade sees a Clerical draft as view only, and no Send button"; warehouse › "pull flow" (shorts). Server: test_policy_matrix.py, test_policy_shop.py, test_policy_warehouse.py |
|
||||
| 10 | tdemo | Demo data walkthrough: banner text, thumbnails, welder notice | Dropped: demo mode and the claude.ai build target (Drop list). The banner and the review flow are in shop › "tcosup ..." |
|
||||
| 11 | tflag | Flagged column before Ready to release, flag reasons on cards; one sync progress bar | visual-feed › "tflag and tflagsearch: the Flagged column, the flag texts and the Flag filter". The helper's progress bar: dropped (PowerShell sync and its heartbeat, Drop list; the From VISUAL panel shows the worker's runs) |
|
||||
| 12 | tflagsearch | Search and filter flagged drafts by reason; list rows show the flag | visual-feed › "tflag and tflagsearch: ..."; findbar › "tflt and tlist: ..." |
|
||||
| 13 | tflt | Find bar: search keeps focus, Esc, status and station, shorts chip, list view, sort, saved filters | findbar › "tflt: search, Esc, status and station, clear, the shorts chip"; findbar › "tflt and tlist: the list view, sort, Enter opens, saved filters, only the screen's packets" |
|
||||
| 14 | tfs | Full-screen drawing: grows, survives a re-render, Esc and Exit leave it, leaving the tab ends it; MTR viewer | viewer › "tfs: full screen from the viewer; Exit, Esc and leaving the tab end it"; viewer › "tmtr: ...". Marking in full screen: shop › "tsec2 ..." marks a weld on the Weld map |
|
||||
| 15 | thand | Clerical uploads the drawing or the BOM by hand on a flagged draft only, with a reason; not the warehouse; the feed keeps a hand BOM | visual-feed › "thand: Clerical uploads the drawing and the BOM by hand on a flagged draft; the warehouse cannot". Server: test_feed_api.py `test_hand_drawing`, `test_hand_bom`; test_handbom.py |
|
||||
| 16 | tht | Heat table on the drawing: place, drag, size, live heat, VOID page, Q.C. read only | shop › "tht: the heat table on the drawing" |
|
||||
| 17 | titp | Digital ITP: template, initials, N/A, customer witness, block signatures, hold callout, sign-off PDF, reopen | qc › "titp: the digital ITP, the hold callout, every block signed, sign-off, reopen"; close › "ITP: signed off, its PDF is filed in the unit folder ..."; close › "C3: an open ITP on the unit blocks filing ..." |
|
||||
| 18 | tlay2 | KPI tap filters and KPIs stay; list view keeps the header; Filters opens and closes; search keeps focus | findbar › "tlay2: a KPI tap filters the board and the KPIs stay; Filters opens and closes" |
|
||||
| 19 | tlist | List view shows the same packets as that role's board | findbar › "tflt and tlist: ..." |
|
||||
| 20 | tmat | Job pull materials land on VISUAL packets: other drawings' lines left off, sub-assemblies kept, no change on a second pull, not overwritten in the shop | visual-feed › "tmat: the work order's materials from VISUAL are the WELD-PART packet's BOM". Server: test_feed_apply.py, test_candidates.py. `materials.json` and the browser apply: dropped (Drop list) |
|
||||
| 21 | tmotion | Motion switch, reduced motion, a card moved by someone else glides | findbar › "tmotion: the switch, reduced motion, and a card moved by someone else glides" |
|
||||
| 22 | tmtr | MTR upload (PDF and PNG) per line, view in the viewer, open in a new tab | viewer › "tmtr: a PDF and a PNG MTR, each shown in the MTR viewer and served for a new tab"; warehouse › "pull flow" |
|
||||
| 23 | tnav | Back goes one level back to the same scroll spot | findbar › "tnav: Back from a packet lands on the home at the same scroll spot" |
|
||||
| 24 | tpan2 | Touch drags pan a zoomed drawing; after re-renders a drag still pans once | viewer › "tpan2: a mouse drag pans by exactly the drag after re-renders"; viewer › "tpan2: on a touch device each finger drag moves the view" |
|
||||
| 25 | tpdocs | Packet documents for the data book: Q.C. adds, a non-PDF is refused, read-only for others, taken out with a reason | qc › "tpdocs: a packet document added, refused, seen read-only, taken out" |
|
||||
| 26 | tpm | PM screen: overview, drill down, tabs, scope, exports, view only | pm › "tpm: overview, projects, drill down, tabs, scope, exports, view only"; pm › "tpm: the period changes the history figures"; pm › "C10: ..." |
|
||||
| 27 | tpop | Sheets keep their margins on desktop and phone widths | findbar › "tpop: the New packet sheet keeps its margins on desktop and phone widths" |
|
||||
| 28 | tpr | Print menu kinds, traveler, data book, print logged to the audit | close › "R1: the print menu lists the kinds; a traveler print is logged and shows on the History tab"; close › "ITP: ..." (ITP print); close › "api: print guards and a signed-out read"; admin-stampverify (the printed stamp). Server: test_prints.py, test_pdf_traveler.py |
|
||||
| 29 | tqcf | Q.C. project and unit folders, a new revision supersedes, non-PDF refused, no Add for the warehouse | qc › "tqcf: project and unit folders, a new revision, a refused file, no Q.C. screen for wh.wade" |
|
||||
| 30 | trel | Clerical's Released section: tiles per place with overdue and 7+ day counts, list, open, Back | Not covered: the new Clerical home has no Released section (old `released.js`). See the note below |
|
||||
| 31 | trevchk2 | M-Files rev vs VISUAL rev: flags with drafting status, mismatch callout, a Clerical lead approves with a reason, the flag returns on a new VISUAL rev | visual-feed › "tflag and tflagsearch: ..."; visual-feed › "trevchk2: a Clerical supervisor approves the M-Files rev with a reason and a signature". Server: test_drawing_decide.py (`test_approved_other_rev_still_mismatches` and the rest), test_feed_api.py `test_rev_approve` |
|
||||
| 32 | tsec2 | Signatures end to end, wrong PIN, cancel, seal, admin reason, idle lock, formula guard, integrity, access log | shop › "tsec2 ..."; signin › "8. three idle minutes lock the screen and the PIN unlocks it"; close › "R1: filed ... with the PIN ..." and "R1: admin.ann reopens with a reason and her password ..."; admin-security; admin-accesslog › "CSV export: a cell that starts with = is guarded". Second-admin approval: dropped (Drop list) |
|
||||
| 33 | tsend | Heat map answer first; send straight to a department or to Q.C.; send back to Clerical with a reason | send › "tsend: the heat map answer and the drawing come first; straight to a department, straight to Q.C."; send › "tsend: the department's supervisor sees it and sends it back to Clerical with a reason"; clerical-draft › "clerk.clara makes a hand draft and sends it to the warehouse" |
|
||||
| 34 | tsync | Two screens at once: merges, renumbered weld ids, two verifies kept, a stale step refused, filed packet blocked, long history | shop › "tsync: two sessions at once". Server: test_pipeline_concurrency.py, test_shop_concurrency.py |
|
||||
| 35 | tsyncsafe | The feed never overwrites shop packets or records and never makes duplicates | Server: test_feed_apply.py (`test_removed_packet_key_is_never_recreated`, `test_hand_customer_is_never_overwritten`, change orders for in-shop packets), test_candidates.py. The browser-side apply it raced: dropped (Drop list) |
|
||||
| 36 | tunits | Projects and units: many units at once, duplicate refused, move a packet, renumber with an alias, closed unit | projects › "tunits: a new project with three units at once; a duplicate unit is refused"; projects › "tunits: a packet moves to another unit through the two-step picker; the warehouse cannot move one"; projects › "tunits: a Clerical supervisor renumbers a unit; its packet follows and the old number is an alias"; projects › "tunits: a closed unit takes no new packet". Server: test_projects_api.py |
|
||||
| 37 | tvis | Excel Job Release import in the browser; op-to-department map | Dropped: Excel upload of Job Release Information (Drop list); the feed replaces it (row 6). The resource map: server test_feed_api.py |
|
||||
| 38 | twh | Warehouse board: stages, hold, drag, drop on Released refused, auto-start on a tick, stage shown to others | warehouse › "warehouse board"; warehouse › "sup.wes sees the warehouse pill and line but no warehouse buttons"; warehouse › "pull flow" |
|
||||
|
||||
## Phase 10 additions beyond the old bench
|
||||
|
||||
- archive › "Admin saves the archive folder under File locations; the check passes" and "a filed packet is copied to
|
||||
the archive folder" (Gitea issue 7).
|
||||
- version › "the app page carries the final Content Security Policy and the other headers" and "a new build on the
|
||||
server shows the banner; it reloads only on Reload" (the update lock in the Drop list is replaced by the banner).
|
||||
- The CSP guard in `helpers.ts` on every test, and version › "the CSP guard sees a refused inline style and a
|
||||
refused inline script" to prove the guard catches a violation.
|
||||
|
||||
## Note on trel
|
||||
|
||||
The old Clerical home had a "Released" section (`src/js/released.js` in the old repo): where every released packet
|
||||
is, by place, with overdue and 7+ day counts. It is not in the Drop list and the new UI does not have it. The find
|
||||
bar (status and department filters), the Supervisor and Warehouse boards and the PM screen's Aging tab show the same
|
||||
packets another way. Whether that is enough is a question for Seth; until then this row stays uncovered.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
python -m tests.fixtures.generator --out tests/fixtures/generated
|
||||
docker compose -p packetstation-ci --env-file deploy/ci/ci.env -f deploy/compose.yaml -f deploy/compose.ci.yaml up -d --build --wait
|
||||
docker compose -p packetstation-ci --env-file deploy/ci/ci.env -f deploy/compose.yaml -f deploy/compose.ci.yaml --profile e2e run --rm playwright
|
||||
```
|
||||
|
||||
The specs share one database and run in file order on one worker. visual-feed writes the fixture state and edits
|
||||
`drawings.1.json` before its first run, so the stack must start from freshly generated fixtures.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
import { api, closeAll } from "./shophelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
import { admin, badSignin, csvFrom, me, openAdminTab, parseCsv, runId } from "./adminhelpers";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
import { api, closeAll } from "./shophelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
import { admin, call, openAdminTab, runId } from "./adminhelpers";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
import { api, closeAll, type Actor } from "./shophelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
import { admin, call, me, openAdminTab, runId } from "./adminhelpers";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { dcUser } from "./helpers";
|
||||
import { expect, test, dcUser } from "./helpers";
|
||||
import { actor, api, closeAll } from "./shophelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
import { admin, me, openAdminTab } from "./adminhelpers";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
import { api, closeAll } from "./shophelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
import { admin, badSignin, call, openAdminTab } from "./adminhelpers";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { dcUser } from "./helpers";
|
||||
import { expect, test, dcUser } from "./helpers";
|
||||
import { actor, api, closeAll } from "./shophelpers";
|
||||
import { makeDraft, makeProject, makeUnit, runSuffix, type Made } from "./pmhelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
@@ -18,6 +18,7 @@ export const A = {
|
||||
accesslog: "Access log",
|
||||
stampverify: "Stamp check",
|
||||
presence: "Who is in",
|
||||
storage: "Storage",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "./helpers";
|
||||
import { admin, call, openAdminTab } from "./adminhelpers";
|
||||
import { atCompleteApi, makeCloseProject } from "./closehelpers";
|
||||
import type { Crew } from "./qchelpers";
|
||||
import { actor, api, closeAll, packet, sign, stepApi, userId } from "./shophelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
// Phase 10 archive location (Gitea issue 7). The CI stack runs with ARCHIVE_ROOT=/archive and an existing folder
|
||||
// /archive/packets (compose.ci.yaml). admin.ann opens Admin > Storage > File locations, a path outside the root is
|
||||
// refused, "packets" passes the check and is saved, a packet is filed, and the copy shows as done with nothing failed.
|
||||
// Clerical cannot read or change the setting.
|
||||
|
||||
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
|
||||
const PROJECT = `AR${RUN}`;
|
||||
|
||||
test.describe.serial("archive", () => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
test("Admin saves the archive folder under File locations; the check passes", async ({ browser }) => {
|
||||
const ann = await admin(browser);
|
||||
const page = ann.page;
|
||||
const panel = await openAdminTab(page, "storage");
|
||||
const box = panel.getByRole("region", { name: "File locations" });
|
||||
await expect(box).toBeVisible();
|
||||
await expect(box.getByText("Archive root: /archive")).toBeVisible();
|
||||
const folder = box.getByLabel("Archive folder");
|
||||
|
||||
// A path that leaves the root is refused by the check
|
||||
await folder.fill("../etc");
|
||||
await box.getByRole("button", { name: "Check", exact: true }).click();
|
||||
await expect(box.getByText(/^Check passed/)).toHaveCount(0);
|
||||
const bad = await call(ann, "post", "/api/admin/file-locations/check", { archive_dir: "../etc" });
|
||||
expect(bad.status).toBe(422);
|
||||
expect(bad.body.code).toBe("bad_path");
|
||||
|
||||
// packets exists under the root: the check passes, then Save
|
||||
await folder.fill("packets");
|
||||
await box.getByRole("button", { name: "Check", exact: true }).click();
|
||||
await expect(box.getByText(/^Check passed\. \/archive\/packets can be written\./)).toBeVisible();
|
||||
await box.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(box.getByText("Archive folder saved.")).toBeVisible();
|
||||
await expect(box.getByTestId("archive-saved")).toContainText("packets");
|
||||
await expect(box.getByTestId("archive-saved")).toContainText("/archive/packets");
|
||||
await expect(box.getByTestId("archive-last-check")).toContainText("passed");
|
||||
|
||||
const fl = await api(ann, "get", "/api/admin/file-locations");
|
||||
expect(fl.enabled).toBe(true);
|
||||
expect(fl.root).toBe("/archive");
|
||||
expect(fl.archive_dir).toBe("packets");
|
||||
expect(fl.last_check?.ok).toBe(true);
|
||||
await closeAll(ann);
|
||||
|
||||
// Not for Clerical
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
expect((await call(clara, "get", "/api/admin/file-locations")).status).toBe(403);
|
||||
expect((await call(clara, "put", "/api/admin/file-locations", { archive_dir: "packets", version: fl.version })).status).toBe(403);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("a filed packet is copied to the archive folder", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const andy = await actor(browser, "weld.andy");
|
||||
const quinn = await actor(browser, "qc.quinn");
|
||||
const crew: Crew = { clara, wade, wes, andy, andyId: await userId(andy, "weld.andy") };
|
||||
const proj = await makeCloseProject(clara, PROJECT, "Archive skid");
|
||||
const p = await atCompleteApi(crew, quinn, proj.unit, `Archive spool ${RUN}`, "Stall 2");
|
||||
await api(clara, "post", `/api/packets/${p.id}/vclosed`, { closed: true });
|
||||
const filed = await stepApi(clara, p.id, "file", { sign: sign("clerk.clara") });
|
||||
expect(filed.status).toBe("filed");
|
||||
expect((await packet(clara, p.id)).seal).toBeTruthy();
|
||||
await closeAll(clara, wade, wes, andy, quinn);
|
||||
|
||||
// FILE made the copy in the filing transaction (Gitea issue 57). The Admin section shows it done.
|
||||
const ann = await admin(browser);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const fl = await api(ann, "get", "/api/admin/file-locations");
|
||||
const c = fl.copies as Json;
|
||||
const failed = ((c.recent_failures ?? []) as Json[]).find((f) => Number(f.packet_id) === p.id);
|
||||
if (failed) return `failed: ${failed.last_error}`;
|
||||
return c.done >= 1 && c.failed === 0 && c.pending === 0 ? "done" : `pending ${c.pending}`;
|
||||
},
|
||||
{ timeout: 120_000, intervals: [2_000, 3_000, 5_000] },
|
||||
)
|
||||
.toBe("done");
|
||||
const panel = await openAdminTab(ann.page, "storage");
|
||||
const box = panel.getByRole("region", { name: "File locations" });
|
||||
await box.getByRole("button", { name: "Refresh" }).click();
|
||||
await expect(box.getByTestId("archive-copies")).toHaveText(/^Copies: [1-9]\d* done, 0 waiting, 0 failed\.$/);
|
||||
await expect(box.getByRole("table", { name: "Failed copies" })).toHaveCount(0);
|
||||
await expect(box.getByRole("button", { name: "Retry now" })).toHaveCount(0);
|
||||
await closeAll(ann);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
import { actor, api, closeAll } from "./shophelpers";
|
||||
import { call } from "./adminhelpers";
|
||||
import type { Json } from "./whhelpers";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { dcUser, signInPassword } from "./helpers";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test, dcUser, signInPassword } from "./helpers";
|
||||
|
||||
// Phase 2 Clerical scenario on the CI stack. Runs after signin.spec.ts on the one worker.
|
||||
// clerk.clara sets up project L999 with unit 001, makes a hand draft, attaches a drawing with a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { PIN } from "./helpers";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test, PIN } from "./helpers";
|
||||
import {
|
||||
actor,
|
||||
api,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { signInPassword } from "./helpers";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test, signInPassword } from "./helpers";
|
||||
import { actor, api, closeAll, stepApi } from "./shophelpers";
|
||||
import {
|
||||
L,
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test as base, expect, type Browser, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------- CSP guard
|
||||
//
|
||||
// Every spec imports `test` from here. The guard fails a test when any page it opened reports a Content Security
|
||||
// Policy violation: a `securitypolicyviolation` event in any frame, or a CSP error on the console (workers and
|
||||
// anything the event misses). It covers the default `context` and `page` fixtures and every context a test makes
|
||||
// with browser.newContext() or browser.newPage().
|
||||
|
||||
const CSP_CONSOLE = /Content Security Policy|Refused to (load|apply|execute|connect|frame|create|evaluate)/i;
|
||||
const guarded = new WeakSet<BrowserContext>();
|
||||
|
||||
async function guardContext(ctx: BrowserContext, log: string[]): Promise<void> {
|
||||
if (guarded.has(ctx)) return;
|
||||
guarded.add(ctx);
|
||||
await ctx.exposeBinding("__psCspViolation", ({ frame }, text: string) => {
|
||||
log.push(`${text} (on ${frame.url()})`);
|
||||
});
|
||||
await ctx.addInitScript(() => {
|
||||
document.addEventListener(
|
||||
"securitypolicyviolation",
|
||||
(e) => {
|
||||
const w = window as unknown as { __psCspViolation?: (t: string) => Promise<void> };
|
||||
try {
|
||||
void w.__psCspViolation?.(
|
||||
`securitypolicyviolation: ${e.effectiveDirective || e.violatedDirective} blocked ${e.blockedURI || "(inline)"}` +
|
||||
` at ${e.sourceFile || "?"}:${e.lineNumber}`,
|
||||
);
|
||||
} catch {
|
||||
// a frame torn down mid-report
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
const watch = (page: Page) => {
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error" && CSP_CONSOLE.test(m.text())) log.push(`console: ${m.text()} (on ${page.url()})`);
|
||||
});
|
||||
};
|
||||
ctx.pages().forEach(watch);
|
||||
ctx.on("page", watch);
|
||||
}
|
||||
|
||||
export const test = base.extend<{ cspGuard: string[] }>({
|
||||
cspGuard: [
|
||||
async ({ browser }, use) => {
|
||||
const log: string[] = [];
|
||||
const orig = browser.newContext;
|
||||
browser.newContext = async function (this: Browser, ...args: Parameters<Browser["newContext"]>) {
|
||||
const ctx = await orig.apply(this, args);
|
||||
await guardContext(ctx, log);
|
||||
return ctx;
|
||||
};
|
||||
try {
|
||||
await use(log);
|
||||
} finally {
|
||||
browser.newContext = orig;
|
||||
}
|
||||
expect(log, "Content Security Policy violations").toEqual([]);
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
context: async ({ context, cspGuard }, use) => {
|
||||
await guardContext(context, cspGuard);
|
||||
await use(context);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
// users.json is written by deploy/samba/seed.sh on the ci-dc-export volume.
|
||||
// Shape: {"domain": "pstest.local", "users": {"<sam>": {"password", "emp_no", "display_name"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
import { actor, api, closeAll, packet, stepApi } from "./shophelpers";
|
||||
import {
|
||||
L,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { dcUser } from "./helpers";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test, dcUser } from "./helpers";
|
||||
import { actor, api, closeAll } from "./shophelpers";
|
||||
import { L, makeDraft, makeProject, makeUnit, pullWithHeat, runSuffix, type Made } from "./pmhelpers";
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { expect, test } from "./helpers";
|
||||
import { makeDraft, runSuffix } from "./pmhelpers";
|
||||
import { actor, api, closeAll, packet, type Actor } from "./shophelpers";
|
||||
import { getJson, type Json } from "./whhelpers";
|
||||
|
||||
// Phase 10, the old tunits on the new UI. clerk.clara makes a project with three units at once (the Add units sheet's
|
||||
// "Several units"), a duplicate unit number is refused, a packet moves to another unit through the two-step picker,
|
||||
// and a closed unit takes no new packet. clerk.leo (Clerical supervisor) renumbers a unit; the packet follows, the
|
||||
// old number stays an alias and cannot be taken again. clara has no Renumber, and the warehouse cannot move a packet.
|
||||
|
||||
let RUN = "";
|
||||
let PROJECT = "";
|
||||
let projectId = 0;
|
||||
const units: Record<string, Json> = {};
|
||||
let packetId = 0;
|
||||
|
||||
async function unitsOf(a: Actor): Promise<Json[]> {
|
||||
const p = await getJson(a.page.request, `/api/projects/${projectId}`);
|
||||
return (p.units ?? []) as Json[];
|
||||
}
|
||||
|
||||
test.describe.serial("projects and units", () => {
|
||||
test("tunits: a new project with three units at once; a duplicate unit is refused", async ({ browser }, info) => {
|
||||
RUN = runSuffix(info.project.name);
|
||||
PROJECT = `U${RUN}`;
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await page.goto("/clerical");
|
||||
await page.getByRole("button", { name: "New project" }).click();
|
||||
const proj = page.getByRole("dialog", { name: "New project" });
|
||||
// typed in lower case: the number is kept in capitals
|
||||
await proj.getByLabel("Project number").fill(PROJECT.toLowerCase());
|
||||
await proj.getByLabel("Project name").fill("CI units");
|
||||
await proj.getByLabel("Customer", { exact: true }).fill("CI Customer");
|
||||
await proj.getByRole("button", { name: "Create project" }).click();
|
||||
|
||||
const add = page.getByRole("dialog", { name: "Add units" });
|
||||
await expect(add).toBeVisible();
|
||||
await add.getByRole("group", { name: "One or several" }).getByRole("button", { name: "Several units" }).click();
|
||||
await add.getByLabel("How many").fill("3");
|
||||
await add.getByLabel("Starting lot").fill("001");
|
||||
await expect(add.getByText(`Numbers: ${PROJECT}001, ${PROJECT}002, ${PROJECT}003`)).toBeVisible();
|
||||
await add.getByLabel("Description (all of them)").fill("CI separator");
|
||||
await add.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/clerical\/projects\/\d+$/);
|
||||
projectId = Number(new URL(page.url()).pathname.split("/").pop());
|
||||
await expect(page.getByRole("heading", { level: 1, name: `${PROJECT} · CI units` })).toBeVisible();
|
||||
for (const lot of ["001", "002", "003"]) {
|
||||
await expect(page.getByRole("row", { name: new RegExp(`${PROJECT}${lot}`) })).toBeVisible();
|
||||
}
|
||||
for (const u of await unitsOf(clara)) units[String(u.lot)] = u;
|
||||
expect(Object.keys(units).sort()).toEqual(["001", "002", "003"]);
|
||||
|
||||
// The same unit number again is refused with the reason
|
||||
await page.getByRole("button", { name: "Add units" }).click();
|
||||
await expect(add).toBeVisible();
|
||||
await add.getByRole("group", { name: "One or several" }).getByRole("button", { name: "One unit" }).click();
|
||||
await add.getByLabel("Lot", { exact: true }).fill("002");
|
||||
await add.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(add.getByRole("alert")).toHaveText(`${PROJECT}002 already exists as a unit.`);
|
||||
await add.getByRole("button", { name: "Cancel" }).click();
|
||||
expect((await unitsOf(clara)).length).toBe(3);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("tunits: a packet moves to another unit through the two-step picker; the warehouse cannot move one", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const u1 = units["001"] as Json;
|
||||
const made = await makeDraft(clara, { id: Number(u1.id), number: String(u1.number), lot: "001" }, `Move spool ${RUN}`);
|
||||
packetId = made.id;
|
||||
const page = clara.page;
|
||||
await page.goto(`/packets/${packetId}`);
|
||||
await page.getByRole("button", { name: "Move", exact: true }).click();
|
||||
const sheet = page.getByRole("dialog", { name: "Move to another unit" });
|
||||
await expect(sheet).toBeVisible();
|
||||
// The project is picked already (the packet's own); step two is the unit
|
||||
await expect(sheet.getByText(PROJECT, { exact: true }).first()).toBeVisible();
|
||||
// Moving to its own unit is refused in the sheet
|
||||
await sheet.getByLabel("Unit", { exact: true }).selectOption({ label: `${PROJECT}001 · CI separator` });
|
||||
await sheet.getByRole("button", { name: "Move packet" }).click();
|
||||
await expect(sheet.getByRole("alert")).toHaveText("The packet is in that unit already.");
|
||||
await sheet.getByLabel("Unit", { exact: true }).selectOption({ label: `${PROJECT}002 · CI separator` });
|
||||
await sheet.getByLabel("Reason (optional)").fill("Built on the second unit");
|
||||
await sheet.getByRole("button", { name: "Move packet" }).click();
|
||||
await expect(page.getByText("Packet moved.")).toBeVisible();
|
||||
await expect.poll(async () => String((await packet(clara, packetId)).unit.number)).toBe(`${PROJECT}002`);
|
||||
await closeAll(clara);
|
||||
|
||||
// The warehouse may not move a packet (server check)
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const u3 = units["003"] as Json;
|
||||
await api(wade, "post", `/api/packets/${packetId}/move`, { unit_id: u3.id }, { status: 403 });
|
||||
expect(String((await packet(wade, packetId)).unit.number)).toBe(`${PROJECT}002`);
|
||||
await closeAll(wade);
|
||||
});
|
||||
|
||||
test("tunits: a Clerical supervisor renumbers a unit; its packet follows and the old number is an alias", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const u2 = units["002"] as Json;
|
||||
await clara.page.goto(`/clerical/units/${u2.id}`);
|
||||
await expect(clara.page.getByRole("heading", { level: 1, name: new RegExp(`^${PROJECT}002`) })).toBeVisible();
|
||||
await expect(clara.page.getByRole("button", { name: "Renumber" })).toHaveCount(0);
|
||||
await closeAll(clara);
|
||||
|
||||
const leo = await actor(browser, "clerk.leo");
|
||||
const page = leo.page;
|
||||
await page.goto(`/clerical/units/${u2.id}`);
|
||||
await page.getByRole("button", { name: "Renumber" }).click();
|
||||
const sheet = page.getByRole("dialog", { name: "Renumber unit" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await sheet.getByLabel("New lot").fill("010");
|
||||
await sheet.getByRole("button", { name: "Change number" }).click();
|
||||
await expect(sheet.getByRole("alert")).toHaveText("Give a reason.");
|
||||
await sheet.getByLabel("Reason").fill("Customer tag numbers changed");
|
||||
await sheet.getByRole("button", { name: "Change number" }).click();
|
||||
await expect(page.getByText(`Unit renumbered to ${PROJECT}010`)).toBeVisible();
|
||||
await expect(page.getByRole("heading", { level: 1, name: new RegExp(`^${PROJECT}010`) })).toBeVisible();
|
||||
// The packet follows its unit
|
||||
expect(String((await packet(leo, packetId)).unit.number)).toBe(`${PROJECT}010`);
|
||||
// The old number still finds the unit, as an alias, and cannot be used again
|
||||
const found = await getJson(page.request, `/api/lookup/number?n=${PROJECT}002`);
|
||||
expect(found.kind).toBe("unit");
|
||||
expect(Number(found.id)).toBe(Number(u2.id));
|
||||
expect(found.alias).toBeTruthy();
|
||||
const again = await api(leo, "post", `/api/projects/${projectId}/units`, { lot: "002" }, { status: 409 });
|
||||
expect(String(again.detail ?? again.message ?? JSON.stringify(again))).toContain(`old number of ${PROJECT}010`);
|
||||
await closeAll(leo);
|
||||
});
|
||||
|
||||
test("tunits: a closed unit takes no new packet", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
const u3 = units["003"] as Json;
|
||||
await page.goto(`/clerical/units/${u3.id}`);
|
||||
await expect(page.getByRole("button", { name: "Add packet" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Edit unit" }).click();
|
||||
const sheet = page.getByRole("dialog", { name: "Edit unit" });
|
||||
await sheet.getByLabel("Status").selectOption({ label: "Closed" });
|
||||
await sheet.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(page.getByText("Unit saved")).toBeVisible();
|
||||
await expect(page.getByText(/· Closed$/).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Add packet" })).toHaveCount(0);
|
||||
// The server refuses a packet on it too
|
||||
const r = await page.request.post("/api/packets", {
|
||||
headers: { Origin: new URL(page.url()).origin, "X-Requested-With": "PacketStation" },
|
||||
data: { unit_id: u3.id, dept_code: "03", op: "WELD-PART", title: `Closed ${RUN}` },
|
||||
});
|
||||
expect(r.status(), await r.text()).toBeGreaterThanOrEqual(400);
|
||||
expect(r.status()).toBeLessThan(500);
|
||||
await closeAll(clara);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { PIN } from "./helpers";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test, PIN } from "./helpers";
|
||||
import {
|
||||
L as SL,
|
||||
actor,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test } from "./helpers";
|
||||
import { cardOf, findBar, makeDraft, makeProject, makeUnit, runSuffix, searchBox, type Made } from "./pmhelpers";
|
||||
import { actor, closeAll, packet, signWithPassword } from "./shophelpers";
|
||||
import { onePagePdf } from "./whhelpers";
|
||||
|
||||
// Phase 10, the old tsend on the new UI. clerk.clara answers "Heat map required?" and attaches the drawing before a
|
||||
// draft can go anywhere; she sends one straight to a department (skipping the warehouse) with a note, and one straight
|
||||
// to Q.C. sup.wes finds the first on the Supervisor screen and sends it back to Clerical with a reason.
|
||||
|
||||
let RUN = "";
|
||||
let one: Made;
|
||||
let two: Made;
|
||||
|
||||
async function attachDrawing(page: Page): Promise<void> {
|
||||
await page.getByLabel("Attach drawing (PDF)").setInputFiles({ name: "send.pdf", mimeType: "application/pdf", buffer: onePagePdf() });
|
||||
await signWithPassword(page, "clerk.clara");
|
||||
await expect(page.getByText(/Drawing attached/)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe.serial("send a draft", () => {
|
||||
test("tsend: the heat map answer and the drawing come first; straight to a department, straight to Q.C.", async ({
|
||||
browser,
|
||||
}, info) => {
|
||||
RUN = runSuffix(info.project.name);
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const project = await makeProject(clara, `T${RUN}`);
|
||||
const unit = await makeUnit(clara, Number(project.id), "001");
|
||||
one = await makeDraft(clara, unit, `Send dept ${RUN}`);
|
||||
two = await makeDraft(clara, unit, `Send qc ${RUN}`);
|
||||
const page = clara.page;
|
||||
|
||||
// Nothing can be sent before the heat map question is answered and a drawing is on
|
||||
await page.goto(`/packets/${one.id}`);
|
||||
await expect(page.getByTestId("status-pill")).toHaveText("Ready to release");
|
||||
for (const name of ["Send to warehouse", "Send to a department", "Send to Q.C."]) {
|
||||
await expect(page.getByRole("button", { name, exact: true })).toBeDisabled();
|
||||
}
|
||||
await expect(page.getByText("Say whether this packet needs a heat map.").first()).toBeVisible();
|
||||
await attachDrawing(page);
|
||||
const hm = page.getByRole("group", { name: "Heat map required?" });
|
||||
await hm.getByRole("button", { name: "No" }).click();
|
||||
await expect(hm.getByRole("button", { name: "No" })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByText("Not required", { exact: true })).toBeVisible();
|
||||
|
||||
// Straight to a department, with a note
|
||||
const toDept = page.getByRole("button", { name: "Send to a department", exact: true });
|
||||
await expect(toDept).toBeEnabled();
|
||||
await toDept.click();
|
||||
const sheet = page.getByRole("dialog", { name: "Send straight to a department" });
|
||||
await expect(sheet.getByText("Skips the warehouse parts pull.", { exact: false })).toBeVisible();
|
||||
await sheet.getByLabel("Department").selectOption("03");
|
||||
await sheet.getByLabel("Note (optional, they will see it)").fill("Parts already staged at the station");
|
||||
await sheet.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await expect(page.getByTestId("status-pill")).toHaveText("Ready to assign");
|
||||
let p = await packet(clara, one.id);
|
||||
expect(p.status).toBe("ready");
|
||||
expect(p.dept.code).toBe("03");
|
||||
expect(p.heat_map).toBe(false);
|
||||
|
||||
// Straight to Q.C.
|
||||
await page.goto(`/packets/${two.id}`);
|
||||
await attachDrawing(page);
|
||||
await page.getByRole("group", { name: "Heat map required?" }).getByRole("button", { name: "Yes" }).click();
|
||||
await page.getByRole("button", { name: "Send to Q.C.", exact: true }).click();
|
||||
const qc = page.getByRole("dialog", { name: "Send straight to Q.C." });
|
||||
await expect(qc.getByText("Skips the warehouse and the shop.", { exact: false })).toBeVisible();
|
||||
await qc.getByLabel("Note (optional, Q.C. will see it)").fill("Inspect the as-received spool");
|
||||
await qc.getByRole("button", { name: "Send to Q.C." }).click();
|
||||
await expect(page.getByTestId("status-pill")).toHaveText("Q.C. inspection");
|
||||
p = await packet(clara, two.id);
|
||||
expect(p.status).toBe("qc");
|
||||
expect(p.heat_map).toBe(true);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("tsend: the department's supervisor sees it and sends it back to Clerical with a reason", async ({ browser }) => {
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const page = wes.page;
|
||||
await page.goto("/supervisor");
|
||||
await expect(findBar(page)).toBeVisible();
|
||||
await searchBox(page).fill(one.title);
|
||||
await expect(cardOf(page, one)).toBeVisible();
|
||||
await page.goto(`/packets/${one.id}`);
|
||||
await page.getByRole("button", { name: "Send back to Clerical", exact: true }).click();
|
||||
const sheet = page.getByRole("dialog", { name: "Send back to Clerical" });
|
||||
await sheet.getByLabel("Reason").fill("short");
|
||||
await sheet.getByRole("button", { name: "Send back to Clerical" }).click();
|
||||
await expect(sheet.getByRole("alert")).toHaveText("Give a reason of at least 8 characters.");
|
||||
await sheet.getByLabel("Reason").fill("Wrong department, this is code work");
|
||||
await sheet.getByRole("button", { name: "Send back to Clerical" }).click();
|
||||
await expect(page.getByTestId("status-pill")).toHaveText("Ready to release");
|
||||
expect((await packet(wes, one.id)).status).toBe("draft");
|
||||
await closeAll(wes);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { PIN } from "./helpers";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test, PIN } from "./helpers";
|
||||
import {
|
||||
L,
|
||||
actor,
|
||||
@@ -398,6 +398,13 @@ test.describe.serial("shop", () => {
|
||||
tile.getByRole("button", { name: L.coTag(label) }),
|
||||
).toBeVisible();
|
||||
|
||||
// tcl: only the departments the change order names get the banner. multi.mia supervises 06, not 03.
|
||||
const mia = await actor(browser, "multi.mia");
|
||||
await mia.page.goto("/supervisor");
|
||||
await expect(mia.page.getByText(L.supervisorHead)).toBeVisible();
|
||||
await expect(mia.page.getByRole("alert", { name: L.coBanner })).toHaveCount(0);
|
||||
await closeAll(mia);
|
||||
|
||||
await banner
|
||||
.getByRole("button", { name: L.reviewAndVerify(label) })
|
||||
.first()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
PIN,
|
||||
dcUser,
|
||||
enterPin,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test, signInPassword } from "./helpers";
|
||||
|
||||
// Phase 10: the page's security headers as served, and the build-version banner (it replaces the old in-app update
|
||||
// lock, which is in the Drop list). The banner test answers /api/version with another build after load and brings
|
||||
// the window back into focus; the banner shows and nothing reloads until Reload is pressed.
|
||||
|
||||
const CSP =
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob:; font-src 'self'; connect-src 'self'; " +
|
||||
"worker-src 'self'; frame-src 'none'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
||||
|
||||
test("the app page carries the final Content Security Policy and the other headers", async ({ page }) => {
|
||||
const r = await page.request.get("/");
|
||||
expect(r.status()).toBe(200);
|
||||
const h = r.headers();
|
||||
expect(h["content-security-policy"]).toBe(CSP);
|
||||
expect(h["content-security-policy"]).not.toContain("unsafe-inline");
|
||||
expect(h["x-frame-options"]).toBe("DENY");
|
||||
expect(h["x-content-type-options"]).toBe("nosniff");
|
||||
// the built index.html has no inline script or style for the policy to refuse
|
||||
const html = await r.text();
|
||||
expect(html).not.toMatch(/<script(?![^>]*\bsrc=)[^>]*>/i);
|
||||
expect(html).not.toMatch(/<style\b/i);
|
||||
expect(html).not.toMatch(/\sstyle="/i);
|
||||
});
|
||||
|
||||
test("a new build on the server shows the banner; it reloads only on Reload", async ({ page }) => {
|
||||
await signInPassword(page, "clerk.clara");
|
||||
await expect(page).toHaveURL(/\/clerical/);
|
||||
const first = (await (await page.request.get("/api/version")).json()) as Record<string, unknown>;
|
||||
expect(first.build ?? first.version).toBeTruthy();
|
||||
await expect(page.getByTestId("version-banner")).toHaveCount(0);
|
||||
|
||||
await page.route("**/api/version", async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
await route.fulfill({ response: res, json: { ...body, build: `${String(body.build ?? body.version)}-next` } });
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __psMark?: number }).__psMark = 1;
|
||||
});
|
||||
// The banner checks at most once every 10 seconds; then a focus asks again
|
||||
await page.waitForTimeout(10_500);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
const banner = page.getByTestId("version-banner");
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toHaveText(/Packet Station was updated\. Reload to get the new version\./);
|
||||
await expect(banner).toHaveAttribute("role", "status");
|
||||
// No reload of its own: the page is the same document
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(await page.evaluate(() => (window as unknown as { __psMark?: number }).__psMark)).toBe(1);
|
||||
|
||||
await page.unroute("**/api/version");
|
||||
await Promise.all([page.waitForEvent("load"), banner.getByRole("button", { name: "Reload" }).click()]);
|
||||
expect(await page.evaluate(() => (window as unknown as { __psMark?: number }).__psMark ?? null)).toBeNull();
|
||||
await expect(page.getByTestId("version-banner")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the CSP guard sees a refused inline style and a refused inline script", async ({ page, cspGuard }) => {
|
||||
await page.goto("/signin");
|
||||
await page.evaluate(() => {
|
||||
const s = document.createElement("style");
|
||||
s.textContent = "body { outline: 1px solid red; }";
|
||||
document.head.appendChild(s);
|
||||
const j = document.createElement("script");
|
||||
j.textContent = "window.__psInline = 1;";
|
||||
document.head.appendChild(j);
|
||||
});
|
||||
await expect.poll(() => cspGuard.filter((l) => l.includes("securitypolicyviolation")).length).toBeGreaterThanOrEqual(2);
|
||||
await expect.poll(() => cspGuard.filter((l) => l.startsWith("console:")).length).toBeGreaterThanOrEqual(2);
|
||||
expect(await page.evaluate(() => (window as unknown as { __psInline?: number }).__psInline ?? null)).toBeNull();
|
||||
// Seen and checked here: empty the log so the guard itself passes this test
|
||||
await page.waitForTimeout(500);
|
||||
cspGuard.length = 0;
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { type CDPSession, type Locator, type Page } from "@playwright/test";
|
||||
import { expect, test, signInPassword } from "./helpers";
|
||||
import {
|
||||
actor,
|
||||
closeAll,
|
||||
drawing,
|
||||
drawingReady,
|
||||
makeShopDraft,
|
||||
packetTab,
|
||||
pullAndRelease,
|
||||
type Actor,
|
||||
type ShopPacket,
|
||||
} from "./shophelpers";
|
||||
import { getJson, onePagePdf, tinyPng } from "./whhelpers";
|
||||
|
||||
// Phase 10, the old tfs, tpan2 and tmtr on the new UI. sup.wes opens the Heat map of a packet ready to assign: Full
|
||||
// screen and back (button, Esc, leaving the tab); the drawing pans by mouse after re-renders by exactly the drag,
|
||||
// so no listeners pile up; on a touch device four finger drags each move the view. wh.wade uploads a PDF and a PNG
|
||||
// MTR on a warehouse packet and opens each in the MTR viewer (pdf.js on a canvas, no frame); "Open in new tab" serves
|
||||
// the file with the PDF policy.
|
||||
|
||||
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
|
||||
const PROJECT = `V${RUN}`;
|
||||
const VIEWER = "Heat map";
|
||||
let ready: ShopPacket;
|
||||
let atWh: ShopPacket;
|
||||
|
||||
function viewerBox(page: Page): Locator {
|
||||
return page.getByRole("group", { name: VIEWER, exact: true });
|
||||
}
|
||||
|
||||
/** The stage's translate as [x, y]. */
|
||||
async function pan(page: Page): Promise<[number, number]> {
|
||||
const t = await viewerBox(page)
|
||||
.locator(".stage")
|
||||
.evaluate((el) => (el as HTMLElement).style.transform);
|
||||
const m = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(t);
|
||||
if (!m) throw new Error(`no translate in "${t}"`);
|
||||
return [Number(m[1]), Number(m[2])];
|
||||
}
|
||||
|
||||
async function openHeatMap(page: Page, id: number): Promise<void> {
|
||||
await page.goto(`/packets/${id}`);
|
||||
await packetTab(page, /^Heat map/).click();
|
||||
await drawingReady(page, VIEWER);
|
||||
}
|
||||
|
||||
async function canvasPoint(page: Page, fx: number, fy: number): Promise<{ x: number; y: number }> {
|
||||
const c = drawing(page, VIEWER);
|
||||
await c.scrollIntoViewIfNeeded();
|
||||
const b = await c.boundingBox();
|
||||
if (!b) throw new Error("drawing not visible");
|
||||
return { x: Math.round(b.x + b.width * fx), y: Math.round(b.y + b.height * fy) };
|
||||
}
|
||||
|
||||
async function touchDrag(cdp: CDPSession, from: { x: number; y: number }, dx: number, dy: number): Promise<void> {
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from.x, y: from.y }] });
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: from.x + (dx * i) / 8, y: from.y + (dy * i) / 8 }],
|
||||
});
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
}
|
||||
|
||||
test.describe.serial("drawing viewer and MTRs", () => {
|
||||
test("setup: a packet ready to assign and one at the warehouse", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
ready = await makeShopDraft(clara, PROJECT, `Viewer spool ${RUN}`);
|
||||
await pullAndRelease(wade, ready.id);
|
||||
atWh = await makeShopDraft(clara, PROJECT, `MTR spool ${RUN}`);
|
||||
await closeAll(clara, wade);
|
||||
});
|
||||
|
||||
test("tfs: full screen from the viewer; Exit, Esc and leaving the tab end it", async ({ browser }) => {
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const page = wes.page;
|
||||
await openHeatMap(page, ready.id);
|
||||
const box = viewerBox(page);
|
||||
const before = await box.boundingBox();
|
||||
const body = page.locator("body");
|
||||
|
||||
await box.getByRole("button", { name: "Full screen", exact: true }).click();
|
||||
await expect(body).toHaveClass(/\bfs-view\b/);
|
||||
await expect(box.getByRole("button", { name: "Exit full screen", exact: true })).toBeVisible();
|
||||
const big = await box.boundingBox();
|
||||
expect(big && before && big.width * big.height > before.width * before.height).toBe(true);
|
||||
await expect(page.locator('[data-ready="1"]').and(box)).toBeVisible();
|
||||
await box.getByRole("button", { name: "Exit full screen", exact: true }).click();
|
||||
await expect(body).not.toHaveClass(/\bfs-view\b/);
|
||||
|
||||
await box.getByRole("button", { name: "Full screen", exact: true }).click();
|
||||
await expect(body).toHaveClass(/\bfs-view\b/);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(body).not.toHaveClass(/\bfs-view\b/);
|
||||
|
||||
await box.getByRole("button", { name: "Full screen", exact: true }).click();
|
||||
await expect(body).toHaveClass(/\bfs-view\b/);
|
||||
await packetTab(page, /^Materials/).click();
|
||||
await expect(body).not.toHaveClass(/\bfs-view\b/);
|
||||
await closeAll(wes);
|
||||
});
|
||||
|
||||
test("tpan2: a mouse drag pans by exactly the drag after re-renders", async ({ browser }) => {
|
||||
const wes = await actor(browser, "sup.wes");
|
||||
const page = wes.page;
|
||||
await openHeatMap(page, ready.id);
|
||||
const box = viewerBox(page);
|
||||
// Re-render the viewer a few times: zoom, fit, full screen in and out
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await box.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await box.getByRole("button", { name: "Fit drawing" }).click();
|
||||
await box.getByRole("button", { name: "Full screen", exact: true }).click();
|
||||
await box.getByRole("button", { name: "Exit full screen", exact: true }).click();
|
||||
}
|
||||
await box.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
for (const [dx, dy] of [
|
||||
[60, 30],
|
||||
[-40, 25],
|
||||
[30, -50],
|
||||
] as const) {
|
||||
const p0 = await pan(page);
|
||||
const from = await canvasPoint(page, 0.5, 0.5);
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 4 });
|
||||
await page.mouse.move(from.x + dx, from.y + dy, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
const p1 = await pan(page);
|
||||
expect(Math.abs(p1[0] - p0[0] - dx)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(p1[1] - p0[1] - dy)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await closeAll(wes);
|
||||
});
|
||||
|
||||
test("tpan2: on a touch device each finger drag moves the view", async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== "chromium", "touch is sent through the Chrome DevTools Protocol");
|
||||
const ctx = await browser.newContext({ hasTouch: true, viewport: { width: 1024, height: 768 } });
|
||||
const page = await ctx.newPage();
|
||||
const wes: Actor = { sam: "sup.wes", ctx, page };
|
||||
await signInPassword(page, "sup.wes");
|
||||
await openHeatMap(page, ready.id);
|
||||
await viewerBox(page).getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
for (const [dx, dy] of [
|
||||
[50, 0],
|
||||
[0, 40],
|
||||
[-30, -30],
|
||||
[45, 20],
|
||||
] as const) {
|
||||
const p0 = await pan(page);
|
||||
await touchDrag(cdp, await canvasPoint(page, 0.5, 0.5), dx, dy);
|
||||
const p1 = await pan(page);
|
||||
expect(Math.abs(p1[0] - p0[0] - dx)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(p1[1] - p0[1] - dy)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
await closeAll(wes);
|
||||
});
|
||||
|
||||
test("tmtr: a PDF and a PNG MTR, each shown in the MTR viewer and served for a new tab", async ({ browser }) => {
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const page = wade.page;
|
||||
await page.goto(`/packets/${atWh.id}`);
|
||||
await packetTab(page, /^Materials/).click();
|
||||
const r1 = page.getByRole("row").filter({ hasText: "4in pipe SCH40" });
|
||||
const r2 = page.getByRole("row").filter({ hasText: "4in flange" });
|
||||
await page
|
||||
.getByLabel("Line 1 MTR file", { exact: true })
|
||||
.setInputFiles({ name: "mtr-1.pdf", mimeType: "application/pdf", buffer: onePagePdf(`MTR ${RUN}`) });
|
||||
await expect(r1.getByRole("button", { name: "MTR", exact: true })).toBeVisible();
|
||||
await page
|
||||
.getByLabel("Line 2 MTR file", { exact: true })
|
||||
.setInputFiles({ name: "mtr-2.png", mimeType: "image/png", buffer: tinyPng() });
|
||||
await expect(r2.getByRole("button", { name: "MTR", exact: true })).toBeVisible();
|
||||
const p = await getJson(page.request, `/api/packets/${atWh.id}`);
|
||||
const bom = p.bom as Array<Record<string, unknown>>;
|
||||
expect(bom.find((b) => b.item === 1)?.mtr_asset_id).toBeTruthy();
|
||||
expect(bom.find((b) => b.item === 2)?.mtr_asset_id).toBeTruthy();
|
||||
|
||||
// The PDF: drawn by pdf.js in the sheet, no frame
|
||||
await r1.getByRole("button", { name: "MTR", exact: true }).click();
|
||||
let sheet = page.getByRole("dialog", { name: /^MTR · item 1/ });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByRole("img", { name: /Drawing page 1/ }).first()).toBeVisible();
|
||||
await expect(sheet.locator("iframe, object, embed")).toHaveCount(0);
|
||||
const href = await sheet.getByRole("link", { name: "Open in new tab" }).first().getAttribute("href");
|
||||
expect(href).toBeTruthy();
|
||||
const pdf = await page.request.get(String(href));
|
||||
expect(pdf.status()).toBe(200);
|
||||
expect(pdf.headers()["content-type"]).toContain("application/pdf");
|
||||
expect((await pdf.body()).subarray(0, 4).toString("latin1")).toBe("%PDF");
|
||||
const pdfCsp = pdf.headers()["content-security-policy"] ?? "";
|
||||
expect(pdfCsp).toContain("object-src 'self'");
|
||||
expect(pdfCsp).toContain("frame-ancestors 'none'");
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
|
||||
// The PNG: shown as an image
|
||||
await r2.getByRole("button", { name: "MTR", exact: true }).click();
|
||||
sheet = page.getByRole("dialog", { name: /^MTR · item 2/ });
|
||||
const img = sheet.getByRole("img", { name: /^MTR · item 2/ });
|
||||
await expect(img).toBeVisible();
|
||||
await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
|
||||
const png = await page.request.get(String(await sheet.getByRole("link", { name: "Open in new tab" }).first().getAttribute("href")));
|
||||
expect(png.status()).toBe(200);
|
||||
expect(png.headers()["content-type"]).toContain("image/png");
|
||||
await closeAll(wade);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import { dcUser, signInPassword } from "./helpers";
|
||||
import { type APIRequestContext, type Page } from "@playwright/test";
|
||||
import { cardOf, column } from "./closehelpers";
|
||||
import { expect, test, dcUser, signInPassword } from "./helpers";
|
||||
import { expectShowing, filtersButton, fltSelect, pickProject } from "./pmhelpers";
|
||||
import { actor, closeAll, packetTab, signWithPassword } from "./shophelpers";
|
||||
import { onePagePdf } from "./whhelpers";
|
||||
|
||||
// Phase 3 VISUAL feed scenario on the CI stack. The worker reads the synthetic fixture files
|
||||
// (tests/fixtures/generator.py) mounted at /fixtures; this container mounts them read-write so the
|
||||
@@ -9,9 +13,18 @@ import { dcUser, signInPassword } from "./helpers";
|
||||
// drafts, the chained drawing run attaches the WELD-PART drawing, the packet goes to the warehouse and
|
||||
// wh.wade releases it to the shop. State 2 moves that drawing to rev 2: the next run opens a change
|
||||
// order, the drawing run stages the rev 2 PDF, and clara approves it with her password.
|
||||
//
|
||||
// Phase 10 (old tflag, tflagsearch, trevchk2, thand, tmat), in state 1 before the drawing moves: the Flagged column
|
||||
// and its flag texts, the Flag filter, a rev mismatch approved by a Clerical supervisor (clerk.leo) with a reason
|
||||
// and a signature, the drawing and the BOM uploaded by hand on a flagged draft, and the VISUAL materials on the
|
||||
// WELD-PART packet. As in the server tests (test_feed_api.py), drawings.1.json loses L900-0201's revisions before
|
||||
// the first run, so M-Files has no rev 2 and the rev mismatch stays flagged.
|
||||
|
||||
const FIXTURE_DIR = process.env.FIXTURE_DIR ?? "/fixtures";
|
||||
const KEY_WELD_PART = "L900001|WELD-PART|L900-0101";
|
||||
const KEY_TWO_OPS = "L900001|WELD-CODE|L900-0201"; // VISUAL rev 2, M-Files rev 3: rev mismatch
|
||||
const KEY_DRAFTING = "L900002|LAYOUT|L900-0501"; // in Drafting review: not released
|
||||
const KEY_NOT_FOUND = "L900002|WELD-PART|L900-0701"; // not in M-Files
|
||||
const POLL_MS = 60_000;
|
||||
|
||||
type Json = Record<string, any>;
|
||||
@@ -79,6 +92,11 @@ test.describe.serial("VISUAL feed", () => {
|
||||
|
||||
test("Run now makes project L900, its drafts and attaches the WELD-PART drawing", async ({ page }) => {
|
||||
writeFileSync(join(FIXTURE_DIR, "state"), "1");
|
||||
// M-Files has no rev 2 of L900-0201 at all (the server tests' set_revisions): the rev mismatch stays flagged
|
||||
const dwgFile = join(FIXTURE_DIR, "drawings.1.json");
|
||||
const dwgs = JSON.parse(readFileSync(dwgFile, "utf-8")) as Json;
|
||||
delete dwgs["L900-0201"].revisions;
|
||||
writeFileSync(dwgFile, JSON.stringify(dwgs, null, 1));
|
||||
await signInPassword(page, "clerk.clara");
|
||||
await expect(page).toHaveURL(/\/clerical/);
|
||||
await expect(page.getByText(/from visual/i).first()).toBeVisible();
|
||||
@@ -138,6 +156,164 @@ test.describe.serial("VISUAL feed", () => {
|
||||
expect(pkt.status).toBe("warehouse");
|
||||
});
|
||||
|
||||
test("tflag and tflagsearch: the Flagged column, the flag texts and the Flag filter", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
// The chained drawing run flags the three drafts
|
||||
await expect
|
||||
.poll(async () => (await packetByKey(page, KEY_TWO_OPS)).flags, { timeout: POLL_MS, intervals: [1_000, 2_000] })
|
||||
.toContain("rev_mismatch");
|
||||
const rev = await packetByKey(page, KEY_TWO_OPS);
|
||||
const nr = await packetByKey(page, KEY_DRAFTING);
|
||||
const nf = await packetByKey(page, KEY_NOT_FOUND);
|
||||
expect(nr.flags).toContain("not_released");
|
||||
expect(nf.flags).toContain("not_found");
|
||||
expect(rev.flag).toMatch(/^Rev mismatch/);
|
||||
expect(nf.flag).toBe("Drawing not in M-Files - check the drawing number in VISUAL");
|
||||
|
||||
await page.goto("/clerical");
|
||||
await pickProject(page, "L900");
|
||||
const flagged = column(page, "Flagged");
|
||||
await expect(cardOf(flagged, rev.id)).toContainText(String(rev.flag));
|
||||
await expect(cardOf(flagged, nr.id)).toContainText(String(nr.flag));
|
||||
await expect(cardOf(flagged, nf.id)).toContainText("Drawing not in M-Files");
|
||||
// The clean WELD-PART packet is not flagged
|
||||
await expect(cardOf(flagged, packetId)).toHaveCount(0);
|
||||
|
||||
// The Flag filter narrows the board to one reason
|
||||
const main = page.locator("main");
|
||||
await filtersButton(page).click();
|
||||
await fltSelect(page, "Flag").selectOption({ label: "Rev mismatch with M-Files" });
|
||||
await expect(cardOf(main, rev.id)).toBeVisible();
|
||||
await expect(cardOf(main, nf.id)).toHaveCount(0);
|
||||
await expectShowing(page, 1);
|
||||
await fltSelect(page, "Flag").selectOption({ label: "Drawing not found in M-Files" });
|
||||
await expect(cardOf(main, nf.id)).toBeVisible();
|
||||
await expect(cardOf(main, rev.id)).toHaveCount(0);
|
||||
await fltSelect(page, "Flag").selectOption({ label: "Not flagged" });
|
||||
await expect(cardOf(main, nf.id)).toHaveCount(0);
|
||||
await expect(cardOf(main, rev.id)).toHaveCount(0);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("trevchk2: a Clerical supervisor approves the M-Files rev with a reason and a signature", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const pkt = await packetByKey(clara.page, KEY_TWO_OPS);
|
||||
// The staged M-Files copy is there before anyone can approve it
|
||||
await expect
|
||||
.poll(
|
||||
async () => String((await getJson(clara.page.request, `/api/packets/${pkt.id}`)).visual?.staged_rev?.rev ?? ""),
|
||||
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
|
||||
)
|
||||
.toBe("3");
|
||||
await clara.page.goto(`/packets/${pkt.id}`);
|
||||
const box = clara.page.getByRole("region", { name: "From VISUAL" });
|
||||
await expect(box.getByText("A Clerical supervisor can approve rev 3 for this packet.")).toBeVisible();
|
||||
await expect(box.getByRole("button", { name: /^Approve rev/ })).toHaveCount(0);
|
||||
await closeAll(clara);
|
||||
|
||||
const leo = await actor(browser, "clerk.leo");
|
||||
const page = leo.page;
|
||||
await page.goto(`/packets/${pkt.id}`);
|
||||
await page
|
||||
.getByRole("region", { name: "From VISUAL" })
|
||||
.getByRole("button", { name: "Approve rev 3 for this packet" })
|
||||
.click();
|
||||
const sheet = page.getByRole("dialog", { name: "Approve rev 3 for L900-0201" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await sheet.getByLabel("Reason").fill("Engineering confirmed rev 3 is right");
|
||||
await sheet.getByRole("button", { name: "Approve and attach" }).click();
|
||||
await signWithPassword(page, "clerk.leo");
|
||||
await expect(page.getByText("Rev 3 approved by hand").first()).toBeVisible();
|
||||
const after = await getJson(page.request, `/api/packets/${pkt.id}`);
|
||||
expect(after.flags).not.toContain("rev_mismatch");
|
||||
expect(after.drawing_state).toBe("attached");
|
||||
expect(String(after.visual.rev_ok.rev)).toBe("3");
|
||||
expect(String(after.visual.rev_ok.vis_rev)).toBe("2");
|
||||
expect(((after.pages ?? []) as Json[]).some((pg) => pg.type === "drawing" && String(pg.rev) === "3" && !pg.void)).toBe(
|
||||
true,
|
||||
);
|
||||
await closeAll(leo);
|
||||
});
|
||||
|
||||
test("thand: Clerical uploads the drawing and the BOM by hand on a flagged draft; the warehouse cannot", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const wade = await actor(browser, "wh.wade");
|
||||
const nf = await packetByKey(wade.page, KEY_NOT_FOUND);
|
||||
await wade.page.goto(`/packets/${nf.id}`);
|
||||
await expect(wade.page.getByTestId("status-pill")).toBeVisible();
|
||||
await expect(wade.page.getByRole("button", { name: "Upload drawing" })).toHaveCount(0);
|
||||
await expect(wade.page.getByRole("button", { name: "Upload BOM" })).toHaveCount(0);
|
||||
await closeAll(wade);
|
||||
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
await page.goto(`/packets/${nf.id}`);
|
||||
await expect(page.getByText("Flagged - Clerical can upload the drawing or the BOM by hand.")).toBeVisible();
|
||||
|
||||
// The drawing: a reason is needed, then the signature
|
||||
await page.getByRole("button", { name: "Upload drawing" }).click();
|
||||
let sheet = page.getByRole("dialog", { name: "Upload the drawing" });
|
||||
await sheet.getByLabel("File (PDF, up to 25 MB)").setInputFiles({
|
||||
name: "L900-0701 hand.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: onePagePdf(`hand drawing ${Date.now()}`),
|
||||
});
|
||||
await sheet.getByRole("button", { name: "Upload and sign" }).click();
|
||||
await expect(sheet.getByRole("alert")).toHaveText("Say why it is uploaded by hand.");
|
||||
await sheet.getByLabel("Why by hand (kept on the packet)").fill("Drafting sent the PDF by email");
|
||||
await sheet.getByRole("button", { name: "Upload and sign" }).click();
|
||||
await signWithPassword(page, "clerk.clara");
|
||||
await expect(page.getByText("Drawing attached. Check it, then send it on.")).toBeVisible();
|
||||
const handLine = page.getByText(/^Drawing uploaded by hand: L900-0701 hand\.pdf/);
|
||||
await expect(handLine).toBeVisible();
|
||||
await expect(handLine).toContainText("Drafting sent the PDF by email");
|
||||
let p = await getJson(page.request, `/api/packets/${nf.id}`);
|
||||
expect(p.drawing_state).toBe("hand");
|
||||
expect(p.flags).toContain("hand_drawing");
|
||||
expect(p.flags).not.toContain("not_found");
|
||||
expect(p.visual.manual_dwg.reason).toBe("Drafting sent the PDF by email");
|
||||
|
||||
// The BOM, as CSV
|
||||
await page.getByRole("button", { name: "Upload BOM" }).click();
|
||||
sheet = page.getByRole("dialog", { name: "Upload the BOM" });
|
||||
await sheet.getByLabel("File (XLSX or CSV, up to 25 MB)").setInputFiles({
|
||||
name: "hand_bom.csv",
|
||||
mimeType: "text/csv",
|
||||
buffer: Buffer.from("Item,Qty,Part ID,Description\n1,6,PIPE-8-SCH40,8in pipe\n2,2,FLG-8-150,8in flange\n", "utf-8"),
|
||||
});
|
||||
await sheet.getByLabel("Why by hand (kept on the packet)").fill("No materials in VISUAL yet");
|
||||
await sheet.getByRole("button", { name: "Upload and sign" }).click();
|
||||
await signWithPassword(page, "clerk.clara");
|
||||
await expect(page.getByText("BOM uploaded. Check it, then send it on.")).toBeVisible();
|
||||
p = await getJson(page.request, `/api/packets/${nf.id}`);
|
||||
expect(((p.bom ?? []) as Json[]).map((b) => b.part_id)).toEqual(["PIPE-8-SCH40", "FLG-8-150"]);
|
||||
expect(p.flags).toContain("hand_bom");
|
||||
expect(p.flag).toBe("Drawing and BOM uploaded by hand - check it and send it on");
|
||||
await expect(page.getByRole("button", { name: "Use VISUAL's BOM again" })).toBeVisible();
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("tmat: the work order's materials from VISUAL are the WELD-PART packet's BOM", async ({ browser }) => {
|
||||
const clara = await actor(browser, "clerk.clara");
|
||||
const page = clara.page;
|
||||
const p = await getJson(page.request, `/api/packets/${packetId}`);
|
||||
const parts = ((p.bom ?? []) as Json[]).map((b) => String(b.part_id));
|
||||
// seq 20's lines: the MAIN warehouse line, the WIP sub-assembly and the line with a blank drawing. The line that
|
||||
// names another drawing stays off, and seq 10's lines belong to the LAYOUT packet.
|
||||
expect(parts).toEqual(expect.arrayContaining(["PIPE-6-SCH40", "L900-01-SUB", "WOL-1-6"]));
|
||||
expect(parts).not.toContain("ELB-6-90");
|
||||
expect(parts).not.toContain("FLG-6-150");
|
||||
await page.goto(`/packets/${packetId}`);
|
||||
await packetTab(page, /^Materials/).click();
|
||||
const panel = page.getByRole("tabpanel");
|
||||
await expect(panel.getByText("PIPE-6-SCH40").first()).toBeVisible();
|
||||
await expect(panel.getByText("L900-01-SUB").first()).toBeVisible();
|
||||
await expect(panel.getByText("ELB-6-90")).toHaveCount(0);
|
||||
await closeAll(clara);
|
||||
});
|
||||
|
||||
test("wh.wade releases it to the shop", async ({ page }) => {
|
||||
await signInPassword(page, "wh.wade");
|
||||
let pkt = await getJson(page.request, `/api/packets/${packetId}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { signInPassword } from "./helpers";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, test, signInPassword } from "./helpers";
|
||||
import { L, card, column, drag, getJson, makeWarehousePacket, tinyPng, whStage, type MadePacket } from "./whhelpers";
|
||||
|
||||
// Phase 4 warehouse scenarios on the CI stack. Runs after the other specs on the one worker.
|
||||
|
||||
@@ -72,7 +72,8 @@ async function sendJson(page: Page, method: "post" | "patch", url: string, data:
|
||||
}
|
||||
|
||||
/** A minimal valid one-page PDF, built from raw bytes with a correct xref table. */
|
||||
export function onePagePdf(): Buffer {
|
||||
/** A one-page PDF. A `label` goes into a comment line, so the file has its own sha256 (the server dedupes by it). */
|
||||
export function onePagePdf(label?: string): Buffer {
|
||||
const content = "0 0 m 612 792 l S";
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
@@ -80,7 +81,7 @@ export function onePagePdf(): Buffer {
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
|
||||
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
|
||||
];
|
||||
let out = "%PDF-1.4\n";
|
||||
let out = label ? `%PDF-1.4\n%${label.replace(/[\r\n]/g, " ")}\n` : "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((body, i) => {
|
||||
offsets.push(out.length);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The archive location (Gitea issue 7): the path rules, the check, the File locations API, the archive-copy job
|
||||
(copy, idempotence, failure and backoff, the Admin condition, old seals queued) and filing with the archive off or
|
||||
broken. Synthetic data only; the archive root is a temp folder."""
|
||||
"""The archive location (Gitea issue 7): the path rules, the check, the File locations API, the copy FILE makes in
|
||||
its own transaction, filing refused when that copy fails (Gitea issue 57), and the archive-copy job (idempotence,
|
||||
failure and backoff, the Admin condition, old seals queued). Synthetic data only; the archive root is a temp
|
||||
folder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import file_packet, filed, q1, qall, rich_complete, vclose
|
||||
from closehelpers import at_complete, file_packet, filed, q1, qall, rich_complete, vclose
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import make_settings, needs_pg
|
||||
from shophelpers import Shop, ok, open_shop, refused, run
|
||||
@@ -283,12 +284,9 @@ def test_copy_job(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run(s, "UPDATE bom_shorts SET closed_at = now() WHERE packet_id = %s", (pid,))
|
||||
out = ok(file_packet(shop, vclose(shop, pkt)))
|
||||
assert out["status"] == "filed"
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy' AND claimed_at IS NULL")[0] == 1
|
||||
# FILE made the copy itself, in the filing transaction: nothing waits for the worker
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
|
||||
seal_id, book_id = q1(s, "SELECT id, databook_asset_id FROM packet_seals WHERE packet_id = %s", (pid,))
|
||||
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "ok", row
|
||||
assert row["trigger"] == "request" and row["counts"]["queued"] == 1 and row["counts"]["copied"] == 1
|
||||
((sid, status, attempts, err, nxt, folder),) = copies(s)
|
||||
assert (sid, status, attempts, err, nxt) == (seal_id, "done", 1, None, None)
|
||||
project, unit = q1(
|
||||
@@ -315,11 +313,13 @@ def test_copy_job(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert q1(s, "SELECT manifest_sha256 FROM archive_copies")[0] == sha(where / "manifest.json")
|
||||
(done,) = audits(s, "archive.copied")
|
||||
assert done["seal_id"] == seal_id and done["folder"] == folder and done["files"] == len(names)
|
||||
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
|
||||
assert done["source"] == "filing"
|
||||
|
||||
# idempotent: a second run copies nothing; a row set back to pending finds the identical files and is done
|
||||
# idempotent: a run copies nothing; a row set back to pending finds the identical files and is done
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "ok" and row["counts"]["queued"] == 0 and row["counts"]["copied"] == 0, row
|
||||
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
|
||||
events.clear()
|
||||
assert run_copy(s)["counts"]["copied"] == 0
|
||||
run(s, "UPDATE archive_copies SET status = 'pending'")
|
||||
before = {p.name: p.stat().st_mtime_ns for p in where.iterdir()}
|
||||
assert run_copy(s)["counts"]["copied"] == 1
|
||||
@@ -390,50 +390,89 @@ def test_old_seals_are_queued_when_the_archive_is_turned_on(off_shop: Shop, tmp_
|
||||
assert copies(s)[0][:2] == (seal, "done")
|
||||
|
||||
|
||||
def open_filing_events(settings: Settings, pid: int) -> list[Any]:
|
||||
return qall(
|
||||
settings,
|
||||
"SELECT title, body, detail, severity, raised_count FROM notification_events"
|
||||
" WHERE event_type = 'archive_failed' AND subject = %s AND resolved_at IS NULL",
|
||||
(f"filing:{pid}",),
|
||||
)
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_filing_succeeds_with_the_archive_broken(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_filing_is_refused_when_the_archive_folder_is_gone(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
root = Path(str(s.archive_root))
|
||||
events: list[tuple[str, str, dict[str, Any]]] = []
|
||||
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: events.append(("raise", t, kw)))
|
||||
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: events.append(("resolve", t, kw)))
|
||||
save_dir(s, "gone") # saved, then the folder went away
|
||||
pkt = filed(shop)
|
||||
assert pkt["status"] == "filed"
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 1
|
||||
pkt = vclose(shop, at_complete(shop))
|
||||
pid = pkt["id"]
|
||||
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "degraded" and "does not exist" in row["counts"]["check"]
|
||||
body = refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert body["detail"].startswith("The packet could not be copied to the archive: The archive folder check failed:")
|
||||
assert "does not exist" in body["detail"] and "Nothing was filed, and Admin has been told." in body["detail"]
|
||||
# the whole filing rolled back: still complete, no seal, no data book, no copy row
|
||||
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
|
||||
assert view["status"] == "complete" and view["seal"] is None
|
||||
assert q1(s, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
|
||||
assert q1(s, "SELECT databook_at FROM packets WHERE id = %s", (pid,))[0] is None
|
||||
assert copies(s) == []
|
||||
((kind, etype, kw),) = events
|
||||
assert (kind, etype, kw["subject"], kw["condition"]) == ("raise", "archive_failed", "archive", True)
|
||||
checks = audits(s, "archive.checked")
|
||||
assert [(c["source"], c["ok"]) for c in checks] == [("job", False)]
|
||||
run_copy(s)
|
||||
assert len(audits(s, "archive.checked")) == 1 # the same result again is not audited again
|
||||
# recorded on its own: the audit row and one open Admin condition with the details
|
||||
(blocked,) = audits(s, "archive.filing_blocked")
|
||||
assert blocked["packet_id"] == pid and blocked["archive_dir"] == "gone" and blocked["by"] == "Clara Clerk"
|
||||
assert blocked["status"] == "complete" and "does not exist" in blocked["error"]
|
||||
((title, text, detail, severity, raised),) = open_filing_events(s, pid)
|
||||
assert title.startswith("Filing blocked: archive copy failed: ") and blocked["packet_label"] in title
|
||||
assert "Clara Clerk tried to file" in text and "Archive folder: gone" in text and "does not exist" in text
|
||||
assert severity == "critical" and raised == 1 and detail["packet_id"] == pid
|
||||
|
||||
# the folder comes back: the copy is made and the condition cleared
|
||||
# a second try fails the same way: the open condition only counts it
|
||||
refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert open_filing_events(s, pid)[0][4] == 2 and len(audits(s, "archive.filing_blocked")) == 2
|
||||
|
||||
# the folder comes back: the filing goes through, the copy is done and the condition is cleared
|
||||
(root / "gone").mkdir()
|
||||
events.clear()
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "ok" and row["counts"]["copied"] == 1
|
||||
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
|
||||
assert [(c["source"], c["ok"]) for c in audits(s, "archive.checked")] == [("job", False), ("job", True)]
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["status"] == "filed"
|
||||
((_, status, attempts, err, _, folder),) = copies(s)
|
||||
assert (status, attempts, err) == ("done", 1, None) and folder.startswith("gone/")
|
||||
assert open_filing_events(s, pid) == []
|
||||
cleared = q1(
|
||||
s,
|
||||
"SELECT title, body FROM notification_events WHERE event_type = 'archive_failed' AND subject = %s"
|
||||
" AND recovery_of IS NOT NULL",
|
||||
(f"filing:{pid}",),
|
||||
)
|
||||
assert cleared[0].startswith("Cleared: Filing blocked") and cleared[1] == "The packet was filed and copied."
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_filing_succeeds_when_the_request_fails(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_filing_is_refused_when_the_copy_fails(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = shop.settings
|
||||
save_dir(s)
|
||||
|
||||
def boom(*a: Any, **k: Any) -> None:
|
||||
raise RuntimeError("no requests today")
|
||||
def boom(*a: Any, **k: Any) -> Any:
|
||||
raise archive.CopyError("A different file is already at Data_Book.pdf in the archive; it was not overwritten.")
|
||||
|
||||
monkeypatch.setattr(archive, "request_copy", boom)
|
||||
monkeypatch.setattr(archive, "copy_seal", boom)
|
||||
pkt = vclose(shop, at_complete(shop))
|
||||
body = refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert "it was not overwritten. Nothing was filed" in body["detail"]
|
||||
assert q1(s, "SELECT status FROM packets WHERE id = %s", (pkt["id"],))[0] == "complete"
|
||||
assert q1(s, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pkt["id"],))[0] == 0
|
||||
((_, text, detail, _, _),) = open_filing_events(s, pkt["id"])
|
||||
assert "not overwritten" in text and detail["archive_dir"] == "packets"
|
||||
|
||||
# anything unexpected blocks the filing the same way
|
||||
monkeypatch.setattr(archive, "copy_seal", lambda *a, **k: 1 / 0)
|
||||
body = refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert "ZeroDivisionError" in body["detail"]
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_filing_with_no_folder_saved_copies_nothing(shop: Shop) -> None:
|
||||
# ARCHIVE_ROOT is set but the Admin has not saved a folder: the archive is not in use yet, so filing goes ahead
|
||||
pkt = filed(shop)
|
||||
assert pkt["status"] == "filed"
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
|
||||
assert run_copy(s)["counts"]["copied"] == 1 # the scheduled run picks it up
|
||||
assert pkt["status"] == "filed" and copies(shop.settings) == []
|
||||
|
||||
|
||||
@needs_pg
|
||||
|
||||
Reference in New Issue
Block a user