mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
hotfix(staging): deploy vendor table overflow fix (#155)
* ci: add protected staging frontend deployment lane (#151) * ci: add protected staging deployment lane * fix: constrain staging publisher permissions * fix: handle first-push governance baseline --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid> * fix(vendors): truncate long textual table values (#154) * fix(vendors): truncate long locations in table * fix(vendors): truncate textual table cells --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid> --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
This commit is contained in:
parent
40ce66fbb3
commit
fcf2ce185f
9 changed files with 540 additions and 85 deletions
25
.github/workflows/ci.yaml
vendored
25
.github/workflows/ci.yaml
vendored
|
|
@ -2,9 +2,9 @@ name: Frontend checks
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, staging]
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, staging]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
|
|
@ -32,18 +32,35 @@ jobs:
|
|||
# push-> the previous commit on the branch (github.event.before)
|
||||
# manual -> dev, for exact-head recovery runs
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'origin/dev' }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Resolve governance comparison ref
|
||||
id: governance-ref
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
|
||||
base="${PR_BASE_SHA}"
|
||||
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
|
||||
base="${EVENT_BEFORE}"
|
||||
else
|
||||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
visual-regression:
|
||||
name: Visual regression
|
||||
|
|
|
|||
140
.github/workflows/deploy-staging.yml
vendored
Normal file
140
.github/workflows/deploy-staging.yml
vendored
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
name: Deploy staging
|
||||
|
||||
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
|
||||
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
|
||||
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
|
||||
# job to declare `environment: staging` and run in this repo, with the
|
||||
# non-secret role ARN pinned below (created by the staging stack itself).
|
||||
#
|
||||
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
|
||||
# No secrets are used — OIDC + the static role ARN are the only credentials.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to staging
|
||||
# Deploy only the exact staging branch ref, never a tag or other ref
|
||||
# (workflow_dispatch can be invoked from arbitrary refs).
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
env:
|
||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||
AWS_REGION: us-east-1
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Resolve governance comparison ref
|
||||
id: governance-ref
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
|
||||
base="${EVENT_BEFORE}"
|
||||
else
|
||||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
# Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin
|
||||
# (npm@11.16.0) matches this CI environment.
|
||||
- name: Quality gates (full verify before any deploy)
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
- name: Assume staging deploy role (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds the SPA with the staging VITE_API_URL (process env overrides the
|
||||
# dev value committed in .env.production), syncs to the staging bucket,
|
||||
# and invalidates CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
STACK_NAME: shoc-frontend-staging
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name shoc-frontend-staging \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
DIST_DOMAIN="$(stack_output DistributionDomainName)"
|
||||
SITE_URL="$(stack_output SiteUrl)"
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then
|
||||
echo "::error::Could not resolve bucket/distribution from stack outputs." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}"
|
||||
|
||||
aws s3api head-bucket --bucket "${BUCKET}"
|
||||
echo "Bucket exists."
|
||||
# The distribution is proven to exist and serve by the HTTPS check
|
||||
# below: the custom domain is an alias to this distribution, and the
|
||||
# deploy role deliberately carries no cloudfront:GetDistribution
|
||||
# (least privilege; the dev template is shared and must not drift).
|
||||
|
||||
if grep -Rq "api.dev.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2
|
||||
grep -Rl "api.dev.seahaven.com" dist/ >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Built assets carry no dev API URL."
|
||||
grep -Rq "api.staging.seahaven.com" dist/
|
||||
echo "Built assets reference the staging API URL."
|
||||
|
||||
# Verify the actual post-invalidation HTML and its referenced assets,
|
||||
# not only the local build or a generic endpoint response.
|
||||
remote_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${remote_dir}"' EXIT
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then
|
||||
break
|
||||
fi
|
||||
echo "Endpoint not ready (attempt ${i}); retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
test -s "${remote_dir}/index.html"
|
||||
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \
|
||||
| sed -E 's/^(src|href)="([^"]+)"$/\2/' \
|
||||
| sort -u > "${remote_dir}/asset-paths.txt"
|
||||
test -s "${remote_dir}/asset-paths.txt"
|
||||
while IFS= read -r asset_path; do
|
||||
curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \
|
||||
>> "${remote_dir}/assets.txt"
|
||||
done < "${remote_dir}/asset-paths.txt"
|
||||
if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then
|
||||
echo "::error::Deployed assets contain the dev API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt"
|
||||
echo "Deployed staging assets reference only the staging API URL."
|
||||
53
e2e/vendors/vendors.spec.ts
vendored
53
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -61,6 +61,16 @@ const inactiveVendors = [
|
|||
},
|
||||
];
|
||||
|
||||
const LONG_TEXT_VENDOR = {
|
||||
CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis",
|
||||
ContactName: "Adam Christopher Whyte Senior Field Technician",
|
||||
Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example",
|
||||
Phone: "+1 (314) 555-0198 extension 123456",
|
||||
Address: "New York, NY, Estados Unidos, 123 Long Address Avenue",
|
||||
City: "",
|
||||
State: "",
|
||||
};
|
||||
|
||||
interface MockState {
|
||||
listUrls: string[];
|
||||
createdBody?: Record<string, unknown>;
|
||||
|
|
@ -88,10 +98,12 @@ async function mockVendorApi(
|
|||
deactivationBlocked?: boolean;
|
||||
deleteConflict?: boolean;
|
||||
legacyGateway?: boolean;
|
||||
longText?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const state: MockState = { listUrls: [] };
|
||||
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
|
||||
if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR);
|
||||
if (options.legacyGateway) {
|
||||
vendorRecords[0].Phone = "555-1234 ext 9";
|
||||
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
|
||||
|
|
@ -437,6 +449,47 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("truncates every non-abbreviated textual column", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await mockVendorApi(page, { longText: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
const row = page.locator("tbody tr").first();
|
||||
const fields = [
|
||||
{ cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName },
|
||||
{ cellIndex: 1, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
|
||||
{ cellIndex: 2, testId: "vendor-text-location", value: LONG_TEXT_VENDOR.Address },
|
||||
{ cellIndex: 4, testId: "vendor-text-phone", value: LONG_TEXT_VENDOR.Phone },
|
||||
{ cellIndex: 5, testId: "vendor-text-email", value: LONG_TEXT_VENDOR.Email },
|
||||
];
|
||||
|
||||
for (const expected of fields) {
|
||||
const cell = row.locator("td").nth(expected.cellIndex);
|
||||
const field = cell.getByTestId(expected.testId);
|
||||
await expect(field).toBeVisible();
|
||||
|
||||
const metrics = await field.evaluate((element) => {
|
||||
const fieldRect = element.getBoundingClientRect();
|
||||
const cellRect = element.closest("td")?.getBoundingClientRect();
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
withinCell:
|
||||
cellRect !== undefined &&
|
||||
fieldRect.left >= cellRect.left - 1 &&
|
||||
fieldRect.right <= cellRect.right + 1,
|
||||
};
|
||||
});
|
||||
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
|
||||
expect(metrics.withinCell).toBe(true);
|
||||
|
||||
await field.hover();
|
||||
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("adds a technician to an existing company, then views and edits the aggregate", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ deployed through the org's **reusable** GitHub Actions workflow.
|
|||
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
|
||||
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
|
||||
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
|
||||
- **Environments:** `dev` only today, deployed on push to the `dev` branch.
|
||||
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
|
||||
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
|
||||
|
||||
```
|
||||
infra/cdk/
|
||||
|
|
@ -25,8 +26,9 @@ infra/cdk/
|
|||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
.github/workflows/
|
||||
ci.yml quality gates (lint / build / test / e2e)
|
||||
ci.yaml quality gates (lint / build / test / e2e)
|
||||
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
```
|
||||
|
||||
## What the stack creates
|
||||
|
|
@ -137,25 +139,83 @@ the `SiteUrl` output.
|
|||
|
||||
---
|
||||
|
||||
## Adding staging / prod later
|
||||
## Staging environment (same account, exact OIDC subject)
|
||||
|
||||
Separate accounts: deploy this stack there with per-env `domainNames`,
|
||||
`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's
|
||||
`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`.
|
||||
Staging lives in the same AWS account (396287094661) but deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
|
||||
reusable `cd-cdk.yaml`:
|
||||
|
||||
Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is
|
||||
baked into `vite build`** — so each environment needs its own build with its own
|
||||
API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment
|
||||
in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than
|
||||
relying on the committed `.env.production` (which carries the dev value). The
|
||||
backend must also allow CORS from each frontend origin.
|
||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||
environment subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
|
||||
(`StringEquals` on both `aud` and `sub`). The workflow declares
|
||||
`environment: staging`, so only runs in that environment can assume the role.
|
||||
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
|
||||
unchanged.
|
||||
- **No secret:** the role ARN is static (the role name is deterministic), so
|
||||
the workflow pins
|
||||
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
|
||||
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
|
||||
- **Gates first:** the workflow runs the full `npm run verify` before assuming
|
||||
the staging role, then runs `scripts/deploy-web.sh` with
|
||||
`STACK_NAME=shoc-frontend-staging`,
|
||||
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
|
||||
CloudFront invalidation to complete.
|
||||
- **Application-only role:** the recurring staging workflow can describe only
|
||||
its exact stack, publish only to its exact bucket, and invalidate only its
|
||||
exact distribution. It cannot assume the shared CDK bootstrap roles or
|
||||
modify infrastructure. Staging infrastructure changes use the Administrator
|
||||
command below.
|
||||
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
|
||||
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
|
||||
contain the staging API URL and no dev API URL. (Not browser QA.)
|
||||
|
||||
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
|
||||
|
||||
1. **GitHub Admin — create the `staging` environment** (Settings →
|
||||
Environments → New environment → `staging`). Add protection rules as
|
||||
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
|
||||
the environment does not exist, GitHub creates it unprotected on first use.
|
||||
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
|
||||
OIDC provider and bootstrap already exist in this account):
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy shoc-frontend-staging \
|
||||
-c envName=staging \
|
||||
-c deployBranch=staging \
|
||||
-c githubEnvironment=staging \
|
||||
-c domainNames=staging.seahaven.com \
|
||||
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
|
||||
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
|
||||
-c hostedZoneName=staging.seahaven.com
|
||||
```
|
||||
|
||||
The `DeployRoleArn` output must match the ARN pinned in
|
||||
`deploy-staging.yml` (it will — the role name is deterministic).
|
||||
|
||||
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
|
||||
allow the `https://staging.seahaven.com` origin.
|
||||
4. Push to `staging` — `ci.yaml` runs the quality gates and
|
||||
`deploy-staging.yml` deploys.
|
||||
|
||||
### Adding prod later
|
||||
|
||||
Same pattern: a prod account/stack with its own contexts and, ideally, its own
|
||||
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
|
||||
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
|
||||
for prod.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
||||
- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still
|
||||
deploys (matches the org's push-time-CD model). Gating deploy on CI is a
|
||||
follow-up, not part of enabling CICD.
|
||||
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
|
||||
differs only in that its CD workflow also runs `npm run verify` itself
|
||||
before deploying); a red-CI commit still deploys on `dev` (matches the
|
||||
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
|
||||
of enabling CICD.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ const app = new App();
|
|||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
||||
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
|
||||
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
|
||||
// branch-ref trust.
|
||||
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
|
||||
|
||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
|
|
@ -21,10 +25,17 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
|||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
const terminationProtection = envName !== "dev";
|
||||
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment,
|
||||
terminationProtection,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ export interface FrontendStackProps extends StackProps {
|
|||
readonly githubRepo: string;
|
||||
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
||||
readonly deployBranch: string;
|
||||
/**
|
||||
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
|
||||
* trust uses the EXACT environment subject
|
||||
* `repo:<owner/name>:environment:<env>` (StringEquals) instead of the
|
||||
* deploy-branch ref match below. Unset = dev-style branch-ref trust.
|
||||
*/
|
||||
readonly githubEnvironment?: string;
|
||||
/**
|
||||
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
|
||||
* Empty = serve on the default *.cloudfront.net domain.
|
||||
|
|
@ -57,6 +64,7 @@ export class FrontendStack extends Stack {
|
|||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment = "",
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
|
|
@ -147,35 +155,52 @@ export class FrontendStack extends Stack {
|
|||
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
|
||||
);
|
||||
|
||||
// Trust conditions for the OIDC principal. With a GitHub environment
|
||||
// (staging): exact StringEquals match on both aud and the environment
|
||||
// subject — the staging workflow declares `environment: staging`, so only
|
||||
// runs in that environment can assume the role. Without one (dev): keep
|
||||
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
|
||||
// deploy branch (reusable-workflow runs still carry the caller-based sub).
|
||||
const oidcConditions = githubEnvironment
|
||||
? {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
|
||||
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||
maxSessionDuration: Duration.hours(1),
|
||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
}),
|
||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||
});
|
||||
|
||||
// The whole `cd-cdk.yaml` job runs as this role. Permissions it needs:
|
||||
// 1. assume the CDK bootstrap roles -> `cdk deploy`
|
||||
// 2. describe the stack -> cd-cdk pre-flight / health-check / output reads
|
||||
// 3. read/write the bucket -> post-deploy `aws s3 sync`
|
||||
// 4. invalidate the distribution -> post-deploy cache bust
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AssumeCdkBootstrapRoles",
|
||||
actions: ["sts:AssumeRole"],
|
||||
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
|
||||
}),
|
||||
);
|
||||
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
||||
// intentionally narrower: its recurring promotion workflow only publishes
|
||||
// application assets to this stack's bucket/distribution. Infrastructure
|
||||
// changes remain an administrator-run CDK operation, so the staging OIDC
|
||||
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
||||
if (!githubEnvironment) {
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AssumeCdkBootstrapRoles",
|
||||
actions: ["sts:AssumeRole"],
|
||||
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
|
||||
}),
|
||||
);
|
||||
}
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "DescribeStack",
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ set -euo pipefail
|
|||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
REGION="${AWS_REGION:-us-east-1}"
|
||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from .env.production)..."
|
||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
|
|
@ -48,8 +49,17 @@ aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
|||
--content-type "text/html"
|
||||
|
||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||
aws cloudfront create-invalidation \
|
||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--paths "/*"
|
||||
--paths "/*" \
|
||||
--query 'Invalidation.Id' \
|
||||
--output text)"
|
||||
|
||||
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
|
||||
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
|
||||
aws cloudfront wait invalidation-completed \
|
||||
--distribution-id "${DIST_ID}" \
|
||||
--id "${INVALIDATION_ID}"
|
||||
fi
|
||||
|
||||
echo "Web deploy complete."
|
||||
|
|
|
|||
|
|
@ -51,6 +51,78 @@ function stopPropagation(event: MouseEvent): void {
|
|||
event.stopPropagation();
|
||||
}
|
||||
|
||||
interface TruncatedTextProps {
|
||||
value: string;
|
||||
testId: string;
|
||||
focusable?: boolean;
|
||||
href?: string;
|
||||
color?: string;
|
||||
variant?: "description";
|
||||
}
|
||||
|
||||
const truncatedTextSx = {
|
||||
display: "block",
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
function TruncatedText({
|
||||
value,
|
||||
testId,
|
||||
focusable = false,
|
||||
href,
|
||||
color = "inherit",
|
||||
variant,
|
||||
}: TruncatedTextProps) {
|
||||
const hasValue = value !== "—";
|
||||
const content = href ? (
|
||||
<Link
|
||||
href={href}
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
data-testid={testId}
|
||||
sx={{ ...truncatedTextSx, color }}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
) : variant ? (
|
||||
<Text
|
||||
variant={variant}
|
||||
data-testid={testId}
|
||||
sx={{ ...truncatedTextSx, color, flex: "1 1 auto" }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
) : (
|
||||
<Box
|
||||
component="span"
|
||||
data-testid={testId}
|
||||
tabIndex={focusable && hasValue ? 0 : undefined}
|
||||
sx={{ ...truncatedTextSx, color }}
|
||||
>
|
||||
{value}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={hasValue ? value : ""} placement="top" arrow describeChild>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function getVendorDisplayValues(row: VendorListItem) {
|
||||
const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", ");
|
||||
return {
|
||||
locationText: cityStateText || row.address.trim() || "—",
|
||||
phoneText: row.phone.trim() || row.companyPhone.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
interface VendorTableRowProps {
|
||||
row: VendorListItem;
|
||||
onOpenDetail: (row: VendorListItem) => void;
|
||||
|
|
@ -61,9 +133,7 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
|||
const trades = splitTrades(row.tradeSpecialties);
|
||||
const primaryTrade = trades[0] ?? "—";
|
||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||
const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", ");
|
||||
const locationText = cityStateText || row.address.trim() || "—";
|
||||
const phoneText = row.phone.trim() || row.companyPhone.trim();
|
||||
const { locationText, phoneText } = getVendorDisplayValues(row);
|
||||
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
@ -84,17 +154,25 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
|||
onClick={() => onOpenDetail(row)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<TableCell>
|
||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||
{row.companyName || "—"}
|
||||
</Text>
|
||||
<TableCell sx={{ overflow: "hidden" }}>
|
||||
<TruncatedText
|
||||
value={row.companyName || "—"}
|
||||
testId="vendor-text-company"
|
||||
variant="description"
|
||||
color="var(--color-foreground)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
||||
<TableCell sx={{ overflow: "hidden" }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.25}
|
||||
sx={{ alignItems: "center", minWidth: 0, overflow: "hidden" }}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--color-bg-muted)",
|
||||
color: "var(--color-primary)",
|
||||
fontSize: 10.5,
|
||||
|
|
@ -103,12 +181,22 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
|||
>
|
||||
{getInitials(row.contactName)}
|
||||
</Avatar>
|
||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||
{row.contactName || "—"}
|
||||
</Text>
|
||||
<TruncatedText
|
||||
value={row.contactName || "—"}
|
||||
testId="vendor-text-technician"
|
||||
variant="description"
|
||||
color="var(--color-foreground)"
|
||||
/>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
|
||||
<TruncatedText
|
||||
value={locationText}
|
||||
testId="vendor-text-location"
|
||||
focusable
|
||||
color="var(--color-muted-foreground)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||
{primaryTrade === "—" ? (
|
||||
"—"
|
||||
|
|
@ -133,33 +221,21 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
|||
</Stack>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||
{phoneText ? (
|
||||
<Link
|
||||
href={`tel:${phoneText}`}
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{phoneText}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
|
||||
<TruncatedText
|
||||
value={phoneText || "—"}
|
||||
testId="vendor-text-phone"
|
||||
href={phoneText ? `tel:${phoneText}` : undefined}
|
||||
color="var(--color-muted-foreground)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.email ? (
|
||||
<Link
|
||||
href={`mailto:${row.email}`}
|
||||
underline="hover"
|
||||
sx={{ color: "#6b7a96" }}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{row.email}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
<TableCell sx={{ overflow: "hidden" }}>
|
||||
<TruncatedText
|
||||
value={row.email || "—"}
|
||||
testId="vendor-text-email"
|
||||
href={row.email ? `mailto:${row.email}` : undefined}
|
||||
color="#6b7a96"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import VendorsListPage from "@/app/(protected)/vendors/index";
|
||||
|
|
@ -181,6 +181,69 @@ describe("VendorsListPage", () => {
|
|||
expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("truncates every textual table field and reveals each full value", async () => {
|
||||
setupDefaults();
|
||||
const longCompany = "Gateway Plumbing and Water Systems of Greater Saint Louis";
|
||||
const longTechnician = "Adam Christopher Whyte Senior Field Technician";
|
||||
const longAddress = "New York, NY, Estados Unidos, 123 Long Address Avenue";
|
||||
const longPhone = "+1 (314) 555-0198 extension 123456";
|
||||
const longEmail = "dispatch-and-after-hours-coordination@gatewayplumbing.example";
|
||||
const fallbackVendor: VendorListItem = {
|
||||
...activeVendor,
|
||||
id: 4,
|
||||
companyName: longCompany,
|
||||
contactName: longTechnician,
|
||||
phone: longPhone,
|
||||
email: longEmail,
|
||||
address: longAddress,
|
||||
city: "",
|
||||
state: "",
|
||||
};
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], 1);
|
||||
return params.isActive ? result([fallbackVendor], 1) : result([], 0);
|
||||
});
|
||||
|
||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||
|
||||
const fields = [
|
||||
{ testId: "vendor-text-company", value: longCompany },
|
||||
{ testId: "vendor-text-technician", value: longTechnician },
|
||||
{ testId: "vendor-text-location", value: longAddress },
|
||||
{ testId: "vendor-text-phone", value: longPhone },
|
||||
{ testId: "vendor-text-email", value: longEmail },
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
const text = screen.getByTestId(field.testId);
|
||||
const cell = text.closest("td");
|
||||
expect(cell).not.toBeNull();
|
||||
expect(cell).toHaveStyle({ overflow: "hidden" });
|
||||
expect(text).toHaveStyle({
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
await userEvent.hover(text);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(field.value);
|
||||
await userEvent.unhover(text);
|
||||
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
expect(screen.getByTestId("vendor-text-phone")).toHaveAttribute("href", `tel:${longPhone}`);
|
||||
expect(screen.getByTestId("vendor-text-email")).toHaveAttribute("href", `mailto:${longEmail}`);
|
||||
|
||||
const locationText = screen.getByTestId("vendor-text-location");
|
||||
fireEvent.keyDown(document, { key: "Tab" });
|
||||
locationText.focus();
|
||||
expect(locationText).toHaveFocus();
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(longAddress);
|
||||
expect(within(screen.getByRole("cell", { name: longAddress })).getByText(longAddress)).toBe(
|
||||
locationText,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
||||
setupDefaults();
|
||||
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue