mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #806 from MBombeck/fix/demo-published-credentials
Write the published demo password on every migration
This commit is contained in:
3 files changed
+133
-1
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The demo password is published in the README so anyone can sign in to
|
||||
* demo.healthlog.dev. Its Argon2id hash lives in two places that must agree:
|
||||
*
|
||||
* - scripts/seed-demo.ts writes it when a demo tenant is seeded
|
||||
* - scripts/migrate-demo-edge01.sh overwrites it after a migration load
|
||||
*
|
||||
* Public issue #805: the migration used to carry the SOURCE account's hash
|
||||
* across instead, so once the operator rotated their own demo password the
|
||||
* credentials the README advertises stopped working, and nothing noticed until
|
||||
* a visitor filed a bug. These assertions make the two copies move together.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const SEED = join(REPO_ROOT, "scripts", "seed-demo.ts");
|
||||
const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-edge01.sh");
|
||||
|
||||
/** Every Argon2id hash literal in a file, in source order. */
|
||||
function argon2Hashes(path: string): string[] {
|
||||
const source = readFileSync(path, "utf8");
|
||||
return source.match(/\$argon2id\$[^"'\s]+/g) ?? [];
|
||||
}
|
||||
|
||||
describe("published demo password", () => {
|
||||
it("is the same hash in the seed script and the edge01 migration", () => {
|
||||
const seeded = argon2Hashes(SEED);
|
||||
const migrated = argon2Hashes(MIGRATE);
|
||||
|
||||
// An empty match set would make every assertion below vacuously true —
|
||||
// the failure mode this repo keeps rediscovering. Demand a hit first.
|
||||
expect(seeded.length).toBeGreaterThan(0);
|
||||
expect(migrated.length).toBeGreaterThan(0);
|
||||
|
||||
// Each file states the hash exactly once, so a stray second copy (the
|
||||
// thing that lets the two drift again) is a failure in its own right.
|
||||
expect(new Set(seeded).size).toBe(1);
|
||||
expect(new Set(migrated).size).toBe(1);
|
||||
|
||||
expect(migrated[0]).toBe(seeded[0]);
|
||||
});
|
||||
|
||||
it("is written by the migration rather than carried from the source", () => {
|
||||
const script = readFileSync(MIGRATE, "utf8");
|
||||
|
||||
// The overwrite must exist...
|
||||
expect(script).toMatch(
|
||||
/UPDATE users SET password_hash = '\$\{?PUBLIC_DEMO_PASSWORD_HASH\}?'/,
|
||||
);
|
||||
|
||||
// ...and it must be proven, not merely attempted. Without the check a
|
||||
// mangled hash would reach a visitor's login screen instead of the script.
|
||||
expect(script).toContain(
|
||||
"ABORT/FAIL: demo password_hash on edge01 is not the published literal",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the overwrite off the ssh command line", () => {
|
||||
const script = readFileSync(MIGRATE, "utf8");
|
||||
|
||||
// The hash carries '$argon2id', '$v', '$m', '$t', '$p'. Interpolated into
|
||||
// a double-quoted remote command those expand to empty strings and the
|
||||
// stored hash matches nothing. It has to travel on stdin.
|
||||
const sshWithHashInline = new RegExp(
|
||||
String.raw`ssh\s+"\$DST_SSH"\s+"[^"]*PUBLIC_DEMO_PASSWORD_HASH`,
|
||||
);
|
||||
expect(script).not.toMatch(sshWithHashInline);
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,17 @@ reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
|
||||
Forced flags: `telegram_enabled=false`,
|
||||
`role='ADMIN'`.
|
||||
|
||||
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
|
||||
**Overwritten:** `password_hash`. The row arrives carrying the source
|
||||
account's hash, and the load is immediately followed by an `UPDATE` that sets
|
||||
the Argon2id hash of the demo password the README publishes, then asserts the
|
||||
stored value matches it byte for byte and aborts if it does not. The source is
|
||||
the operator's own instance, so its demo password can be rotated at any time;
|
||||
carrying that hash across is what silently broke the advertised demo login in
|
||||
issue #805. The statement travels on psql's stdin, never inside the ssh command
|
||||
string, because the hash contains `$argon2id` / `$v` / `$m` / `$t` / `$p`
|
||||
segments that a remote shell would expand to nothing.
|
||||
|
||||
**Kept:** `avatar_bytes` /
|
||||
`avatar_content_type` / `avatar_updated_at` (profile photo), and all
|
||||
display / preference / layout JSON (`thresholds_json`,
|
||||
`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`,
|
||||
|
||||
@@ -102,6 +102,23 @@ readonly SRC_DB_CONTAINER
|
||||
readonly PG_USER="healthlog"
|
||||
readonly PG_DB="healthlog"
|
||||
|
||||
# Argon2id hash of the demo password the README publishes ("demo123demo123").
|
||||
# Not a secret: it is printed in the README so anyone can sign in to the demo,
|
||||
# and it is the same literal that scripts/seed-demo.ts writes on a fresh seed.
|
||||
# The transfer still carries the source column, but the load is followed by an
|
||||
# explicit overwrite with this literal (see "FORCE THE PUBLISHED DEMO PASSWORD"
|
||||
# below). The source is the operator's own instance and its demo password can
|
||||
# be rotated at any time; carrying it across silently invalidated the published
|
||||
# credentials once already (public issue #805). Overwriting afterwards makes the
|
||||
# documented login true by construction after every migration.
|
||||
#
|
||||
# The overwrite travels on psql's STDIN, never inside the ssh command string:
|
||||
# the hash contains '$' segments that a remote shell would expand away.
|
||||
#
|
||||
# scripts/__tests__/demo-published-password-guard.test.ts pins this literal to
|
||||
# the one in scripts/seed-demo.ts, so editing one without the other fails.
|
||||
readonly PUBLIC_DEMO_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI'
|
||||
|
||||
# psql invocation builders. -v ON_ERROR_STOP=1 makes any SQL error a non-zero
|
||||
# exit so `set -e` aborts the pipeline. -X skips ~/.psqlrc.
|
||||
psql_src() {
|
||||
@@ -466,6 +483,38 @@ copy_table() {
|
||||
copy_table "users" "$USERS_SELECT" \
|
||||
"users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# FORCE THE PUBLISHED DEMO PASSWORD
|
||||
# ----------------------------------------------------------------------------
|
||||
# The row just loaded carries the SOURCE account's password_hash. The source is
|
||||
# the operator's own instance, so that hash is whatever the operator last set —
|
||||
# not necessarily the password the README publishes. Overwrite it here so the
|
||||
# documented demo login works after every migration, not just the first one.
|
||||
#
|
||||
# The statement goes in on psql's STDIN. It must NOT be interpolated into the
|
||||
# ssh command string: the Argon2id hash contains '$argon2id', '$v', '$m', '$t'
|
||||
# and '$p' segments, and a remote shell would expand each of those to an empty
|
||||
# string and store a mangled hash that no password can ever match.
|
||||
echo
|
||||
echo "## FORCE published demo password on the loaded row ..."
|
||||
printf '%s\n' \
|
||||
"UPDATE users SET password_hash = '$PUBLIC_DEMO_PASSWORD_HASH' WHERE id = '$DEMO_USER_ID';" \
|
||||
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \
|
||||
psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB"
|
||||
|
||||
# Prove the stored hash is byte-for-byte the published one. A mangled or
|
||||
# carried-over hash fails here instead of at some visitor's login screen.
|
||||
PW_OK="$(printf '%s\n' \
|
||||
"SELECT count(*) FROM users WHERE id = '$DEMO_USER_ID' AND password_hash = '$PUBLIC_DEMO_PASSWORD_HASH';" \
|
||||
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \
|
||||
psql -X -tA -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" | tr -d '[:space:]')"
|
||||
if [[ "$PW_OK" != "1" ]]; then
|
||||
echo "ABORT/FAIL: demo password_hash on edge01 is not the published literal (matched rows: $PW_OK)."
|
||||
echo " The demo login would be broken. Fix before serving the demo."
|
||||
exit 1
|
||||
fi
|
||||
echo " demo password_hash matches the published literal."
|
||||
|
||||
# 2. measurements (live rows only).
|
||||
copy_table "measurements" "$MEAS_COLS" \
|
||||
"measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS"
|
||||
|
||||
Reference in new issue
Block a user