From eb40d1bdc4051595d77402843c39c52f1fca0a68 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 00:05:58 +0200 Subject: [PATCH] docs(08-10): add the 103-method PHP-to-Go OAuth test map Auditable one-to-one map of every PHP OAuth functional/security test method to its named Go test/subtest evidence, mechanically verified by fonoteka.go/parity/oauth_audit_test.go's TestPHPTestMap. --- .../08-PHP-TEST-MAP.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 .planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md b/.planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md new file mode 100644 index 0000000..e6ee429 --- /dev/null +++ b/.planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md @@ -0,0 +1,184 @@ +# Phase 08 PHP OAuth Test Map + +**D-18: every PHP OAuth functional/security test method maps to a named passing Go test/subtest.** + +Distribution (exactly 103 unique PHP method rows): authorize 11, client-command 8, +metadata 2, migration 7, register 10, token 10, consent/scope-ceiling 7, +refresh-rotation 10, revocation 8, surface-isolation 30. + +`parity/oauth_audit_test.go`'s `TestPHPTestMap` parses this table mechanically and +fails if any row is missing, duplicated, or points at a Go test/subtest that does +not exist in the stated package. Column `Repo` is `summercms.go` or `fonoteka.go` +(sibling directories in the meta repo). Column `Go Package` is a Go import-style +relative path from that repo's root. `Subtest` is empty for a bare top-level +`func TestX(t *testing.T)`. + +## Deferred-scope notice + +Several `security/TokenSurfaceIsolationTest.php` rows (and one +`security/OAuthRevocationTest.php` row) exercise route families Phase 8 does not +port (albums, discogs, csv import, wishlist, collection share/switch, +oauth-identities, public share) per 08-CONTEXT.md's Phase Boundary and +PROJECT.md's still-Active "All 154 Płytarium API routes ported" requirement. +Those rows are marked `deferred-scope` in Notes: the named Go subtest asserts the +honest current state (the route family does not exist in the assembled table +yet), not the full PHP behavior, which cannot exist until a later phase ports +the feature. `OAuthMigrationTest`'s four `v119` rows are marked +`architecture-difference`: 08-02-PLAN.md folded `scope_ceiling` directly into +the initial `CREATE TABLE` instead of a second incremental migration, so there +is no separate Go migration step to test add/idempotent/down/missing-table-noop +against in isolation; the named subtests assert the real equivalent fact this +architecture produces. + +## authorize (functional/OAuthAuthorizeTest.php, 11) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_unknown_client_returns_400_with_no_location | summercms.go | wristband | TestAuthorizeUnknownClientReturnsLocal400NoLocation | | | +| test_unregistered_redirect_uri_returns_400_with_no_location | summercms.go | wristband | TestAuthorizeUnregisteredRedirectURIReturnsLocal400NoLocation | | | +| test_trailing_slash_mismatch_is_unregistered | summercms.go | wristband | TestAuthorizeTrailingSlashMismatchIsUnregistered | | | +| test_plain_pkce_redirects_with_invalid_request_and_preserves_state | summercms.go | wristband | TestPKCEChallengeMethodMustBeS256 | | | +| test_valid_request_redirects_to_connect_with_opaque_handle_only | summercms.go | wristband | TestAuthorizeValidRequestRedirectsToConnectWithOpaqueHandleOnly | | | +| test_iss_is_present_on_every_error_redirect | summercms.go | wristband | TestAuthorizeIssIsPresentOnEveryErrorRedirect | | | +| test_null_ceiling_preserves_ai_scope | summercms.go | wristband | TestAuthorizeNullCeilingPreservesAiScope | | | +| test_ceiling_truncates_ai_without_error | summercms.go | wristband | TestAuthorizeCeilingTruncatesAiWithoutError | | | +| test_ceiling_preserves_offline_access_flag | summercms.go | wristband | TestAuthorizeCeilingPreservesOfflineAccessFlag | | | +| test_ceiling_rejects_ai_only_as_invalid_scope_on_trusted_redirect | summercms.go | wristband | TestAuthorizeCeilingRejectsAiOnlyAsInvalidScopeOnTrustedRedirect | | | +| test_ceiling_rejects_ai_plus_offline_access_as_invalid_scope | summercms.go | wristband | TestAuthorizeCeilingRejectsAiPlusOfflineAccessAsInvalidScope | | | + +## client-command (functional/OAuthClientCommandTest.php, 8) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_list_prints_client_id_and_uris_never_the_secret | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret | | | +| test_client_id_adds_redirect_uri_without_rotating_secret | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandAddRedirectURIsWithoutRotatingSecret | | | +| test_unknown_client_id_fails | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandUnknownClientIDFails | | | +| test_scope_option_persists_read_write_ceiling | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandScopeCeilingPersistsAndListsNeverTheSecret | | one Go test, 3 distinct assertions -> 3 PHP methods (this row + next 2) | +| test_absent_scope_option_stores_null_ceiling | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandScopeCeilingPersistsAndListsNeverTheSecret | | | +| test_invalid_scope_fails_without_creating_a_client | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandInvalidScopeFailsWithoutCreatingAClient | | | +| test_list_prints_scope_ceiling_never_the_secret | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandScopeCeilingPersistsAndListsNeverTheSecret | | | +| test_client_id_adds_redirect_uri_without_changing_ceiling | fonoteka.go | plugins/golem15/fonoteka/console | TestOAuthClientCommandAddRedirectURIWithoutChangingCeiling | | new (08-10 Task 1) | + +## metadata (functional/OAuthMetadataTest.php, 2) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_authorization_server_metadata_is_unwrapped_rfc8414_document | fonoteka.go | plugins/golem15/fonoteka | TestOAuthMetadataAssembled | | | +| test_authorize_without_client_is_local_error_and_register_exists | fonoteka.go | plugins/golem15/fonoteka | TestOAuthMetadataAuthorizeWithoutClientIsLocalErrorAndRegisterExists | | new (08-10 Task 1) | + +## migration (functional/OAuthMigrationTest.php, 7) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_v117_creates_three_oauth_tables_and_named_unique_indexes | fonoteka.go | plugins/golem15/fonoteka/updates | TestOAuthMigrationV117CreatesThreeOAuthTablesAndNamedUniqueIndexes | | new (08-10 Task 1) | +| test_v117_second_up_is_idempotent | fonoteka.go | plugins/golem15/fonoteka/updates | TestOAuthMigrationV117SecondUpIsIdempotent | | new; proven at the gormigrate re-Migrate() boundary, not a raw double-func-call | +| test_v117_down_drops_the_three_oauth_tables | fonoteka.go | plugins/golem15/fonoteka/updates | TestOAuthMigrationV117DownDropsTheThreeOAuthTables | | new | +| test_v119_adds_nullable_scope_ceiling | fonoteka.go | plugins/golem15/fonoteka/updates | TestOAuthMigrationV119ScopeCeilingBakedIntoInitialSchema | test_v119_adds_nullable_scope_ceiling | architecture-difference: see notice above | +| test_v119_second_up_is_idempotent | fonoteka.go | plugins/golem15/fonoteka/updates | TestOAuthMigrationV119ScopeCeilingBakedIntoInitialSchema | test_v119_second_up_is_idempotent | architecture-difference | +| test_v119_down_drops_scope_ceiling | fonoteka.go | plugins/golem15/fonoteka/updates | TestOAuthMigrationV119ScopeCeilingBakedIntoInitialSchema | test_v119_down_drops_scope_ceiling | architecture-difference | +| test_v119_up_on_missing_table_is_noop | fonoteka.go | plugins/golem15/fonoteka/updates | TestOAuthMigrationV119ScopeCeilingBakedIntoInitialSchema | test_v119_up_on_missing_table_is_noop | architecture-difference | + +## register (functional/OAuthRegisterTest.php, 10) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_public_client_is_issued_without_a_secret | summercms.go | wristband | TestRegisterPublicClientResponse | | | +| test_omitted_auth_method_defaults_to_public | summercms.go | wristband | TestRegisterPublicClientResponse | | same test, 2nd distinct assertion (token_endpoint_auth_method="none") | +| test_confidential_client_returns_secret_once | summercms.go | wristband | TestRegisterConfidentialClientReturnsSecretOnce | | | +| test_http_non_loopback_and_javascript_uris_are_rejected | summercms.go | wristband | TestRegisterJavascriptURIIsRejected | | new (08-10 Task 1); http-non-loopback half already in TestRegisterRedirectURIBounds/http-non-loopback | +| test_loopback_http_is_accepted | summercms.go | wristband | TestRegisterLoopbackHTTPIsAccepted | | new | +| test_missing_redirect_uris_is_invalid | summercms.go | wristband | TestRegisterRedirectURIBounds | missing | | +| test_form_urlencoded_is_rejected | summercms.go | wristband | TestRegisterWrongContentType | | | +| test_orphan_sweep_deletes_stale_dcr_rows_not_artisan_clients | summercms.go | wristband | TestRegisterSweepsStaleUnconsentedButKeepsArtisanClients | | | +| test_registered_public_client_can_authorize_and_exchange_without_secret | fonoteka.go | parity | TestOAuthFlows | | full recorded 17-step lifecycle includes a public-client authorize+exchange leg | +| test_error_body_has_no_secret_or_stack | summercms.go | wristband | TestRegisterErrorBodyHasNoSecretOrStackTrace | | new | + +## token (functional/OAuthTokenTest.php, 10) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_valid_code_returns_bearer_inv_token | fonoteka.go | plugins/golem15/fonoteka/classes/auth | TestOAuthCodeExchangeMintsInvAccessAndRefreshTokens | | | +| test_access_token_authenticates_me_with_granted_scopes_and_collections | fonoteka.go | parity | TestOAuthFlows | | "me" step replays the real /api/v1/fonoteka/me response against an OAuth-exchanged token | +| test_replaying_the_code_returns_invalid_grant | summercms.go | wristband | TestTokenCodeSequentialReplayIsInvalidGrantSecondTime | | | +| test_wrong_verifier_returns_invalid_grant | summercms.go | wristband | TestTokenWrongVerifierIsInvalidGrant | | | +| test_wrong_client_secret_returns_invalid_client | summercms.go | wristband | TestTokenConfidentialClientWrongSecretIsInvalidClient | | | +| test_json_body_does_not_five_hundred | summercms.go | wristband | TestTokenRejectsJSONBodyEvenWithValidQueryParams | | | +| test_offline_access_is_echoed_but_not_persisted_on_api_token | fonoteka.go | plugins/golem15/fonoteka/classes/auth | TestOAuthOfflineAccessEchoedButNotPersistedOnAPIToken | | new (08-10 Task 1) | +| test_scope_omits_offline_access_when_not_requested | fonoteka.go | plugins/golem15/fonoteka/classes/auth | TestOAuthScopeOmitsOfflineAccessWhenNotRequested | | new | +| test_public_client_exchanges_code_without_secret | summercms.go | wristband | TestTokenPublicClientIgnoresSuppliedSecret | | | +| test_confidential_client_without_secret_is_invalid_client | summercms.go | wristband | TestTokenConfidentialClientMissingSecretIsInvalidClient | | | + +## consent/scope-ceiling (security/OAuthConsentScopeCeilingTest.php, 7) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_consent_requesting_ai_when_client_asked_read_grants_read_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthConsentIntersectionGrantsOnlyOriginallyRequestedScopes | | new (08-10 Task 1) | +| test_consent_submitting_a_scope_outside_mintable_is_422 | fonoteka.go | plugins/golem15/fonoteka | TestOAuthConsentEmptySubmittedScopeIntersectionIs422 | | | +| test_submitted_inaccessible_collection_id_never_enters_the_grant | fonoteka.go | plugins/golem15/fonoteka | TestOAuthConsentCollectionIDsAlwaysServerResolvedActivePin | | new; collection_ids is never read from the request body at all (architecturally stronger than PHP's runtime filter) | +| test_issued_collection_ids_equal_the_active_pin | fonoteka.go | plugins/golem15/fonoteka | TestOAuthConsentCollectionIDsAlwaysServerResolvedActivePin | | same test, 2nd distinct assertion | +| test_second_consent_on_the_same_request_id_fails | fonoteka.go | plugins/golem15/fonoteka | TestOAuthConsentSecondConsentOnSameRequestIDFails | | new | +| test_show_returns_host_and_pinned_name_without_collection_id | fonoteka.go | plugins/golem15/fonoteka | TestOAuthConsentShowExactKeySet | | new | +| test_authorize_route_ceiling_drops_ai_from_scopes_requested | fonoteka.go | plugins/golem15/fonoteka | TestOAuthConsentScopeCeilingViaShow | | | + +## refresh-rotation (security/OAuthRefreshRotationTest.php, 10) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_s256_verification_accepts_matching_verifier | fonoteka.go | plugins/golem15/fonoteka/classes/auth | TestOAuthCodeExchangeMintsInvAccessAndRefreshTokens | | real-Postgres positive S256 match | +| test_s256_verification_rejects_wrong_verifier | fonoteka.go | plugins/golem15/fonoteka/classes/auth | TestOAuthCodeExchangeWrongVerifierIsInvalidGrantAndMintsNothing | | | +| test_plain_pkce_is_rejected_even_when_verifier_equals_challenge | summercms.go | wristband | TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge | | new (08-10 Task 1) | +| test_code_cannot_be_exchanged_twice | summercms.go | wristband | TestTokenCodeSequentialReplayIsInvalidGrantSecondTime | | | +| test_expired_code_is_rejected | summercms.go | wristband | TestTokenExpiredCodeIsInvalidGrant | | | +| test_code_with_different_redirect_uri_is_rejected | summercms.go | wristband | TestTokenRedirectURIMismatchIsInvalidGrant | | | +| test_code_presented_by_a_different_client_is_rejected | summercms.go | wristband | TestTokenClientMismatchIsInvalidGrant | | | +| test_rotation_issues_a_new_refresh_and_marks_the_old_one_rotated | summercms.go | wristband | TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence | | | +| test_replaying_rotated_refresh_revokes_the_entire_chain | fonoteka.go | plugins/golem15/fonoteka/classes/auth | TestOAuthRefreshReplayRevokesLineageAndBothAccessTokens | | | +| test_revoke_chain_leaves_no_usable_api_token | fonoteka.go | plugins/golem15/fonoteka/classes/auth | TestOAuthConnectedAppRevokeStoreKillsAccessAndLineage | | | + +## revocation (security/OAuthRevocationTest.php, 8) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_connected_app_revoke_kills_access_and_refresh | fonoteka.go | plugins/golem15/fonoteka | TestConnectedAppsRevokeKillsAccessAndRefreshThenDisappearsFromList | | | +| test_rotated_predecessor_is_dead_after_connected_app_revoke | fonoteka.go | plugins/golem15/fonoteka | TestOAuthRevocationRotatedPredecessorIsDeadAfterConnectedAppRevoke | | new (08-10 Task 1) | +| test_manual_token_route_on_oauth_token_kills_refresh_chain | fonoteka.go | plugins/golem15/fonoteka | TestOAuthRevocationManualTokenRouteKillsRefreshChain | | new; GREEN companion of this plan's Rule-1 fix to controllers/api/token_api_controller.go Destroy | +| test_foreign_or_missing_connected_app_id_returns_404_never_403 | fonoteka.go | plugins/golem15/fonoteka | TestConnectedAppsRevokeForeignAndMissingAndManualShareExact404 | | | +| test_second_consent_survives_revoking_the_first | fonoteka.go | plugins/golem15/fonoteka | TestOAuthRevocationSecondGrantSurvivesRevokingFirst | | new | +| test_oauth_token_reading_album_outside_allow_list_is_404_never_403 | fonoteka.go | plugins/golem15/fonoteka | TestOAuthRevocationReadingOutsideAllowListRouteNotYetPorted | | new; deferred-scope (albums unported, see notice above) | +| test_revoked_and_rotated_oauth_tokens_leave_the_connected_apps_list | fonoteka.go | plugins/golem15/fonoteka | TestOAuthRevocationRotatedTokenLeavesExactlyOneConnectedApp | | new; revoke-then-empty half covered by TestOAuthRevocationListsSignalEachOtherExcludingOAuthFromManual | +| test_lists_signal_each_other_and_oauth_tokens_are_absent_from_manual_list | fonoteka.go | plugins/golem15/fonoteka | TestOAuthRevocationListsSignalEachOtherExcludingOAuthFromManual | | new | + +## surface-isolation (security/TokenSurfaceIsolationTest.php, 30) + +| PHP Method | Repo | Go Package | Go Test | Subtest | Notes | +|---|---|---|---|---|---| +| test_final_surfaces_have_flat_albums_and_no_legacy_hierarchy_routes | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_final_surfaces_have_flat_albums_and_no_legacy_hierarchy_routes | deferred-scope: albums unported | +| test_token_surface_excludes_collection_create_membership_and_token_management | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_token_surface_excludes_collection_create_membership_and_token_management | real: token surface has exactly genres+me today | +| test_fixed_album_routes_precede_numeric_album_route | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_fixed_album_routes_precede_numeric_album_route | deferred-scope | +| test_jwt_group_carries_no_token_scope_middleware | fonoteka.go | plugins/golem15/fonoteka | TestFullRouteTableAuthGroupMutualExclusivity | | existing Phase 6 test (T-06-02/T-06-10), walks the real assembled table | +| test_read_only_token_is_forbidden_on_write_and_ai_routes | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_read_only_token_is_forbidden_on_write_and_ai_routes | deferred-scope: no write/ai-scoped route exists yet | +| test_discogs_import_route_exists_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_discogs_import_route_exists_on_jwt_surface_only | deferred-scope: discogs unported | +| test_csv_import_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_csv_import_routes_exist_on_jwt_surface_only | deferred-scope: csv import unported | +| test_discogs_credential_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_discogs_credential_routes_exist_on_jwt_surface_only | deferred-scope | +| test_oauth_identity_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_oauth_identity_routes_exist_on_jwt_surface_only | deferred-scope: social login explicitly out of scope (08-CONTEXT.md) | +| test_album_stats_route_exists_on_jwt_and_token_surfaces | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_album_stats_route_exists_on_jwt_and_token_surfaces | deferred-scope | +| test_album_sync_route_exists_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_album_sync_route_exists_on_jwt_surface_only | deferred-scope | +| test_album_missing_route_exists_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_album_missing_route_exists_on_jwt_surface_only | deferred-scope | +| test_album_match_and_apply_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_album_match_and_apply_routes_exist_on_jwt_surface_only | deferred-scope | +| test_wishlist_match_and_apply_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_wishlist_match_and_apply_routes_exist_on_jwt_surface_only | deferred-scope: wishlist unported | +| test_token_surface_wishlist_album_routes_are_index_store_update_destroy_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_token_surface_wishlist_album_routes_are_index_store_update_destroy_only | deferred-scope | +| test_collection_share_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_collection_share_routes_exist_on_jwt_surface_only | deferred-scope | +| test_wishlist_share_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_wishlist_share_routes_exist_on_jwt_surface_only | deferred-scope | +| test_me_locale_routes_exist_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_me_locale_routes_exist_on_jwt_surface_only | real: /_fonoteka/api/v1/me/locale exists on jwt surface only | +| test_collection_switch_route_exists_on_jwt_surface_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_collection_switch_route_exists_on_jwt_surface_only | deferred-scope | +| test_public_share_routes_are_get_only_and_absent_from_token_surface | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_public_share_routes_are_get_only_and_absent_from_token_surface | deferred-scope: public_share group registers zero routes today | +| test_public_wishlist_share_routes_are_get_only_and_absent_from_token_surface | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_public_wishlist_share_routes_are_get_only_and_absent_from_token_surface | deferred-scope | +| test_public_group_carries_no_jwt_or_scope_middleware | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_public_group_carries_no_jwt_or_scope_middleware | deferred-scope: no route registered in this group yet | +| test_missing_token_returns_401_and_write_token_reaches_collection_update | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_missing_token_returns_401_and_write_token_reaches_collection_update | deferred-scope: no collection-update route yet | +| test_oauth_mcp_routes_carry_neither_jwt_scope_nor_web_middleware | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_oauth_mcp_routes_carry_neither_jwt_scope_nor_web_middleware | real | +| test_oauth_mcp_routes_resolve_to_fonoteka_not_social_auth | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_oauth_mcp_routes_resolve_to_fonoteka_not_social_auth | real: asserts RouteInfo.PluginID == golem15.fonoteka | +| test_jwt_group_contains_oauth_consent_and_connected_apps | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_jwt_group_contains_oauth_consent_and_connected_apps | real | +| test_code_or_token_producing_routes_do_not_carry_inv_scope | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_code_or_token_producing_routes_do_not_carry_inv_scope | real | +| test_token_surface_has_exactly_one_discogs_route | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_token_surface_has_exactly_one_discogs_route | deferred-scope: current count is 0, PHP's is 1; update once discogs ships | +| test_interactive_discogs_import_routes_stay_jwt_only | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_interactive_discogs_import_routes_stay_jwt_only | deferred-scope | +| test_no_route_on_the_token_prefix_is_bearerless | fonoteka.go | plugins/golem15/fonoteka | TestOAuthTokenSurfaceIsolationCoverage | test_no_route_on_the_token_prefix_is_bearerless | real |