Verify Play physical location lifecycle
This commit is contained in:
parent
53c6099c36
commit
5df616a1e3
|
|
@ -15,15 +15,18 @@
|
||||||
- [x] Accept Play App Signing.
|
- [x] Accept Play App Signing.
|
||||||
- [x] Record the upload-certificate SHA-1 and SHA-256 with the source-bound
|
- [x] Record the upload-certificate SHA-1 and SHA-256 with the source-bound
|
||||||
candidate.
|
candidate.
|
||||||
- [ ] Record every Play App Signing SHA-1 and SHA-256 displayed by Play after
|
- [x] Record every Play App Signing SHA-1 and SHA-256 displayed by Play after
|
||||||
the first AAB upload makes Play generate its signing identities and
|
the first AAB upload makes Play generate its signing identities and
|
||||||
before any tester rollout. Do not assume that a new app has only one
|
before any tester rollout. Do not assume that a new app has only one
|
||||||
Play certificate: current Play quantum-ready hybrid signing can expose
|
Play certificate: current Play quantum-ready hybrid signing can expose
|
||||||
multiple classical/post-quantum identities for different Android
|
multiple classical/post-quantum identities for different Android
|
||||||
generations.
|
generations. The protected identity document records all three Play
|
||||||
- [ ] Add every applicable Play App Signing SHA-256 to production
|
identities shown for version `0.1.0 (1)` plus the independent upload
|
||||||
Google/Firebase Android configuration.
|
identity; no certificate values are stored in this public checklist.
|
||||||
- [ ] Publish and verify
|
- [x] Add every applicable Play App Signing SHA-1/SHA-256 identity to the
|
||||||
|
production Google/Firebase Android configuration. A freshly downloaded
|
||||||
|
production client configuration was validated after the import.
|
||||||
|
- [x] Publish and verify
|
||||||
`https://whoneedhelp.com/.well-known/assetlinks.json` for the Play
|
`https://whoneedhelp.com/.well-known/assetlinks.json` for the Play
|
||||||
certificate identities used to sign delivered APKs.
|
certificate identities used to sign delivered APKs.
|
||||||
Use `scripts/import-play-android-config.sh` in plan mode first, then
|
Use `scripts/import-play-android-config.sh` in plan mode first, then
|
||||||
|
|
@ -42,9 +45,11 @@
|
||||||
lint, package name, version code/name, target SDK, and production origin.
|
lint, package name, version code/name, target SDK, and production origin.
|
||||||
- [x] Install the release APK generated from the same source-bound build on the authorized
|
- [x] Install the release APK generated from the same source-bound build on the authorized
|
||||||
physical phone and run the release smoke test.
|
physical phone and run the release smoke test.
|
||||||
- [ ] Save and publish the source-bound AAB currently uploaded to the internal
|
- [x] Save and publish the source-bound AAB currently uploaded to the internal
|
||||||
testing draft. Do not mark this complete until Play Console shows one
|
testing draft. Do not mark this complete until Play Console shows one
|
||||||
accepted artifact and the internal release is available to testers.
|
accepted artifact and the internal release is available to testers. The
|
||||||
|
exact `0.1.0 (1)` release is active only on the Internal testing track;
|
||||||
|
no Closed or Production rollout was started.
|
||||||
|
|
||||||
## Production capability gate
|
## Production capability gate
|
||||||
|
|
||||||
|
|
@ -53,10 +58,10 @@
|
||||||
checks reported `READY`, with zero blocking items and zero local-only
|
checks reported `READY`, with zero blocking items and zero local-only
|
||||||
warnings. The validator did not print secret values and did not modify
|
warnings. The validator did not print secret values and did not modify
|
||||||
production.
|
production.
|
||||||
- [ ] Pass the stricter `--require-release` gate. Its only remaining blocking
|
- [x] Pass the stricter `--require-release` gate. On 2026-08-09 the live
|
||||||
item on 2026-08-08 is the not-yet-generated set of Play App Signing
|
production `.env` reported all twelve capabilities `READY`, with zero
|
||||||
SHA-256 fingerprints;
|
blocking items and zero local-only warnings after the Play identities
|
||||||
every other capability reported `READY`.
|
were imported.
|
||||||
|
|
||||||
## Store presence
|
## Store presence
|
||||||
|
|
||||||
|
|
@ -100,12 +105,21 @@
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- [ ] Internal track smoke test passed.
|
- [x] Internal track smoke test passed.
|
||||||
Before exercising product flows, run
|
Before exercising product flows, run
|
||||||
`scripts/verify-play-installed-android.sh` with the protected Play
|
`scripts/verify-play-installed-android.sh` with the protected Play
|
||||||
identity document, physical-device serial, and exact expected version.
|
identity document, physical-device serial, and exact expected version.
|
||||||
It must confirm the Google Play installer, Play signing identity,
|
It must confirm the Google Play installer, Play signing identity,
|
||||||
verified production App Link, and `MainActivity` resolution.
|
verified production App Link, and `MainActivity` resolution.
|
||||||
|
The 2026-08-09 physical-device replay passed that verifier, Google
|
||||||
|
sign-in, production App Link routing, Android notification permission,
|
||||||
|
production FCM receipt and notification-tap routing. The same
|
||||||
|
Play-delivered build then passed the separate consent-driven foreground
|
||||||
|
location flow: explicit disclosure, Android permission, persistent
|
||||||
|
notification, minimized-app sampling, notification Stop cleanup,
|
||||||
|
offline/reconnect, and stopped-process recreation without sticky
|
||||||
|
tracking. This verifies observed app behavior; it does not complete the
|
||||||
|
separate Play Console policy declarations above.
|
||||||
- [ ] Closed track created and opt-in link tested.
|
- [ ] Closed track created and opt-in link tested.
|
||||||
- [ ] At least 12 testers continuously opted in for 14 days.
|
- [ ] At least 12 testers continuously opted in for 14 days.
|
||||||
- [ ] Tester feedback and fixes documented.
|
- [ ] Tester feedback and fixes documented.
|
||||||
|
|
|
||||||
|
|
@ -2113,3 +2113,82 @@ promoted.
|
||||||
changed and no release was saved, submitted, or published. The Play App
|
changed and no release was saved, submitted, or published. The Play App
|
||||||
Signing certificate set and Play-delivered device flow remain unverified
|
Signing certificate set and Play-delivered device flow remain unverified
|
||||||
gates.
|
gates.
|
||||||
|
|
||||||
|
# 2026-08-09 Google Play internal-track and production-provider verification
|
||||||
|
|
||||||
|
- Google Play accepted the source-bound AAB with SHA-256
|
||||||
|
`03d39a9a08e9ca7569caccf1c7bd75e9349f7655935e7bbf23d1998cd37b3837`
|
||||||
|
as version `0.1.0 (1)`. The release named `0.1.0 internal verification` is
|
||||||
|
active only on the Internal testing track. No Closed or Production rollout
|
||||||
|
was created or started.
|
||||||
|
- Every Play App Signing identity displayed for the accepted artifact was
|
||||||
|
recorded in a mode-`0600`, ignored provider document. The production
|
||||||
|
Firebase Android app and Google OAuth clients were reconciled with all Play
|
||||||
|
SHA-1 identities while preserving the independent upload identity. A fresh
|
||||||
|
production Android client configuration contained four Android OAuth
|
||||||
|
clients and one Web OAuth client and passed the repository validator. Secret
|
||||||
|
provider material is intentionally not reproduced in this document.
|
||||||
|
- The production application was recreated with the reconciled provider
|
||||||
|
configuration while retaining its exact application image
|
||||||
|
`who-need-help:production-0ad9a5430e1a`. A subsequent read-only observation
|
||||||
|
reported zero container restarts, healthy Docker state, and the expected
|
||||||
|
`{"status":"ready"}` response both locally on the server and through the
|
||||||
|
public HTTPS endpoint. The frozen hackathon test containers remained healthy
|
||||||
|
and were not changed.
|
||||||
|
- The public Android association response contained one statement and four
|
||||||
|
unique SHA-256 signing identities. On the connected physical phone,
|
||||||
|
`scripts/verify-play-installed-android.sh` confirmed package
|
||||||
|
`org.whoneedhelp.mobile`, version `0.1.0 (1)`, installer Google Play, a member
|
||||||
|
of the recorded Play App Signing set, verified `whoneedhelp.com` domain
|
||||||
|
state, and production App Link resolution to `MainActivity`.
|
||||||
|
- The Play-delivered build completed production Google sign-in without a
|
||||||
|
secondary ownership email, opened `/requests` through the verified App Link,
|
||||||
|
registered its Android FCM device after the system notification permission
|
||||||
|
was granted, received one scoped production FCM notification, and routed a
|
||||||
|
tap to the in-app notifications inbox. The notification displayed the
|
||||||
|
privacy-safe localized title and body rather than the internal event copy.
|
||||||
|
- The exact scoped notification and its two related Oban jobs were deleted
|
||||||
|
after the delivery proof. A follow-up query found zero remaining
|
||||||
|
notifications for its unique idempotency key. This cleanup did not delete
|
||||||
|
any other notification or background job.
|
||||||
|
- The strict live production readiness replay reported all twelve capability
|
||||||
|
groups `READY`, zero blocking items, and zero local-only warnings. The
|
||||||
|
structural production environment validator also passed without printing
|
||||||
|
secrets. These checks verify configuration and the observed flows above;
|
||||||
|
they do not establish closed-test completion, public Production-track
|
||||||
|
approval, or capacity limits.
|
||||||
|
|
||||||
|
## Play-delivered physical foreground-location replay
|
||||||
|
|
||||||
|
- A run-scoped production fixture linked one synthetic requester and one
|
||||||
|
synthetic medicine-pickup request to the already authenticated physical
|
||||||
|
tester. Before starting, the phone reported both fine and coarse location
|
||||||
|
permissions denied and production reported no active tracking session for
|
||||||
|
that tester.
|
||||||
|
- The Play-delivered `0.1.0 (1)` build opened the exact request through the
|
||||||
|
verified production App Link. Starting sharing displayed the native
|
||||||
|
prominent disclosure first, then Android's foreground-location permission.
|
||||||
|
After consent, Android reported the `TrackingService` foreground service and
|
||||||
|
an ongoing `Sharing live location` notification with a `Stop sharing`
|
||||||
|
action.
|
||||||
|
- Production observed three samples and one retained current position while
|
||||||
|
the app was visible. After Home minimized the app, the same session remained
|
||||||
|
active, its ongoing notification remained present, and the observed sample
|
||||||
|
count increased to eleven. No raw coordinate was printed into verification
|
||||||
|
output or retained in this document.
|
||||||
|
- Invoking `Stop sharing` from the system notification removed both the
|
||||||
|
foreground service and ongoing notification. Production then observed one
|
||||||
|
inactive ended session with forty summary samples and zero retained raw
|
||||||
|
positions.
|
||||||
|
- With airplane mode enabled, a cold application start showed the native
|
||||||
|
offline screen and explicitly stated that private pages are not stored on
|
||||||
|
the device. Airplane mode was restored to its original disabled state; the
|
||||||
|
exact authenticated request returned without a new login. A subsequent
|
||||||
|
application force-stop and verified App Link restart also restored the
|
||||||
|
authenticated stopped state while leaving both the tracking service and
|
||||||
|
tracking notification absent.
|
||||||
|
- Cleanup removed exactly one fixture request, assignment, tracking session,
|
||||||
|
and synthetic requester. A follow-up production query found zero fixture
|
||||||
|
users and zero fixture requests while the pre-existing tester and its one
|
||||||
|
push device remained present. The frozen hackathon test deployment and the
|
||||||
|
public Git remote were not changed.
|
||||||
|
|
|
||||||
356
scripts/production-play-physical-fixture.exs
Normal file
356
scripts/production-play-physical-fixture.exs
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Oban.Job
|
||||||
|
alias WhoNeedHelp.Accounts.{Scope, User, UserToken}
|
||||||
|
alias WhoNeedHelp.Catalog.Category
|
||||||
|
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||||
|
alias WhoNeedHelp.Messaging.Message
|
||||||
|
alias WhoNeedHelp.Notifications.Notification
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
alias WhoNeedHelp.Tracking
|
||||||
|
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
|
||||||
|
alias WhoNeedHelp.Trust.{AuditEvent, RateLimitBucket, Report, Review}
|
||||||
|
|
||||||
|
@allowed_actions ~w(prepare verify-active verify-stopped cleanup)
|
||||||
|
|
||||||
|
def run(action, options) when is_binary(action) and is_map(options) do
|
||||||
|
unless action in @allowed_actions, do: raise("unsupported fixture action")
|
||||||
|
context = verified_context(action, options)
|
||||||
|
|
||||||
|
case action do
|
||||||
|
"prepare" -> prepare(context)
|
||||||
|
"verify-active" -> verify_active(context)
|
||||||
|
"verify-stopped" -> verify_stopped(context)
|
||||||
|
"cleanup" -> cleanup(context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verified_context(action, options) do
|
||||||
|
run_id = required_option!(options, :run_id)
|
||||||
|
expected_database = required_option!(options, :expected_database)
|
||||||
|
helper_email = required_option!(options, :helper_email) |> String.downcase()
|
||||||
|
manifest_path = required_option!(options, :manifest_path)
|
||||||
|
|
||||||
|
unless Regex.match?(~r/^[a-z0-9-]+$/, run_id), do: raise("invalid run id")
|
||||||
|
|
||||||
|
unless String.starts_with?(manifest_path, "/tmp/wnh-play-physical-") do
|
||||||
|
raise "invalid manifest path"
|
||||||
|
end
|
||||||
|
|
||||||
|
%Postgrex.Result{rows: [[actual_database]]} =
|
||||||
|
Repo.query!("SELECT current_database()", [], log: false)
|
||||||
|
|
||||||
|
unless actual_database == expected_database, do: raise("database identity mismatch")
|
||||||
|
|
||||||
|
%{
|
||||||
|
action: action,
|
||||||
|
run_id: run_id,
|
||||||
|
database: actual_database,
|
||||||
|
helper_email: helper_email,
|
||||||
|
requester_email: "wnh-play-physical-#{run_id}-requester@example.invalid",
|
||||||
|
manifest_path: manifest_path
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare(context) do
|
||||||
|
if File.exists?(context.manifest_path), do: raise("manifest already exists")
|
||||||
|
assert_requester_absent!(context)
|
||||||
|
|
||||||
|
helper = Repo.get_by(User, email: context.helper_email)
|
||||||
|
|
||||||
|
unless match?(%User{moderation_status: :active, confirmed_at: %DateTime{}}, helper) do
|
||||||
|
raise "helper is missing, unconfirmed, or inactive"
|
||||||
|
end
|
||||||
|
|
||||||
|
if Repo.exists?(from(s in TrackingSession, where: s.user_id == ^helper.id and s.active)) do
|
||||||
|
raise "helper already has an active tracking session"
|
||||||
|
end
|
||||||
|
|
||||||
|
now = DateTime.utc_now(:second)
|
||||||
|
category = Repo.get_by!(Category, slug: "medicine-pickup", active: true)
|
||||||
|
|
||||||
|
{:ok, fixture} =
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
requester =
|
||||||
|
%User{}
|
||||||
|
|> User.registration_changeset(%{
|
||||||
|
"email" => context.requester_email,
|
||||||
|
"display_name" => "Play physical smoke requester",
|
||||||
|
"locale" => "en",
|
||||||
|
"terms_accepted" => true
|
||||||
|
})
|
||||||
|
|> Ecto.Changeset.put_change(:confirmed_at, now)
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
request =
|
||||||
|
%HelpRequest{requester_id: requester.id}
|
||||||
|
|> HelpRequest.create_changeset(%{
|
||||||
|
"title" => "Play physical location smoke #{context.run_id}",
|
||||||
|
"description" => "Run-scoped synthetic request for foreground location verification.",
|
||||||
|
"structured_data" => %{"pickup_status" => "reserved"},
|
||||||
|
"location_label" => "Synthetic Play verification area",
|
||||||
|
"latitude" => "50.4501",
|
||||||
|
"longitude" => "30.5234",
|
||||||
|
"location_radius_meters" => 500,
|
||||||
|
"urgency" => "now",
|
||||||
|
"location_visibility" => "exact_for_active_match",
|
||||||
|
"expires_at" => DateTime.add(now, 2 * 60 * 60, :second),
|
||||||
|
"category_id" => category.id,
|
||||||
|
"safety_confirmed" => true
|
||||||
|
})
|
||||||
|
|> Ecto.Changeset.put_change(:status, :matched)
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
assignment =
|
||||||
|
%Assignment{}
|
||||||
|
|> Assignment.changeset(%{
|
||||||
|
request_id: request.id,
|
||||||
|
helper_id: helper.id,
|
||||||
|
status: :accepted,
|
||||||
|
accepted_at: now,
|
||||||
|
handover_code_hash: :crypto.hash(:sha256, context.run_id)
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
%{requester: requester, helper: helper, request: request, assignment: assignment}
|
||||||
|
end)
|
||||||
|
|
||||||
|
manifest = %{
|
||||||
|
"schema_version" => 1,
|
||||||
|
"run_id" => context.run_id,
|
||||||
|
"database" => context.database,
|
||||||
|
"requester" => %{"id" => fixture.requester.id, "email" => fixture.requester.email},
|
||||||
|
"helper" => %{"id" => fixture.helper.id, "email" => fixture.helper.email},
|
||||||
|
"request" => %{
|
||||||
|
"id" => fixture.request.id,
|
||||||
|
"path" => "/requests/#{fixture.request.id}"
|
||||||
|
},
|
||||||
|
"assignment" => %{"id" => fixture.assignment.id}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
|
||||||
|
File.chmod!(context.manifest_path, 0o600)
|
||||||
|
IO.puts("fixture_prepared=true")
|
||||||
|
IO.puts("request_path=#{manifest["request"]["path"]}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_active(context) do
|
||||||
|
fixture = context |> load_and_validate_manifest!() |> validate_fixture_links!()
|
||||||
|
sessions = fixture_sessions(fixture.assignment.id, fixture.helper.id)
|
||||||
|
positions = fixture_position_count(sessions)
|
||||||
|
|
||||||
|
unless match?([%TrackingSession{active: true, sample_count: n}] when n >= 1, sessions) and
|
||||||
|
positions == 1 do
|
||||||
|
raise "expected one active sampled session and one current raw position"
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts("active_tracking_verified=true")
|
||||||
|
IO.puts("sample_count=#{hd(sessions).sample_count}")
|
||||||
|
IO.puts("retained_position_count=#{positions}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_stopped(context) do
|
||||||
|
fixture = context |> load_and_validate_manifest!() |> validate_fixture_links!()
|
||||||
|
sessions = fixture_sessions(fixture.assignment.id, fixture.helper.id)
|
||||||
|
positions = fixture_position_count(sessions)
|
||||||
|
|
||||||
|
unless match?(
|
||||||
|
[%TrackingSession{active: false, ended_at: %DateTime{}, sample_count: n}]
|
||||||
|
when n >= 1,
|
||||||
|
sessions
|
||||||
|
) and positions == 0 do
|
||||||
|
raise "expected one stopped sampled session and zero retained raw positions"
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts("stopped_tracking_verified=true")
|
||||||
|
IO.puts("sample_count=#{hd(sessions).sample_count}")
|
||||||
|
IO.puts("retained_position_count=#{positions}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cleanup(context) do
|
||||||
|
fixture = context |> load_and_validate_manifest!() |> validate_fixture_links!()
|
||||||
|
|
||||||
|
if Repo.exists?(
|
||||||
|
from(s in TrackingSession,
|
||||||
|
where:
|
||||||
|
s.assignment_id == ^fixture.assignment.id and s.user_id == ^fixture.helper.id and
|
||||||
|
s.active
|
||||||
|
)
|
||||||
|
) do
|
||||||
|
case Tracking.stop_session(Scope.for_user(fixture.helper), fixture.assignment) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> raise "could not stop fixture tracking: #{inspect(reason)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
sessions = fixture_sessions(fixture.assignment.id, fixture.helper.id)
|
||||||
|
session_ids = Enum.map(sessions, & &1.id)
|
||||||
|
message_ids = exact_ids(Message, :assignment_id, fixture.assignment.id)
|
||||||
|
|
||||||
|
notification_ids =
|
||||||
|
Notification
|
||||||
|
|> where(
|
||||||
|
[n],
|
||||||
|
fragment("?->>'assignment_id' = ?", n.data, ^fixture.assignment.id) or
|
||||||
|
fragment("?->>'request_id' = ?", n.data, ^fixture.request.id)
|
||||||
|
)
|
||||||
|
|> select([n], n.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
job_ids = fixture_job_ids(fixture, notification_ids)
|
||||||
|
target_ids = [fixture.requester.id, fixture.request.id, fixture.assignment.id] ++ message_ids
|
||||||
|
requester_scope_hash = :crypto.hash(:sha256, fixture.requester.id)
|
||||||
|
|
||||||
|
{:ok, deleted} =
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
%{
|
||||||
|
jobs: delete_ids(Job, job_ids),
|
||||||
|
notifications: delete_ids(Notification, notification_ids),
|
||||||
|
audit_events: AuditEvent |> where([e], e.target_id in ^target_ids) |> delete_count(),
|
||||||
|
reports:
|
||||||
|
Report
|
||||||
|
|> where(
|
||||||
|
[r],
|
||||||
|
r.request_id == ^fixture.request.id or r.assignment_id == ^fixture.assignment.id or
|
||||||
|
r.message_id in ^message_ids
|
||||||
|
)
|
||||||
|
|> delete_count(),
|
||||||
|
reviews:
|
||||||
|
Review
|
||||||
|
|> where([r], r.assignment_id == ^fixture.assignment.id)
|
||||||
|
|> delete_count(),
|
||||||
|
positions:
|
||||||
|
Position
|
||||||
|
|> where([p], p.tracking_session_id in ^session_ids)
|
||||||
|
|> delete_count(),
|
||||||
|
sessions: delete_ids(TrackingSession, session_ids),
|
||||||
|
messages: delete_ids(Message, message_ids),
|
||||||
|
assignment: delete_ids(Assignment, [fixture.assignment.id]),
|
||||||
|
request: delete_ids(HelpRequest, [fixture.request.id]),
|
||||||
|
requester_tokens:
|
||||||
|
UserToken
|
||||||
|
|> where([t], t.user_id == ^fixture.requester.id)
|
||||||
|
|> delete_count(),
|
||||||
|
requester_rate_limits:
|
||||||
|
RateLimitBucket
|
||||||
|
|> where([b], b.scope_hash == ^requester_scope_hash)
|
||||||
|
|> delete_count(),
|
||||||
|
requester: delete_ids(User, [fixture.requester.id])
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
unless deleted.assignment == 1 and deleted.request == 1 and deleted.requester == 1 do
|
||||||
|
raise "cleanup did not remove the exact fixture"
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_requester_absent!(context)
|
||||||
|
File.rm!(context.manifest_path)
|
||||||
|
IO.puts("fixture_cleanup_verified=true")
|
||||||
|
IO.puts("deleted=#{inspect(deleted, limit: :infinity)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_and_validate_manifest!(context) do
|
||||||
|
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
|
||||||
|
|
||||||
|
valid? =
|
||||||
|
manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
|
||||||
|
manifest["database"] == context.database and
|
||||||
|
manifest["requester"]["email"] == context.requester_email and
|
||||||
|
manifest["helper"]["email"] == context.helper_email and
|
||||||
|
uuid?(manifest["requester"]["id"]) and uuid?(manifest["helper"]["id"]) and
|
||||||
|
uuid?(manifest["request"]["id"]) and uuid?(manifest["assignment"]["id"]) and
|
||||||
|
manifest["request"]["path"] == "/requests/#{manifest["request"]["id"]}"
|
||||||
|
|
||||||
|
unless valid?, do: raise("manifest does not match this exact run")
|
||||||
|
manifest
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_fixture_links!(manifest) do
|
||||||
|
requester = Repo.get(User, manifest["requester"]["id"])
|
||||||
|
helper = Repo.get(User, manifest["helper"]["id"])
|
||||||
|
request = Repo.get(HelpRequest, manifest["request"]["id"])
|
||||||
|
assignment = Repo.get(Assignment, manifest["assignment"]["id"])
|
||||||
|
|
||||||
|
valid? =
|
||||||
|
match?(%User{}, requester) and match?(%User{}, helper) and
|
||||||
|
match?(%HelpRequest{}, request) and match?(%Assignment{}, assignment) and
|
||||||
|
requester.email == manifest["requester"]["email"] and
|
||||||
|
helper.email == manifest["helper"]["email"] and request.requester_id == requester.id and
|
||||||
|
assignment.request_id == request.id and assignment.helper_id == helper.id and
|
||||||
|
assignment.status in [:accepted, :in_progress] and assignment.active
|
||||||
|
|
||||||
|
unless valid?, do: raise("fixture links no longer match the manifest")
|
||||||
|
|
||||||
|
if Repo.exists?(
|
||||||
|
from(r in HelpRequest,
|
||||||
|
where: r.requester_id == ^requester.id and r.id != ^request.id
|
||||||
|
)
|
||||||
|
) do
|
||||||
|
raise "synthetic requester owns unexpected requests"
|
||||||
|
end
|
||||||
|
|
||||||
|
%{requester: requester, helper: helper, request: request, assignment: assignment}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fixture_sessions(assignment_id, helper_id) do
|
||||||
|
TrackingSession
|
||||||
|
|> where([s], s.assignment_id == ^assignment_id and s.user_id == ^helper_id)
|
||||||
|
|> order_by([s], asc: s.inserted_at)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fixture_position_count([]), do: 0
|
||||||
|
|
||||||
|
defp fixture_position_count(sessions) do
|
||||||
|
ids = Enum.map(sessions, & &1.id)
|
||||||
|
Position |> where([p], p.tracking_session_id in ^ids) |> Repo.aggregate(:count)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fixture_job_ids(fixture, notification_ids) do
|
||||||
|
notification_ids = MapSet.new(notification_ids)
|
||||||
|
|
||||||
|
Job
|
||||||
|
|> where(
|
||||||
|
[job],
|
||||||
|
job.worker in [
|
||||||
|
"WhoNeedHelp.Push.DeliveryWorker",
|
||||||
|
"WhoNeedHelp.Push.NotificationDispatchWorker",
|
||||||
|
"WhoNeedHelp.Push.DeviceDeliveryWorker",
|
||||||
|
"WhoNeedHelp.Push.NotificationEmailWorker"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|> select([job], {job.id, job.args})
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.filter(fn {_id, args} ->
|
||||||
|
args["assignment_id"] == fixture.assignment.id or
|
||||||
|
args["request_id"] == fixture.request.id or
|
||||||
|
MapSet.member?(notification_ids, args["notification_id"])
|
||||||
|
end)
|
||||||
|
|> Enum.map(&elem(&1, 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp exact_ids(schema, name, value) do
|
||||||
|
schema |> where([row], field(row, ^name) == ^value) |> select([row], row.id) |> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp delete_ids(_schema, []), do: 0
|
||||||
|
defp delete_ids(schema, ids), do: schema |> where([row], row.id in ^ids) |> delete_count()
|
||||||
|
defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)
|
||||||
|
|
||||||
|
defp assert_requester_absent!(context) do
|
||||||
|
if Repo.exists?(from(u in User, where: u.email == ^context.requester_email)) do
|
||||||
|
raise "run-scoped requester already exists"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp required_option!(options, name) do
|
||||||
|
case Map.get(options, name) do
|
||||||
|
value when is_binary(value) and value != "" -> value
|
||||||
|
_ -> raise "#{name} is required"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value))
|
||||||
|
defp uuid?(_), do: false
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user