20 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 14.1-oauth-identities-and-fonoteka-me-routes | 02 | execute | 2 |
|
|
true |
|
|
|
Phase Goal
As a signed-in Nuxt user (and as a fonoteka-mcp token caller), I want to list and unlink connected OAuth identities and receive the full personal-token /me body, so that Settings → Connected accounts and MCP bootstrap work against Go with zero pending routes.
This plan's slice: the dedicated unit-test plan (CLAUDE.md lean mode). Plan 01 already shipped the production path; this plan makes every D-11 behaviour, migration, /me null, threat, and corpus count fail when broken.
Purpose: lean-mode last plan; QA-05 / API-09 evidence for /gsd-verify-work 14.1.
Output: tests in sm-user-plugin, fonoteka plugin, and parity. No summercms.go module API changes.
Repos: fonoteka.go / sm-user-plugin. Never add co-author tags.
<execution_context>
@/.codex/gsd-core/workflows/execute-plan.md
@/.codex/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
TestOAuthIdentitiesIndexEmptyList(from 01) plus D-11: unlink 204 keeps sibling row, 409 EN/PL, missing/foreign/unknown Winter 404, 401 on/google, secret-leak, last-method still 409 when the account has a password.updates/oauth_identities_test.gomigration up/down (skip on-shortvia existingdedicatedDB).- Rewritten MeToken nil-collection test requiring
"collection_ids":nulland"scopes":[]. - phase08 jwt-only GET and DELETE plus DELETE
throttle:10,1. TestParityCorpus175/175/0.
Assumptions
- Assumption-delta remains closed: D-06/D-13 already answered second-method lockout; tests prove count-only 409, they do not add social login.
- Plan 01 flipped GET (and DELETE) surfaces; this plan asserts them fail-closed and adds throttle contains-check if Task 3 of 01 left a gap.
- Do not retarget
scripts/check-phase14.sh(EXPECTED_PENDING=3is a Phase 14 artifact).
(1) Expand plugin-root oauth_identities_test.go (package user) using sessionApp, insertUser, httptest, bouncer.WithUser, and the constructors. Seed rows with struct literals (empty Fillable). Destroy tests pass OAuthIdentitiesOptions{WriteNotFound: ...} writing the same Winter HTML 404 bytes the host uses (WriteWinterHTTPError via a test double that copies winter_404.html headers/body, or a stub that records identical bytes for every 404 branch).
Behaviours: unlink 204 empty body and the sibling provider row remains; last remaining identity returns 409 JSON error equal to the EN string when locale is en and the PL string when locale is pl (phrasebook Get / request locale analog already used in account tests); missing, foreign, and unknown provider (linkedin while authenticated) return status 404, Content-Type: text/html; charset=UTF-8, and byte-identical bodies; 401 without a JWT on GET and DELETE /google (not an unknown provider). Last-method 409 still fires when that user also has a password (D-06). Keep TestOAuthIdentitiesIndexEmptyList.
(2) phase08 test_oauth_identity_routes_exist_on_jwt_surface_only: assertRouteSurfaces GET /oauth-identities jwt-only and DELETE /oauth-identities/{provider} jwt-only. Assert DELETE middleware contains throttle:10,1. No identity suffix on /api/v1/fonoteka.
go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/user ./plugins/golem15/fonoteka -count=1 -v -run 'OAuthIdentit|oauth_identity_routes_exist_on_jwt'
<fails_when>Non-zero exit; verbose run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks PASS for the oauth-identity tests including the phase08 jwt-only subtest.</fails_when>
<acceptance_criteria>
- grep -c 'StatusNoContent' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go prints at least 1.
- grep -c 'last_method_blocked' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go prints at least 1.
- grep -c '/google' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go prints at least 1.
- grep -c 'assertRouteSurfaces(t, rt, http.MethodGet, "/oauth-identities", true, false)' ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go prints at least 1.
- grep -c 'assertRouteSurfaces(t, rt, http.MethodDelete, "/oauth-identities/{provider}", true, false)' ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go prints at least 1.
- grep -c 'assertAbsent(t, "oauth-identit"' ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go prints 0.
</acceptance_criteria>
D-11 identity behaviours and jwt-only surfaces fail when broken, including EN/PL 409 and byte-identical Winter 404s.
(1) updates/oauth_identities_test.go: dedicatedDB, lagoon.Use, gormigrate.New(..., TableName: "summer_migrations_golem15_user", UseTransaction: true, All()), Migrate(), assert HasTable golem15_user_oauth_identities, columns including access_token, refresh_token, profile_data, linked_at, unique index names oauth_identities_user_provider_unique and oauth_identities_provider_identity_unique, information_schema.columns udt jsonb for profile_data, FK user_id → users(id) ON DELETE CASCADE. RollbackMigration of this migration drops the table. Skip through existing dedicatedDB/-short behaviour; a missing container is a fail, not a pass.
(2) Rewrite TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName: scopes remains a JSON array (not null); collection_ids MUST be the JSON null token when CollectionIDs is invalid or empty (D-09). Keep the four-field restricted test and the no-requery test. Rename the test if the old name would lie.
(3) Secret-leak (T-14.1-01 / DATA-07): insert an identity with lagoon.NewEncrypted plaintext plus profile_data containing a distinctive string; GET Index body must not contain the plaintext, must not contain JSON keys access_token, refresh_token, or profile_data, and must not contain [redacted] (Encrypted marshal leak of key names). Same assertion on the D-10 list-with-rows shape (provider + linked_at only).
go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/controllers/api/... && go -C ../fonoteka.go test ./plugins/golem15/user/updates ./plugins/golem15/user ./plugins/golem15/fonoteka/controllers/api -count=1 -v -run 'OAuthIdentit|MeTokenHandlerNil|oauth_identities'
<fails_when>Non-zero exit; verbose run prints "--- FAIL", "no tests to run" or "--- SKIP" for the named migration, MeToken nil, or secret-leak tests; updates tests skip because Postgres is missing.</fails_when>
<acceptance_criteria>
- grep -c 'golem15_user_oauth_identities' ../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go prints at least 1 and grep -c 'jsonb' ../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go prints at least 1.
- grep -c '"collection_ids":null' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go prints at least 1.
- grep -c '"scopes":null' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go prints at least 1 (the rewritten test still treats a null scopes token as failure).
- grep -c 'access_token' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go prints at least 1 (the leak assertion names the keys that must be absent from the body).
</acceptance_criteria>
Migration up/down, D-09 /me null, and Encrypted-token leak tests fail when their protections are removed.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Test process → handlers | Tests must not mint production JWTs or log Encrypted.Reveal() |
| Corpus replay → Go app | Pending must never count as passing |
| Test 404 writer → Destroy | Foreign/missing/unknown must be indistinguishable |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-14.1-09 | Information Disclosure | oauth_identities_test.go GET | high | mitigate | Fail if body contains plaintext, Encrypted JSON keys, or [redacted] |
| T-14.1-10 | Information Disclosure | Destroy 404 tests | high | mitigate | Byte-identical Winter HTML for missing, foreign, unknown; JSON 404 fails the test |
| T-14.1-11 | Tampering | TestParityCorpus | high | mitigate | expectedPortedRoutes 175; pending 0; rewritten mismatch helper on a ported fixture |
| T-14.1-12 | Elevation of Privilege | phase08 TokenSurfaceIsolation | high | mitigate | assertRouteSurfaces jwt-only; token group never gains identity paths |
| T-14.1-SC | Tampering | package installs | high | mitigate | No new modules |
ASVS L1: all high threats mitigated. Plan 01's T-14.1-01..08 remain in force; these IDs are the test-plane controls that make those mitigations fail-closed. </threat_model>
Full app-side gate: `go -C ../fonoteka.go vet ./...` and `go -C ../fonoteka.go test ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/... -count=1`. Corpus 175/175/0. Framework tree in summercms.go unchanged besides this planning commit. QA-05 consumers are frozen: sign in to the Nuxt app, open Settings → Connected accounts against the Go backend (list/unlink). Call fonoteka-mcp `me()` against Go; extra `collection_ids` is ignored by its TypeScript type.<success_criteria>
- D-11 PHP test port is green.
- Migration up/down proven on real Postgres.
- MeToken unrestricted
collection_idsis JSON null under test. - Secret-leak tests fail when the explicit map is replaced by model marshal.
TestParityCorpusis 175/175/0. </success_criteria>