package integration_test import ( "context" "testing" "time" "galaxy/integration/testenv" usermodel "galaxy/model/user" "galaxy/transcoder" ) // TestSessionRevoke_SubsequentRequestsRejected revokes the caller's // session through the user surface (signed gRPC end-to-end) and // asserts that subsequent authenticated calls bound to that session // are rejected by gateway. func TestSessionRevoke_SubsequentRequestsRejected(t *testing.T) { plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() sess := testenv.RegisterSession(t, plat, "pilot+revoke@example.com") gw, err := sess.DialAuthenticated(ctx, plat) if err != nil { t.Fatalf("dial: %v", err) } defer gw.Close() // Sanity: the authenticated path works before revoke. getPayload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{}) if err != nil { t.Fatalf("encode get-account payload: %v", err) } if _, err := gw.Execute(ctx, usermodel.MessageTypeGetMyAccount, getPayload, testenv.ExecuteOptions{}); err != nil { t.Fatalf("pre-revoke call failed: %v", err) } // Revoke own session through signed gRPC. revokePayload, err := transcoder.RevokeMySessionRequestToPayload(&usermodel.RevokeMySessionRequest{ DeviceSessionID: sess.DeviceSessionID, }) if err != nil { t.Fatalf("encode revoke payload: %v", err) } revokeResult, err := gw.Execute(ctx, usermodel.MessageTypeRevokeMySession, revokePayload, testenv.ExecuteOptions{}) if err != nil { t.Fatalf("revoke: %v", err) } if revokeResult.ResultCode != "ok" { t.Fatalf("revoke result_code = %q, want ok", revokeResult.ResultCode) } // Authenticated requests must now be rejected. Allow up to 2s // for the session-invalidation push frame to propagate to gateway // and close any cached state. deadline := time.Now().Add(2 * time.Second) var lastErr error for time.Now().Before(deadline) { _, lastErr = gw.Execute(ctx, usermodel.MessageTypeGetMyAccount, getPayload, testenv.ExecuteOptions{}) if lastErr != nil { break } time.Sleep(100 * time.Millisecond) } if lastErr == nil { t.Fatalf("post-revoke call still succeeded; expected rejection") } // Gateway maps a revoked session to FailedPrecondition ("device // session is revoked"); a session that vanished from the cache // before the call lands as Unauthenticated. Either is a correct // rejection. if !testenv.IsFailedPrecondition(lastErr) && !testenv.IsUnauthenticated(lastErr) { t.Fatalf("post-revoke status: %v", lastErr) } } // TestSessionRevoke_RejectsForeignSession checks that a caller cannot // revoke a session that belongs to a different user. Backend returns // the same shape as a missing session (no foreign-id probing). func TestSessionRevoke_RejectsForeignSession(t *testing.T) { plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() owner := testenv.RegisterSession(t, plat, "owner+foreign@example.com") attacker := testenv.RegisterSession(t, plat, "attacker+foreign@example.com") attackerGW, err := attacker.DialAuthenticated(ctx, plat) if err != nil { t.Fatalf("dial attacker: %v", err) } defer attackerGW.Close() revokePayload, err := transcoder.RevokeMySessionRequestToPayload(&usermodel.RevokeMySessionRequest{ DeviceSessionID: owner.DeviceSessionID, }) if err != nil { t.Fatalf("encode revoke payload: %v", err) } result, err := attackerGW.Execute(ctx, usermodel.MessageTypeRevokeMySession, revokePayload, testenv.ExecuteOptions{}) if err != nil { t.Fatalf("attacker revoke: %v", err) } if result.ResultCode == "ok" { t.Fatalf("attacker revoke result_code = ok, want a not-found error") } // Decoded error envelope must carry the not-found code so attackers // see the same shape as a genuinely missing session. errResp, err := transcoder.PayloadToErrorResponse(result.PayloadBytes) if err != nil { t.Fatalf("decode error: %v", err) } // Backend's user-side handlers stamp 404 responses with // `httperr.CodeNotFound = "not_found"`; the gateway forwards a // non-empty code as-is and only synthesises `subject_not_found` // when the upstream payload omits the code field. Both shapes // satisfy the "no foreign-id probing" contract — the attacker // learns the same thing for a missing session and a session that // belongs to someone else. if code := errResp.Error.Code; code != "not_found" && code != "subject_not_found" { t.Fatalf("error.code = %q, want not_found or subject_not_found", code) } } // TestSessionRevoke_RevokeAll covers the bulk logout path. Two // sessions for the same user, then revoke-all, then both sessions // must reject authenticated traffic. func TestSessionRevoke_RevokeAll(t *testing.T) { plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() const email = "pilot+revoke-all@example.com" first := testenv.RegisterSession(t, plat, email) second := testenv.RegisterSession(t, plat, email) firstGW, err := first.DialAuthenticated(ctx, plat) if err != nil { t.Fatalf("dial first: %v", err) } defer firstGW.Close() revokeAllPayload, err := transcoder.RevokeAllMySessionsRequestToPayload(&usermodel.RevokeAllMySessionsRequest{}) if err != nil { t.Fatalf("encode revoke-all payload: %v", err) } result, err := firstGW.Execute(ctx, usermodel.MessageTypeRevokeAllMySessions, revokeAllPayload, testenv.ExecuteOptions{}) if err != nil { t.Fatalf("revoke-all: %v", err) } if result.ResultCode != "ok" { t.Fatalf("revoke-all result_code = %q, want ok", result.ResultCode) } resp, err := transcoder.PayloadToRevokeAllMySessionsResponse(result.PayloadBytes) if err != nil { t.Fatalf("decode revoke-all payload: %v", err) } if resp.Summary.RevokedCount != 2 { t.Fatalf("summary.revoked_count = %d, want 2 (sessions: %s, %s)", resp.Summary.RevokedCount, first.DeviceSessionID, second.DeviceSessionID) } }