diff --git a/.claude/commands/migrate-to-sqlite.md b/.claude/commands/migrate-to-sqlite.md deleted file mode 100644 index 93be15f5..00000000 --- a/.claude/commands/migrate-to-sqlite.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Migrate production RDS database to local SQLite ---- - -Migrate the production PostgreSQL database to local SQLite for development. - -Steps: -1. Navigate to backend directory -2. Run the migration script: `python scripts/migrate_rds_to_sqlite.py` -3. Verify the migration completed successfully -4. Note that all user passwords will be reset to 'pass' - -The script will: -- Export data from production RDS using AWS SSM credentials -- Clean and process data (remove leaderboard entries, reset passwords) -- Create fresh SQLite database with migrations -- Import cleaned data -- Backup existing db.sqlite3 before replacing diff --git a/.claude/commands/sync-db.md b/.claude/commands/sync-db.md index 230f7859..15576c1b 100644 --- a/.claude/commands/sync-db.md +++ b/.claude/commands/sync-db.md @@ -1,55 +1,77 @@ --- -description: Sync production database to local/dev environment +description: Sync production database to local development --- -Run the database migration script to sync production data to local development environment. +Put a copy of the production database in local `db.sqlite3`: -## Prerequisites -- Virtual environment must be activated: `source backend/env/bin/activate` -- AWS CLI configured with Parameter Store access -- Docker installed (for database operations) +```bash +cd backend +source env/bin/activate +python scripts/sync_prod_to_sqlite.py +``` -## Script Location -`backend/scripts/migrate-prod-to-dev.sh` +Roughly 30 minutes. Requires Docker running and AWS credentials for Parameter +Store. Every user password becomes `pass`. The existing `db.sqlite3` is renamed +to `db.sqlite3.backup_` first — those are ~1.6GB each, so prune them. -## Usage Options +Flags for resuming after a failure: -### Download Production Database Only (Safest) -```bash -cd backend/scripts -./migrate-prod-to-dev.sh --download -``` -Downloads production database to `backend/backups/` without making any local changes. +- `--reuse-dump` — skip the pg_dump, use the newest `backups/*.sql` +- `--reuse-postgres` — the `tally-local-pg` container already holds the data +- `--keep-container` — leave Postgres up (`docker start tally-local-pg` to reuse) +- `--keep-json` — keep the intermediate `backups/prod_snapshot.json` (a full + production fixture; delete it manually when done) +- `--no-leaderboard` — skip the leaderboard rebuild -### Upload Latest Dump to Dev Database -```bash -cd backend/scripts -./migrate-prod-to-dev.sh --upload -``` -Restores the most recent backup file to development database. +Verified end to end on 2026-08-01: 16 minutes with the dump already local, +0 dangling foreign keys, 56,304 users, 106,937 contributions. -### Run Django Migrations and Create Admin User Only -```bash -cd backend/scripts -./migrate-prod-to-dev.sh --setup -``` -Runs migrations and creates/updates admin user (`dev@genlayer.foundation` / `password`). +## Do not use the other two scripts -### Full Migration (Download + Upload + Setup) -```bash -cd backend/scripts -./migrate-prod-to-dev.sh -``` -Complete workflow: download production data, restore to dev, run migrations, and create admin. - -## What It Does -1. Fetches production database credentials from AWS Parameter Store -2. Downloads production PostgreSQL database using Docker (matching version) -3. Restores to development database (local or AWS dev instance) -4. Runs Django migrations -5. Creates/updates admin user with Steward role - -## Notes -- Backups are saved to `backend/backups/` with timestamps -- Uses Docker to avoid PostgreSQL version mismatch issues -- See `backend/scripts/README.md` for detailed documentation and troubleshooting +`scripts/migrate_rds_to_sqlite.py` runs `dumpdata` straight against production +RDS. Django emits a query per row for many-to-many fields, so over a remote link +it manages about 90 user rows per minute — a 3.5 hour run did not finish the +users table, and production has ~56k users. + +`scripts/migrate-prod-to-dev.sh` targets a **PostgreSQL** database (the shared +AWS dev instance), not local SQLite. Local Django uses SQLite unless +`DATABASE_URL` is set, so it is not the local-development path. Its upload step +is untested here. + +## Why the working script is shaped the way it is + +Each stage is scar tissue from a real failure; do not "simplify" them away: + +1. **pg_dump, not dumpdata, against production** — one streamed dump takes + minutes instead of never finishing. +2. **Explicit Docker `--platform`** — a cached amd64 `postgres:17` on Apple + Silicon fails with `exec format error`. +3. **Migrate the local Postgres copy before exporting** — production's schema + lags the code, so `dumpdata` otherwise fails on columns that exist only in + the models (it died on `ethereum_auth_pendingwalletsignup.acquisition_campaign_link_id`). +4. **Do not exclude contenttypes/auth.permission from the export** — the m2m + rows reference production's permission ids; a freshly migrated database + generates different ones, and `loaddata` then fails its foreign-key check at + commit, rolling back the entire load. +5. **Clear every table except `django_migrations` before loading** — data + migrations seed rows that collide with the snapshot on natural keys such as + `projects.Project.slug`. +6. **Suppress model signals during the load** — see the known bug below. +7. **Rebuild the leaderboard afterwards** — leaderboard entries are excluded + from the export, so it is empty until `manage.py update_leaderboard` runs. + +## Known bug this works around + +`contributions/models.py` `sync_contribution_discord_xp_state` and +`sync_social_task_completion_discord_xp_state` do not check +`kwargs.get('raw')`, so a fixture load recreates every +`ContributionDiscordXPState` and collides on `contribution_id` at the first row. +`users/signals.py` `create_referral_code` and `poaps/signals.py` +`attach_legacy_poap_claims` have the same gap on User. + +Neighbouring receivers guard correctly — `ensure_validator_profile_for_graduation_contribution` +in the same file, and `update_leaderboard_on_contribution` in +`leaderboard/models.py`, whose comment reads "Skip during fixture loading +(loaddata) to avoid ordering issues". The real fix is a one-line +`if kwargs.get('raw', False): return` in each of the four. Until that lands, the +sync script suppresses signals for the duration of the load. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b94873e..1652aad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable user-facing changes to this project will be documented in this file. ## Unreleased +- Validators can link Telegram support groups to their validator: generate a one-time code on the new Telegram Support page, paste it in a Telegram group with the Deckard support bot, and the group is bound to the validator (multiple groups supported, codes expire in 48 hours and can be revoked) (0cd7e5f) + +- Marketing can create campaign links like portal.genlayer.foundation/join/builders/ethcc from the admin panel without a deployment; each link tracks visits, signups, and role activations per campaign, campaign traffic reaches Google Analytics with clean final URLs, and new accounts are attributed to the campaign that brought them (810bdc32) + - Notification bodies in the navbar dropdown now stop at 120 characters with an ellipsis while keeping their formatting and links (b4815f48) - Builder submissions now pass through AI review before standard steward review, high-point acceptances are escalated as proposals to top-level stewards, and apex stewards have a focused queue for accepted contributions marked interesting. Appeals and more-information resubmissions remain visible to both AI review stages. diff --git a/amplify.yml b/amplify.yml index 7453a254..56c8c5bf 100644 --- a/amplify.yml +++ b/amplify.yml @@ -25,6 +25,13 @@ applications: VITE_APP_NAME: Tally VITE_VALIDATOR_RPC_URL: https://rpc.testnet-chain.genlayer.com customRules: + # Campaign vanity links: reverse-proxy the reserved /join/ namespace to + # the Django resolver. Must stay BEFORE the SPA catch-all (rules apply + # in order). One dynamic rule for all campaigns; never add per-campaign + # rules here. + - source: '/join/<*>' + target: 'https://tally-backend.33qpgck0g28d0.us-east-1.cs.amazonlightsail.com/campaigns/redirect/<*>' + status: '200' - source: '' target: '/index.html' status: '200' diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 30b070c3..133fb126 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -27,13 +27,14 @@ backend/ ├── api/ # Core API app ├── contributions/ # Contribution tracking ├── leaderboard/ # Leaderboard and rankings -├── social_connections/ # OAuth (GitHub, Twitter, Discord) + encrypted token storage +├── social_connections/ # OAuth (GitHub, Twitter, Discord) + encrypted token storage + Telegram (bot-confirmed, no OAuth) ├── social_tasks/ # Repeatable social tasks (follow, join, like) and completions ├── users/ # User management and auth ├── partners/ # Ecosystem partners directory ├── gen_tv/ # Gen TV livestream index ├── notifications/ # Portal notification system ├── service_accounts/ # Machine identities + scoped bearer tokens (AI review agent) +├── campaigns/ # Marketing vanity links + campaign acquisition attribution ├── utils/ # Shared utilities └── tally/ # Django project settings (settings.py, urls.py) ``` @@ -117,9 +118,24 @@ backend/ - **Models**: `ServiceAccount` (name, description, is_active; acts as the DRF request principal; it is NOT a User and can never become a session) and `ServiceAccountToken` (unique non-secret `identifier`; unique SHA-256 `digest` of the plaintext, which is never stored; `scopes` list; `expires_at`/`revoked_at`/`last_used_at`, with `last_used_at` writes throttled to once per minute). - **Auth class**: `service_accounts.authentication.ServiceAccountAuthentication` parses `Bearer sa__`, looks up the token by non-secret id, compares digests in constant time, rejects expired/revoked/inactive with a generic 401. Non-`sa_` credentials pass through to other authenticators. - **Permission**: `service_accounts.permissions.HasServiceAccountScope`; views declare `required_scopes = {'': '', '*': ''}`. +- **Scopes** (`service_accounts/scopes.py`): `ai_review:read`, `ai_review:propose` (AI review agent), `telegram_bind:redeem` (Deckard Telegram bot redeeming validator group bind codes). New scopes must be added to `ALLOWED_SERVICE_ACCOUNT_SCOPES` or admin token issuance rejects them. - **Issue a token**: Django admin Service account change page -> "Issue token", or `python manage.py issue_service_account_token --scopes ... [--expires-days N]`; plaintext is shown exactly once. Rotate = issue new + revoke old (admin action on Service account tokens); kill switch = deactivate the account. - **Tests**: `service_accounts/tests/`; test helper `service_accounts.testing.service_account_auth_headers()` returns client auth kwargs. +### Campaigns (Marketing Vanity Links + Attribution) + +- **App**: `campaigns/`. Marketing creates campaigns and role links in Django admin; no deploy per campaign. Public URL contract: `{FRONTEND_URL}/join//`, reverse-proxied by an Amplify rule (`amplify.yml`, before the SPA catch-all) to `GET /campaigns/redirect//`. +- **Models** (`campaigns/models.py`): + - `MarketingCampaign` - name, unique `tracking_key` (published as utm_campaign, readonly after create), date window, `is_active`, `created_by`. + - `CampaignLink` - FK campaign, server-generated immutable `tracking_id` (published as utm_id), role, alias (UNIQUE role+alias, locked after create), `destination_path` (validated relative portal path: allowlist + reserved-prefix rejection in `validate_destination_path`, re-run by the resolver so corrupt data fails closed), required utm_source/utm_medium, optional content/term, optional window overrides. `redirect_target` builds the UTM query from stored fields only. + - `CampaignRedirectHit` - append-only resolver request log with UA bot classification (`services.classify_user_agent`, substring list). Privacy: never store IPs, full referrers, full UAs, wallets, emails. Retention via `python manage.py purge_campaign_hits [--days N] [--dry-run]` (default `CAMPAIGN_HIT_RETENTION_DAYS=90`). + - `UserAcquisitionAttribution` - write-once first-touch signup attribution (OneToOne user). Keeps immutable snapshot columns (campaign_key/source/medium/...) alongside the SET_NULL link FK so history survives campaign edits/deletes. +- **Resolver** (`campaigns/views.py:campaign_redirect`): anonymous GET/HEAD, throttle scope `campaign_redirect` (120/min), 302 + `Cache-Control: no-store` (never 301), 404 unknown/inactive/future, 410 expired, hit-log failure never blocks the redirect. Forwards ONLY allowlisted ad click IDs (`CLICK_ID_FORWARD_PARAMS`: gclid/gbraid/wbraid/fbclid/twclid/msclkid/ttclid, length-capped) from the request onto the destination; never a request-supplied destination. +- **Attribution flow** (mirrors the referral_code pattern): frontend sends `attribution: {utm_id, landing_path, captured_at}` in the `/api/auth/login/` body → `services.apply_pending_attribution` resolves the link server-side (never trusts browser UTM text; unknown/expired IDs silently ignored; first touch never overwritten; expired pending reuse resets stale data; window `CAMPAIGN_ATTRIBUTION_WINDOW_DAYS=30`) and writes `PendingWalletSignup.acquisition_*` → email confirm calls `services.record_user_acquisition` inside the signup transaction (internally savepointed: an attribution failure can never abort user creation). Attribution must never make signup fail. +- **Activation reporting**: `services.campaign_report(campaign)` returns Portal-DB-only funnel counts (redirect hits human/bot, attributed wallet connects, signups, distinct-user activations: builder = first builder-category SubmittedContribution any state, validator = `validator-waitlist` Contribution, community = completion of a `SocialTask` with `counts_as_activation=True`). Rendered on the campaign admin change page; a future internal-dashboard staff API should wrap this same service. +- **Admin**: `Marketing` group (data migration `campaigns/0002`) gets add/change/view on campaigns/links + view on hits/attributions; members need `is_staff` set manually. Hits and attributions are read-only in admin. +- **Tests**: `campaigns/tests/` (models, resolver, attribution incl. SIWE login + email-confirm integration, reporting, admin permissions). + ### Node Upgrade (Sub-app) - **Models**: `contributions/node_upgrade/models.py` - TargetNodeVersion - Active target version for node upgrades. Per-network, single @@ -280,6 +296,7 @@ backend/ - ValidatorWalletStatusSnapshot - Daily wallet rollup. On-chain `status` (owned by the on-chain sync, for uptime lookback) PLUS the latched observability verdict written by the Grafana sync: `metrics_status` / `logs_status` / `version_status`, `metrics_samples` / `logs_samples` counters, and `node_version`. **Metrics and logs latch pessimistically** (worst-of-day: shame at ANY observation → the day is shame). **Version latches optimistically** (best-of-day: a single up-to-date observation → the day is OK, since once a node upgrades that day an earlier stale reading must not shame it; `on` > `warning` > `shame`). A day is "clean" only if `status=='active'` and both sample counters are ≥1 and neither metrics nor logs is `shame` and version is not `shame`. The two syncs write disjoint columns (bulk_create update_conflicts on `(wallet, date)`), so neither clobbers the other. - ValidatorWalletObservation - Append-only raw log; one row per active wallet per Grafana sync run (`observed_at`, `onchain_status`, `metrics_status`, `logs_status`, `version_status`, `node_version`). Source of truth the daily rollup is materialised from and rebuildable via `rebuild_daily_snapshots`. - SyncLock - Database-backed sync coordination row with owner token for cross-worker locking + - TelegramGroupBindCode - One-time code binding a Telegram group to a validator via the Deckard support bot. Plaintext (`tgb__`) is returned exactly once at issuance; only the SHA-256 digest is stored (identifier lookup + constant-time compare, mirroring ServiceAccountToken). 48h expiry (lazy: `effective_status`), statuses issued/redeemed/expired/revoked, multiple active codes per validator (one group per code). Redemption records `redeemed_group_chat_id` + `redeemed_by_telegram_uid` and upserts the issuing user's `social_connections.TelegramConnection` (numeric Telegram uid = identity, username display-only, no OAuth tokens). - **Services**: `validators/grafana_service.py` - GrafanaValidatorStatusService - Polls Grafana Cloud (`/api/ds/query`) Prometheus + Loki datasources and updates `ValidatorWallet.metrics_status` / `logs_status` for `status='active'` wallets, per network. The Prometheus query also reads the `version` label from `genlayer_node_info` — **normalised at ingest** in `parse_response` ('v' prefix stripped, capped to the 50-char column; when a node briefly reports two version series right after an upgrade, the higher parseable one wins). Each run writes a `ValidatorWalletObservation` and latches today's `ValidatorWalletStatusSnapshot` rollup (`_record_history`, best-effort — never breaks the live status sync). Observations are retained forever by explicit decision — no pruning in points. Used by the Wall of Shame cron. - GrafanaValidatorStatusService is also the **source of truth for node versions** (`_sync_node_versions`, best-effort, runs before the active-wallet early return so networks with zero active wallets are still covered): version detection covers **every reporting node on the network regardless of on-chain status** (a quarantined node can still record its upgrade), **except banned wallets**, and only counts versions observed on wallets known to the DB and linked to an operator — the `version` label is self-reported by the node being judged and rewarded, so unknown Prometheus series count for nothing. Only versions that are both semver-valid AND PEP 440-parseable drive comparisons (e.g. `0.6.0-genlayer.1` is excluded — `packaging` can't parse it; in the shame loop an unparseable observed version or an unparseable active target yields `version_status='unknown'`, never a lexicographic fallback verdict). It auto-creates a `TargetNodeVersion` when a STABLE release (bare `x.y.z`, no pre-release/build) higher than the active target is reported by **at least `NODE_VERSION_MIN_OPERATORS_FOR_AUTO_TARGET` (default 1: the first adopter creates the target) distinct operators** (`target_date=now`; an unparseable active target is never blindly superseded; a broadcast notification is emitted via `broadcast_target_node_version`), raises each linked operator's `node_version_` to their highest observed version via a direct `.update()` (**monotonic** — a wallet skipping a scrape cycle can't transiently downgrade the field; genuine downgrades need admin correction), and directly awards an already-approved `node-upgrade` Contribution (`_award_node_upgrade`, early-bonus 4/3/2/1) when a visible operator first reaches the active target. **Removing the node-upgrade multiplier pauses the auto-award** (it is skipped with a warning, not created at 1.0). The per-operator loop is individually fault-isolated — one operator's failure never blocks the rest. Dedup shares the exact `version {v} [{network}]` notes key with the old manual flow so nothing double-awards. A run where a whole datasource comes back empty (no Prometheus series or no Loki counts) still updates live wallet statuses (they self-heal) but **skips the permanent history latch** — a datasource blackout must not shame every validator's recorded day. @@ -291,6 +308,10 @@ backend/ - `/api/v1/validators/wallets/sync/` - POST cron-protected background sync trigger with DB-backed lock (on-chain validator sync) - `/api/v1/validators/wallets/sync-grafana/` - POST cron-protected background sync trigger for Grafana observability cross-check (separate SyncLock row `grafana_status_sync` so it can run alongside the on-chain sync) - `/api/v1/validators/wallets/wall-of-shame/` - Public read-only endpoint listing active validator wallets with `metrics_status` / `logs_status`. SHAME rows sort first. Cached 60s. Optional `?network=asimov|bradbury` filter. Each wallet also carries `clean_streak_days` + `clean_streak_broken_by` (consecutive not-shamed days for that node, from `validators/streaks.py` over the daily rollup). The grouped `validators` output adds `network_streaks` — per-operator-per-network any-node-clean streaks (a network-day is clean if ≥1 of the operator's nodes was clean) — plus per-node `clean_streak_days` on each `networks` entry. Streaks start accumulating at deploy (history wasn't recorded before). Days with no Grafana data while the node was active (sync outage, pre-history) are SKIPPED — they neither count nor break, so an infra failure on our side never resets streaks; days spent non-active per the on-chain sync break the streak with `broken_by: ['status']`. + - `/api/v1/validators/telegram-bind-codes/` - POST (auth, validator profile required, `telegram_bind_issue` throttle 10/hour) issues a bind code and returns the plaintext ONCE + - `/api/v1/validators/telegram-bind-codes/mine/` - GET the current user's codes (metadata only, never raw codes) + - `/api/v1/validators/telegram-bind-codes/{id}/revoke/` - POST owner-only revoke of an unredeemed code (409 if already redeemed) + - `/api/v1/validators/telegram-bind-codes/redeem/` - POST for the Deckard Telegram bot only: service account bearer token with scope `telegram_bind:redeem`. Body `{code, group_chat_id, telegram_uid, telegram_username?}`. Single-use atomic redemption: marks the code, records group + uid, upserts TelegramConnection. Errors carry a machine `code`: `invalid_request` (400), `invalid_code` (404), `already_redeemed`/`revoked` (409), `expired` (410). DM-vs-group is enforced bot-side. - `/api/v1/validators/wallets/grafana/` - Public minimal roster for the Grafana Infinity datasource (`GrafanaValidatorSerializer`). Flat array, one row per wallet across ALL statuses; fields: `network` (Grafana label value e.g. `asimov-phase5`), `node` (on-chain validator address == Prometheus `genlayer_node_info` `node` label, lowercased), `name`, `status`, `operator`, `account`/`account_name` (only for visible operators), `explorer_url`, plus **raw link/identity facts** (verdicts are computed dashboard-side, NOT here): `linked` (bool — wallet attributed to a portal account; a bare fact, safe for non-visible operators), `moniker` and `logo_uri` (raw synced `getIdentity()` values, empty string = unset), `has_description` (presence bool so the roster doesn't ship long texts). Excludes observability/shame fields by design. Cached 60s. Optional `?network=asimov|bradbury` filter. - The roster **also appends one synthetic `status='missing'` row per network** for every graduated portal validator (visible Validator role user) with no wallet linked on that network (`_missing_graduated_rows` in the view) — graduated validators are expected on every testnet, and an absent one otherwise has no row for dashboards to show. On these rows `node` = the account address (unique join key only — matches no metric series), `operator` is null, and the link/identity facts (`linked`/`moniker`/`logo_uri`/`has_description`) are **null** (no wallet to describe — distinct from `false`/empty on a real wallet). A wallet of ANY status (incl. `inactive`) suppresses the missing row; an unlinked wallet (`operator=None`) does not. @@ -404,11 +425,16 @@ cd backend/scripts - Creates timestamped backups in `backend/backups/` - See `backend/scripts/README.md` for detailed setup and troubleshooting -### RDS to SQLite Migration -- **Script**: `backend/scripts/migrate_rds_to_sqlite.py` -- **Purpose**: Convert production PostgreSQL to local SQLite for development -- **Usage**: `python scripts/migrate_rds_to_sqlite.py` (from backend directory) -- **Notes**: Resets all passwords to 'pass', excludes leaderboard entries, backs up existing db.sqlite3 +### Production to SQLite Sync (local development) +- **Script**: `backend/scripts/sync_prod_to_sqlite.py` +- **Purpose**: Put a copy of production in local `db.sqlite3` (the default local database) +- **Usage**: `python scripts/sync_prod_to_sqlite.py` (from backend directory, venv active, Docker running) +- **Takes**: ~30 min. Flags: `--reuse-dump`, `--reuse-postgres`, `--keep-container`, `--keep-json`, `--no-leaderboard` +- **Notes**: Resets all passwords to `pass`, backs up the existing `db.sqlite3` (~1.6GB per backup — prune them), rebuilds the leaderboard at the end +- **How**: pg_dump production → restore into a local Postgres container → `migrate` that copy → `dumpdata` locally → `loaddata` into a fresh SQLite. It never runs `dumpdata` against production, because Django's per-row m2m queries make that ~90 rows/minute over a remote link (hours, never finishes). + +- **Do NOT use `backend/scripts/migrate_rds_to_sqlite.py`** — it exports directly from production RDS and does not complete. Kept only for reference. Its `loaddata json_file 'exclude' 'leaderboard'` call is also wrong: those trailing strings are parsed as fixture labels, not as an exclude option. +- **Known bug the sync script works around**: `sync_contribution_discord_xp_state` and `sync_social_task_completion_discord_xp_state` (this file's `contributions/models.py`), plus `users/signals.py:create_referral_code` and `poaps/signals.py:attach_legacy_poap_claims`, do not check `kwargs.get('raw')`, so a fixture load recreates rows the fixture already contains and collides on `ContributionDiscordXPState.contribution_id`. Neighbouring receivers guard correctly; the fix is a one-line early return in each. ## API Endpoints Summary @@ -472,6 +498,12 @@ GET /api/v1/leaderboard/user_stats/by-address/{address}/ (requires auth) GET /api/v1/multipliers/ (requires auth) GET /api/v1/multiplier-periods/ +# Validators - Telegram group bind codes (Deckard support bot) +POST /api/v1/validators/telegram-bind-codes/ (requires auth + validator profile; returns plaintext code ONCE; throttled 10/hour) +GET /api/v1/validators/telegram-bind-codes/mine/ (requires auth; metadata only) +POST /api/v1/validators/telegram-bind-codes/{id}/revoke/ (requires auth, owner-only) +POST /api/v1/validators/telegram-bind-codes/redeem/ (service account bearer token, scope telegram_bind:redeem) + # Validators - Wall of Shame POST /api/v1/validators/wallets/sync-grafana/ (cron-protected, X-Cron-Token, background) GET /api/v1/validators/wallets/wall-of-shame/ (public, cached 60s, ?network= filter) @@ -510,6 +542,9 @@ GET /api/v1/notifications/ (requires auth, ?unread=true ?categor GET /api/v1/notifications/unread-count/ (requires auth) POST /api/v1/notifications/{id}/mark-read/ (requires auth) POST /api/v1/notifications/mark-all-read/ (requires auth) + +# Campaign vanity links (public; proxied from portal /join// by Amplify) +GET /campaigns/redirect/{role}/{alias} (anonymous, 302 with UTMs, throttled 120/min) ``` ### Leaderboard monthly date ranges @@ -548,6 +583,7 @@ Located in `.env` file: - `RECAPTCHA_PRIVATE_KEY` - Google reCAPTCHA secret key (required - use test key from .env.example for development) - `RECAPTCHA_ALLOW_TEST_KEYS` - Optional opt-in flag for non-production deployments that intentionally use Google's reCAPTCHA test keys with `DEBUG=False`. Set to `true` to silence `django_recaptcha.recaptcha_test_key_error`; production must not set this flag. The logic lives in `tally/settings.py` near `_RECAPTCHA_TEST_PUBLIC_KEY` and `SILENCED_SYSTEM_CHECKS`. - `CRON_SYNC_TOKEN` - Cron-protected endpoint auth (used by `sync` and `sync-grafana`) +- `SLOW_REQUEST_LOG_MS` - Duration in ms (default `1000`) at or above which `APILoggingMiddleware` emits one sanitized WARNING for a non-5xx request. Production otherwise logs only 5xx, so slow successful requests leave no trace. The message carries method, redacted path, status and duration only, never query strings or bodies. - `DISCORD_SYNAPSE_ROLE_ID` / `DISCORD_BRAIN_ROLE_ID` / `DISCORD_NEUROCREATIVE_ROLE_ID` - Discord role IDs for the earned community role automation (Synapse/Brain assignment). All three must be set or the assignment job is a no-op. - `GRAFANA_BASE_URL` - Grafana Cloud base URL (default `https://genlayerfoundation.grafana.net`) - `GRAFANA_API_TOKEN` - Grafana service-account bearer token (required for Wall of Shame). Store in AWS SSM (`/tally/{env}/grafana_api_token`) for production. @@ -556,6 +592,8 @@ Located in `.env` file: - `GRAFANA_ASIMOV_LABEL` / `GRAFANA_BRADBURY_LABEL` - Override the `network` label values Grafana queries use per testnet (defaults: `asimov-phase5`, `bradbury-phase1`) - `NODE_VERSION_SHAME_GRACE_DAYS` - Grace period (days) after a target's `target_date` before a node still behind it is version-shamed, applied globally (default `3`) - `NODE_VERSION_MIN_OPERATORS_FOR_AUTO_TARGET` - Minimum distinct operators that must be observed running a new stable node version before the Grafana sync auto-creates it as the fleet-wide upgrade target (default `1`: the first adopter creates the target; raise it to require corroboration if version spoofing ever becomes a concern) +- `CAMPAIGN_HIT_RETENTION_DAYS` - Retention window for detailed campaign redirect hits, used as the default of `purge_campaign_hits` (default `90`) +- `CAMPAIGN_ATTRIBUTION_WINDOW_DAYS` - Maximum age of a browser-captured campaign first touch accepted for signup attribution (default `30`) - `SORSA_API_BASE_URL` - Sorsa API base URL (default `https://api.sorsa.io/v3`); used for Twitter follow verification in social_tasks and X follower counts in overview metrics. - `SORSA_API_KEY` - Sorsa API key sent in the `ApiKey` header (secret, required). Store in AWS SSM (`/tally/{env}/sorsa_api_key`) for production. - Note: the Sorsa request timeout and follow endpoint path are intentionally code constants in `social_tasks/sorsa_client.py`, not env vars. Changing the endpoint requires a code deploy anyway because the response parser lives in the same file. @@ -643,6 +681,39 @@ The project uses **context-aware serialization** to optimize API performance: - `LightEvidenceURLTypeSerializer` - Minimal (id, name, slug, is_generic) for nested use in Evidence responses - `EvidenceURLTypeSerializer` - Full serializer with url_patterns for client-side detection, used in ContributionType responses +### Per-request user lookup (do not reintroduce the address scan) + +`EthereumAuthentication` (`ethereum_auth/authentication.py`) runs on EVERY DRF request: +it is first in `DEFAULT_AUTHENTICATION_CLASSES` and DRF resolves `request.user` +unconditionally. It resolves the user from Django's own session machinery via +`request._request.user` (one primary-key lookup, plus `_auth_user_backend` / +`_auth_user_hash` / `is_active` validation), then checks that the session's +`ethereum_address` still binds to that user **case-insensitively** (login stores the +lowercased SIWE address, `signup_email_confirm` stores database casing, and production +holds mixed-case rows). A wallet mismatch RAISES rather than returning `None`, so the +next authenticator in the chain cannot grant the request off the same session user. + +Never look the user up by `address__iexact` in a per-request path. `iexact` compiles to +`UPPER(address) = UPPER(...)` on PostgreSQL and the only unique index on the column is +case-sensitive, so it is a sequential scan. Migration `users/0022` adds a non-unique +functional index on `Upper('address')` for the case-insensitive lookups that legitimately +remain (`users/utils.py::user_lookup_kwargs`, address search). Guards: +`ethereum_auth/test_authentication.py::WalletSessionQueryShapeTests`. + +Session-based tests must build a real session with +`ethereum_auth.testing.login_wallet_session(client, user)`; hand-seeding only +`ethereum_address` + `authenticated` produces a session the authenticator rejects. + +### Community aggregate caching + +`community_xp/cache.py` holds a 60s cache for the two shared, non-personalized community +aggregates: the ranking snapshot (`list[(user_id, total_points)]`) and the stats summary. +Both take no request input. Search, `user_rank`, `profile_context` and hydration stay live +per request. There is no `CACHES` setting, so this is per-process `LocMemCache`: the relief +scales with worker/container count and is strongest at steady state. Tests that touch the +community endpoints must call `clear_community_caches()` in `setUp` (LocMemCache is not +reset between tests). Query-count guards: `leaderboard/tests/test_community_query_counts.py`. + ## Testing - **Test Organization Best Practice**: Use `{app}/tests/` folder structure for better organization - Create `{app}/tests/__init__.py` to make it a Python package diff --git a/backend/Dockerfile b/backend/Dockerfile index 9360464c..3dd93ce5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -42,4 +42,4 @@ EXPOSE 8000 USER app # Run startup script -CMD ["./startup.sh", "gunicorn", "--bind", "0.0.0.0:8000", "--timeout", "180", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--log-level", "info", "tally.wsgi:application"] +CMD ["./startup.sh", "gunicorn", "--no-control-socket", "--bind", "0.0.0.0:8000", "--timeout", "180", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--log-level", "info", "tally.wsgi:application"] diff --git a/backend/campaigns/__init__.py b/backend/campaigns/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/campaigns/admin.py b/backend/campaigns/admin.py new file mode 100644 index 00000000..fc1e0d9b --- /dev/null +++ b/backend/campaigns/admin.py @@ -0,0 +1,177 @@ +from django.contrib import admin +from django.db.models import Count, Q +from django.utils.html import format_html, format_html_join + +from .models import CampaignLink, CampaignRedirectHit, MarketingCampaign, UserAcquisitionAttribution +from .services import campaign_report + + +class CampaignLinkInline(admin.TabularInline): + model = CampaignLink + extra = 1 + # Deleting a link cascades away its redirect-hit history; pause with + # is_active instead. + can_delete = False + fields = ( + 'role', 'alias', 'destination_path', + 'utm_source', 'utm_medium', 'utm_content', 'utm_term', + 'is_active', 'link_url', + ) + readonly_fields = ('link_url',) + + @admin.display(description='Public URL') + def link_url(self, obj): + return obj.public_url if obj.pk else '' + + +@admin.register(MarketingCampaign) +class MarketingCampaignAdmin(admin.ModelAdmin): + list_display = ('name', 'tracking_key', 'is_active', 'starts_at', 'ends_at', 'link_count') + list_filter = ('is_active',) + search_fields = ('name', 'tracking_key') + inlines = [CampaignLinkInline] + + def get_queryset(self, request): + return super().get_queryset(request).annotate(link_count=Count('links', distinct=True)) + + @admin.display(ordering='link_count', description='Links') + def link_count(self, obj): + return obj.link_count + + def get_readonly_fields(self, request, obj=None): + # Published tracking keys are immutable: clone the campaign instead of + # rewriting history. + return ('tracking_key', 'performance') if obj else ('performance',) + + @admin.display(description='Performance (Portal DB)') + def performance(self, obj): + if not obj or not obj.pk: + return 'Available after the campaign is saved.' + report = campaign_report(obj) + activations = report['activations'] + rows = [ + ('Redirect hits (human)', report['redirect_hits_human']), + ('Redirect hits (probable bots)', report['redirect_hits_bot']), + ('Wallet connects (attributed pending signups)', report['wallet_connects']), + ('Registered users', report['signups']), + ('Activated builders (first builder submission)', activations['builder']), + ('Activated validators (joined waitlist)', activations['validator']), + ('Activated community (flagged task completed)', activations['community']), + ] + body = format_html_join( + '', + '{}{}', + rows, + ) + return format_html( + '{}
' + '

Source: Portal DB. Hits are redirect requests, not unique ' + 'visitors; use GA for session-level traffic.

', + body, + ) + + def save_model(self, request, obj, form, change): + if not change and not obj.created_by: + obj.created_by = request.user + super().save_model(request, obj, form, change) + + def save_formset(self, request, form, formset, change): + instances = formset.save(commit=False) + for instance in instances: + if isinstance(instance, CampaignLink) and not instance.created_by_id: + instance.created_by = request.user + instance.save() + formset.save_m2m() + + +@admin.register(CampaignLink) +class CampaignLinkAdmin(admin.ModelAdmin): + list_display = ( + 'alias', 'role', 'campaign', 'utm_source', 'utm_medium', + 'is_active', 'human_hits', 'bot_hits', 'signups', 'public_url', + ) + list_filter = ('role', 'is_active', 'campaign') + search_fields = ('alias', 'campaign__name', 'campaign__tracking_key', 'utm_source', 'utm_medium') + + def get_queryset(self, request): + return super().get_queryset(request).annotate( + human_hit_count=Count('hits', filter=Q(hits__is_probable_bot=False), distinct=True), + bot_hit_count=Count('hits', filter=Q(hits__is_probable_bot=True), distinct=True), + signup_count=Count('acquisitions', distinct=True), + ) + + @admin.display(ordering='human_hit_count', description='Hits (human)') + def human_hits(self, obj): + return obj.human_hit_count + + @admin.display(ordering='bot_hit_count', description='Hits (bots)') + def bot_hits(self, obj): + return obj.bot_hit_count + + @admin.display(ordering='signup_count', description='Signups') + def signups(self, obj): + return obj.signup_count + + def get_readonly_fields(self, request, obj=None): + base = ('tracking_id', 'redirect_preview') + # Campaign, role, and alias define the published link's identity; once + # it is live they must not silently change meaning (moving a link + # between campaigns would also move its hit history). Create a new + # link instead. + return base + ('campaign', 'role', 'alias') if obj else base + + def has_delete_permission(self, request, obj=None): + # Deleting a link cascades away its redirect-hit history; pause with + # is_active instead. Superuser escape hatch only. + return request.user.is_superuser + + @admin.display(description='Redirect target (preview)') + def redirect_preview(self, obj): + if not obj or not obj.pk: + return 'Available after the link is saved.' + return format_html( + 'Public URL: {0}
Redirects to: {1}', obj.public_url, obj.redirect_target, + ) + + def save_model(self, request, obj, form, change): + if not change and not obj.created_by: + obj.created_by = request.user + super().save_model(request, obj, form, change) + + +@admin.register(CampaignRedirectHit) +class CampaignRedirectHitAdmin(admin.ModelAdmin): + list_display = ( + 'campaign_link', 'occurred_at', 'referrer_host', + 'user_agent_family', 'device_category', 'is_probable_bot', + ) + list_filter = ('is_probable_bot', 'device_category') + date_hierarchy = 'occurred_at' + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + # Escape hatch for manual cleanup; routine retention goes through the + # purge_campaign_hits management command. + return request.user.is_superuser + + +@admin.register(UserAcquisitionAttribution) +class UserAcquisitionAttributionAdmin(admin.ModelAdmin): + list_display = ('user', 'campaign_key', 'source', 'medium', 'link_role', 'registered_at') + list_filter = ('link_role', 'campaign_key') + search_fields = ('user__email', 'user__name', 'campaign_key', 'link_tracking_id') + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + # Acquisition facts are immutable. + return False + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser diff --git a/backend/campaigns/apps.py b/backend/campaigns/apps.py new file mode 100644 index 00000000..0cab42cc --- /dev/null +++ b/backend/campaigns/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CampaignsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'campaigns' diff --git a/backend/campaigns/management/__init__.py b/backend/campaigns/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/campaigns/management/commands/__init__.py b/backend/campaigns/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/campaigns/management/commands/purge_campaign_hits.py b/backend/campaigns/management/commands/purge_campaign_hits.py new file mode 100644 index 00000000..43659beb --- /dev/null +++ b/backend/campaigns/management/commands/purge_campaign_hits.py @@ -0,0 +1,30 @@ +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone + +from campaigns.models import CampaignRedirectHit + + +class Command(BaseCommand): + help = 'Delete campaign redirect hits older than the retention window (default 90 days).' + + def add_arguments(self, parser): + parser.add_argument( + '--days', + type=int, + default=settings.CAMPAIGN_HIT_RETENTION_DAYS, + help='Retention window in days.', + ) + parser.add_argument('--dry-run', action='store_true', help='Only report what would be deleted.') + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(days=options['days']) + queryset = CampaignRedirectHit.objects.filter(occurred_at__lt=cutoff) + count = queryset.count() + if options['dry_run']: + self.stdout.write(f'Would delete {count} redirect hits older than {cutoff.isoformat()}.') + return + queryset.delete() + self.stdout.write(self.style.SUCCESS(f'Deleted {count} redirect hits older than {cutoff.isoformat()}.')) diff --git a/backend/campaigns/migrations/0001_initial.py b/backend/campaigns/migrations/0001_initial.py new file mode 100644 index 00000000..dbdafd6f --- /dev/null +++ b/backend/campaigns/migrations/0001_initial.py @@ -0,0 +1,108 @@ +# Generated by Django 6.0.6 on 2026-07-29 11:09 + +import campaigns.models +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CampaignLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('tracking_id', models.CharField(default=campaigns.models.generate_tracking_id, editable=False, help_text='Server-generated opaque ID published as utm_id. Immutable.', max_length=20, unique=True)), + ('role', models.CharField(choices=[('builder', 'Builder'), ('validator', 'Validator'), ('community', 'Community')], max_length=16)), + ('alias', models.CharField(help_text='URL segment after the role, e.g. "ethcc" for /join/builders/ethcc.', max_length=64, validators=[django.core.validators.RegexValidator('^[a-z0-9-]+$', 'Use only lowercase letters, digits, and hyphens.')])), + ('destination_path', models.CharField(help_text='Relative portal path the link redirects to, e.g. /builders.', max_length=200)), + ('utm_source', models.CharField(help_text='e.g. x, discord, newsletter, ethcc', max_length=64)), + ('utm_medium', models.CharField(help_text='e.g. organic_social, paid_social, email, event', max_length=64)), + ('utm_content', models.CharField(blank=True, help_text='Optional creative ID, e.g. launch_post_01', max_length=64)), + ('utm_term', models.CharField(blank=True, max_length=64)), + ('is_active', models.BooleanField(default=True, help_text='Prefer pausing over deleting.')), + ('starts_at', models.DateTimeField(blank=True, help_text='Optional override of the campaign window.', null=True)), + ('ends_at', models.DateTimeField(blank=True, help_text='Optional override of the campaign window.', null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='CampaignRedirectHit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('occurred_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('referrer_host', models.CharField(blank=True, max_length=100)), + ('user_agent_family', models.CharField(blank=True, max_length=32)), + ('device_category', models.CharField(choices=[('desktop', 'Desktop'), ('mobile', 'Mobile'), ('tablet', 'Tablet'), ('bot', 'Bot'), ('unknown', 'Unknown')], default='unknown', max_length=10)), + ('is_probable_bot', models.BooleanField(default=False)), + ('campaign_link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hits', to='campaigns.campaignlink')), + ], + options={ + 'ordering': ['-occurred_at'], + }, + ), + migrations.CreateModel( + name='MarketingCampaign', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('tracking_key', models.CharField(help_text='Published as utm_campaign, e.g. ethcc_role_recruitment. Immutable once links are live; clone the campaign instead of changing its meaning.', max_length=64, unique=True, validators=[django.core.validators.RegexValidator('^[a-z0-9_]+$', 'Use only lowercase letters, digits, and underscores.')])), + ('description', models.TextField(blank=True)), + ('starts_at', models.DateTimeField(blank=True, null=True)), + ('ends_at', models.DateTimeField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True, help_text='Prefer deactivating over deleting.')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='campaignlink', + name='campaign', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='campaigns.marketingcampaign'), + ), + migrations.CreateModel( + name='UserAcquisitionAttribution', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('link_tracking_id', models.CharField(max_length=20)), + ('campaign_key', models.CharField(max_length=64)), + ('source', models.CharField(blank=True, max_length=64)), + ('medium', models.CharField(blank=True, max_length=64)), + ('content', models.CharField(blank=True, max_length=64)), + ('term', models.CharField(blank=True, max_length=64)), + ('link_role', models.CharField(blank=True, max_length=16)), + ('landing_path', models.CharField(blank=True, max_length=200)), + ('captured_at', models.DateTimeField(blank=True, null=True)), + ('registered_at', models.DateTimeField()), + ('campaign_link', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acquisitions', to='campaigns.campaignlink')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='acquisition_attribution', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-registered_at'], + }, + ), + migrations.AddConstraint( + model_name='campaignlink', + constraint=models.UniqueConstraint(fields=('role', 'alias'), name='unique_campaign_link_role_alias'), + ), + ] diff --git a/backend/campaigns/migrations/0002_marketing_group.py b/backend/campaigns/migrations/0002_marketing_group.py new file mode 100644 index 00000000..80ccc05a --- /dev/null +++ b/backend/campaigns/migrations/0002_marketing_group.py @@ -0,0 +1,46 @@ +from django.apps import apps as global_apps +from django.contrib.auth.management import create_permissions +from django.db import migrations + +MARKETING_GROUP_NAME = 'Marketing' + +# (model, actions) the marketing staff group needs. Members additionally need +# is_staff=True, assigned manually per user. +MARKETING_PERMISSIONS = [ + ('marketingcampaign', ('add', 'change', 'view')), + ('campaignlink', ('add', 'change', 'view')), + ('campaignredirecthit', ('view',)), + ('useracquisitionattribution', ('view',)), +] + + +def create_marketing_group(apps, schema_editor): + # Permissions are normally created by post_migrate, which has not run yet + # for this app on a fresh database; create them explicitly first. + app_config = global_apps.get_app_config('campaigns') + create_permissions(app_config, apps=apps, verbosity=0) + + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + group, _ = Group.objects.get_or_create(name=MARKETING_GROUP_NAME) + for model, actions in MARKETING_PERMISSIONS: + for action in actions: + permission = Permission.objects.filter( + content_type__app_label='campaigns', + codename=f'{action}_{model}', + ).first() + if permission: + group.permissions.add(permission) + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0001_initial'), + ] + + operations = [ + # Reverse is a no-op: the forward path may have reused a pre-existing + # Marketing group, so rollback must not delete it (or its members). + migrations.RunPython(create_marketing_group, migrations.RunPython.noop), + ] diff --git a/backend/campaigns/migrations/__init__.py b/backend/campaigns/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/campaigns/models.py b/backend/campaigns/models.py new file mode 100644 index 00000000..52dbd356 --- /dev/null +++ b/backend/campaigns/models.py @@ -0,0 +1,307 @@ +"""Marketing campaign vanity links and durable acquisition attribution. + +Marketing creates campaigns and role links in Django admin; the public URL is +always {FRONTEND_URL}/join//, reverse-proxied by Amplify +to the resolver in views.py. Creating a campaign is data only: no route, +Amplify, or DNS change is ever needed per campaign. +""" +import secrets +from urllib.parse import urlencode + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone + +from utils.models import BaseModel + +ROLE_BUILDER = 'builder' +ROLE_VALIDATOR = 'validator' +ROLE_COMMUNITY = 'community' +ROLE_CHOICES = [ + (ROLE_BUILDER, 'Builder'), + (ROLE_VALIDATOR, 'Validator'), + (ROLE_COMMUNITY, 'Community'), +] + +# Public URL segment <-> canonical role (Category slug) mapping. +ROLE_SEGMENT_TO_ROLE = { + 'builders': ROLE_BUILDER, + 'validators': ROLE_VALIDATOR, + 'community': ROLE_COMMUNITY, +} +ROLE_TO_SEGMENT = {role: segment for segment, role in ROLE_SEGMENT_TO_ROLE.items()} + +# Ad click IDs forwarded from the vanity request onto the redirect target so +# auto-tagged paid traffic keeps its ad-platform join in GA. Keep in sync with +# ATTRIBUTION_PARAMS in frontend/src/lib/analytics.js. +CLICK_ID_FORWARD_PARAMS = ('gclid', 'gbraid', 'wbraid', 'fbclid', 'twclid', 'msclkid', 'ttclid') + +MAX_DESTINATION_LENGTH = 200 + +# Destinations must sit under one of these portal prefixes (exact match or +# prefix + '/'). Extend when marketing needs a new landing surface. +ALLOWED_DESTINATION_PREFIXES = ( + '/', + '/builders', + '/validators', + '/community', + '/how-it-works', + '/referral-program', + '/hackathon', + '/gen-tv', + '/gen-news', + '/ecosystem-partners', +) +RESERVED_DESTINATION_PREFIXES = ( + '/admin', + '/api', + '/oauth', + '/static', + '/media', + '/join', + '/swagger', + '/campaigns', +) + + +def generate_tracking_id(): + """Opaque, non-sensitive link ID published as utm_id.""" + return 'cl-' + secrets.token_hex(6) + + +def _matches_prefix(path, prefix): + return path == prefix or path.startswith(prefix.rstrip('/') + '/') + + +def validate_destination_path(path): + """Validate a campaign destination as a safe relative portal path. + + Runs on write (model clean) AND again in the resolver before every + redirect, so corrupt stored data fails closed instead of redirecting. + """ + if not isinstance(path, str) or not path: + raise ValidationError('Destination is required.') + if len(path) > MAX_DESTINATION_LENGTH: + raise ValidationError('Destination is too long.') + if not path.startswith('/') or path.startswith('//'): + raise ValidationError('Destination must be a relative portal path starting with "/".') + # '%' rejected so percent-encoded traversal (%2e%2e) cannot bypass the + # allowlist after URL normalization; portal paths never need encoding. + if any(ch in path for ch in ('#', '?', '@', '\\', ' ', '%')) or '..' in path or ':' in path: + raise ValidationError('Destination must not include a scheme, host, query, fragment, or encoded/traversal syntax.') + if any(_matches_prefix(path, prefix) for prefix in RESERVED_DESTINATION_PREFIXES): + raise ValidationError('Destination points at a reserved path.') + # '/' allows only the portal root, never every path. + allowed = path == '/' or any( + _matches_prefix(path, prefix) for prefix in ALLOWED_DESTINATION_PREFIXES if prefix != '/' + ) + if not allowed: + raise ValidationError('Destination is not an allowed portal path.') + + +class MarketingCampaign(BaseModel): + name = models.CharField(max_length=200) + tracking_key = models.CharField( + max_length=64, + unique=True, + validators=[RegexValidator(r'^[a-z0-9_]+$', 'Use only lowercase letters, digits, and underscores.')], + help_text=( + 'Published as utm_campaign, e.g. ethcc_role_recruitment. Immutable once ' + 'links are live; clone the campaign instead of changing its meaning.' + ), + ) + description = models.TextField(blank=True) + starts_at = models.DateTimeField(null=True, blank=True) + ends_at = models.DateTimeField(null=True, blank=True) + is_active = models.BooleanField(default=True, help_text='Prefer deactivating over deleting.') + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='+', + ) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return self.name + + def clean(self): + if self.tracking_key: + self.tracking_key = self.tracking_key.strip().lower() + if self.pk: + persisted_key = ( + MarketingCampaign.objects.filter(pk=self.pk) + .values_list('tracking_key', flat=True) + .first() + ) + if persisted_key and persisted_key != self.tracking_key and self.links.exists(): + raise ValidationError({ + 'tracking_key': ( + 'Published tracking keys are immutable once links exist; ' + 'clone the campaign instead.' + ), + }) + if self.starts_at and self.ends_at and self.ends_at <= self.starts_at: + raise ValidationError({'ends_at': 'End must be after start.'}) + + def is_expired_at(self, dt): + return bool(self.ends_at and dt >= self.ends_at) + + def is_live_at(self, dt): + if not self.is_active or self.is_expired_at(dt): + return False + return not (self.starts_at and dt < self.starts_at) + + +class CampaignLink(BaseModel): + campaign = models.ForeignKey(MarketingCampaign, on_delete=models.CASCADE, related_name='links') + tracking_id = models.CharField( + max_length=20, + unique=True, + editable=False, + default=generate_tracking_id, + help_text='Server-generated opaque ID published as utm_id. Immutable.', + ) + role = models.CharField(max_length=16, choices=ROLE_CHOICES) + alias = models.CharField( + max_length=64, + validators=[RegexValidator(r'^[a-z0-9-]+$', 'Use only lowercase letters, digits, and hyphens.')], + help_text='URL segment after the role, e.g. "ethcc" for /join/builders/ethcc.', + ) + destination_path = models.CharField( + max_length=MAX_DESTINATION_LENGTH, + help_text='Relative portal path the link redirects to, e.g. /builders.', + ) + utm_source = models.CharField(max_length=64, help_text='e.g. x, discord, newsletter, ethcc') + utm_medium = models.CharField(max_length=64, help_text='e.g. organic_social, paid_social, email, event') + utm_content = models.CharField(max_length=64, blank=True, help_text='Optional creative ID, e.g. launch_post_01') + utm_term = models.CharField(max_length=64, blank=True) + is_active = models.BooleanField(default=True, help_text='Prefer pausing over deleting.') + starts_at = models.DateTimeField(null=True, blank=True, help_text='Optional override of the campaign window.') + ends_at = models.DateTimeField(null=True, blank=True, help_text='Optional override of the campaign window.') + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='+', + ) + + class Meta: + ordering = ['-created_at'] + constraints = [ + models.UniqueConstraint(fields=['role', 'alias'], name='unique_campaign_link_role_alias'), + ] + + def __str__(self): + return f'/join/{ROLE_TO_SEGMENT.get(self.role, self.role)}/{self.alias}' + + def clean(self): + for field in ('alias', 'utm_source', 'utm_medium', 'utm_content', 'utm_term'): + value = getattr(self, field) + if value: + setattr(self, field, value.strip().lower()) + if self.starts_at and self.ends_at and self.ends_at <= self.starts_at: + raise ValidationError({'ends_at': 'End must be after start.'}) + validate_destination_path(self.destination_path) + + @property + def public_url(self): + return f'{settings.FRONTEND_URL}/join/{ROLE_TO_SEGMENT.get(self.role, self.role)}/{self.alias}' + + @property + def utm_query(self): + params = { + 'utm_id': self.tracking_id, + 'utm_source': self.utm_source, + 'utm_medium': self.utm_medium, + 'utm_campaign': self.campaign.tracking_key, + } + if self.utm_content: + params['utm_content'] = self.utm_content + if self.utm_term: + params['utm_term'] = self.utm_term + return urlencode(params) + + @property + def redirect_target(self): + return f'{settings.FRONTEND_URL}{self.destination_path}?{self.utm_query}' + + def is_expired_at(self, dt): + return bool(self.ends_at and dt >= self.ends_at) or self.campaign.is_expired_at(dt) + + def is_live_at(self, dt): + if not self.is_active or not self.campaign.is_live_at(dt): + return False + if self.starts_at and dt < self.starts_at: + return False + return not (self.ends_at and dt >= self.ends_at) + + +class CampaignRedirectHit(models.Model): + """One resolver request. These are redirect requests, not unique humans: + link preview bots and scanners hit vanity URLs too (classified below). + + Deliberately NOT a BaseModel: this is an append-only, purgeable log table + (occurred_at is its only meaningful timestamp), matching the ethereum_auth + log-model precedent. + + Privacy: never add raw IPs, full referrer URLs, full user agents, wallet + addresses, or emails to this table. + """ + + DEVICE_DESKTOP = 'desktop' + DEVICE_MOBILE = 'mobile' + DEVICE_TABLET = 'tablet' + DEVICE_BOT = 'bot' + DEVICE_UNKNOWN = 'unknown' + DEVICE_CHOICES = [ + (DEVICE_DESKTOP, 'Desktop'), + (DEVICE_MOBILE, 'Mobile'), + (DEVICE_TABLET, 'Tablet'), + (DEVICE_BOT, 'Bot'), + (DEVICE_UNKNOWN, 'Unknown'), + ] + + campaign_link = models.ForeignKey(CampaignLink, on_delete=models.CASCADE, related_name='hits') + occurred_at = models.DateTimeField(default=timezone.now, db_index=True) + referrer_host = models.CharField(max_length=100, blank=True) + user_agent_family = models.CharField(max_length=32, blank=True) + device_category = models.CharField(max_length=10, choices=DEVICE_CHOICES, default=DEVICE_UNKNOWN) + is_probable_bot = models.BooleanField(default=False) + + class Meta: + ordering = ['-occurred_at'] + + def __str__(self): + return f'{self.campaign_link_id} @ {self.occurred_at:%Y-%m-%d %H:%M}' + + +class UserAcquisitionAttribution(BaseModel): + """Authoritative first-touch signup attribution, written once when the + user is created from a pending wallet signup. + + The snapshot columns duplicate the FK on purpose: a campaign may later be + renamed, archived, or deleted, and historical acquisition facts must not + silently change. + """ + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='acquisition_attribution', + ) + campaign_link = models.ForeignKey( + CampaignLink, null=True, blank=True, on_delete=models.SET_NULL, related_name='acquisitions', + ) + link_tracking_id = models.CharField(max_length=20) + campaign_key = models.CharField(max_length=64) + source = models.CharField(max_length=64, blank=True) + medium = models.CharField(max_length=64, blank=True) + content = models.CharField(max_length=64, blank=True) + term = models.CharField(max_length=64, blank=True) + link_role = models.CharField(max_length=16, blank=True) + landing_path = models.CharField(max_length=MAX_DESTINATION_LENGTH, blank=True) + captured_at = models.DateTimeField(null=True, blank=True) + registered_at = models.DateTimeField() + + class Meta: + ordering = ['-registered_at'] + + def __str__(self): + return f'{self.user_id} <- {self.campaign_key}' diff --git a/backend/campaigns/services.py b/backend/campaigns/services.py new file mode 100644 index 00000000..cfd0437f --- /dev/null +++ b/backend/campaigns/services.py @@ -0,0 +1,264 @@ +import logging +from datetime import timedelta +from urllib.parse import urlencode, urlparse + +from django.conf import settings +from django.db import transaction +from django.db.models import Count, Q +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from .models import ( + CLICK_ID_FORWARD_PARAMS, + MAX_DESTINATION_LENGTH, + ROLE_SEGMENT_TO_ROLE, + CampaignLink, + CampaignRedirectHit, + UserAcquisitionAttribution, +) + +logger = logging.getLogger(__name__) + +# ponytail: substring UA classification, swap for a UA-parser dependency only +# if the bot split ever proves too coarse. +BOT_UA_SUBSTRINGS = ( + 'bot', + 'crawler', + 'spider', + 'slurp', + 'preview', + 'facebookexternalhit', + 'whatsapp', + 'embedly', + 'curl', + 'wget', + 'python-requests', + 'httpclient', + 'headless', + 'lighthouse', + 'scanner', +) +_BROWSER_FAMILIES = ( + ('edg', 'edge'), + ('opr', 'opera'), + ('firefox', 'firefox'), + ('chrome', 'chrome'), + ('safari', 'safari'), +) +_MAX_FORWARDED_CLICK_ID_LENGTH = 100 + + +def classify_user_agent(user_agent): + """Return (family, device_category, is_probable_bot) from a raw UA string.""" + ua = (user_agent or '').lower() + if not ua: + return ('', CampaignRedirectHit.DEVICE_UNKNOWN, True) + for marker in BOT_UA_SUBSTRINGS: + if marker in ua: + return (marker[:32], CampaignRedirectHit.DEVICE_BOT, True) + if 'ipad' in ua or 'tablet' in ua: + device = CampaignRedirectHit.DEVICE_TABLET + elif 'mobi' in ua or 'android' in ua: + device = CampaignRedirectHit.DEVICE_MOBILE + else: + device = CampaignRedirectHit.DEVICE_DESKTOP + family = 'other' + for marker, name in _BROWSER_FAMILIES: + if marker in ua: + family = name + break + return (family, device, False) + + +def resolve_campaign_link(role_segment, alias): + role = ROLE_SEGMENT_TO_ROLE.get((role_segment or '').lower()) + if not role: + return None + return ( + CampaignLink.objects.select_related('campaign') + .filter(role=role, alias=(alias or '').lower()) + .first() + ) + + +def record_redirect_hit(link, request): + """Best effort: a failed hit insert must never block the redirect.""" + try: + referrer_host = '' + referer = request.META.get('HTTP_REFERER', '') + if referer: + referrer_host = (urlparse(referer).hostname or '')[:100] + family, device, is_bot = classify_user_agent(request.META.get('HTTP_USER_AGENT', '')) + CampaignRedirectHit.objects.create( + campaign_link=link, + referrer_host=referrer_host, + user_agent_family=family, + device_category=device, + is_probable_bot=is_bot, + ) + except Exception: + logger.warning('Campaign redirect hit logging failed for link %s', link.pk, exc_info=True) + + +def build_redirect_url(link, request): + """Stored destination + stored UTMs, plus allowlisted ad click IDs + forwarded from the incoming request (nothing else is ever forwarded).""" + url = link.redirect_target + forwarded = { + key: request.GET[key][:_MAX_FORWARDED_CLICK_ID_LENGTH] + for key in CLICK_ID_FORWARD_PARAMS + if request.GET.get(key) + } + if forwarded: + url = f'{url}&{urlencode(forwarded)}' + return url + + +def _clear_pending_attribution_fields(pending): + pending.acquisition_campaign_link = None + pending.acquisition_snapshot = {} + pending.acquisition_captured_at = None + + +_ATTRIBUTION_UPDATE_FIELDS = [ + 'acquisition_campaign_link', 'acquisition_snapshot', 'acquisition_captured_at', 'updated_at', +] + + +def apply_pending_attribution(pending, payload, reset=False): + """Write first-touch campaign attribution onto a pending wallet signup. + + Defensive by design: unknown or expired IDs and malformed payloads are + ignored silently, never surfaced as errors (attribution must never make + signup fail). The snapshot is built ONLY from the resolved link and its + campaign, never from browser-supplied UTM text. + """ + if reset and pending.acquisition_captured_at: + # The pending row is being reused after expiry; stale acquisition data + # must not leak into the new signup attempt. + _clear_pending_attribution_fields(pending) + pending.save(update_fields=_ATTRIBUTION_UPDATE_FIELDS) + if pending.acquisition_captured_at: + return # first touch wins + if not isinstance(payload, dict): + return + utm_id = payload.get('utm_id') + if not isinstance(utm_id, str) or not utm_id or len(utm_id) > 64: + return + captured_raw = payload.get('captured_at') + captured_dt = parse_datetime(captured_raw) if isinstance(captured_raw, str) else None + if captured_dt is None or timezone.is_naive(captured_dt): + return + now = timezone.now() + window = timedelta(days=settings.CAMPAIGN_ATTRIBUTION_WINDOW_DAYS) + if captured_dt > now + timedelta(minutes=5) or captured_dt < now - window: + return + landing_path = payload.get('landing_path') + if ( + not isinstance(landing_path, str) + or not landing_path.startswith('/') + or len(landing_path) > MAX_DESTINATION_LENGTH + ): + landing_path = '' + else: + landing_path = landing_path.split('?')[0].split('#')[0] + link = CampaignLink.objects.select_related('campaign').filter(tracking_id=utm_id).first() + if link is None or not link.is_live_at(captured_dt): + return + pending.acquisition_campaign_link = link + pending.acquisition_snapshot = { + 'link_tracking_id': link.tracking_id, + 'campaign_key': link.campaign.tracking_key, + 'source': link.utm_source, + 'medium': link.utm_medium, + 'content': link.utm_content, + 'term': link.utm_term, + 'link_role': link.role, + 'landing_path': landing_path, + 'captured_at': captured_dt.isoformat(), + } + pending.acquisition_captured_at = captured_dt + pending.save(update_fields=_ATTRIBUTION_UPDATE_FIELDS) + + +def record_user_acquisition(user, pending_signup): + """Copy pending-signup attribution into the write-once acquisition record. + + Called inside the signup transaction so the record commits atomically with + the new User. The nested atomic() creates a savepoint: a failure here rolls + back only this insert and never aborts user creation (a bare try/except + would poison the outer Postgres transaction). + """ + if pending_signup is None or not pending_signup.acquisition_captured_at: + return + try: + with transaction.atomic(): + if UserAcquisitionAttribution.objects.filter(user=user).exists(): + return + snapshot = pending_signup.acquisition_snapshot or {} + + def _text(key, max_length): + value = snapshot.get(key) + return value[:max_length] if isinstance(value, str) else '' + + UserAcquisitionAttribution.objects.create( + user=user, + campaign_link=pending_signup.acquisition_campaign_link, + link_tracking_id=_text('link_tracking_id', 20), + campaign_key=_text('campaign_key', 64), + source=_text('source', 64), + medium=_text('medium', 64), + content=_text('content', 64), + term=_text('term', 64), + link_role=_text('link_role', 16), + landing_path=_text('landing_path', MAX_DESTINATION_LENGTH), + captured_at=pending_signup.acquisition_captured_at, + registered_at=timezone.now(), + ) + except Exception: + logger.exception('Failed to record acquisition attribution for user %s', user.pk) + + +def campaign_report(campaign): + """Campaign funnel numbers from durable portal records, for the admin + change page (and, later, the internal dashboard staff API). + + All user-level numbers are distinct users reaching their first qualifying + outcome, never event counts. Source: Portal DB only; GA remains the + session/multi-touch layer. + """ + from contributions.models import Contribution, SubmittedContribution + from ethereum_auth.models import PendingWalletSignup + from social_tasks.models import SocialTaskCompletion + + hit_counts = CampaignRedirectHit.objects.filter(campaign_link__campaign=campaign).aggregate( + human=Count('id', filter=Q(is_probable_bot=False)), + bot=Count('id', filter=Q(is_probable_bot=True)), + ) + wallet_connects = PendingWalletSignup.objects.filter( + acquisition_campaign_link__campaign=campaign, + ).count() + # Filter on the snapshot key so acquisitions survive link deletion. + user_ids = list( + UserAcquisitionAttribution.objects.filter( + Q(campaign_link__campaign=campaign) | Q(campaign_key=campaign.tracking_key) + ).values_list('user_id', flat=True).distinct() + ) + return { + 'source': 'portal_db', + 'redirect_hits_human': hit_counts['human'] or 0, + 'redirect_hits_bot': hit_counts['bot'] or 0, + 'wallet_connects': wallet_connects, + 'signups': len(user_ids), + 'activations': { + 'builder': SubmittedContribution.objects.filter( + user_id__in=user_ids, contribution_type__category__slug='builder', + ).values('user_id').distinct().count(), + 'validator': Contribution.objects.filter( + user_id__in=user_ids, contribution_type__slug='validator-waitlist', + ).values('user_id').distinct().count(), + 'community': SocialTaskCompletion.objects.filter( + user_id__in=user_ids, task__counts_as_activation=True, + ).values('user_id').distinct().count(), + }, + } diff --git a/backend/campaigns/tests/__init__.py b/backend/campaigns/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/campaigns/tests/test_admin_permissions.py b/backend/campaigns/tests/test_admin_permissions.py new file mode 100644 index 00000000..e5cbce5d --- /dev/null +++ b/backend/campaigns/tests/test_admin_permissions.py @@ -0,0 +1,76 @@ +from importlib import import_module + +from django.apps import apps +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import Client, TestCase + +from campaigns.models import MarketingCampaign + +User = get_user_model() + + +def _run_marketing_group_migration(): + # Migrations do not run under tally.test_settings; invoke the data + # migration function directly against the live apps registry. + module = import_module('campaigns.migrations.0002_marketing_group') + module.create_marketing_group(apps, None) + + +class MarketingGroupTests(TestCase): + def setUp(self): + _run_marketing_group_migration() + self.group = Group.objects.get(name='Marketing') + self.marketer = User.objects.create_user( + email='marketing@example.com', password='pass12345', is_staff=True, + ) + self.marketer.groups.add(self.group) + self.client = Client() + self.client.force_login(self.marketer) + + def test_group_has_exactly_the_campaign_permissions(self): + granted = set( + self.group.permissions.values_list('content_type__app_label', 'codename') + ) + expected = { + ('campaigns', 'add_marketingcampaign'), + ('campaigns', 'change_marketingcampaign'), + ('campaigns', 'view_marketingcampaign'), + ('campaigns', 'add_campaignlink'), + ('campaigns', 'change_campaignlink'), + ('campaigns', 'view_campaignlink'), + ('campaigns', 'view_campaignredirecthit'), + ('campaigns', 'view_useracquisitionattribution'), + } + self.assertEqual(granted, expected) + + def test_marketing_user_can_manage_campaigns(self): + response = self.client.get('/admin/campaigns/marketingcampaign/') + self.assertEqual(response.status_code, 200) + response = self.client.post('/admin/campaigns/marketingcampaign/add/', { + 'name': 'Test Campaign', + 'tracking_key': 'test_campaign', + 'description': '', + 'is_active': 'on', + 'links-TOTAL_FORMS': '0', + 'links-INITIAL_FORMS': '0', + }) + self.assertEqual(response.status_code, 302) + self.assertTrue(MarketingCampaign.objects.filter(tracking_key='test_campaign').exists()) + + def test_marketing_user_cannot_access_other_apps(self): + response = self.client.get('/admin/users/user/') + self.assertEqual(response.status_code, 403) + + def test_anonymous_denied(self): + anonymous = Client() + response = anonymous.get('/admin/campaigns/marketingcampaign/') + self.assertEqual(response.status_code, 302) + self.assertIn('/admin/login', response['Location']) + + def test_non_staff_denied(self): + plain = User.objects.create_user(email='plain@example.com', password='pass12345') + client = Client() + client.force_login(plain) + response = client.get('/admin/campaigns/marketingcampaign/') + self.assertEqual(response.status_code, 302) diff --git a/backend/campaigns/tests/test_attribution.py b/backend/campaigns/tests/test_attribution.py new file mode 100644 index 00000000..c8b999e3 --- /dev/null +++ b/backend/campaigns/tests/test_attribution.py @@ -0,0 +1,317 @@ +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.db import transaction +from django.test import TestCase, override_settings +from django.utils import timezone +from eth_account import Account +from eth_account.messages import encode_defunct +from rest_framework.test import APIClient + +from campaigns.models import CampaignLink, UserAcquisitionAttribution +from campaigns.services import apply_pending_attribution, record_user_acquisition +from campaigns.tests.test_models import make_campaign, make_link +from ethereum_auth.models import Nonce, PendingWalletSignup + +User = get_user_model() + + +def make_pending(address='0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', **kwargs): + defaults = {'address': address, 'expires_at': timezone.now() + timedelta(minutes=30)} + defaults.update(kwargs) + return PendingWalletSignup.objects.create(**defaults) + + +def attribution_payload(link, **overrides): + payload = { + 'utm_id': link.tracking_id, + 'landing_path': '/builders', + 'captured_at': timezone.now().isoformat(), + } + payload.update(overrides) + return payload + + +class ApplyPendingAttributionTests(TestCase): + def setUp(self): + self.link = make_link() + self.pending = make_pending() + + def test_valid_utm_id_writes_fk_and_snapshot(self): + apply_pending_attribution(self.pending, attribution_payload(self.link)) + self.pending.refresh_from_db() + self.assertEqual(self.pending.acquisition_campaign_link, self.link) + self.assertIsNotNone(self.pending.acquisition_captured_at) + snapshot = self.pending.acquisition_snapshot + self.assertEqual(snapshot['link_tracking_id'], self.link.tracking_id) + self.assertEqual(snapshot['campaign_key'], 'ethcc_role_recruitment') + self.assertEqual(snapshot['source'], 'x') + self.assertEqual(snapshot['medium'], 'organic_social') + self.assertEqual(snapshot['link_role'], 'builder') + self.assertEqual(snapshot['landing_path'], '/builders') + + def test_snapshot_never_copies_browser_utm_text(self): + payload = attribution_payload(self.link, utm_source='SPOOFED', campaign='SPOOFED') + apply_pending_attribution(self.pending, payload) + self.pending.refresh_from_db() + self.assertEqual(self.pending.acquisition_snapshot['source'], 'x') + self.assertNotIn('SPOOFED', str(self.pending.acquisition_snapshot)) + + def test_unknown_utm_id_is_silently_ignored(self): + apply_pending_attribution(self.pending, attribution_payload(self.link, utm_id='cl-unknown')) + self.pending.refresh_from_db() + self.assertIsNone(self.pending.acquisition_captured_at) + + def test_link_not_live_at_capture_time_ignored(self): + CampaignLink.objects.filter(pk=self.link.pk).update(is_active=False) + apply_pending_attribution(self.pending, attribution_payload(self.link)) + self.pending.refresh_from_db() + self.assertIsNone(self.pending.acquisition_captured_at) + + def test_captured_at_outside_window_ignored(self): + stale = (timezone.now() - timedelta(days=45)).isoformat() + apply_pending_attribution(self.pending, attribution_payload(self.link, captured_at=stale)) + future = (timezone.now() + timedelta(days=2)).isoformat() + apply_pending_attribution(self.pending, attribution_payload(self.link, captured_at=future)) + self.pending.refresh_from_db() + self.assertIsNone(self.pending.acquisition_captured_at) + + def test_first_touch_never_overwritten(self): + apply_pending_attribution(self.pending, attribution_payload(self.link)) + other_link = make_link(make_campaign(tracking_key='other_campaign'), alias='other') + apply_pending_attribution(self.pending, attribution_payload(other_link)) + self.pending.refresh_from_db() + self.assertEqual(self.pending.acquisition_campaign_link, self.link) + + def test_reset_clears_stale_attribution(self): + apply_pending_attribution(self.pending, attribution_payload(self.link)) + apply_pending_attribution(self.pending, None, reset=True) + self.pending.refresh_from_db() + self.assertIsNone(self.pending.acquisition_campaign_link) + self.assertEqual(self.pending.acquisition_snapshot, {}) + self.assertIsNone(self.pending.acquisition_captured_at) + + def test_reset_then_new_attribution_applies(self): + apply_pending_attribution(self.pending, attribution_payload(self.link)) + other_link = make_link(make_campaign(tracking_key='other_campaign'), alias='other') + apply_pending_attribution(self.pending, attribution_payload(other_link), reset=True) + self.pending.refresh_from_db() + self.assertEqual(self.pending.acquisition_campaign_link, other_link) + + def test_malformed_payloads_never_raise(self): + for payload in ( + None, + [], + 'string', + {}, + {'utm_id': 42}, + {'utm_id': 'x' * 200}, + {'utm_id': self.link.tracking_id}, # missing captured_at + {'utm_id': self.link.tracking_id, 'captured_at': 'not-a-date'}, + {'utm_id': self.link.tracking_id, 'captured_at': '2026-01-01T00:00:00'}, # naive + attribution_payload(self.link, landing_path='https://evil.example.com'), + attribution_payload(self.link, landing_path='x' * 500), + ): + apply_pending_attribution(self.pending, payload) + self.pending.refresh_from_db() + # The two payloads with a bad landing_path are otherwise valid: they + # attribute with an empty landing path rather than failing. + self.assertEqual(self.pending.acquisition_snapshot.get('landing_path'), '') + + +class RecordUserAcquisitionTests(TestCase): + def setUp(self): + self.link = make_link() + self.pending = make_pending() + apply_pending_attribution(self.pending, attribution_payload(self.link)) + self.pending.refresh_from_db() + self.user = User.objects.create_user(email='acq@example.com', password='x') + + def test_creates_write_once_record_from_snapshot(self): + record_user_acquisition(self.user, self.pending) + record = UserAcquisitionAttribution.objects.get(user=self.user) + self.assertEqual(record.campaign_link, self.link) + self.assertEqual(record.link_tracking_id, self.link.tracking_id) + self.assertEqual(record.campaign_key, 'ethcc_role_recruitment') + self.assertEqual(record.source, 'x') + self.assertEqual(record.link_role, 'builder') + self.assertIsNotNone(record.registered_at) + + # Second call is a no-op. + record_user_acquisition(self.user, self.pending) + self.assertEqual(UserAcquisitionAttribution.objects.filter(user=self.user).count(), 1) + + def test_no_attribution_no_record(self): + blank_pending = make_pending(address='0x1111111111111111111111111111111111111111') + record_user_acquisition(self.user, blank_pending) + record_user_acquisition(self.user, None) + self.assertFalse(UserAcquisitionAttribution.objects.exists()) + + def test_failure_does_not_poison_outer_transaction(self): + with transaction.atomic(): + with patch( + 'campaigns.services.UserAcquisitionAttribution.objects.create', + side_effect=RuntimeError('boom'), + ): + record_user_acquisition(self.user, self.pending) + # The outer transaction must still be usable after the failure. + marker = User.objects.create_user(email='still-works@example.com', password='x') + self.assertTrue(User.objects.filter(pk=marker.pk).exists()) + self.assertFalse(UserAcquisitionAttribution.objects.exists()) + + +class LoginAttributionIntegrationTests(TestCase): + """The SIWE login view resolves browser attribution onto the pending signup.""" + + def setUp(self): + cache.clear() + self.client = APIClient() + self.link = make_link() + + def _session_key(self): + session = self.client.session + session.save() + return session.session_key + + def _nonce(self, value): + return Nonce.objects.create( + value=value, + session_key=self._session_key(), + purpose=Nonce.PURPOSE_LOGIN, + expires_at=timezone.now() + timedelta(minutes=5), + ) + + def _login_message(self, account, nonce_value): + return ( + 'localhost:5173 wants you to sign in with your Ethereum account:\n' + f'{account.address}\n\n' + 'Sign in with Ethereum to GenLayer Testnet Contributions\n\n' + 'URI: http://localhost:5173\n' + 'Version: 1\n' + 'Chain ID: 1\n' + f'Nonce: {nonce_value}\n' + f'Issued At: {timezone.now().isoformat()}' + ) + + def _login(self, account, nonce_value, attribution=None): + nonce = self._nonce(nonce_value) + message = self._login_message(account, nonce.value) + signature = Account.sign_message( + encode_defunct(text=message), private_key=account.key, + ).signature.hex() + payload = {'message': message, 'signature': signature} + if attribution is not None: + payload['attribution'] = attribution + return self.client.post('/api/auth/login/', payload, format='json') + + def test_login_with_attribution_populates_pending(self): + account = Account.create() + response = self._login(account, 'attribNonce1', attribution_payload(self.link)) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['pending_signup']) + pending = PendingWalletSignup.objects.get(address=account.address.lower()) + self.assertEqual(pending.acquisition_campaign_link, self.link) + + def test_repeated_login_keeps_first_touch(self): + account = Account.create() + self._login(account, 'attribNonce2', attribution_payload(self.link)) + other_link = make_link(make_campaign(tracking_key='other_campaign'), alias='other') + self._login(account, 'attribNonce3', attribution_payload(other_link)) + pending = PendingWalletSignup.objects.get(address=account.address.lower()) + self.assertEqual(pending.acquisition_campaign_link, self.link) + + def test_expired_pending_reuse_resets_stale_attribution(self): + account = Account.create() + self._login(account, 'attribNonce4', attribution_payload(self.link)) + PendingWalletSignup.objects.filter(address=account.address.lower()).update( + expires_at=timezone.now() - timedelta(minutes=1), + ) + self._login(account, 'attribNonce5') + pending = PendingWalletSignup.objects.get(address=account.address.lower()) + self.assertIsNone(pending.acquisition_campaign_link) + self.assertIsNone(pending.acquisition_captured_at) + + def test_garbage_attribution_never_blocks_signup(self): + account = Account.create() + response = self._login( + account, 'attribNonce6', {'utm_id': ['not', 'a', 'string'], 'captured_at': 12345}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['pending_signup']) + + def test_existing_user_login_ignores_attribution(self): + account = Account.create() + User.objects.create_user( + email='existing@example.com', password='x', address=account.address.lower(), + ) + response = self._login(account, 'attribNonce7', attribution_payload(self.link)) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['authenticated']) + self.assertFalse(response.data['created']) + self.assertFalse(PendingWalletSignup.objects.filter(address=account.address.lower()).exists()) + self.assertFalse(UserAcquisitionAttribution.objects.exists()) + + +@override_settings(TURNSTILE_SECRET_KEY='test-secret', TURNSTILE_ALLOWED_HOSTNAMES=[]) +class EmailConfirmAttributionTests(TestCase): + """Email confirmation copies the pending attribution into the durable + acquisition record in the same transaction that creates the user.""" + + def setUp(self): + cache.clear() + self.client = APIClient() + self.link = make_link() + + def _pending_signup_in_session(self): + pending = make_pending() + apply_pending_attribution(pending, attribution_payload(self.link)) + pending.refresh_from_db() + session = self.client.session + session['pending_wallet_signup_id'] = pending.id + session['pending_wallet_address'] = pending.address + session.save() + return pending + + def _start_and_confirm(self, email='campaign-user@example.com'): + with ( + patch('ethereum_auth.email_verification._generate_verification_code', return_value='123456'), + patch('ethereum_auth.email_verification.validate_email') as mock_validate_email, + patch('ethereum_auth.email_verification.requests.post') as mock_post, + ): + mock_post.return_value = Mock(json=lambda: {'success': True, 'hostname': 'localhost'}) + mock_validate_email.return_value = SimpleNamespace( + normalized=email, domain=email.split('@', 1)[1], + ) + start = self.client.post('/api/auth/signup/email/start/', { + 'email': email, + 'name': 'Campaign User', + 'turnstile_token': 'ok-token', + }, format='json') + self.assertEqual(start.status_code, 200, start.data) + # Confirm re-validates the email, so it must run inside the patches. + return self.client.post('/api/auth/signup/email/confirm/', {'code': '123456'}, format='json') + + def test_confirm_creates_acquisition_record(self): + pending = self._pending_signup_in_session() + response = self._start_and_confirm() + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data['created']) + user = User.objects.get(address__iexact=pending.address) + record = UserAcquisitionAttribution.objects.get(user=user) + self.assertEqual(record.campaign_link, self.link) + self.assertEqual(record.campaign_key, 'ethcc_role_recruitment') + + def test_confirm_survives_attribution_failure(self): + pending = self._pending_signup_in_session() + with patch( + 'campaigns.services.UserAcquisitionAttribution.objects.create', + side_effect=RuntimeError('boom'), + ): + response = self._start_and_confirm(email='resilient@example.com') + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['created']) + self.assertTrue(User.objects.filter(address__iexact=pending.address).exists()) + self.assertFalse(UserAcquisitionAttribution.objects.exists()) diff --git a/backend/campaigns/tests/test_models.py b/backend/campaigns/tests/test_models.py new file mode 100644 index 00000000..9249c35c --- /dev/null +++ b/backend/campaigns/tests/test_models.py @@ -0,0 +1,168 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.test import TestCase +from django.utils import timezone + +from campaigns.models import ( + CampaignLink, + MarketingCampaign, + generate_tracking_id, + validate_destination_path, +) + + +def make_campaign(**kwargs): + defaults = {'name': 'ETHCC Role Recruitment', 'tracking_key': 'ethcc_role_recruitment'} + defaults.update(kwargs) + return MarketingCampaign.objects.create(**defaults) + + +def make_link(campaign=None, **kwargs): + campaign = campaign or make_campaign() + defaults = { + 'campaign': campaign, + 'role': 'builder', + 'alias': 'ethcc', + 'destination_path': '/builders', + 'utm_source': 'x', + 'utm_medium': 'organic_social', + } + defaults.update(kwargs) + return CampaignLink.objects.create(**defaults) + + +class DestinationValidationTests(TestCase): + def test_valid_destinations(self): + for path in ('/', '/builders', '/builders/tasks', '/community', '/how-it-works'): + validate_destination_path(path) + + def test_invalid_destinations_rejected(self): + bad = [ + '', + None, + 'https://evil.example.com', + '//evil.example.com', + '/builders/../admin', + '/builders/%2e%2e/admin', + '/builders/%2E%2E/admin', + '/admin', + '/admin/login', + '/api/v1/users', + '/join/builders/x', + '/oauth', + '/static/app.js', + '/campaigns/redirect/builders/x', + '/builders#frag', + '/builders?x=1', + '/builders with space', + '/not-a-real-prefix', + '/' + 'a' * 400, + ] + for path in bad: + with self.assertRaises(ValidationError, msg=f'accepted: {path!r}'): + validate_destination_path(path) + + +class CampaignModelTests(TestCase): + def test_tracking_key_rejects_bad_characters(self): + campaign = MarketingCampaign(name='X', tracking_key='Bad-Key!') + with self.assertRaises(ValidationError): + campaign.full_clean() + + def test_date_window_validation(self): + now = timezone.now() + campaign = MarketingCampaign( + name='X', tracking_key='x_campaign', starts_at=now, ends_at=now - timezone.timedelta(days=1), + ) + with self.assertRaises(ValidationError): + campaign.full_clean() + + def test_tracking_key_immutable_once_links_exist(self): + link = make_link() + campaign = link.campaign + campaign.tracking_key = 'renamed_key' + with self.assertRaises(ValidationError): + campaign.full_clean() + + def test_tracking_key_editable_while_campaign_has_no_links(self): + campaign = make_campaign(tracking_key='draft_key') + campaign.tracking_key = 'renamed_key' + campaign.full_clean() + + +class CampaignLinkModelTests(TestCase): + def test_clean_normalizes_utm_values(self): + link = make_link() + link.utm_source = ' X ' + link.utm_medium = 'Organic_Social' + link.utm_content = ' Launch_Post_01 ' + link.full_clean() + self.assertEqual(link.utm_source, 'x') + self.assertEqual(link.utm_medium, 'organic_social') + self.assertEqual(link.utm_content, 'launch_post_01') + + def test_alias_rejects_script_html_and_uppercase(self): + link = make_link() + for alias in (' diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js index 2a03a822..d1e7abc3 100644 --- a/frontend/src/lib/analytics.js +++ b/frontend/src/lib/analytics.js @@ -190,6 +190,158 @@ export function templateRoute(path) { return match ? match[1] : route; } +// Structured campaign attribution for the backend (separate from the GA +// querystring above). The backend only trusts the opaque utm_id and resolves +// the campaign server-side; everything else here is captured for debugging. +// First touch (localStorage, 30 days) is written once and never overwritten; +// the session touch refreshes on every new campaign landing. +const FIRST_TOUCH_KEY = 'campaign_first_touch'; +const SESSION_TOUCH_KEY = 'campaign_session_touch'; +const FIRST_TOUCH_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +const STRUCTURED_ATTRIBUTION_KEYS = [ + ['utm_id', 'utm_id'], + ['utm_source', 'source'], + ['utm_medium', 'medium'], + ['utm_campaign', 'campaign'], + ['utm_content', 'content'], + ['utm_term', 'term'], +]; + +function buildStructuredAttribution() { + try { + if (!canUseBrowser()) return null; + const params = new URLSearchParams(window.location.search || ''); + const attribution = {}; + for (const [param, key] of STRUCTURED_ATTRIBUTION_KEYS) { + const value = params.get(param); + if (value) attribution[key] = value.slice(0, MAX_STRING_LENGTH); + } + // Only touches with the opaque link ID are stored: the backend resolves + // campaigns exclusively from utm_id, and a utm_id-less (or whitespace) + // touch must never lock the 30-day first-touch slot against a later real + // campaign click. + const utmId = resolvableUtmId(attribution); + if (!utmId) return null; + attribution.utm_id = utmId; + // Templated so a wallet-address landing path never reaches storage. + attribution.landing_path = templateRoute(window.location.pathname); + attribution.captured_at = new Date().toISOString(); + return attribution; + } catch { + return null; + } +} + +function resolvableUtmId(touch) { + return typeof touch?.utm_id === 'string' ? touch.utm_id.trim() : ''; +} + +function readStoredTouch(storage, key, ttlMs) { + try { + const raw = storage.getItem(key); + if (!raw) return null; + const stored = JSON.parse(raw); + const capturedAt = Date.parse(stored?.captured_at); + if (!Number.isFinite(capturedAt) || safeNow() - capturedAt > ttlMs) { + storage.removeItem(key); + return null; + } + return stored; + } catch { + return null; + } +} + +function captureStructuredAttribution() { + try { + if (!canUseBrowser()) return; + const attribution = buildStructuredAttribution(); + if (!attribution) return; + sessionStorage.setItem(SESSION_TOUCH_KEY, JSON.stringify(attribution)); + if (!readStoredTouch(localStorage, FIRST_TOUCH_KEY, FIRST_TOUCH_TTL_MS)) { + localStorage.setItem(FIRST_TOUCH_KEY, JSON.stringify(attribution)); + } + } catch { + // Attribution persistence is best-effort only. + } +} + +captureStructuredAttribution(); + +export function getAcquisitionAttribution() { + try { + if (!canUseBrowser()) return null; + // A stored first touch without a resolvable ID (legacy or hand-edited) + // must fall through to the session touch instead of shadowing it. + const firstTouch = readStoredTouch(localStorage, FIRST_TOUCH_KEY, FIRST_TOUCH_TTL_MS); + const touch = resolvableUtmId(firstTouch) + ? firstTouch + : readStoredTouch(sessionStorage, SESSION_TOUCH_KEY, ATTRIBUTION_TTL_MS); + const utmId = resolvableUtmId(touch); + if (!utmId) return null; + return { + utm_id: utmId, + landing_path: typeof touch.landing_path === 'string' ? touch.landing_path : '/', + captured_at: touch.captured_at, + }; + } catch { + return null; + } +} + +export function clearAcquisitionAttribution() { + try { + if (!canUseBrowser()) return; + localStorage.removeItem(FIRST_TOUCH_KEY); + sessionStorage.removeItem(SESSION_TOUCH_KEY); + } catch { + // Best-effort only. + } +} + +export function cleanTrackingParamsFromUrl(extraKeys = []) { + // Remove only recognized attribution params from the visible URL, after + // capture has already happened at module load. Everything else in the query + // (OAuth codes, email-verification tokens, product params) is preserved. + // Raw replaceState on purpose: the router stores are untouched, so no + // navigation event fires and no duplicate page_view is emitted. + try { + if (!canUseBrowser() || !window.history?.replaceState) return false; + const params = new URLSearchParams(window.location.search || ''); + let removed = false; + for (const key of [...ATTRIBUTION_PARAMS, ...extraKeys]) { + if (params.has(key)) { + params.delete(key); + removed = true; + } + } + if (!removed) return false; + const remaining = params.toString(); + const cleanUrl = window.location.pathname + (remaining ? `?${remaining}` : '') + window.location.hash; + window.history.replaceState({}, '', cleanUrl); + return true; + } catch { + return false; + } +} + +export function trackSignUp(responseData, params = {}) { + // GA's recommended sign_up event: fires only when email confirmation + // actually created the account. Returning wallet logins have created:false + // and never reach here with true. + try { + if (responseData?.created !== true) return false; + const tracked = trackEvent('sign_up', getAnalyticsContext({ method: 'siwe_email', ...params })); + // The stored touch has converted; clearing it prevents another wallet + // registered later in this browser from inheriting this campaign. + clearAcquisitionAttribution(); + return tracked; + } catch { + return false; + } +} + function roleContextFromRoute(path) { const route = normalizePath(path); if (route.startsWith('/builders')) return 'builder'; diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index fdac386f..0e57aa0e 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -1,6 +1,6 @@ import axios from 'axios'; import { API_BASE_URL } from './config.js'; -import { attachCsrfToken } from './csrf.js'; +import { attachCsrfToken, clearCsrfToken, isCsrfFailure } from './csrf.js'; const INVALID_QUERY_VALUES = new Set(['undefined', 'null']); const METRICS_CACHE_TTL_MS = 30 * 1000; @@ -118,8 +118,15 @@ api.interceptors.request.use( api.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 403 || error.response?.status === 401) { - // If we get an auth error, verify auth status + if (isCsrfFailure(error)) { + // Drop the stale token so the next attempt fetches a fresh one. The + // request is NOT retried: POAP claims and other non-idempotent mutations + // share this instance and must never be replayed automatically. + clearCsrfToken(); + } else if (error.response?.status === 403 || error.response?.status === 401) { + // DRF answers 403 (not 401) for an expired session, because the first + // authenticator in the chain supplies no authenticate_header. Both codes + // therefore have to trigger a re-verify. import('./auth.js').then(({ verifyAuth }) => { verifyAuth({ force: true }); }); @@ -283,7 +290,12 @@ export const validatorsAPI = { getMyValidatorWallets: () => api.get('/validators/my-wallets/'), linkValidatorWalletsByOperator: (operatorAddress) => api.post('/validators/link-by-operator/', { operator_address: operatorAddress }), getNetworks: () => api.get('/validators/wallets/networks/'), - getWallOfShame: (params = {}) => api.get('/validators/wallets/wall-of-shame/', { params }) + getWallOfShame: (params = {}) => api.get('/validators/wallets/wall-of-shame/', { params }), + // Telegram group bind codes (Deckard support bot). The raw code is only in + // the issue response — list/revoke responses carry metadata only. + issueTelegramBindCode: () => api.post('/validators/telegram-bind-codes/'), + getMyTelegramBindCodes: () => api.get('/validators/telegram-bind-codes/mine/'), + revokeTelegramBindCode: (id) => api.post(`/validators/telegram-bind-codes/${id}/revoke/`) }; // Builders API diff --git a/frontend/src/lib/auth.js b/frontend/src/lib/auth.js index 3dfab999..e95c8a4e 100644 --- a/frontend/src/lib/auth.js +++ b/frontend/src/lib/auth.js @@ -3,7 +3,7 @@ import axios from 'axios'; import { writable } from 'svelte/store'; import { userStore } from './userStore'; import { API_BASE_URL } from './config.js'; -import { attachCsrfToken } from './csrf.js'; +import { attachCsrfToken, clearCsrfToken, isCsrfFailure } from './csrf.js'; import { detectCategoryFromRoute } from '../stores/category.js'; import { roleForCategory } from './roleState.js'; @@ -137,6 +137,20 @@ authAxios.interceptors.request.use( (error) => Promise.reject(error) ); +// Mirrors the api.js interceptor. Without it a token rotated by another tab +// would keep being sent from this one: the auth endpoints are the only callers +// here, so nothing else would ever clear it and session refresh would fail +// every five minutes until reload. Not retried, for the same reason as api.js. +authAxios.interceptors.response.use( + (response) => response, + (error) => { + if (isCsrfFailure(error)) { + clearCsrfToken(); + } + return Promise.reject(error); + } +); + // Authentication API endpoints (relative to base URL, not api/v1) const API_ENDPOINTS = { NONCE: `${API_BASE_URL}/api/auth/nonce/`, @@ -346,8 +360,23 @@ export async function signInWithEthereum(provider = null, walletName = 'wallet', loginData.referral_code = referralCode; } + // Attach campaign attribution (opaque utm_id + landing metadata) so a + // brand-new wallet's pending signup can be attributed server-side. Not + // cleared on success: first touch is immutable and the backend ignores + // repeats. Dynamic import to avoid a module cycle (analytics -> auth). + try { + const { getAcquisitionAttribution } = await import('./analytics.js'); + const attribution = getAcquisitionAttribution(); + if (attribution) { + loginData.attribution = attribution; + } + } catch {} + const response = await authAxios.post(API_ENDPOINTS.LOGIN, loginData); + // Django cycles the CSRF token inside login(), so the cached one is stale. + clearCsrfToken(); + // Clear referral code from localStorage after successful login if (referralCode) { localStorage.removeItem('referral_code'); @@ -372,7 +401,7 @@ export async function signInWithEthereum(provider = null, walletName = 'wallet', // Load user data into the store let userData = null; try { - userData = await userStore.loadUser(); + userData = await userStore.loadUser({ force: true }); } catch (err) { // Silently handle user data load failure } @@ -417,6 +446,13 @@ export async function signInWithEthereum(provider = null, walletName = 'wallet', // Track verification promise to prevent duplicate calls let verificationInProgress = null; let refreshSessionPromise = null; +// Set after a 5xx/network verify failure; see verifyAuth. +let verifyCooldownUntil = 0; +const VERIFY_FAILURE_COOLDOWN_MS = 30 * 1000; +// Guards the visibilitychange refresh so tab flipping cannot produce one +// POST /auth/refresh/ per flip on top of the 5-minute interval. +let lastRefreshAt = 0; +const REFRESH_MIN_INTERVAL_MS = 60 * 1000; /** * Verify authentication status. @@ -438,8 +474,15 @@ export async function verifyAuth(options = {}) { return Promise.resolve(state.isAuthenticated); } + // A backend that just failed to answer will very likely fail again. Without + // this, a brownout turns every 401/403-triggered verify into an unthrottled + // retry, because the 5xx branch deliberately leaves hasVerified unset. + if (Date.now() < verifyCooldownUntil) { + return Promise.resolve(state.isAuthenticated); + } + // Start new verification - verificationInProgress = performVerification(); + verificationInProgress = performVerification({ force }); try { const result = await verificationInProgress; @@ -449,9 +492,10 @@ export async function verifyAuth(options = {}) { } } -async function performVerification() { +async function performVerification({ force = false } = {}) { try { const response = await authAxios.get(API_ENDPOINTS.VERIFY); + verifyCooldownUntil = 0; const isAuthenticated = response.data.authenticated; const address = response.data.address || null; @@ -467,7 +511,9 @@ async function performVerification() { restoreProvider(); try { - await userStore.loadUser(); + // A forced verify means something changed (login, wallet switch, an + // auth rejection), so re-read the profile rather than trusting the TTL. + await userStore.loadUser({ force }); } catch (err) { // Silently handle user data load failure } @@ -480,6 +526,7 @@ async function performVerification() { const status = error.response?.status; if (status && status < 500) { // Definitive rejection: the session is gone. + verifyCooldownUntil = 0; authState.setAuthenticated(false, null); userStore.clearUser(); return false; @@ -487,6 +534,7 @@ async function performVerification() { // Network error / 5xx: the backend couldn't answer, which says nothing // about the session. Keep the current state (often restored from // localStorage) and leave hasVerified unset so a later call retries. + verifyCooldownUntil = Date.now() + VERIFY_FAILURE_COOLDOWN_MS; return authState.get().isAuthenticated; } } @@ -501,6 +549,8 @@ export async function logout() { } catch (error) { // Silently handle logout errors } finally { + // session.flush() invalidates the CSRF token along with the session. + clearCsrfToken(); // Detach wallet listeners: while logged out nothing should react to // wallet events. Stale listeners on the SDK provider otherwise fire // during the NEXT connect attempt (the SDK re-emits chainChanged / @@ -531,6 +581,7 @@ export async function refreshSession() { refreshSessionPromise = (async () => { try { await authAxios.post(API_ENDPOINTS.REFRESH); + lastRefreshAt = Date.now(); return true; } catch (error) { // If refresh fails, verify auth state again @@ -560,9 +611,11 @@ function verificationPayload(credential) { export async function confirmPendingSignupEmail(credential) { const response = await authAxios.post(API_ENDPOINTS.SIGNUP_EMAIL_CONFIRM, verificationPayload(credential)); if (response.data?.authenticated) { + // This path also calls Django login(), which cycles the CSRF token. + clearCsrfToken(); authState.setAuthenticated(true, response.data.address); try { - await userStore.loadUser(); + await userStore.loadUser({ force: true }); } catch (err) { // Silently handle user data load failure } @@ -753,11 +806,12 @@ if (typeof window !== 'undefined') { }, 5 * 60 * 1000); // Refresh every 5 minutes // The interval skips hidden tabs, so catch up as soon as the tab is visible - // again instead of waiting for the next tick. + // again instead of waiting for the next tick. Throttled, so flipping between + // tabs does not fire one refresh per flip. document.addEventListener('visibilitychange', () => { - if (!document.hidden && authState.get().isAuthenticated) { - refreshSession().catch(() => {}); - } + if (document.hidden || !authState.get().isAuthenticated) return; + if (Date.now() - lastRefreshAt < REFRESH_MIN_INTERVAL_MS) return; + refreshSession().catch(() => {}); }); } diff --git a/frontend/src/lib/config.js b/frontend/src/lib/config.js index 485220cb..e624bae8 100644 --- a/frontend/src/lib/config.js +++ b/frontend/src/lib/config.js @@ -13,3 +13,7 @@ export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''; // External Links Configuration export const FAUCET_URL = 'https://testnet-faucet.genlayer.foundation/'; + +// Deckard Telegram support bot @username (without the @). When unset, the +// Telegram group linking page falls back to generic wording. +export const DECKARD_BOT_USERNAME = import.meta.env.VITE_DECKARD_BOT_USERNAME || ''; diff --git a/frontend/src/lib/csrf.js b/frontend/src/lib/csrf.js index 7f9b70e2..c9409fa0 100644 --- a/frontend/src/lib/csrf.js +++ b/frontend/src/lib/csrf.js @@ -5,6 +5,12 @@ const UNSAFE_METHODS = new Set(['post', 'put', 'patch', 'delete']); let csrfCookieName = 'csrftoken'; let csrfTokenRequest = null; +// In production the API lives on a different host than the SPA, so the CSRF +// cookie is never readable from document.cookie and every unsafe request would +// otherwise refetch /api/csrf/. Held in memory only, never localStorage or any +// persistent browser storage, and cleared by clearCsrfToken() on every path +// that rotates the server-side token. +let cachedCsrfToken = null; function getCookie(name) { if (typeof document === 'undefined' || !document.cookie) { @@ -31,17 +37,23 @@ function isUnsafeMethod(method = 'get') { } async function getCsrfToken() { + // Same-origin dev keeps working off the cookie, which Django rotates for us. const existingToken = getCookieToken(); if (existingToken) { return existingToken; } + if (cachedCsrfToken) { + return cachedCsrfToken; + } + if (!csrfTokenRequest) { csrfTokenRequest = axios .get(`${API_BASE_URL}/api/csrf/`, { withCredentials: true }) .then((response) => { csrfCookieName = response.data?.csrfCookieName || csrfCookieName; - return response.data?.csrfToken || getCookieToken(); + cachedCsrfToken = response.data?.csrfToken || getCookieToken() || null; + return cachedCsrfToken; }) .finally(() => { csrfTokenRequest = null; @@ -51,6 +63,27 @@ async function getCsrfToken() { return csrfTokenRequest; } +/** + * Drop the cached token. Call from every path that rotates the server-side + * token: login, logout, wallet switch, and email confirmation, plus a genuine + * CSRF rejection. Session refresh does not rotate it. + */ +export function clearCsrfToken() { + cachedCsrfToken = null; + csrfTokenRequest = null; +} + +/** + * Distinguish a real CSRF rejection from an authorization 403. DRF raises + * PermissionDenied('CSRF Failed: ...') from enforce_csrf, so the detail string + * is the only reliable signal; both cases are 403. + */ +export function isCsrfFailure(error) { + if (error?.response?.status !== 403) return false; + const detail = error.response?.data?.detail; + return typeof detail === 'string' && detail.startsWith('CSRF Failed'); +} + export async function attachCsrfToken(config) { if (!isUnsafeMethod(config.method)) { return config; diff --git a/frontend/src/lib/notificationStore.js b/frontend/src/lib/notificationStore.js index cef9a989..2d253eb3 100644 --- a/frontend/src/lib/notificationStore.js +++ b/frontend/src/lib/notificationStore.js @@ -37,9 +37,26 @@ function createNotificationStore() { // Local read mutations supersede item lists that started loading before // those mutations completed. let itemWriteVersion = 0; + // Failed polls back off so a degraded backend is not hammered at a fixed + // rate by every open tab. Reset on the first success. + let pollFailures = 0; + let skipPollUntil = 0; + + const POLL_BACKOFF_STEPS = [0, 60000, 180000, 240000]; + + function notePollResult(ok) { + if (ok) { + pollFailures = 0; + skipPollUntil = 0; + return; + } + pollFailures = Math.min(pollFailures + 1, POLL_BACKOFF_STEPS.length - 1); + skipPollUntil = Date.now() + POLL_BACKOFF_STEPS[pollFailures]; + } function pollUnreadCountIfVisible() { if (document.hidden || !authState.get().isAuthenticated) return; + if (Date.now() < skipPollUntil) return; loadUnreadCount(); } @@ -93,11 +110,16 @@ function createNotificationStore() { const request = notificationsAPI .unreadCount() .then((response) => { + // Record the outcome only for the current, unsuperseded request: a + // stale failure would otherwise re-arm backoff after reset() or after a + // newer success had already cleared it. if (requestEpoch !== epoch || requestUnreadVersion !== unreadWriteVersion) return; + notePollResult(true); update((state) => ({ ...state, unreadCount: response.data?.count || 0 })); }) .catch((error) => { if (requestEpoch !== epoch || requestUnreadVersion !== unreadWriteVersion) return; + notePollResult(false); update((state) => ({ ...state, error })); }) .finally(() => { @@ -139,6 +161,9 @@ function createNotificationStore() { ...state, items: state.items.map((item) => (item.id === id ? updated : item)) })); + // Refetch rather than decrement locally: a count request can observe the + // server-side mark-read before this POST resolves, and a blind decrement + // would then subtract it twice. await loadUnreadCount({ force: true }); return updated; @@ -163,6 +188,8 @@ function createNotificationStore() { unreadWriteVersion += 1; inflightLatest = null; inflightCount = null; + pollFailures = 0; + skipPollUntil = 0; set({ items: [], unreadCount: 0, loading: false, error: null }); } diff --git a/frontend/src/lib/userStore.js b/frontend/src/lib/userStore.js index 89fdde1f..f240e983 100644 --- a/frontend/src/lib/userStore.js +++ b/frontend/src/lib/userStore.js @@ -1,6 +1,8 @@ import { writable, get } from 'svelte/store'; import { getCurrentUser } from './api'; +const USER_CACHE_TTL_MS = 30 * 1000; + // Create the user store function createUserStore() { const { subscribe, set, update } = writable({ @@ -9,21 +11,77 @@ function createUserStore() { error: null }); let loadUserPromise = null; + // Identifies the newest queued load, so a superseded one cannot clear the + // in-flight handle out from under it. + let currentLoadToken = null; + // Every role-gated navigation calls loadUser(), and in-flight coalescing only + // covers overlapping calls, so sequential navigation used to refetch every + // time. Route guards are a UX gate, not a security boundary (the backend + // enforces permissions on every request), so a short success cache is safe. + // Pass { force: true } wherever state must be re-read immediately. + let lastLoadedAt = 0; + + // Callers that write the store directly are holding authoritative + // post-mutation state (a profile save, a role join, a claim) or clearing the + // session, so a read that started earlier must not land on top of it. Loads + // started afterwards take a fresh token and are unaffected. + function invalidateInFlightLoad() { + currentLoadToken = null; + loadUserPromise = null; + } return { subscribe, - + + USER_CACHE_TTL_MS, + // Load user data from API - async loadUser() { - if (loadUserPromise) { + async loadUser({ force = false } = {}) { + // Unforced callers share whatever is already in flight. + if (!force && loadUserPromise) { return loadUserPromise; } - update(state => ({ ...state, loading: true, error: null })); + const state = get({ subscribe }); + if ( + !force + && state.user + && Date.now() - lastLoadedAt < USER_CACHE_TTL_MS + ) { + return state.user; + } + + // A forced caller has just changed server state (login, wallet switch, + // profile edit, role change), so it must not settle for a response to a + // request that started before that change. Queue behind any in-flight + // load instead of joining it; sequencing also keeps the older response + // from landing after the newer one. + const previous = loadUserPromise; + const token = {}; + currentLoadToken = token; + + const request = (async () => { + if (previous) { + await previous.catch(() => {}); + } + + // Superseded by clearUser() or a newer load while we were queued. + if (currentLoadToken !== token) { + return get({ subscribe }).user; + } + + update(state => ({ ...state, loading: true, error: null })); - loadUserPromise = (async () => { try { const userData = await getCurrentUser(); + // A response that lands after logout or a wallet switch must not + // restore the previous account, nor start a TTL for data that never + // reached the store. clearUser() drops the token to invalidate it. + if (currentLoadToken !== token) { + return userData; + } + // Only successful loads start the TTL; failures must never extend it. + lastLoadedAt = Date.now(); update(state => ({ ...state, user: userData, @@ -35,42 +93,52 @@ function createUserStore() { // Only a definitive auth rejection means "no user". On network/5xx // failures keep any previously loaded user so role gating and journey // state don't reset while the backend is down. - const status = err.response?.status; - const unauthenticated = status === 401 || status === 403; - update(state => ({ - ...state, - user: unauthenticated ? null : state.user, - loading: false, - error: err.message || 'Failed to load user data' - })); + if (currentLoadToken === token) { + const status = err.response?.status; + const unauthenticated = status === 401 || status === 403; + update(state => ({ + ...state, + user: unauthenticated ? null : state.user, + loading: false, + error: err.message || 'Failed to load user data' + })); + } throw err; } finally { - loadUserPromise = null; + if (currentLoadToken === token) { + loadUserPromise = null; + } } })(); - return loadUserPromise; + loadUserPromise = request; + return request; }, // Update user data (partial update) updateUser(updates) { + invalidateInFlightLoad(); update(state => ({ ...state, user: state.user ? { ...state.user, ...updates } : null })); }, - + // Set full user data setUser(userData) { + invalidateInFlightLoad(); + lastLoadedAt = Date.now(); update(state => ({ ...state, user: userData, error: null })); }, - + // Clear user data (on logout) clearUser() { + lastLoadedAt = 0; + invalidateInFlightLoad(); set({ user: null, loading: false, diff --git a/frontend/src/routes/BuilderJourney.svelte b/frontend/src/routes/BuilderJourney.svelte index dcfe2c86..e2e5460e 100644 --- a/frontend/src/routes/BuilderJourney.svelte +++ b/frontend/src/routes/BuilderJourney.svelte @@ -262,7 +262,7 @@ .startBuilderJourney() .then((res) => { if (res.data?.user) userStore.updateUser(res.data.user); - else userStore.loadUser?.(); + else userStore.loadUser?.({ force: true })?.catch(() => {}); markFunnelTime('journey_start:builder'); markLifecycleTime('first_journey_start:builder'); trackEvent('journey_started', getAnalyticsContext({ @@ -589,7 +589,7 @@ try { const res = await journeyAPI.linkGithubAccount(); if (res.data?.user) userStore.updateUser(res.data.user); - else await userStore.loadUser?.(); + else await userStore.loadUser?.({ force: true }); trackBuilderStepEvent('journey_step_verified', 'github'); showSuccess('GitHub linked. 25 BP awarded.'); } catch (err) { @@ -605,7 +605,7 @@ function handleGithubLinked(updatedUser) { if (updatedUser) userStore.updateUser(updatedUser); - else userStore.loadUser?.(); + else userStore.loadUser?.({ force: true })?.catch(() => {}); } function handleTaskCompleted(result) { @@ -619,7 +619,7 @@ : task ); loadTasks({ showLoading: false }); - userStore.loadUser?.(); + userStore.loadUser?.({ force: true })?.catch(() => {}); } function triggerWalletConnect() { @@ -650,7 +650,7 @@ completing = true; try { await journeyAPI.completeBuilderJourney(); - await userStore.loadUser(); + await userStore.loadUser({ force: true }); markLifecycleTime('role_unlocked:builder'); trackEvent('builder_role_claim_success', getAnalyticsContext(claimParams)); trackEvent('journey_completed', getAnalyticsContext({ diff --git a/frontend/src/routes/CommunityJourney.svelte b/frontend/src/routes/CommunityJourney.svelte index 4367e1cb..5523f802 100644 --- a/frontend/src/routes/CommunityJourney.svelte +++ b/frontend/src/routes/CommunityJourney.svelte @@ -174,7 +174,7 @@ time_from_wallet_auth_success_ms: getFunnelDurationMs('wallet_auth_success'), time_from_profile_completion_ms: getFunnelDurationMs('profile_completion'), })); - userStore.loadUser?.(); + userStore.loadUser?.({ force: true })?.catch(() => {}); }) .catch((err) => { trackEvent('journey_start_error', getAnalyticsContext({ @@ -339,7 +339,7 @@ try { const res = isX ? await journeyAPI.linkXAccount() : await journeyAPI.linkDiscordAccount(); if (res.data?.user) userStore.updateUser(res.data.user); - else await userStore.loadUser?.(); + else await userStore.loadUser?.({ force: true }); trackCommunityStepEvent('journey_step_verified', stepId); markStepDone(stepId); showSuccess(isX ? 'X account linked for community points.' : 'Discord account linked for community points.'); @@ -379,7 +379,7 @@ : task ); loadJourney({ showLoading: false }); - userStore.loadUser?.(); + userStore.loadUser?.({ force: true })?.catch(() => {}); } async function copyShareText() { @@ -494,7 +494,7 @@ try { const res = await journeyAPI.completeCommunityJourney(); if (res.data?.user) userStore.updateUser(res.data.user); - await userStore.loadUser?.(); + await userStore.loadUser?.({ force: true }); markLifecycleTime('role_unlocked:community'); trackEvent('community_role_claim_success', getAnalyticsContext(claimParams)); trackEvent('journey_completed', getAnalyticsContext({ diff --git a/frontend/src/routes/CommunityJourneyGate.svelte b/frontend/src/routes/CommunityJourneyGate.svelte index 41550302..734760f5 100644 --- a/frontend/src/routes/CommunityJourneyGate.svelte +++ b/frontend/src/routes/CommunityJourneyGate.svelte @@ -12,7 +12,7 @@ verificationState = 'loading'; try { - const user = await userStore.loadUser(); + const user = await userStore.loadUser({ force: true }); if (currentRequestId !== requestId) return; if (!user) { diff --git a/frontend/src/routes/PoapClaim.svelte b/frontend/src/routes/PoapClaim.svelte index 15d7a949..19cd076d 100644 --- a/frontend/src/routes/PoapClaim.svelte +++ b/frontend/src/routes/PoapClaim.svelte @@ -125,7 +125,7 @@ status = 'checking'; message = 'Checking claim access...'; try { - const user = await userStore.loadUser(); + const user = await userStore.loadUser({ force: true }); if (destroyed) return; if (!$authState.isAuthenticated) { status = 'auth'; diff --git a/frontend/src/routes/PoapDetail.svelte b/frontend/src/routes/PoapDetail.svelte index f7f50453..be0829ef 100644 --- a/frontend/src/routes/PoapDetail.svelte +++ b/frontend/src/routes/PoapDetail.svelte @@ -244,7 +244,7 @@ $effect(() => { if ($authState.isAuthenticated && !$userStore.user && !$userStore.loading) { - userStore.loadUser().catch(() => {}); + userStore.loadUser({ force: true }).catch(() => {}); } }); diff --git a/frontend/src/routes/ValidatorTelegram.svelte b/frontend/src/routes/ValidatorTelegram.svelte new file mode 100644 index 00000000..5cef71db --- /dev/null +++ b/frontend/src/routes/ValidatorTelegram.svelte @@ -0,0 +1,249 @@ + + +
+
+

+ Link a Telegram group +

+

+ Connect a Telegram group to your validator so {botLabel} can support you + there. You can link as many groups as you need — each one uses its own + one-time code. Direct messages are not supported: the bot only binds + groups. +

+
+ + +
+

+ How it works +

+
    +
  1. Create a new Telegram group for your validator.
  2. +
  3. Add {botLabel} to that group.
  4. +
  5. Generate a code below, then run /bindcode <code> in the group.
  6. +
+

+ Each code binds one group, works once, and expires after 48 hours. +

+
+ + {#if error} +
+ {error} +
+ {:else} + +
+
+
+

+ Generate a group code +

+

+ The code is shown only once — paste it in your group right away. +

+
+ +
+ + {#if freshCode} +
+

+ Your one-time code +

+
+ + /bindcode {freshCode} + + +
+

+ Run this command in your Telegram group. It will not be shown + again{freshCodeExpiresAt ? ` and expires ${formatDate(freshCodeExpiresAt)}` : ''}. +

+
+ {/if} +
+ + +
+

+ Your codes +

+ {#if loading} +

Loading...

+ {:else if codes.length === 0} +

+ No codes yet. Generate one above to link your first group. +

+ {:else} +
+ {#each codes as code (code.id)} +
+
+
+
+ tgb_{code.identifier}_… + + {STATUS_LABELS[code.status] || code.status} + +
+

+ Created {formatDate(code.created_at)} + {#if code.status === 'issued'} + · Expires {formatDate(code.expires_at)} + {:else if code.status === 'redeemed'} + · Group linked {formatDate(code.redeemed_at)} + {#if code.redeemed_group_chat_id} + (chat {code.redeemed_group_chat_id}) + {/if} + {/if} +

+
+ {#if code.status === 'issued'} + + {/if} +
+
+ {/each} +
+ {/if} +
+ {/if} +
diff --git a/frontend/src/routes/ValidatorWaitlist.svelte b/frontend/src/routes/ValidatorWaitlist.svelte index c724104c..599474a9 100644 --- a/frontend/src/routes/ValidatorWaitlist.svelte +++ b/frontend/src/routes/ValidatorWaitlist.svelte @@ -282,7 +282,7 @@ } // Joined server-side. The user refresh and success-banner write are both // best-effort and must not block (or undo) the success redirect. - userStore.loadUser?.()?.catch(() => {}); + userStore.loadUser?.({ force: true })?.catch(() => {}); markLifecycleTime('validator_waitlist_joined'); trackEvent('validator_waitlist_joined', getAnalyticsContext({ role_context: 'validator', diff --git a/frontend/src/routes/VerifyEmail.svelte b/frontend/src/routes/VerifyEmail.svelte index 0230ea69..fac8f027 100644 --- a/frontend/src/routes/VerifyEmail.svelte +++ b/frontend/src/routes/VerifyEmail.svelte @@ -104,7 +104,7 @@ let currentUser = userStore.getUser(); try { - currentUser = currentUser || await userStore.loadUser(); + currentUser = currentUser || await userStore.loadUser({ force: true }); } catch {} modalEmail = currentUser?.email || ''; modalDestination = state.address ? `/participant/${state.address}` : '/'; @@ -140,7 +140,7 @@ } } else { await confirmEmailVerification(token); - await userStore.loadUser(); + await userStore.loadUser({ force: true }); destination = '/profile'; } diff --git a/frontend/src/tests/analytics.test.js b/frontend/src/tests/analytics.test.js index 005f2f38..07f83f0b 100644 --- a/frontend/src/tests/analytics.test.js +++ b/frontend/src/tests/analytics.test.js @@ -291,3 +291,161 @@ describe('analytics helper', () => { expect(context).not.toHaveProperty('address'); }); }); + +describe('campaign acquisition attribution', () => { + beforeEach(() => { + stores.reset(); + vi.unstubAllEnvs(); + document.head.innerHTML = ''; + window.dataLayer = undefined; + window.gtag = undefined; + window.history.pushState({}, '', '/'); + sessionStorage.clear(); + localStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it('captures a structured first touch from a campaign landing', async () => { + window.history.pushState({}, '', '/builders?utm_id=cl-abc123&utm_source=x&utm_medium=organic_social&utm_campaign=ethcc&foo=bar'); + const analytics = await loadAnalytics(); + + expect(analytics.getAcquisitionAttribution()).toEqual({ + utm_id: 'cl-abc123', + landing_path: '/builders', + captured_at: expect.any(String), + }); + const firstTouch = JSON.parse(localStorage.getItem('campaign_first_touch')); + expect(firstTouch).toMatchObject({ + utm_id: 'cl-abc123', + source: 'x', + medium: 'organic_social', + campaign: 'ethcc', + }); + expect(firstTouch).not.toHaveProperty('foo'); + }); + + it('returns null without campaign params and requires utm_id', async () => { + let analytics = await loadAnalytics(); + expect(analytics.getAcquisitionAttribution()).toBeNull(); + + // UTMs without the opaque link ID cannot be resolved by the backend. + window.history.pushState({}, '', '/?utm_source=x&utm_medium=social'); + analytics = await loadAnalytics(); + expect(analytics.getAcquisitionAttribution()).toBeNull(); + }); + + it('a utm_id-less landing never locks the first-touch slot', async () => { + window.history.pushState({}, '', '/?utm_source=x&utm_medium=social'); + await loadAnalytics(); + expect(localStorage.getItem('campaign_first_touch')).toBeNull(); + + // Whitespace-only utm_id is just as unresolvable. + window.history.pushState({}, '', '/?utm_id=%20&utm_source=x&utm_medium=social'); + await loadAnalytics(); + expect(localStorage.getItem('campaign_first_touch')).toBeNull(); + + // A later real campaign click must still become the first touch. + window.history.pushState({}, '', '/builders?utm_id=cl-real&utm_source=x&utm_medium=social'); + const analytics = await loadAnalytics(); + expect(analytics.getAcquisitionAttribution().utm_id).toBe('cl-real'); + }); + + it('never overwrites a valid first touch but refreshes the session touch', async () => { + window.history.pushState({}, '', '/builders?utm_id=cl-first&utm_source=x&utm_medium=social'); + await loadAnalytics(); + + window.history.pushState({}, '', '/community?utm_id=cl-second&utm_source=discord&utm_medium=social'); + const analytics = await loadAnalytics(); + + expect(analytics.getAcquisitionAttribution().utm_id).toBe('cl-first'); + expect(JSON.parse(sessionStorage.getItem('campaign_session_touch')).utm_id).toBe('cl-second'); + }); + + it('expires the first touch after 30 days and falls back to the session touch', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + window.history.pushState({}, '', '/builders?utm_id=cl-old&utm_source=x&utm_medium=social'); + await loadAnalytics(); + + vi.setSystemTime(new Date('2026-02-05T00:00:00Z')); + sessionStorage.clear(); + window.history.pushState({}, '', '/'); + const analytics = await loadAnalytics(); + + expect(analytics.getAcquisitionAttribution()).toBeNull(); + expect(localStorage.getItem('campaign_first_touch')).toBeNull(); + }); + + it('templates wallet-address landing paths before storing them', async () => { + window.history.pushState({}, '', '/participant/0x1234567890abcdef1234567890abcdef12345678?utm_id=cl-abc&utm_source=x&utm_medium=social'); + const analytics = await loadAnalytics(); + + expect(analytics.getAcquisitionAttribution().landing_path).toBe('/participant/:address'); + }); + + it('cleans only attribution params from the visible URL', async () => { + window.history.pushState( + {}, '', + '/verify-email?utm_id=cl-abc&utm_source=x&gclid=g1&ref=ABCD1234&code=oauth-code&state=oauth-state#section', + ); + const analytics = await loadAnalytics(); + + expect(analytics.cleanTrackingParamsFromUrl(['ref'])).toBe(true); + expect(window.location.pathname).toBe('/verify-email'); + expect(window.location.search).toBe('?code=oauth-code&state=oauth-state'); + expect(window.location.hash).toBe('#section'); + }); + + it('cleanup is a no-op when nothing needs removing', async () => { + window.history.pushState({}, '', '/builders?page=2'); + const analytics = await loadAnalytics(); + + expect(analytics.cleanTrackingParamsFromUrl(['ref'])).toBe(false); + expect(window.location.search).toBe('?page=2'); + }); + + it('page_location still carries campaign params after URL cleanup', async () => { + vi.stubEnv('VITE_GOOGLE_ANALYTICS_ID', 'G-TEST123'); + window.history.pushState({}, '', '/builders?utm_source=x&utm_medium=social'); + const analytics = await loadAnalytics(); + + analytics.cleanTrackingParamsFromUrl(['ref']); + analytics.trackPageView('/builders'); + + const pageView = dataLayerCalls().find((call) => call[0] === 'event' && call[1] === 'page_view'); + expect(pageView[2].page_location).toBe( + `${window.location.origin}/builders?utm_source=x&utm_medium=social` + ); + }); + + it('trackSignUp fires sign_up only for created:true and clears the stored touch', async () => { + vi.stubEnv('VITE_GOOGLE_ANALYTICS_ID', 'G-TEST123'); + window.history.pushState({}, '', '/builders?utm_id=cl-abc&utm_source=x&utm_medium=social'); + const analytics = await loadAnalytics(); + + expect(analytics.trackSignUp({ authenticated: true, created: false })).toBe(false); + expect(analytics.trackSignUp(undefined)).toBe(false); + expect(analytics.getAcquisitionAttribution()).not.toBeNull(); + expect(dataLayerCalls().filter((call) => call[1] === 'sign_up')).toHaveLength(0); + + expect(analytics.trackSignUp( + { authenticated: true, created: true }, + { selected_role: 'builder', surface: 'profile_completion' }, + )).toBe(true); + + const signUps = dataLayerCalls().filter((call) => call[0] === 'event' && call[1] === 'sign_up'); + expect(signUps).toHaveLength(1); + expect(signUps[0][2]).toMatchObject({ + method: 'siwe_email', + selected_role: 'builder', + surface: 'profile_completion', + }); + // The converted touch is cleared so a later wallet in this browser does + // not inherit this campaign. + expect(analytics.getAcquisitionAttribution()).toBeNull(); + }); +}); diff --git a/frontend/src/tests/authSession.test.js b/frontend/src/tests/authSession.test.js index cb403b8f..e47b4d4a 100644 --- a/frontend/src/tests/authSession.test.js +++ b/frontend/src/tests/authSession.test.js @@ -1,10 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), - requestUse: vi.fn(), -})); +const mocks = vi.hoisted(() => { + const store = { + get: vi.fn(), + post: vi.fn(), + requestUse: vi.fn(), + clearCsrfToken: vi.fn(), + isCsrfFailure: vi.fn(), + // Captured at registration: importAuth() runs vi.clearAllMocks(), which + // would otherwise wipe the recorded interceptor arguments. + capturedResponseRejected: null, + }; + store.responseUse = vi.fn((onFulfilled, onRejected) => { + store.capturedResponseRejected = onRejected; + }); + return store; +}); vi.mock('axios', () => ({ default: { @@ -13,6 +24,7 @@ vi.mock('axios', () => ({ post: mocks.post, interceptors: { request: { use: mocks.requestUse }, + response: { use: mocks.responseUse }, }, })), }, @@ -24,6 +36,8 @@ vi.mock('../lib/config.js', () => ({ vi.mock('../lib/csrf.js', () => ({ attachCsrfToken: vi.fn((config) => config), + clearCsrfToken: mocks.clearCsrfToken, + isCsrfFailure: mocks.isCsrfFailure, })); vi.mock('../lib/userStore.js', () => ({ @@ -115,4 +129,100 @@ describe('auth session refresh', () => { await vi.advanceTimersByTimeAsync(5 * 60 * 1000); expect(mocks.post).toHaveBeenCalledTimes(1); }); + + // resetModules() re-imports auth.js, which registers another visibilitychange + // listener on the shared document, so earlier tests leave listeners behind. + // These assertions therefore measure growth across flips, not absolute counts. + it('throttles the visibility refresh so tab flipping is not one request per flip', async () => { + const { authState } = await importAuth(); + mocks.post.mockResolvedValue({ data: {} }); + authState.setAuthenticated(true, '0x123'); + + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(1000); + const afterFirstFlip = mocks.post.mock.calls.length; + expect(afterFirstFlip).toBeGreaterThan(0); + + for (let flip = 0; flip < 4; flip += 1) { + setDocumentHidden(true); + document.dispatchEvent(new Event('visibilitychange')); + setDocumentHidden(false); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(1000); + } + + expect(mocks.post.mock.calls.length).toBe(afterFirstFlip); + }); + + it('catches up on visibility once the throttle window has passed', async () => { + const { authState } = await importAuth(); + mocks.post.mockResolvedValue({ data: {} }); + authState.setAuthenticated(true, '0x123'); + + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(0); + const afterFirstFlip = mocks.post.mock.calls.length; + expect(afterFirstFlip).toBeGreaterThan(0); + + await vi.advanceTimersByTimeAsync(61 * 1000); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.post.mock.calls.length).toBeGreaterThan(afterFirstFlip); + }); + + it('does not re-verify immediately after a 5xx', async () => { + const { verifyAuth } = await importAuth(); + const serverError = new Error('boom'); + serverError.response = { status: 500 }; + mocks.get.mockRejectedValue(serverError); + + await verifyAuth({ force: true }); + await verifyAuth({ force: true }); + await verifyAuth({ force: true }); + + // One attempt, then the cooldown absorbs the rest. + expect(mocks.get).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(31 * 1000); + await verifyAuth({ force: true }); + expect(mocks.get).toHaveBeenCalledTimes(2); + }); + + // The auth endpoints are the only callers on authAxios, so if a CSRF failure + // there did not clear the cached token nothing else ever would, and the + // 5-minute session refresh would keep failing until the tab reloaded. + it('clears the cached CSRF token when an auth request is rejected for CSRF', async () => { + await importAuth(); + const onRejected = mocks.capturedResponseRejected; + const csrfError = { response: { status: 403, data: { detail: 'CSRF Failed: x' } } }; + mocks.isCsrfFailure.mockReturnValue(true); + + await expect(onRejected(csrfError)).rejects.toBe(csrfError); + + expect(mocks.clearCsrfToken).toHaveBeenCalledTimes(1); + }); + + it('leaves the cached CSRF token alone for a permission rejection', async () => { + await importAuth(); + const onRejected = mocks.capturedResponseRejected; + const permissionError = { + response: { status: 403, data: { detail: 'You do not have permission.' } }, + }; + mocks.isCsrfFailure.mockReturnValue(false); + + await expect(onRejected(permissionError)).rejects.toBe(permissionError); + + expect(mocks.clearCsrfToken).not.toHaveBeenCalled(); + }); + + it('still logs out on a definitive rejection', async () => { + const { verifyAuth, authState } = await importAuth(); + const authError = new Error('gone'); + authError.response = { status: 403 }; + mocks.get.mockRejectedValue(authError); + + await expect(verifyAuth({ force: true })).resolves.toBe(false); + expect(authState.get().isAuthenticated).toBe(false); + }); }); diff --git a/frontend/src/tests/csrf.test.js b/frontend/src/tests/csrf.test.js new file mode 100644 index 00000000..5b38c9f3 --- /dev/null +++ b/frontend/src/tests/csrf.test.js @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: { get: mocks.get }, +})); + +async function loadCsrf() { + vi.resetModules(); + return import('../lib/csrf.js'); +} + +function csrfResponse(token = 'token-1') { + return { data: { csrfToken: token, csrfCookieName: 'csrftoken' } }; +} + +describe('csrf token cache', () => { + beforeEach(() => { + mocks.get.mockReset(); + // Production splits the SPA and API across hosts, so document.cookie never + // carries the CSRF cookie. Model that here. + document.cookie = ''; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not touch the endpoint for safe methods', async () => { + const { attachCsrfToken } = await loadCsrf(); + + const config = await attachCsrfToken({ method: 'get' }); + + expect(mocks.get).not.toHaveBeenCalled(); + expect(config.headers?.['X-CSRFToken']).toBeUndefined(); + }); + + it('fetches once and reuses the token for sequential unsafe requests', async () => { + mocks.get.mockResolvedValue(csrfResponse()); + const { attachCsrfToken } = await loadCsrf(); + + const first = await attachCsrfToken({ method: 'post' }); + const second = await attachCsrfToken({ method: 'patch' }); + const third = await attachCsrfToken({ method: 'delete' }); + + expect(mocks.get).toHaveBeenCalledTimes(1); + expect(first.headers['X-CSRFToken']).toBe('token-1'); + expect(second.headers['X-CSRFToken']).toBe('token-1'); + expect(third.headers['X-CSRFToken']).toBe('token-1'); + }); + + it('coalesces simultaneous unsafe requests into one fetch', async () => { + let resolveGet; + mocks.get.mockImplementation( + () => new Promise((resolve) => { resolveGet = resolve; }) + ); + const { attachCsrfToken } = await loadCsrf(); + + const pending = Promise.all([ + attachCsrfToken({ method: 'post' }), + attachCsrfToken({ method: 'post' }), + ]); + resolveGet(csrfResponse()); + const [first, second] = await pending; + + expect(mocks.get).toHaveBeenCalledTimes(1); + expect(first.headers['X-CSRFToken']).toBe('token-1'); + expect(second.headers['X-CSRFToken']).toBe('token-1'); + }); + + it('refetches after clearCsrfToken', async () => { + mocks.get + .mockResolvedValueOnce(csrfResponse('token-1')) + .mockResolvedValueOnce(csrfResponse('token-2')); + const { attachCsrfToken, clearCsrfToken } = await loadCsrf(); + + await attachCsrfToken({ method: 'post' }); + clearCsrfToken(); + const after = await attachCsrfToken({ method: 'post' }); + + expect(mocks.get).toHaveBeenCalledTimes(2); + expect(after.headers['X-CSRFToken']).toBe('token-2'); + }); + + it('never writes the token to persistent storage', async () => { + mocks.get.mockResolvedValue(csrfResponse('secret-token')); + const localSpy = vi.spyOn(Storage.prototype, 'setItem'); + const { attachCsrfToken } = await loadCsrf(); + + await attachCsrfToken({ method: 'post' }); + + const persisted = localSpy.mock.calls.map(([, value]) => String(value)); + expect(persisted.some((value) => value.includes('secret-token'))).toBe(false); + }); + + it('prefers a readable cookie over the cached token', async () => { + document.cookie = 'csrftoken=cookie-token'; + const { attachCsrfToken } = await loadCsrf(); + + const config = await attachCsrfToken({ method: 'post' }); + + expect(mocks.get).not.toHaveBeenCalled(); + expect(config.headers['X-CSRFToken']).toBe('cookie-token'); + }); +}); + +describe('isCsrfFailure', () => { + it('recognises a DRF CSRF rejection', async () => { + const { isCsrfFailure } = await loadCsrf(); + + expect(isCsrfFailure({ + response: { status: 403, data: { detail: 'CSRF Failed: Origin checking failed.' } }, + })).toBe(true); + }); + + it('does not treat a permission 403 as a CSRF failure', async () => { + const { isCsrfFailure } = await loadCsrf(); + + expect(isCsrfFailure({ + response: { + status: 403, + data: { detail: 'You do not have permission to perform this action.' }, + }, + })).toBe(false); + }); + + it('ignores non-403 responses and network errors', async () => { + const { isCsrfFailure } = await loadCsrf(); + + expect(isCsrfFailure({ response: { status: 401, data: { detail: 'CSRF Failed: x' } } })).toBe(false); + expect(isCsrfFailure({ response: { status: 500, data: {} } })).toBe(false); + expect(isCsrfFailure({})).toBe(false); + expect(isCsrfFailure(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/tests/notificationCenterRequests.test.js b/frontend/src/tests/notificationCenterRequests.test.js new file mode 100644 index 00000000..4d481b19 --- /dev/null +++ b/frontend/src/tests/notificationCenterRequests.test.js @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/svelte/svelte5'; + +/** + * The navbar bell used to call loadLatest() on every route change even while + * closed, which cost a list request plus a redundant unread-count. Nothing + * rendered the component in tests, so the bug shipped unnoticed. These tests + * render it. + */ + +const mocks = vi.hoisted(() => { + // A minimal writable: vi.hoisted runs before imports, so svelte/store is not + // available here. + function store(initial) { + let value = initial; + const subscribers = new Set(); + return { + subscribe(run) { + subscribers.add(run); + run(value); + return () => subscribers.delete(run); + }, + set(next) { + value = next; + subscribers.forEach((run) => run(value)); + }, + }; + } + + return { + store, + loadLatest: vi.fn(), + loadUnreadCount: vi.fn(), + startPolling: vi.fn(() => () => {}), + reset: vi.fn(), + markRead: vi.fn(), + markAllRead: vi.fn(), + location: store('/'), + authState: store({ isAuthenticated: true }), + notifications: store({ + items: [], + unreadCount: 0, + loading: false, + error: null, + }), + }; +}); + +vi.mock('svelte-spa-router', () => ({ + push: vi.fn(), + location: mocks.location, +})); + +vi.mock('../lib/auth.js', () => ({ + authState: mocks.authState, +})); + +vi.mock('../lib/notificationStore.js', () => ({ + notificationStore: { + subscribe: mocks.notifications.subscribe, + loadLatest: mocks.loadLatest, + loadUnreadCount: mocks.loadUnreadCount, + startPolling: mocks.startPolling, + reset: mocks.reset, + markRead: mocks.markRead, + markAllRead: mocks.markAllRead, + }, +})); + +async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('NotificationCenter request volume', () => { + beforeEach(() => { + mocks.loadLatest.mockReset(); + mocks.loadUnreadCount.mockReset(); + mocks.location.set('/'); + mocks.authState.set({ isAuthenticated: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('only refreshes the unread count on route changes while closed', async () => { + const NotificationCenter = (await import('../components/NotificationCenter.svelte')).default; + render(NotificationCenter); + await flush(); + + mocks.loadLatest.mockClear(); + mocks.loadUnreadCount.mockClear(); + + for (const path of ['/builders', '/validators', '/community/poaps', '/profile']) { + mocks.location.set(path); + await flush(); + } + + expect(mocks.loadLatest).not.toHaveBeenCalled(); + expect(mocks.loadUnreadCount).toHaveBeenCalledTimes(4); + }); + + it('loads the list when the panel is opened', async () => { + const NotificationCenter = (await import('../components/NotificationCenter.svelte')).default; + const { getByRole } = render(NotificationCenter); + await flush(); + mocks.loadLatest.mockClear(); + + getByRole('button', { name: /notification/i }).click(); + await flush(); + + expect(mocks.loadLatest).toHaveBeenCalledTimes(1); + }); + + it('does not load the list on the notifications route', async () => { + const NotificationCenter = (await import('../components/NotificationCenter.svelte')).default; + render(NotificationCenter); + await flush(); + mocks.loadLatest.mockClear(); + mocks.loadUnreadCount.mockClear(); + + mocks.location.set('/notifications'); + await flush(); + + expect(mocks.loadLatest).not.toHaveBeenCalled(); + expect(mocks.loadUnreadCount).toHaveBeenCalledTimes(1); + }); + + it('resets instead of fetching when unauthenticated', async () => { + const NotificationCenter = (await import('../components/NotificationCenter.svelte')).default; + render(NotificationCenter); + await flush(); + mocks.loadUnreadCount.mockClear(); + mocks.reset.mockClear(); + + mocks.authState.set({ isAuthenticated: false }); + await flush(); + + expect(mocks.loadUnreadCount).not.toHaveBeenCalled(); + expect(mocks.reset).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/tests/userStore.test.js b/frontend/src/tests/userStore.test.js index 4e5f517d..f4d32649 100644 --- a/frontend/src/tests/userStore.test.js +++ b/frontend/src/tests/userStore.test.js @@ -233,4 +233,177 @@ describe('userStore', () => { unsubscribe(); }); }); + + // Every role-gated navigation calls loadUser(). In-flight coalescing only + // covers overlapping calls, so without a TTL each navigation refetched. + describe('success-cache TTL', () => { + const mockUser = { id: 1, name: 'Cached User', address: '0xabc' }; + + it('serves sequential loads from cache within the TTL', async () => { + getCurrentUser.mockResolvedValue(mockUser); + + await userStore.loadUser(); + const second = await userStore.loadUser(); + await userStore.loadUser(); + + expect(getCurrentUser).toHaveBeenCalledTimes(1); + expect(second).toEqual(mockUser); + }); + + it('refetches once the TTL has elapsed', async () => { + getCurrentUser.mockResolvedValue(mockUser); + const nowSpy = vi.spyOn(Date, 'now'); + + nowSpy.mockReturnValue(1_000_000); + await userStore.loadUser(); + nowSpy.mockReturnValue(1_000_000 + userStore.USER_CACHE_TTL_MS + 1); + await userStore.loadUser(); + + expect(getCurrentUser).toHaveBeenCalledTimes(2); + nowSpy.mockRestore(); + }); + + it('always refetches with force', async () => { + getCurrentUser.mockResolvedValue(mockUser); + + await userStore.loadUser(); + await userStore.loadUser({ force: true }); + + expect(getCurrentUser).toHaveBeenCalledTimes(2); + }); + + it('does not extend the TTL after a 5xx, and keeps the known user', async () => { + const nowSpy = vi.spyOn(Date, 'now'); + const start = 1_000_000; + + getCurrentUser.mockResolvedValueOnce(mockUser); + nowSpy.mockReturnValue(start); + await userStore.loadUser(); + + const serverError = new Error('boom'); + serverError.response = { status: 500 }; + getCurrentUser.mockRejectedValueOnce(serverError); + nowSpy.mockReturnValue(start + 20_000); + await expect(userStore.loadUser({ force: true })).rejects.toThrow('boom'); + expect(get(userStore).user).toEqual(mockUser); + + // Past the original TTL but still inside one measured from the failure. + // An UNFORCED read is the only thing that can catch a failure wrongly + // refreshing the timestamp; a forced read would hit the network anyway. + getCurrentUser.mockResolvedValueOnce(mockUser); + nowSpy.mockReturnValue(start + userStore.USER_CACHE_TTL_MS + 1_000); + await userStore.loadUser(); + + expect(getCurrentUser).toHaveBeenCalledTimes(3); + nowSpy.mockRestore(); + }); + + it('discards a response that lands after clearUser', async () => { + // Logout and wallet switch both clear the store while a load may still + // be in flight; the old account must not reappear when it resolves. + let resolveLoad; + getCurrentUser.mockImplementationOnce( + () => new Promise((resolve) => { resolveLoad = resolve; }) + ); + + const pending = userStore.loadUser(); + userStore.clearUser(); + resolveLoad(mockUser); + await pending; + + expect(get(userStore).user).toBeNull(); + }); + + it('does not let a post-clearUser response seed the cache', async () => { + let resolveLoad; + getCurrentUser.mockImplementationOnce( + () => new Promise((resolve) => { resolveLoad = resolve; }) + ); + + const pending = userStore.loadUser(); + userStore.clearUser(); + resolveLoad(mockUser); + await pending; + + // The discarded response must not have started a TTL, so the next read + // goes to the network instead of serving an account that was logged out. + getCurrentUser.mockResolvedValueOnce(mockUser); + await userStore.loadUser(); + + expect(getCurrentUser).toHaveBeenCalledTimes(2); + expect(get(userStore).user).toEqual(mockUser); + }); + + it('a late response does not overwrite setUser', async () => { + // setUser carries the server's post-mutation user (profile save, role + // join, claim), so a read that started before it must not win. + let resolveLoad; + getCurrentUser.mockImplementationOnce( + () => new Promise((resolve) => { resolveLoad = resolve; }) + ); + const saved = { ...mockUser, name: 'Saved' }; + const stale = { ...mockUser, name: 'Stale' }; + + const pending = userStore.loadUser(); + userStore.setUser(saved); + resolveLoad(stale); + await pending; + + expect(get(userStore).user).toEqual(saved); + }); + + it('a late response does not overwrite updateUser', async () => { + let resolveLoad; + getCurrentUser.mockImplementationOnce( + () => new Promise((resolve) => { resolveLoad = resolve; }) + ); + userStore.setUser({ ...mockUser, name: 'Before' }); + + // setUser seeds the TTL, so force past it to get a real request in flight. + const pending = userStore.loadUser({ force: true }); + userStore.updateUser({ name: 'Merged' }); + resolveLoad({ ...mockUser, name: 'Stale' }); + await pending; + + expect(get(userStore).user.name).toBe('Merged'); + }); + + it('a forced load does not settle for a request that predates it', async () => { + // The forced caller has just mutated server state, so joining the + // in-flight response would hand back pre-mutation data. + let resolveFirst; + getCurrentUser.mockImplementationOnce( + () => new Promise((resolve) => { resolveFirst = resolve; }) + ); + const stale = { ...mockUser, name: 'Stale' }; + const fresh = { ...mockUser, name: 'Fresh' }; + + const backgroundLoad = userStore.loadUser(); + const forcedLoad = userStore.loadUser({ force: true }); + + getCurrentUser.mockResolvedValueOnce(fresh); + resolveFirst(stale); + + await expect(backgroundLoad).resolves.toEqual(stale); + await expect(forcedLoad).resolves.toEqual(fresh); + expect(getCurrentUser).toHaveBeenCalledTimes(2); + // The newer response must be the one left in the store. + expect(get(userStore).user).toEqual(fresh); + }); + + it('clears the cache on 401 so the next load refetches', async () => { + getCurrentUser.mockResolvedValueOnce(mockUser); + await userStore.loadUser(); + + const authError = new Error('unauthenticated'); + authError.response = { status: 401 }; + getCurrentUser.mockRejectedValueOnce(authError); + await expect(userStore.loadUser({ force: true })).rejects.toThrow('unauthenticated'); + expect(get(userStore).user).toBeNull(); + + getCurrentUser.mockResolvedValueOnce(mockUser); + await userStore.loadUser(); + expect(getCurrentUser).toHaveBeenCalledTimes(3); + }); + }); });