fix: send a token on change-password and logout, and six other audit defects - #195
Merged
Merged
Conversation
Both endpoints are [Authorize], but both lived on IAuthApi, which is registered without AuthDelegatingHandler so it never attaches an Authorization header. Change-password therefore answered 401 every time. SettingsViewModel maps 401 to "Session expired, please log in again.", so the user re-logged in, retried, and got the same message forever - the feature has never worked from the app. Logout was worse: it returns IApiResponse, which does not throw on a non-success status, so the 401 sailed past the catch and read exactly like a successful revoke. The client cleared its local tokens while the refresh token stayed live server-side for its full 30-day lifetime. Splits the two authenticated calls onto a new IAccountApi registered with the handler attached. IAuthApi keeps the anonymous endpoints and stays handler-free, preserving the property that a 401 during refresh cannot recurse into refresh. Adds RefitClientCompositionTests, which builds the real container and sends a real request through each client into a capturing primary handler. It asserts every client with [Authorize] endpoints carries a bearer token and that IAuthApi does not. Verified to fail when a handler registration is removed - this is the check whose absence let both defects ship, since the mobile tests substitute fakes and the API tests supply a token directly. Closes #185 Closes #186 Closes #187
Rates accumulated into one list and were upserted only after the loop, so a single unsupported pair or one transient 5xx threw straight past the upsert and discarded every rate already fetched in that pass. One user tracking an exotic currency aged everybody else's totals by a day. Each base currency now fails on its own and the pass continues. A total outage still logs at Error rather than a handful of warnings, so isolating per-currency failures does not quietly downgrade the signal for the case that used to throw. Closes #188
UpdateAsync substitutes the client's version into the tracked entity's xmin original value, so the generated UPDATE asserts nothing has moved. That only works if an UPDATE is generated at all: submit values identical to what is stored and EF marks nothing modified, issues no statement, and SaveChangesAsync cannot raise a concurrency exception. The call returned 200 against a row that had moved on, while the controller documents the 409 unconditionally. This is the shape the check exists for rather than a corner case - a user who opened the edit screen before a mark-paid, changed nothing and pressed Save is exactly the one whose write would silently roll the billing date back. Forces the row to be written when a version was supplied. Omitting the version still applies unconditionally, so older clients are unaffected. Closes #191
An unknown address returned after a single SELECT; a known one generated a code, hashed it, queried outstanding codes, updated each, inserted and saved. Response time alone therefore said whether an address had an account - on the one endpoint whose entire contract is that it reveals nothing, and in a file that goes to real lengths elsewhere to erase exactly this signal (RegisterAsync hashes unconditionally, LoginAsync verifies against a dummy hash). Moves the code generation and SHA-256 above the user lookup so both branches pay for them. An unknown address still writes nothing, which the new test pins. Residual gap accepted and documented in the code: a real account still costs extra round trips. Closing that fully would mean issuing throwaway writes for addresses with no account - real write load driven by anyone who can reach the endpoint - to hide a few milliseconds of database time on an endpoint already IP rate-limited to 10/min. The baseline is re-staged for line-number drift only: inserting a test shifted five already-audited is_secret:false entries in PasswordResetControllerTests.cs. No new finding. Closes #190
CachedSubscription mirrored neither field. Not reachable today - the detail screen always fetches over the network and writes are gated when offline - but the moment any screen edits from cache, a Version of 0 matches no xmin and every save 409s forever, and a null CatalogId silently strips the record's catalog link. Version is stored nullable rather than uint: sqlite-net adds a new column to an existing table but leaves pre-upgrade rows at the default, and a cached 0 read as authoritative is worse than an absent one. Adds a reflection-driven round-trip test over every settable property of SubscriptionDto, so a field added later fails the build instead of being quietly dropped by the mirror. Closes #192
The IsFreeTrial guard sat after the OneTime branch returned, so a one-time purchase marked as a trial was never tested against it and counted in full toward OneTimeThisYear despite nothing being charged. Moves the guard above the cadence branch - whether a trial counts is not a question about its cadence, so no cadence gets to bypass the check. Left after rate resolution so unresolved-rate reporting is unchanged. Closes #193
GET returned Ok(profile) unconditionally, so a vanished row produced a 200 with a null body - indistinguishable from a successful read - while PUT on the same resource has always answered 404. Baseline re-staged for line-number drift only: the new test shifted one already-audited is_secret:false entry in UsersControllerTests.cs by three lines. No new finding. Closes #194
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves the audit tracked in #184. Nine of the ten slices are implemented; one was withdrawn after it turned out to be wrong.
Closes #185
Closes #186
Closes #187
Closes #188
Closes #190
Closes #191
Closes #192
Closes #193
Closes #194
The one that mattered
A user could never change their password from the app, and signing out did not end the session.
IAuthApiis registered withoutAuthDelegatingHandler— deliberately, since it carries/auth/refreshand chaining the handler there would let a 401 during refresh recurse into refresh. Butchange-passwordandlogoutalso lived on it, and both are[Authorize]. Neither ever sent anAuthorizationheader.Change-password answered 401 every time.
SettingsViewModelmaps 401 to "Session expired, please log in again.", so the user re-authenticated, retried, and got the same message forever.Logout was worse. It returns
IApiResponse, which does not throw on a non-success status, so the 401 sailed past thecatchand read exactly like a successful revoke. The client cleared its local tokens while the refresh token stayed live server-side for its full 30-day lifetime — a token lifted from a device backup outlived an explicit sign-out.Fixed by splitting both calls onto a new
IAccountApiregistered with the handler attached.IAuthApikeeps the anonymous endpoints and stays handler-free.The durable part is
RefitClientCompositionTests. It builds the real container and sends a real request through each client into a capturing primary handler, asserting every client with[Authorize]endpoints carries a bearer token and thatIAuthApidoes not. I verified it fails when a handler registration is removed. Nothing asserted this before — the mobile tests substitute fakes and the API tests supply a token directly — which is exactly how two defects shipped together and sat there.The rest
forgot-passwordreturned after one SELECT for an unknown address vs. ~5 queries plus an insert for a known one. Code generation and hashing hoisted above the lookup. Residual gap documented in code — see below.xminpredicate never ran. That is the shape the check exists for: a user who opened the edit screen before a mark-paid and changed nothing.VersionnorCatalogId. Latent today, but a save built from a cached row would 409 forever and silently strip the catalog link.Versionis nullable so a pre-upgrade row is not read as an authoritative0.IsFreeTrialguard sat after theOneTimebranch returned, so a free-trial one-time purchase counted in full.GET /users/mereturned 200 with a null body for a vanished row;PUThas always returned 404.Withdrawn: #189
I filed it, and it was wrong.
MarkPaid_TwiceInARow_AdvancesTwoCyclesalready pins the behaviour I called a defect, with the rationale inline: "Someone clearing a backlog pays one period per tap." Advancing two cycles on two taps is deliberate — it is how a backlog is settled, and it matches the rule inCLAUDE.mdthat the date steps one cycle from the date just settled rather than skipping to today.I implemented the "fix", hit that test, and reverted.
MarkPaidAsyncis byte-identical tomain. I had audited the method without reading the test documenting its intent. Closed as invalid with the full reasoning on the issue.Judgement call on #190
Full timing parity would mean performing throwaway DB writes for addresses with no account — real write load driven by anyone who can reach the endpoint. Chose partial parity instead: both branches now pay for the CSPRNG draw and the SHA-256, and the residual (a known address still costs extra round trips) is documented in the code with its reasoning, so a later refactor does not silently reverse it. The remaining signal is a few milliseconds of database time rather than the ~250ms BCrypt asymmetry the register and login paths exist to erase, on an endpoint already IP rate-limited to 10/min.
Verification
All four CI jobs reproduced locally against the final commit:
SubVora.Application.TestsSubVora.Infrastructure.TestsSubVora.Api.TestsSubVora.Mobile.Testsnet10.0-android, Release, APK)Two fixes were additionally proven by disabling them and watching the new test go red:
Expected: Conflict, Actual: OK.AddHttpMessageHandlerfromIAccountApifails only that client's case; the other seven still pass.No schema change, no migration, no new dependency. The only API contract change is additive:
GET /users/memay now answer 404..secrets.baselinewas re-staged twice for line-number drift only — inserting tests shifted existingis_secret: falseentries. No new findings, and the pre-commit hook was not bypassed.