ALL 127 SECURITY TESTS PASSED — executed against PostgreSQL 17.11

Security Testing

What was tested, how to re-run it yourself, the complete register of results, and an explicit list of the things that were not tested.

Rule applied throughout this project No test is described as passing unless it was actually executed and its result recorded. Everything in section 4 ran against a real PostgreSQL server; everything in section 6 did not run, and is listed as such.
Database assertions
127 / 127
Suites
11
Tables with RLS
21 / 21
Policies
47
Anon table grants
0
Browser UI checks
34 / 34

1. How to re-run everything yourself

Database security suite

cd trading-academy
sudo bash tests/run.sh

The runner creates a scratch database, installs a small local emulation of the Supabase auth schema (tests/00_shim.sql), applies supabase/schema.sql unmodified, loads fixtures, and executes 127 assertions. The last line is either ALL 127 SECURITY TESTS PASSED or a table of failures. Results are written to tests/last-run-results.txt.

Never run tests/00_shim.sql against your real Supabase project. It fabricates an auth schema so the production schema can be tested offline. Supabase already has a real one. The runner only ever touches a local scratch database.

How a single assertion works

Each test switches to a specific user with tests.login(), runs one statement, and records what happened — inside a sub-transaction that is always rolled back, so tests cannot contaminate one another:

-- A student must not be able to promote themselves.
select tests.login('student1');
select tests.run(
  'B. Role escalation',
  'student cannot promote self to admin',
  $$update public.profiles set role = 'admin' where id = tests.uid('student1')$$,
  'denied');

The fourth argument is the expected outcome: denied, allowed, or an exact row count. A statement that silently affects zero rows counts as denied; one that raises 42501 also counts as denied; one that succeeds when it should not is a failure.

A trap worth knowing about An earlier version wrapped each suite in begin; … rollback;. The rollback discarded the result rows as well as the test effects, so 8 of 11 suites vanished from the report while the summary line still printed green. If you adapt this harness, always check the assertion count, not just the summary.

Browser interaction checks

Serve the folder and drive it with Playwright. The repository does not vendor a browser; install one on demand:

python3 -m http.server 8080 &
npm i playwright && npx playwright install chromium
# then drive http://localhost:8080 — see section 5 for what was covered

2. Static checks on the delivered code

CheckCommandResult
No service-role key, secret key or database URL in anything the browser downloads grep -rniE "service_role|sb_secret|SUPABASE_SERVICE|DATABASE_URL" js/ *.html 0 matches
No hard-coded password in the frontend (other than the documented offline demo password) grep -rniE "password\s*[:=]\s*\"[^\"]{4,}" js/*.js 0 matches
Every public table has RLS enabled pg_tables.rowsecurity21 / 21
Policy countpg_policies47
Anonymous role holds no table privilege information_schema.role_table_grants0 grants
Every SECURITY DEFINER function pins search_path pg_proc.proconfig28 / 28
bootstrap_first_admin is not executable by browser roles explicit REVOKE + suite B testrevoked
Schema applies cleanly and is idempotent applied twice to a fresh databaseexit 0 both times
Both seed scripts apply cleanly and are idempotent each applied twice to a fresh databaseno duplicate rows
All 36 lessons have a matching database answer key left join in seed_curriculum.sql0 missing
Every JavaScript file parsesnode --check js/*.js22 / 22

3. Coverage by suite

SuiteWhat it provesAssertions
A — AnonymousA signed-out visitor reads nothing and can call no RPC6
B — Role escalationNobody can raise their own privileges; admins can do their job; the bootstrap function is unreachable from a session10
C — Profile visibilityPeople see only the people they legitimately share a class or school with9
D — Classes & enrolmentClass membership and teacher authorisation gate everything; ID guessing fails; only admins enrol14
E — AssignmentsDrafts hidden, per-student targeting honoured, only authorised teachers may author16
F — SubmissionsOwnership enforced on write, visibility scoped on read, no submitting as somebody else14
G — GradesUnreleased grades invisible to students; only class teachers may grade; no self-grading12
H — Private learner dataJournals, paper trades and practice attempts are readable by their owner and nobody else — including admins10
I — Quiz integrityAnswer keys unreadable, scores computed server-side, forged scores rejected15
J — Admin surface & auditAdmin powers stop at the school boundary; audit rows cannot be altered or deleted by anyone14
K — Class join codesCodes validated in the database, revocation is immediate, a code enrols only the caller7

4. Full result register

Every assertion, exactly as recorded in tests/last-run-results.txt by the last run.

AssertionResult
A. Anonymous / unauthenticated 6 assertions
anon cannot read profilesPASS
anon cannot read classesPASS
anon cannot read gradesPASS
anon cannot read assignmentsPASS
anon cannot call submit_quiz RPCPASS
anon cannot call admin_create_class RPCPASS
B. Role escalation 10 assertions
student cannot promote self to adminPASS
student cannot promote self to teacherPASS
student cannot activate own pending statusPASS
student cannot attach self to a schoolPASS
teacher cannot promote self to adminPASS
student cannot change another user's rolePASS
admin cannot touch another school's userPASS
admin CAN activate a pending user (allowed)PASS
student cannot execute bootstrap_first_adminPASS
student may edit own display name (allowed)PASS
C. Profile visibility 9 assertions
student sees own profilePASS
student cannot see unrelated student in other classPASS
student cannot see a student at another schoolPASS
student can see their own teacherPASS
teacher can see enrolled studentPASS
teacher cannot see student of unrelated classPASS
teacher cannot see other school's studentPASS
admin sees all profiles in own schoolPASS
admin sees NO profiles from another schoolPASS
D. Class visibility & enrollment 14 assertions
student sees enrolled classPASS
student cannot see unenrolled class (ID guess)PASS
student cannot see other school class (ID guess)PASS
teacher sees only authorized classPASS
admin sees own school classesPASS
admin cannot see other school classesPASS
student cannot self-enroll into a classPASS
student cannot enroll a friendPASS
teacher cannot grant self a new classPASS
teacher cannot enroll students (admin-only)PASS
student cannot create a classPASS
teacher cannot create a class (admin-only)PASS
admin CAN create a class (allowed)PASS
admin cannot create class in another schoolPASS
E. Assignment access control 16 assertions
enrolled student sees published assignmentPASS
student cannot see DRAFT assignment (ID guess)PASS
student cannot see other class assignment (ID guess)PASS
student3 cannot see class1 assignment (ID guess)PASS
other-school student cannot see assignmentPASS
targeted assignment visible to the targetPASS
targeted assignment hidden from non-target classmatePASS
student cannot create an assignmentPASS
student cannot publish the draft examPASS
student cannot delete an assignmentPASS
teacher CAN create assignment in own class (allowed)PASS
teacher cannot create assignment in unrelated classPASS
teacher cannot create assignment in another schoolPASS
teacher cannot edit unrelated class assignmentPASS
teacher cannot move assignment into unrelated classPASS
teacher cannot target a non-enrolled studentPASS
F. Submissions 14 assertions
student sees own submissionPASS
classmate cannot read another student's submissionPASS
unrelated student cannot read submissionPASS
authorized teacher reads class submissionsPASS
unrelated teacher cannot read submissionsPASS
student CAN submit to a visible assignment (allowed)PASS
student cannot submit to unrelated assignmentPASS
student cannot submit to a draft assignmentPASS
student cannot forge submission as another studentPASS
student cannot edit someone else's submissionPASS
student cannot revert submitted work to draftPASS
student cannot edit already-submitted contentPASS
student cannot delete a submitted attemptPASS
late submission blocked when allow_late = falsePASS
G. Grades & feedback 12 assertions
student cannot see UNRELEASED gradePASS
authorized teacher sees the gradePASS
unrelated teacher cannot see the gradePASS
classmate cannot see another student's gradePASS
student cannot award themselves pointsPASS
student cannot insert a grade rowPASS
unrelated teacher cannot grade the submissionPASS
authorized teacher CAN release the grade (allowed)PASS
unrelated teacher cannot bulk-release gradesPASS
student cannot bulk-release their own gradesPASS
student sees grade AFTER teacher releases itPASS
classmate still cannot see released gradePASS
H. Private learner data 10 assertions
student reads own trading journalPASS
classmate cannot read the journalPASS
teacher cannot read a student journalPASS
admin cannot read a student journalPASS
student cannot write a journal entry for someone elsePASS
student cannot forge a paper trade for someone elsePASS
student cannot read another student's paper tradesPASS
student cannot read another student's trading accountPASS
student cannot trade on another student's accountPASS
student CAN trade on own account (allowed)PASS
I. Quiz integrity 15 assertions
student cannot read quiz answer keysPASS
teacher CAN read answer keys (allowed)PASS
student cannot tamper with an answer keyPASS
student cannot insert a lessonPASS
student CAN score a quiz via RPC (allowed)PASS
official quiz RPC auto-grades for an enrolled student (allowed)PASS
...and the auto-graded submission existsPASS
...and the student can see the released auto-gradePASS
...and a classmate cannot see that auto-gradePASS
quiz RPC refuses an unrelated assignment idPASS
student cannot forge a quiz attempt for a classmatePASS
student cannot rewrite history of quiz attemptsPASS
student cannot read classmate progressPASS
teacher CAN read own student's progress (allowed)PASS
unrelated teacher cannot read that progressPASS
J. Admin surface & audit 14 assertions
student cannot read audit logsPASS
teacher cannot read audit logsPASS
admin CAN read own school audit logs (allowed)PASS
admin cannot read another school's audit logsPASS
student cannot call admin_create_classPASS
teacher cannot call admin_create_classPASS
student cannot call admin_invitePASS
teacher cannot invite themselves as adminPASS
student cannot read school reportPASS
admin CAN read school report (allowed)PASS
unrelated teacher cannot read class analyticsPASS
authorized teacher CAN read class analytics (allowed)PASS
student cannot transfer themselves between classesPASS
student cannot read invitationsPASS
K. Class join codes 7 assertions
student cannot enable a class join codePASS
teacher CAN rotate own class code (allowed)PASS
invalid class code is rejectedPASS
revoked/inactive class code is rejectedPASS
valid code enrolls a pending student (allowed)PASS
teacher cannot join a class with a codePASS
other-school student cannot use the codePASS

5. Browser interaction checks

Driven with Playwright against Chromium on the real application, in offline demo mode, signing in as genuine student, teacher and administrator accounts. These exercise the user interface; the database suite above is what proves the permissions.

Route coverage — 34 / 34 rendered with no console error

RoleRoutes exercisedResult
Studentdashboard, classes, assignments, grades, academy, practice, trading, journal, tools, progress, settings11 / 11
Teacherdashboard, classes, assignments, new, submissions, gradebook, announcements, analytics, academy, practice, trading, tools, settings13 / 13
Admindashboard, users, teachers, students, classes, enrollments, assignments, reports, audit, settings10 / 10

Mathematics suite — 195 assertions, 0 failures, 0 console errors

Run on 15 October 2026 with Chromium 1134 via playwright-core@1.47.2. The four files are in tests/browser/ and tests/browser/README.md explains how to re-run them.

FileWhat it exercisesResult
01-math-pages.js Sign-in, Mathematics nav group, catalogue (16 cards), course page (9 units + KPIs), unit page, unit-scoped practice, practice runner: wrong answer rejected, hint, worked solution on skip, correct answer accepted, attempt written to the backend, attempt present in the append-only log, progress survives navigation, grapher renders and re-renders, invalid syntax flagged inline, teacher refused an unrelated student, and the multiple-choice runner: four options rendered and lettered A–D, the text box hidden, clicking an option selects without grading, the right option accepted and highlighted, options locked afterwards, a wrong option refused, an empty submission asking for a pick rather than scoring a miss, text items keeping their input, and Geometry / Calculus / Competition units now offering practice 46 / 46
02-lessons.js Unit lesson list with written/outline badges, a written lesson end to end (objective, worked examples, steps, inline MathML, mounted SVG figure with a drawn curve, key idea, common mistake, vocabulary, practice), previous/next navigation, the honest notice on an unwritten lesson, standards still listed there, out-of-range lesson index, light theme 21 / 21
03-teacher-report.js A student answers three items for real, then a teacher opens Class Progress and sees them: KPI row, per-student table, class switcher. A student navigating to #/mathclass is refused and has no link to it. 11 / 11
04-all-skills.js Renders every one of the 150 practice skills three times each in the live browser (450 generated items) and checks each one states a prompt, is actually answerable (options for a choice item, a visible box otherwise), raises no MathML error span, ships a worked solution, and has an answer key the in-page grader marks correct. Also asserts all 102 catalogue units map to at least one skill. 9 / 9
05-features.js Command palette: opens on Ctrl K, lists only the pages the signed-in role's own sidebar rendered (a student is not offered Gradebook), ranks courses / units / skills / formulas, folds accents so lhopital finds L'Hôpital, navigates on Enter, closes on Esc, g r jumps to Review and ? opens the shortcut sheet. Worksheet builder: sheet plus answer key, problem count, working space, steps toggle, and seed determinism — a new seed changes the whole sheet, the old seed reproduces it byte for byte; every unit offered as enabled in Algebra 1, Geometry and AP Statistics actually fills a sheet. Formula reference: 192 entries in 9 sections, >120 rendered as real MathML with no error spans, keyword search, subject tabs, empty state, and the palette landing on a pre-searched formula. Roles: a teacher can build worksheets and read the standards map but is refused #/review. 36 / 36
06-review.js Review queue from an empty start (true empty state, disabled button), then seven skills answered for real through API.math.record. Asserts the exact bucket counts (2 due, 3 under 70 %, 5 scheduled, 143 untouched), that the two five-in-a-row skills read as held, that a one-day item says “tomorrow” and a mastered one “in 21 days”, that Start review drops you into a real practice item, and that the Leitner ladder is exactly 0/1/2/4/7/21 days. Standards map: >150 codes grouped into domains, tinted by that same history, the summary separating codes referenced from the catalogue count, and the per-standard dialog. 23 / 23
07-secrets.js Hidden panels: one code, one switch, one page. Nothing is advertised before the code — #/secrets says only “nothing here”. The Konami code unlocks it, adds a single nav entry, and opens a four-tab page. The terminal does real work (eval 2^10 + sqrt(144) = 1036, an identity reported as equivalent, factor 1729 = 7 × 13 × 19, a generated item graded, unknown commands rejected) and states that the role is enforced by the database, not the prompt; graph hands a function to the grapher. The arcade lights its 32 turn pips. The Lore tab explains the 32, the 1729 and the $0. The old #/terminal and #/arcade addresses redirect into the right tab. The switch in Settings hides the nav entry and every panel, says it is off rather than gone, restores instantly, and does not strand you if you flip it while standing inside. The code does not fire from inside a form field, and a fresh browser profile starts locked again. 49 / 49
What this suite does not prove. It runs against the offline demo backend, so it demonstrates that the interface works and calls the right API methods. It does not exercise the RLS policies in supabase/schema-math.sql — those need a real Postgres and were not run. See section 6.

End-to-end workflows — 26 scenarios, 34 assertions, 0 failures

CheckResult
Lesson page renders its chart figurePASS
Interactive exercise marks an answer and explains itPASS
Lesson quiz has five questions and scores correctlyPASS
Per-answer explanations appear for all five after submittingPASS
Draft saves to the backend and reports the save timePASS
Submitting changes the status and timestamps itPASS
Teacher sees the new submission in the grade queuePASS
Grading dialog shows the student's actual workPASS
Grade saves, releases, and reaches the student's Grades pagePASS
Gradebook matrix builds from real gradesPASS
Teacher creates and publishes an assignment; student sees itPASS
Teacher posts an announcementPASS
Class code rotates; the new code enrols a studentPASS
A revoked / unknown class code is rejected with a clear messagePASS
Paper-trading order fills and changes cash, equity and positionsPASS
Advancing the simulated clock moves prices and evaluates stopsPASS
Journal entry is created and the statistics recomputePASS
Position-size calculator returns the arithmetically correct answerPASS
Admin approves a pending signupPASS
Audit log is populated and visible to the adminPASS
Student is refused the teacher gradebook routePASS
Student is refused the admin audit routePASS
Guessing another class's id returns a refusal, not dataPASS
Light theme toggles and every chart repaints legiblyPASS
Mobile 390 px: hamburger appears and the drawer opensPASS
All 37 chart figures render with non-blank pixels and no errorPASS

Curriculum checks (headless Node)

CheckResult
36 lessons, 0 structural errors, 0 duplicate slugsPASS
Level distribution 8 / 5 / 6 / 5 / 6 / 6PASS
Every lesson has a visual aid36 / 36
Every referenced chart id has a builder36 / 36
Every quiz has exactly 5 questions, 4 options and an explanation per answerPASS
All 19 numeric exercises accept the intended answer and reject wrong onesPASS
Next-lesson chain is correct and terminatesPASS
Market engine: 0 invalid bars across 13 symbols × 5 timeframes; deterministicPASS

6. Tests that were NOT run

These were not performed. Do not treat them as passing.
Not testedWhyWhat to do about it
The policies running on a real hosted Supabase project No Supabase account was created for this build. The identical schema.sql was exercised on PostgreSQL 17.11 with an emulated auth schema. After deploying, re-run the checklist in section 7 with two real browser sessions.
Supabase Auth itself — signup, confirmation email, password reset, JWT expiry, refresh Auth is a hosted service and was not reachable from the build environment. Walk through steps 12–14 and the password-reset flow in SETUP.html.
supabase/schema-math.sql — its 8 RLS policies, 3 guard triggers and 4 functions Not executed against any PostgreSQL instance. The scratch database used for the original 127 assertions was lost when the environment reset, and sudo was broken in this session, so Postgres could not be restarted. The file was written by following the patterns in schema.sql and reviewed by reading, nothing more. Run it on your project and check the NOTICE reads 3 / 3 tables, 8 / 8 policies, 5 / 5 functions. Then sign in as a teacher who does not teach a student and confirm select * from math_skill_progress returns none of that student's rows.
Whether a student can forge a correct answer Not tested, because it is a known and accepted property, not a bug. Practice is graded in the browser, so math_record_attempt(p_correct => true) can be called directly from the console. Nothing — but never use practice figures for grades. Graded work goes through submit_quiz(), which scores inside Postgres.
Supabase Storage permissionsNo bucket is used; file attachments are not implemented. Not applicable unless you add uploads — in which case write bucket policies first.
Rate limiting and brute-force resistance No custom limiting is implemented; only Supabase's gateway defaults apply. If the deployment is public, add an Edge Function or a Cloudflare rule.
Load and concurrencyCorrectness was the objective, not throughput. A class of 30 submitting at once is well inside the free tier, but it was not measured.
Formal accessibility audit (screen reader, full WCAG pass) Keyboard navigation, focus traps, aria-live regions, landmarks and colour contrast were implemented and spot-checked, but no assistive technology was driven end to end. Test with NVDA or VoiceOver before using it with students who rely on one.
Browsers other than ChromiumOnly Chromium was available in the build environment. The code uses no Chromium-only API, but check Safari and Firefox before rollout.
Penetration testing by a third partyOut of scope. Recommended before holding anything sensitive.

7. Manual checklist for your own deployment

Do this after step 27 of the setup guide, with two different browsers open side by side.

  1. Sign in as a student in browser A and as a teacher in browser B.
  2. As the teacher, grade a submission but do not release it. Reload the student: the grade must not appear anywhere, including the CSV export.
  3. Open the browser console as the student and run await API.grades.all() — the unreleased grade must not be in the result.
  4. As the student, run await API.raw.from('profiles').update({role:'admin'}).eq('id',(await API.me()).id). It must return an error.
  5. As the student, run await API.raw.from('lesson_answer_keys').select('*'). It must return zero rows.
  6. As the student, run await API.raw.from('trading_journals').select('*') and confirm every row belongs to them. Repeat as the teacher and as the admin: both must see only their own.
  7. Copy a class id the student is not enrolled in and visit #/classes/<that-id>. You must get the refusal page.
  8. Rotate a class code, then try to join with the old one. It must fail.
  9. Sign out entirely and call the REST API with only the publishable key. Every table must return an empty result or an error.
  10. Open Audit Logs as the admin and confirm your role changes, enrolments and grade releases are all recorded.

If any of these behaves differently from the description, stop and investigate before putting real student data in the system.