Skip to content

Script and skill for setting up portal assignments [CLUE-654] - #2983

Open
scytacki wants to merge 1 commit into
masterfrom
CLUE-654-portal-assignment-setup
Open

Script and skill for setting up portal assignments [CLUE-654]#2983
scytacki wants to merge 1 commit into
masterfrom
CLUE-654-portal-assignment-setup

Conversation

@scytacki

Copy link
Copy Markdown
Member

Why

Most CLUE tests run in demo or qa appMode, whose Firebase paths are governed by rules amounting to if isAuthed(). Only a launch from a portal assignment carrying a portal token exercises the real authed/<portal> rules — which is how release smoke tests are meant to work.

Setting that up by hand takes four coupled portal changes, several of which fail quietly rather than loudly. This automates it, and documents the parts a person still has to decide.

What's here

scripts/setup-portal-assignment.ts — creates or reuses the external activity, its offering on a class, a teacher report, and the OAuth redirect URI, then reads everything back and prints what a student and a teacher will each launch. Idempotent throughout; --dry-run writes nothing.

cd scripts
npx tsx setup-portal-assignment.ts \
  --clue-path version/v7.5.0 --class-id 111 --unit seismic --problem 1.1 --dry-run

--clue-path takes the deployed path (version/<tag> or branch/<name>) so the URL that gets built is visible in the command rather than inferred.

scripts/lib/portal-api.ts — the portal client. The JSON API accepts a bearer token, but external reports, report attachment, and OAuth clients exist only in the admin UI, so its forms are driven with a CSRF token and session cookie. Also adds support for the staging portal's own token, which the existing scripts/lib helpers didn't have.

.claude/skills/setting-up-portal-assignments/ — a skill covering the decisions the script can't make: which teacher and class, which unit, which problem, and what the deployed CLUE path actually is. Plus testing.md, a read-only procedure for verifying the skill after editing it.

Doc correctionsREADME.md and docs/deploy.md both described branch deploy paths without the issue-tracker stripping the deploy action applies, so a Jira-named branch's URL was documented wrongly in two places. scripts/README.md gains the staging token and a note that the shared admin token is not a teacher.

Traps encoded in the script

Reviewers may find these the most interesting part, since each one produces a plausible-looking result rather than an error:

  • append_auth_token must be true. Otherwise the portal appends neither a token nor domain/domain_uid, CLUE falls back to preview mode, and every write lands in the permissive /demo/ tree — the smoke test passes while testing none of the rules it exists for.
  • The report URL needs its own firebaseEnv. Otherwise the teacher's CLUE reaches a different Firebase project than the students' and shows an empty report.
  • redirect_uris is one shared whitespace-separated field, rewritten whole. The script appends and then verifies nothing was dropped, so a parsing mistake can't silently replace every other deployment's URI.
  • Admin indexes are paginated. The production portal's CLUE OAuth client is on page two; a single-page scan concludes it doesn't exist.

On --dry-run

PortalSession.readOnly refuses any non-GET when the flag is set, rather than each call site checking options.dryRun. That's deliberate: the portal offers no read-only probe for "does an activity exist at this url" — the only one is update_by_url, a POST that also sets fields. An earlier revision used it during dry runs and quietly cleared append_auth_token on a real activity. Reasoning about which calls write is exactly the method that let that through, so the session enforces it instead.

The consequence is visible in the output: a dry run cannot tell whether it would create or reuse an activity, and says so rather than guessing.

Testing

  • Verified end to end against the staging portal: activity, offering and report created, report attached, redirect URI appended, all read back independently rather than trusted from the script's own output.
  • The skill was checked with subagents against the scenario and rubric in testing.md — three runs, all reaching the correct command and dry-run output. A baseline without the skill missed the class identification every time, which is the item the skill leads with.
  • Confirmed the dry-run path writes nothing: activity flags unchanged, no records created, class offering count unchanged.
  • tsc --noEmit and eslint clean on both new files. Note scripts/ is outside the repo's lint globs, so this was run against them directly.

🤖 Generated with Claude Code

Testing CLUE against the real Firebase security rules requires a launch from
a portal assignment carrying a portal token. `demo` and `qa` appMode both write
to Firebase paths whose rules amount to `if isAuthed()`, so neither exercises
them. Setting that assignment up by hand takes four coupled portal changes, each
with a trap that fails quietly.

scripts/setup-portal-assignment.ts creates or reuses the external activity, its
offering on a class, a teacher report, and the OAuth redirect URI, then reads
everything back. Every step is idempotent, so re-running after changing one flag
reuses whatever already matches. `--dry-run` reports what would change without
writing.

scripts/lib/portal-api.ts holds the portal client: the JSON API takes a bearer
token, but external reports, report attachment and OAuth clients exist only in
the admin UI, so its forms have to be driven with a CSRF token and session
cookie. It also supports the staging portal's own token, which the existing
helpers did not.

The traps the script encodes:

- `append_auth_token` must be true, or the portal appends neither a token nor
  domain/domain_uid and CLUE silently falls back to preview mode, writing to the
  permissive /demo/ tree. A smoke test would pass while testing nothing.
- The report URL needs its own firebaseEnv, or the teacher's CLUE reaches a
  different Firebase project than the students' and sees no work.
- The OAuth client's redirect_uris is one shared whitespace-separated field
  rewritten whole, so it is appended to and verified rather than replaced.
- Admin indexes are paginated. The production portal's CLUE OAuth client is on
  page two, so a single-page scan reports it does not exist.

`PortalSession.readOnly` refuses any non-GET when `--dry-run` is set. The portal
offers no read-only probe for "does an activity exist at this url" — the only
one is `update_by_url`, a POST that also sets fields — so the guard lives in the
session rather than relying on each call site to check.

Docs corrected along the way. README.md and docs/deploy.md both described branch
deploy paths without the issue-tracker stripping that the deploy action actually
applies, so a Jira-named branch's URL was documented wrongly in two places; the
stale `s3_deploy.sh` is now flagged as dead code. scripts/README.md gains the
staging token and a note that the shared admin token is not a teacher, so
endpoints like `classes/mine` return 403 by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.53%. Comparing base (d09261e) to head (1a60380).

❗ There is a different number of reports uploaded between BASE (d09261e) and HEAD (1a60380). Click for more details.

HEAD has 19 uploads less than BASE
Flag BASE (d09261e) HEAD (1a60380)
cypress-regression 14 0
cypress 5 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #2983       +/-   ##
===========================================
- Coverage   86.18%   70.53%   -15.65%     
===========================================
  Files         989      984        -5     
  Lines       56618    56594       -24     
  Branches    14953    14951        -2     
===========================================
- Hits        48794    39919     -8875     
- Misses       7804    16639     +8835     
- Partials       20       36       +16     
Flag Coverage Δ
cypress ?
cypress-regression ?
cypress-smoke 41.27% <ø> (ø)
jest 57.52% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cypress

cypress Bot commented Aug 26, 2026

Copy link
Copy Markdown

collaborative-learning    Run #20108

Run Properties:  status check passed Passed #20108  •  git commit 1a603806f8: feat: script and skill for portal assignment setup [CLUE-654]
Project collaborative-learning
Branch Review CLUE-654-portal-assignment-setup
Run status status check passed Passed #20108
Run duration 03m 36s
Commit git commit 1a603806f8: feat: script and skill for portal assignment setup [CLUE-654]
Committer Scott Cytacki
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 4
View all changes introduced in this branch ↗︎

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Automates portal assignment setup for authenticated CLUE smoke testing against real Firebase rules.

Changes:

  • Adds an idempotent portal setup script and API client.
  • Adds setup guidance and read-only verification instructions.
  • Corrects deployed branch-path documentation.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
scripts/setup-portal-assignment.ts Creates activities, offerings, reports, and redirects.
scripts/lib/portal-api.ts Adds portal API and admin-form support.
scripts/README.md Documents tokens and assignment setup.
README.md Corrects branch deployment paths.
docs/deploy.md Documents branch-name stripping rules.
.claude/skills/setting-up-portal-assignments/SKILL.md Adds portal setup guidance.
.claude/skills/setting-up-portal-assignments/testing.md Adds read-only skill verification steps.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

// The portal and the Firebase project are independent choices, but pairing them is
// almost always what is wanted: a staging portal launch that wrote to production
// Firebase would be testing the rules of a project the assignment does not belong to.
firebaseEnv: raw["firebase-env"] ?? portal,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — validProjects in src/lib/firebase-config.ts is ["staging", "production"] and currentEnvironment() falls back to production for anything else, so --firebase-env qa would have pointed a staging run at the production database while naming the resource ", qa FB". --firebase-env is now validated against a kFirebaseEnvs constant that names that source, and since the value is always one of the two, IOptions.firebaseEnv is no longer optional and the env && env !== "production" guards collapsed into one firebaseLabel() helper.

Comment on lines +89 to +102
const raw: Record<string, string> = {};
const flags = new Set<string>();
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (!arg.startsWith("--")) usage(`Unexpected argument "${arg}"`);
const key = arg.slice(2);
if (key === "help" || key.startsWith("no-") || key === "dry-run") {
flags.add(key);
} else {
const value = argv[++i];
if (value === undefined) usage(`Missing value for --${key}`);
raw[key] = value;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The parser matched on shape (key.startsWith("no-")) rather than on known names, so --no-reprot became an inert flag and --activty-id 5 swallowed its value. Options are now matched against explicit kValueOptions / kFlagOptions lists and anything else exits with Unknown option "--...".

Comment on lines +132 to +133
activityId: raw["activity-id"] ? Number(raw["activity-id"]) : undefined,
oauthClientId: raw["oauth-client-id"] ? Number(raw["oauth-client-id"]) : undefined,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Both paths were as described: activityId NaN is falsy so ensureActivity treated it as absent, and oauthClientId NaN survives ?? but fails the withReport && clientId guard, silently skipping the report and redirect. Both now go through a parseId() helper that rejects anything that is not a positive safe integer.

});
return true;
} catch (error) {
if (error instanceof PortalError && error.status === 500) return false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The risk is real but the suggested remedy is not available. In the portal (rigse), update_by_url does ExternalActivity.where(url:).first then authorize on the result; for a miss that is authorize nil, which raises Pundit::NotDefinedError — not a subclass of NotAuthorizedError, so it is unrescued. Both staging and production set consider_all_requests_local = false, so the body is the generic 500 page, identical to the one any other server-side failure renders. There is no specific nil-record response to match on.

So I addressed the failure mode instead of the signal:

  • claimActivityByUrl now retries once on a 500 before concluding "absent". A missing activity 500s every time; a transient failure usually does not repeat.
  • Before creating, ensureActivity does one more read-only search and refuses to create if it finds an activity at that URL, pointing the user at --activity-id. ExternalActivity.create has no URL uniqueness constraint, so a duplicate was the concrete bad outcome and this is the cheapest guard against it.

Neither is a proof of absence, and the comment now says so rather than implying the 500 is diagnostic.

Comment on lines +406 to +410
const updated = `${current.replace(/\s*$/, "")}\n${redirectUri}\n`;
await portal.submitForm(
`/admin/clients/${clientId}/edit`,
`/admin/clients/${clientId}`,
{ "client[redirect_uris]": updated },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The race is real, but neither remedy exists on the portal side. redirect_uris is a single text column on clients (rails/db/schema.rb), there is no append endpoint — only the admin PUT that rewrites the field whole — and there is no lock_version column anywhere in the portal schema, so ActiveRecord optimistic locking is not available either. The portal's own admin UI has the identical race when two admins edit this client.

What the code already does cover is the more damaging half: after the write it re-reads the field and throws if any URI from the pre-write snapshot is gone, and it refuses to write at all if the parsed field does not look like a list of URIs. The uncovered case is a URI added by someone else inside the read-write window of a manually-run setup script. Given no portal-side primitive to close it, I am leaving it rather than adding a retry loop that could not detect the conflict anyway. Happy to file a portal ticket for an append endpoint if that seems worth it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants