diff --git a/src/wp-includes/canonical.php b/src/wp-includes/canonical.php
index 617bcb352d..f7c5caa889 100644
--- a/src/wp-includes/canonical.php
+++ b/src/wp-includes/canonical.php
@@ -77,6 +77,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 
 	$redirect     = $original;
 	$redirect_url = false;
+	$redirect_obj = false;
 
 	// Notice fixing.
 	if ( ! isset( $redirect['path'] ) ) {
@@ -102,6 +103,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 
 	if ( is_feed() && $post_id ) {
 		$redirect_url = get_post_comments_feed_link( $post_id, get_query_var( 'feed' ) );
+		$redirect_obj = get_post( $post_id );
 
 		if ( $redirect_url ) {
 			$redirect['query'] = _remove_qs_args_if_not_in_url(
@@ -126,6 +128,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 			}
 
 			$redirect_url = get_permalink( $post_id );
+			$redirect_obj = get_post( $post_id );
 
 			if ( $redirect_url ) {
 				$redirect['query'] = _remove_qs_args_if_not_in_url(
@@ -150,6 +153,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 
 			if ( $post_type_obj && $post_type_obj->public && 'auto-draft' !== $redirect_post->post_status ) {
 				$redirect_url = get_permalink( $redirect_post );
+				$redirect_obj = get_post( $redirect_post );
 
 				$redirect['query'] = _remove_qs_args_if_not_in_url(
 					$redirect['query'],
@@ -197,6 +201,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 
 			if ( $post_id ) {
 				$redirect_url = get_permalink( $post_id );
+				$redirect_obj = get_post( $post_id );
 
 				$redirect['path']  = rtrim( $redirect['path'], (int) get_query_var( 'page' ) . '/' );
 				$redirect['query'] = remove_query_arg( 'page', $redirect['query'] );
@@ -223,27 +228,32 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 		) {
 			if ( ! empty( $_GET['attachment_id'] ) ) {
 				$redirect_url = get_attachment_link( get_query_var( 'attachment_id' ) );
+				$redirect_obj = get_post( get_query_var( 'attachment_id' ) );
 
 				if ( $redirect_url ) {
 					$redirect['query'] = remove_query_arg( 'attachment_id', $redirect['query'] );
 				}
 			} else {
 				$redirect_url = get_attachment_link();
+				$redirect_obj = get_post();
 			}
 		} elseif ( is_single() && ! empty( $_GET['p'] ) && ! $redirect_url ) {
 			$redirect_url = get_permalink( get_query_var( 'p' ) );
+			$redirect_obj = get_post( get_query_var( 'p' ) );
 
 			if ( $redirect_url ) {
 				$redirect['query'] = remove_query_arg( array( 'p', 'post_type' ), $redirect['query'] );
 			}
 		} elseif ( is_single() && ! empty( $_GET['name'] ) && ! $redirect_url ) {
 			$redirect_url = get_permalink( $wp_query->get_queried_object_id() );
+			$redirect_obj = get_post( $wp_query->get_queried_object_id() );
 
 			if ( $redirect_url ) {
 				$redirect['query'] = remove_query_arg( 'name', $redirect['query'] );
 			}
 		} elseif ( is_page() && ! empty( $_GET['page_id'] ) && ! $redirect_url ) {
 			$redirect_url = get_permalink( get_query_var( 'page_id' ) );
+			$redirect_obj = get_post( get_query_var( 'page_id' ) );
 
 			if ( $redirect_url ) {
 				$redirect['query'] = remove_query_arg( 'page_id', $redirect['query'] );
@@ -256,6 +266,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 			&& 'page' === get_option( 'show_on_front' ) && get_query_var( 'page_id' ) === (int) get_option( 'page_for_posts' )
 		) {
 			$redirect_url = get_permalink( get_option( 'page_for_posts' ) );
+			$redirect_obj = get_post( get_option( 'page_for_posts' ) );
 
 			if ( $redirect_url ) {
 				$redirect['query'] = remove_query_arg( 'page_id', $redirect['query'] );
@@ -310,6 +321,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 				&& $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE $wpdb->posts.post_author = %d AND $wpdb->posts.post_status = 'publish' LIMIT 1", $author->ID ) )
 			) {
 				$redirect_url = get_author_posts_url( $author->ID, $author->user_nicename );
+				$redirect_obj = $author;
 
 				if ( $redirect_url ) {
 					$redirect['query'] = remove_query_arg( 'author', $redirect['query'] );
@@ -385,6 +397,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 					|| ! has_term( $category->term_id, 'category', $wp_query->get_queried_object_id() )
 				) {
 					$redirect_url = get_permalink( $wp_query->get_queried_object_id() );
+					$redirect_obj = get_post( $wp_query->get_queried_object_id() );
 				}
 			}
 		}
@@ -395,6 +408,7 @@ function redirect_canonical( $requested_url = null, $do_redirect = true ) {
 
 			if ( ! $redirect_url ) {
 				$redirect_url = get_permalink( get_queried_object_id() );
+				$redirect_obj = get_post( get_queried_object_id() );
 			}
 
 			if ( $page > 1 ) {
@@ -740,6 +754,28 @@ function lowercase_octets( $matches ) {
 		$requested_url = preg_replace_callback( '|%[a-fA-F0-9][a-fA-F0-9]|', 'lowercase_octets', $requested_url );
 	}
 
+	if ( $redirect_obj instanceof WP_Post ) {
+		$post_status_obj = get_post_status_object( get_post_status( $redirect_obj ) );
+		/*
+		 * Unset the redirect object and URL if they are not readable by the user.
+		 * This condition is a little confusing as the condition needs to pass if
+		 * the post is not readable by the user. That's why there are ! (not) conditions
+		 * rather throughout.
+		 */
+		if (
+			// Private post statuses only redirect if the user can read them.
+			! (
+				$post_status_obj->private &&
+				current_user_can( 'read_post', $redirect_obj->ID )
+			) &&
+			// For other posts, only redirect if publicly viewable.
+			! is_post_publicly_viewable( $redirect_obj )
+		) {
+			$redirect_obj = false;
+			$redirect_url = false;
+		}
+	}
+
 	/**
 	 * Filters the canonical redirect URL.
 	 *
diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php
index 22eff2b1e3..ef61d3e0cb 100644
--- a/src/wp-includes/capabilities.php
+++ b/src/wp-includes/capabilities.php
@@ -239,10 +239,10 @@ function map_meta_cap( $cap, $user_id, ...$args ) {
 				break;
 			}
 
-			$status_obj = get_post_status_object( $post->post_status );
+			$status_obj = get_post_status_object( get_post_status( $post ) );
 			if ( ! $status_obj ) {
 				/* translators: 1: Post status, 2: Capability name. */
-				_doing_it_wrong( __FUNCTION__, sprintf( __( 'The post status %1$s is not registered, so it may not be reliable to check the capability "%2$s" against a post with that status.' ), $post->post_status, $cap ), '5.4.0' );
+				_doing_it_wrong( __FUNCTION__, sprintf( __( 'The post status %1$s is not registered, so it may not be reliable to check the capability "%2$s" against a post with that status.' ), get_post_status( $post ), $cap ), '5.4.0' );
 				$caps[] = 'edit_others_posts';
 				break;
 			}
diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php
index 28e6f98ec7..b5e5064d17 100644
--- a/src/wp-includes/link-template.php
+++ b/src/wp-includes/link-template.php
@@ -89,6 +89,56 @@ function permalink_anchor( $mode = 'id' ) {
 	}
 }
 
+/**
+ * Determine whether post should always use an ugly permalink structure.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_Post|int|null $post   Optional. Post ID or post object. Defaults to global $post.
+ * @param bool             $sample Optional. Whether to force consideration based on sample links.
+ * @return bool Whether to use an ugly permalink structure.
+ */
+function wp_force_ugly_post_permalink( $post = null, $sample = null ) {
+	if (
+		null === $sample &&
+		is_object( $post ) &&
+		isset( $post->filter ) &&
+		'sample' === $post->filter
+	) {
+		$sample = true;
+	} else {
+		$post   = get_post( $post );
+		$sample = null !== $sample ? $sample : false;
+	}
+
+	if ( ! $post ) {
+		return true;
+	}
+
+	$post_status_obj = get_post_status_object( get_post_status( $post ) );
+	$post_type_obj   = get_post_type_object( get_post_type( $post ) );
+
+	if ( ! $post_status_obj || ! $post_type_obj ) {
+		return true;
+	}
+
+	if (
+		// Publicly viewable links never have ugly permalinks.
+		is_post_status_viewable( $post_status_obj ) ||
+		(
+			// Private posts don't have ugly links if the user can read them.
+			$post_status_obj->private &&
+			current_user_can( 'read_post', $post->ID )
+		) ||
+		// Protected posts don't have ugly links if getting a sample URL.
+		( $post_status_obj->protected && $sample )
+	) {
+		return false;
+	}
+
+	return true;
+}
+
 /**
  * Retrieves the full permalink for the current post or post ID.
  *
@@ -166,7 +216,7 @@ function get_permalink( $post = 0, $leavename = false ) {
 
 	if (
 		$permalink &&
-		! in_array( $post->post_status, array( 'draft', 'pending', 'auto-draft', 'future', 'trash' ), true )
+		! wp_force_ugly_post_permalink( $post )
 	) {
 
 		$category = '';
@@ -277,7 +327,7 @@ function get_post_permalink( $id = 0, $leavename = false, $sample = false ) {
 
 	$slug = $post->post_name;
 
-	$draft_or_pending = get_post_status( $post ) && in_array( get_post_status( $post ), array( 'draft', 'pending', 'auto-draft', 'future' ), true );
+	$force_ugly_link = wp_force_ugly_post_permalink( $post );
 
 	$post_type = get_post_type_object( $post->post_type );
 
@@ -285,13 +335,13 @@ function get_post_permalink( $id = 0, $leavename = false, $sample = false ) {
 		$slug = get_page_uri( $post );
 	}
 
-	if ( ! empty( $post_link ) && ( ! $draft_or_pending || $sample ) ) {
+	if ( ! empty( $post_link ) && ( ! $force_ugly_link || $sample ) ) {
 		if ( ! $leavename ) {
 			$post_link = str_replace( "%$post->post_type%", $slug, $post_link );
 		}
 		$post_link = home_url( user_trailingslashit( $post_link ) );
 	} else {
-		if ( $post_type->query_var && ( isset( $post->post_status ) && ! $draft_or_pending ) ) {
+		if ( $post_type->query_var && ( isset( $post->post_status ) && ! $force_ugly_link ) ) {
 			$post_link = add_query_arg( $post_type->query_var, $slug, '' );
 		} else {
 			$post_link = add_query_arg(
@@ -373,11 +423,11 @@ function _get_page_link( $post = false, $leavename = false, $sample = false ) {
 
 	$post = get_post( $post );
 
-	$draft_or_pending = in_array( $post->post_status, array( 'draft', 'pending', 'auto-draft' ), true );
+	$force_ugly_link = wp_force_ugly_post_permalink( $post );
 
 	$link = $wp_rewrite->get_page_permastruct();
 
-	if ( ! empty( $link ) && ( ( isset( $post->post_status ) && ! $draft_or_pending ) || $sample ) ) {
+	if ( ! empty( $link ) && ( ( isset( $post->post_status ) && ! $force_ugly_link ) || $sample ) ) {
 		if ( ! $leavename ) {
 			$link = str_replace( '%pagename%', get_page_uri( $post ), $link );
 		}
@@ -417,13 +467,26 @@ function get_attachment_link( $post = null, $leavename = false ) {
 
 	$link = false;
 
-	$post   = get_post( $post );
-	$parent = ( $post->post_parent > 0 && $post->post_parent != $post->ID ) ? get_post( $post->post_parent ) : false;
-	if ( $parent && ! in_array( $parent->post_type, get_post_types(), true ) ) {
-		$parent = false;
+	$post            = get_post( $post );
+	$force_ugly_link = wp_force_ugly_post_permalink( $post );
+	$parent_id       = $post->post_parent;
+	$parent          = $parent_id ? get_post( $parent_id ) : false;
+	$parent_valid    = true; // Default for no parent.
+	if (
+		$parent_id &&
+		(
+			$post->post_parent === $post->ID ||
+			! $parent ||
+			! is_post_type_viewable( get_post_type( $parent ) )
+		)
+	) {
+		// Post is either its own parent or parent post unavailable.
+		$parent_valid = false;
 	}
 
-	if ( $wp_rewrite->using_permalinks() && $parent ) {
+	if ( $force_ugly_link || ! $parent_valid ) {
+		$link = false;
+	} elseif ( $wp_rewrite->using_permalinks() && $parent ) {
 		if ( 'page' === $parent->post_type ) {
 			$parentlink = _get_page_link( $post->post_parent ); // Ignores page_on_front.
 		} else {
diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php
index f04900aed3..a2be7ce8ae 100644
--- a/src/wp-includes/post.php
+++ b/src/wp-includes/post.php
@@ -2016,9 +2016,67 @@ function is_post_type_viewable( $post_type ) {
 		}
 	}
 
+	if ( ! is_object( $post_type ) ) {
+		return false;
+	}
+
 	return $post_type->publicly_queryable || ( $post_type->_builtin && $post_type->public );
 }
 
+/**
+ * Determine whether a post status is considered "viewable".
+ *
+ * For built-in post statuses such as publish and private, the 'public' value will be evaluted.
+ * For all others, the 'publicly_queryable' value will be used.
+ *
+ * @since 5.7.0
+ *
+ * @param string|stdClass $post_status Post status name or object.
+ * @return bool Whether the post status should be considered viewable.
+ */
+function is_post_status_viewable( $post_status ) {
+	if ( is_scalar( $post_status ) ) {
+		$post_status = get_post_status_object( $post_status );
+		if ( ! $post_status ) {
+			return false;
+		}
+	}
+
+	if (
+		! is_object( $post_status ) ||
+		$post_status->internal ||
+		$post_status->protected
+	) {
+		return false;
+	}
+
+	return $post_status->publicly_queryable || ( $post_status->_builtin && $post_status->public );
+}
+
+/**
+ * Determine whether a post is publicly viewable.
+ *
+ * Posts are considered publicly viewable if both the post status and post type
+ * are viewable.
+ *
+ * @since 5.7.0
+ *
+ * @param int|WP_Post|null $post Optional. Post ID or post object. Defaults to global $post.
+ * @return bool Whether the post is publicly viewable.
+ */
+function is_post_publicly_viewable( $post = null ) {
+	$post = get_post( $post );
+
+	if ( ! $post ) {
+		return false;
+	}
+
+	$post_type   = get_post_type( $post );
+	$post_status = get_post_status( $post );
+
+	return is_post_type_viewable( $post_type ) && is_post_status_viewable( $post_status );
+}
+
 /**
  * Retrieves an array of the latest posts, or posts matching the given criteria.
  *
diff --git a/tests/phpunit/tests/canonical/postStatus.php b/tests/phpunit/tests/canonical/postStatus.php
new file mode 100644
index 0000000000..e189a72f1c
--- /dev/null
+++ b/tests/phpunit/tests/canonical/postStatus.php
@@ -0,0 +1,973 @@
+<?php
+
+/**
+ * @group canonical
+ * @group rewrite
+ * @group query
+ */
+class Tests_Canonical_PostStatus extends WP_Canonical_UnitTestCase {
+
+	/**
+	 * User IDs.
+	 *
+	 * @var array
+	 */
+	public static $users;
+
+	/**
+	 * Post Objects.
+	 *
+	 * @var array
+	 */
+	public static $posts;
+
+	public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
+		self::setup_custom_types();
+		self::$users = array(
+			'anon'           => 0,
+			'subscriber'     => $factory->user->create( array( 'role' => 'subscriber' ) ),
+			'content_author' => $factory->user->create( array( 'role' => 'author' ) ),
+			'editor'         => $factory->user->create( array( 'role' => 'editor' ) ),
+		);
+
+		$post_statuses = array( 'publish', 'future', 'draft', 'pending', 'private', 'auto-draft', 'a-private-status' );
+		foreach ( $post_statuses as $post_status ) {
+			$post_date = '';
+			if ( 'future' === $post_status ) {
+				$post_date = strftime( '%Y-%m-%d %H:%M:%S', strtotime( '+1 year' ) );
+			}
+
+			self::$posts[ $post_status ] = $factory->post->create_and_get(
+				array(
+					'post_type'    => 'post',
+					'post_title'   => "$post_status post",
+					'post_name'    => "$post_status-post",
+					'post_status'  => $post_status,
+					'post_content' => "Prevent canonical redirect exposing post slugs.\n\n<!--nextpage-->Page 2",
+					'post_author'  => self::$users['content_author'],
+					'post_date'    => $post_date,
+				)
+			);
+
+			// Add fake attachment to the post (file upload not needed).
+			self::$posts[ "$post_status-attachment" ] = $factory->post->create_and_get(
+				array(
+					'post_type'    => 'attachment',
+					'post_title'   => "$post_status inherited attachment",
+					'post_name'    => "$post_status-inherited-attachment",
+					'post_status'  => 'inherit',
+					'post_content' => "Prevent canonical redirect exposing post via attachments.\n\n<!--nextpage-->Page 2",
+					'post_author'  => self::$users['content_author'],
+					'post_parent'  => self::$posts[ $post_status ]->ID,
+					'post_date'    => $post_date,
+				)
+			);
+
+			// Set up a page with same.
+			self::$posts[ "$post_status-page" ] = $factory->post->create_and_get(
+				array(
+					'post_type'    => 'page',
+					'post_title'   => "$post_status page",
+					'post_name'    => "$post_status-page",
+					'post_status'  => $post_status,
+					'post_content' => "Prevent canonical redirect exposing page slugs.\n\n<!--nextpage-->Page 2",
+					'post_author'  => self::$users['content_author'],
+					'post_date'    => $post_date,
+				)
+			);
+		}
+
+		// Create a public CPT using a private status.
+		self::$posts['a-public-cpt'] = $factory->post->create_and_get(
+			array(
+				'post_type'    => 'a-public-cpt',
+				'post_title'   => 'a-public-cpt',
+				'post_name'    => 'a-public-cpt',
+				'post_status'  => 'private',
+				'post_content' => 'Prevent canonical redirect exposing a-public-cpt titles.',
+				'post_author'  => self::$users['content_author'],
+			)
+		);
+
+		// Add fake attachment to the public cpt (file upload not needed).
+		self::$posts['a-public-cpt-attachment'] = $factory->post->create_and_get(
+			array(
+				'post_type'    => 'attachment',
+				'post_title'   => 'a-public-cpt post inherited attachment',
+				'post_name'    => 'a-public-cpt-inherited-attachment',
+				'post_status'  => 'inherit',
+				'post_content' => "Prevent canonical redirect exposing post via attachments.\n\n<!--nextpage-->Page 2",
+				'post_author'  => self::$users['content_author'],
+				'post_parent'  => self::$posts['a-public-cpt']->ID,
+			)
+		);
+
+		// Create a private CPT with a public status.
+		self::$posts['a-private-cpt'] = $factory->post->create_and_get(
+			array(
+				'post_type'    => 'a-private-cpt',
+				'post_title'   => 'a-private-cpt',
+				'post_name'    => 'a-private-cpt',
+				'post_status'  => 'publish',
+				'post_content' => 'Prevent canonical redirect exposing a-private-cpt titles.',
+				'post_author'  => self::$users['content_author'],
+			)
+		);
+
+		// Add fake attachment to the private cpt (file upload not needed).
+		self::$posts['a-private-cpt-attachment'] = $factory->post->create_and_get(
+			array(
+				'post_type'    => 'attachment',
+				'post_title'   => 'a-private-cpt post inherited attachment',
+				'post_name'    => 'a-private-cpt-inherited-attachment',
+				'post_status'  => 'inherit',
+				'post_content' => "Prevent canonical redirect exposing post via attachments.\n\n<!--nextpage-->Page 2",
+				'post_author'  => self::$users['content_author'],
+				'post_parent'  => self::$posts['a-private-cpt']->ID,
+			)
+		);
+
+		// Post for trashing.
+		self::$posts['trash'] = $factory->post->create_and_get(
+			array(
+				'post_type'    => 'post',
+				'post_title'   => 'trash post',
+				'post_name'    => 'trash-post',
+				'post_status'  => 'publish',
+				'post_content' => "Prevent canonical redirect exposing post slugs.\n\n<!--nextpage-->Page 2",
+				'post_author'  => self::$users['content_author'],
+			)
+		);
+
+		self::$posts['trash-attachment'] = $factory->post->create_and_get(
+			array(
+				'post_type'    => 'attachment',
+				'post_title'   => 'trash post inherited attachment',
+				'post_name'    => 'trash-post-inherited-attachment',
+				'post_status'  => 'inherit',
+				'post_content' => "Prevent canonical redirect exposing post via attachments.\n\n<!--nextpage-->Page 2",
+				'post_author'  => self::$users['content_author'],
+				'post_parent'  => self::$posts['trash']->ID,
+			)
+		);
+
+		// Page for trashing.
+		self::$posts['trash-page'] = $factory->post->create_and_get(
+			array(
+				'post_type'    => 'page',
+				'post_title'   => 'trash page',
+				'post_name'    => 'trash-page',
+				'post_status'  => 'publish',
+				'post_content' => "Prevent canonical redirect exposing page slugs.\n\n<!--nextpage-->Page 2",
+				'post_author'  => self::$users['content_author'],
+			)
+		);
+		wp_trash_post( self::$posts['trash']->ID );
+		wp_trash_post( self::$posts['trash-page']->ID );
+	}
+
+	function setUp() {
+		parent::setUp();
+		self::setup_custom_types();
+	}
+
+	/**
+	 * Set up a custom post type and private status.
+	 *
+	 * This needs to be called both in the class setup and
+	 * test setup.
+	 */
+	public static function setup_custom_types() {
+		// Register public custom post type.
+		register_post_type(
+			'a-public-cpt',
+			array(
+				'public'  => true,
+				'rewrite' => array(
+					'slug' => 'a-public-cpt',
+				),
+			)
+		);
+
+		// Register private custom post type.
+		register_post_type(
+			'a-private-cpt',
+			array(
+				'public'             => false,
+				'publicly_queryable' => false,
+				'rewrite'            => array(
+					'slug' => 'a-private-cpt',
+				),
+				'map_meta_cap'       => true,
+			)
+		);
+
+		// Register custom private post status.
+		register_post_status(
+			'a-private-status',
+			array(
+				'private' => true,
+			)
+		);
+	}
+
+	/**
+	 * Test canonical redirect does not reveal private posts presence.
+	 *
+	 * @ticket 5272
+	 * @dataProvider data_canonical_redirects_to_ugly_permalinks
+	 *
+	 * @param string $post_key  Post key used for creating fixtures.
+	 * @param string $user_role User role.
+	 * @param string $requested Requested URL.
+	 * @param string $expected  Expected URL.
+	 */
+	public function test_canonical_redirects_to_ugly_permalinks( $post_key, $user_role, $requested, $expected ) {
+		wp_set_current_user( self::$users[ $user_role ] );
+		$this->set_permalink_structure( '' );
+		$post = self::$posts[ $post_key ];
+		clean_post_cache( $post->ID );
+
+		/*
+		 * The dataProvider runs before the fixures are set up, therefore the
+		 * post object IDs are placeholders that needs to be replaced.
+		 */
+		$requested = str_replace( '%ID%', $post->ID, $requested );
+		$expected  = str_replace( '%ID%', $post->ID, $expected );
+
+		$this->assertCanonical( $requested, $expected );
+	}
+
+	/**
+	 * Data provider for test_canonical_redirects_to_ugly_permalinks.
+	 *
+	 * @return array[] Array of arguments for tests {
+	 *     @type string $post_key  Post key used for creating fixtures.
+	 *     @type string $user_role User role.
+	 *     @type string $requested Requested URL.
+	 *     @type string $expected  Expected URL.
+	 * }
+	 */
+	function data_canonical_redirects_to_ugly_permalinks() {
+		$data              = array();
+		$all_user_list     = array( 'anon', 'subscriber', 'content_author', 'editor' );
+		$select_allow_list = array( 'content_author', 'editor' );
+		$select_block_list = array( 'anon', 'subscriber' );
+		// All post/page keys
+		$all_user_post_status_keys    = array( 'publish' );
+		$select_user_post_status_keys = array( 'private', 'a-private-status' );
+		$no_user_post_status_keys     = array( 'future', 'draft', 'pending', 'auto-draft' ); // Excludes trash for attachment rules.
+		$select_user_post_type_keys   = array( 'a-public-cpt' );
+		$no_user_post_type_keys       = array( 'a-private-cpt' );
+
+		foreach ( $all_user_post_status_keys as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				/*
+				 * In the event `redirect_canonical()` is updated to redirect ugly permalinks
+				 * to a canonical ugly version, these expected values can be changed.
+				 */
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				// Ensure rss redirects to rss2.
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss2&p=%ID%',
+				);
+
+				// Ensure rss redirects to rss2.
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss2&page_id=%ID%',
+				);
+			}
+		}
+
+		foreach ( $select_user_post_status_keys as $post_key ) {
+			foreach ( $select_allow_list as $user ) {
+				/*
+				 * In the event `redirect_canonical()` is updated to redirect ugly permalinks
+				 * to a canonical ugly version, these expected values can be changed.
+				 */
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				// Ensure rss redirects to rss2.
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss2&p=%ID%',
+				);
+
+				// Ensure rss redirects to rss2.
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss2&page_id=%ID%',
+				);
+			}
+
+			foreach ( $select_block_list as $user ) {
+				/*
+				 * In the event `redirect_canonical()` is updated to redirect ugly permalinks
+				 * to a canonical ugly version, these expected values MUST NOT be changed.
+				 */
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				// Ensure post's existence is not demonstrated by changing rss to rss2.
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+
+				// Ensure post's existence is not demonstrated by changing rss to rss2.
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss&page_id=%ID%',
+				);
+			}
+		}
+
+		foreach ( $no_user_post_status_keys as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				/*
+				 * In the event `redirect_canonical()` is updated to redirect ugly permalinks
+				 * to a canonical ugly version, these expected values MUST NOT be changed.
+				 */
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				// Ensure post's existence is not demonstrated by changing rss to rss2.
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+
+				// Ensure post's existence is not demonstrated by changing rss to rss2.
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss&page_id=%ID%',
+				);
+			}
+		}
+
+		foreach ( array( 'trash' ) as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				/*
+				 * In the event `redirect_canonical()` is updated to redirect ugly permalinks
+				 * to a canonical ugly version, these expected values MUST NOT be changed.
+				 */
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				// Ensure post's existence is not demonstrated by changing rss to rss2.
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+
+				// Ensure post's existence is not demonstrated by changing rss to rss2.
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss&page_id=%ID%',
+				);
+			}
+		}
+
+		foreach ( $select_user_post_type_keys as $post_key ) {
+			foreach ( $select_allow_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?a-public-cpt=a-public-cpt',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key&post_type=$post_key",
+					"/?name=$post_key&post_type=$post_key",
+				);
+
+				// Ensure rss is replaced by rss2.
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?a-public-cpt=a-public-cpt&feed=rss2',
+				);
+			}
+
+			foreach ( $select_block_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key&post_type=$post_key",
+					"/?name=$post_key&post_type=$post_key",
+				);
+
+				// Ensure rss is not replaced with rss2.
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+			}
+		}
+
+		foreach ( $no_user_post_type_keys as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key&post_type=$post_key",
+					"/?name=$post_key&post_type=$post_key",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+			}
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Test canonical redirect does not reveal private slugs.
+	 *
+	 * @ticket 5272
+	 * @dataProvider data_canonical_redirects_to_pretty_permalinks
+	 *
+	 * @param string $post_key  Post key used for creating fixtures.
+	 * @param string $user_role User role.
+	 * @param string $requested Requested URL.
+	 * @param string $expected  Expected URL.
+	 */
+	public function test_canonical_redirects_to_pretty_permalinks( $post_key, $user_role, $requested, $expected ) {
+		wp_set_current_user( self::$users[ $user_role ] );
+		$this->set_permalink_structure( '/%postname%/' );
+		$post = self::$posts[ $post_key ];
+		clean_post_cache( $post->ID );
+
+		/*
+		 * The dataProvider runs before the fixures are set up, therefore the
+		 * post object IDs are placeholders that needs to be replaced.
+		 */
+		$requested = str_replace( '%ID%', $post->ID, $requested );
+		$expected  = str_replace( '%ID%', $post->ID, $expected );
+
+		$this->assertCanonical( $requested, $expected );
+	}
+
+	/**
+	 * Data provider for test_canonical_redirects_to_pretty_permalinks.
+	 *
+	 * @return array[] Array of arguments for tests {
+	 *     @type string $post_key  Post key used for creating fixtures.
+	 *     @type string $user_role User role.
+	 *     @type string $requested Requested URL.
+	 *     @type string $expected  Expected URL.
+	 * }
+	 */
+	function data_canonical_redirects_to_pretty_permalinks() {
+		$data              = array();
+		$all_user_list     = array( 'anon', 'subscriber', 'content_author', 'editor' );
+		$select_allow_list = array( 'content_author', 'editor' );
+		$select_block_list = array( 'anon', 'subscriber' );
+		// All post/page keys
+		$all_user_post_status_keys    = array( 'publish' );
+		$select_user_post_status_keys = array( 'private', 'a-private-status' );
+		$no_user_post_status_keys     = array( 'future', 'draft', 'pending', 'auto-draft' ); // Excludes trash for attachment rules.
+		$select_user_post_type_keys   = array( 'a-public-cpt' );
+		$no_user_post_type_keys       = array( 'a-private-cpt' );
+
+		foreach ( $all_user_post_status_keys as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					"/$post_key-post/",
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					"/$post_key-post/$post_key-inherited-attachment/",
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					"/$post_key-page/",
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?page_id=%ID%',
+					"/$post_key-page/",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/$post_key-post/",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					"/$post_key-post/feed/",
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					"/$post_key-page/feed/",
+				);
+			}
+		}
+
+		foreach ( $select_user_post_status_keys as $post_key ) {
+			foreach ( $select_allow_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					"/$post_key-post/",
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					"/$post_key-post/$post_key-inherited-attachment/",
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					"/$post_key-page/",
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?page_id=%ID%',
+					"/$post_key-page/",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/$post_key-post/",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					"/$post_key-post/feed/",
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					"/$post_key-page/feed/",
+				);
+			}
+
+			foreach ( $select_block_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?page_id=%ID%',
+					'/?page_id=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss&page_id=%ID%',
+				);
+			}
+		}
+
+		foreach ( $select_user_post_type_keys as $post_key ) {
+			foreach ( $select_allow_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					"/$post_key/$post_key/",
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					"/$post_key/$post_key/$post_key-inherited-attachment/",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key&post_type=$post_key",
+					"/$post_key/$post_key/?post_type=$post_key",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					"/$post_key/$post_key/feed/",
+				);
+			}
+
+			foreach ( $select_block_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key&post_type=$post_key",
+					"/?name=$post_key&post_type=$post_key",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+			}
+		}
+
+		foreach ( $no_user_post_type_keys as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+					// "/$post_key-inherited-attachment/",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key&post_type=$post_key",
+					"/?name=$post_key&post_type=$post_key",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+			}
+		}
+
+		foreach ( $no_user_post_status_keys as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?page_id=%ID%',
+					'/?page_id=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss&page_id=%ID%',
+				);
+			}
+		}
+
+		foreach ( array( 'trash' ) as $post_key ) {
+			foreach ( $all_user_list as $user ) {
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?p=%ID%',
+					'/?p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/?attachment_id=%ID%',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/trash-post/trash-post-inherited-attachment/',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-attachment",
+					$user,
+					'/trash-post__trashed/trash-post-inherited-attachment/',
+					'/?attachment_id=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?post_type=page&p=%ID%',
+					'/?post_type=page&p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?page_id=%ID%',
+					'/?page_id=%ID%',
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					"/?name=$post_key-post",
+					"/?name=$post_key-post",
+				);
+
+				$data[] = array(
+					$post_key,
+					$user,
+					'/?feed=rss&p=%ID%',
+					'/?feed=rss&p=%ID%',
+				);
+
+				$data[] = array(
+					"$post_key-page",
+					$user,
+					'/?feed=rss&page_id=%ID%',
+					'/?feed=rss&page_id=%ID%',
+				);
+			}
+		}
+
+		return $data;
+	}
+}
diff --git a/tests/phpunit/tests/link.php b/tests/phpunit/tests/link.php
index 62f55fbd7d..9eae2a1401 100644
--- a/tests/phpunit/tests/link.php
+++ b/tests/phpunit/tests/link.php
@@ -204,6 +204,9 @@ public function test_attachment_attached_to_non_existent_post_type_has_a_pretty_
 			}
 		}
 
-		$this->assertSame( home_url( user_trailingslashit( $attachment->post_name ) ), get_permalink( $attachment_id ) );
+		$this->assertSame( home_url( "/?attachment_id={$attachment->ID}" ), get_permalink( $attachment_id ) );
+		// Visit permalink.
+		$this->go_to( get_permalink( $attachment_id ) );
+		$this->assertQueryTrue( 'is_attachment', 'is_single', 'is_singular' );
 	}
 }
diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php
index e7326fec65..055618f620 100644
--- a/tests/phpunit/tests/media.php
+++ b/tests/phpunit/tests/media.php
@@ -3122,11 +3122,11 @@ function test_gallery_shortcode_when_is_feed_true() {
 	 * @ticket 51776
 	 *
 	 * @param string $post_key     Post as keyed in the shared fixture array.
-	 * @param string $expected     Expected result.
+	 * @param string $expected_url Expected permalink.
 	 * @param bool   $expected_404 Whether the page is expected to return a 404 result.
 	 *
 	 */
-	function test_attachment_permalinks_based_on_parent_status( $post_key, $expected, $expected_404 ) {
+	function test_attachment_permalinks_based_on_parent_status( $post_key, $expected_url, $expected_404 ) {
 		$this->set_permalink_structure( '/%postname%' );
 		$post = get_post( self::$post_ids[ $post_key ] );
 
@@ -3134,11 +3134,16 @@ function test_attachment_permalinks_based_on_parent_status( $post_key, $expected
 		 * The dataProvider runs before the fixures are set up, therefore the
 		 * post object IDs are placeholders that needs to be replaced.
 		 */
-		$expected = home_url( str_replace( '%ID%', $post->ID, $expected ) );
+		$expected_url = home_url( str_replace( '%ID%', $post->ID, $expected_url ) );
 
-		$this->assertSame( $expected, get_permalink( $post ) );
 		$this->go_to( get_permalink( $post ) );
-		$this->assertSame( $expected_404, is_404() );
+		$this->assertSame( $expected_url, get_permalink( $post ) );
+		if ( $expected_404 ) {
+			$this->assertQueryTrue( 'is_404' );
+		} else {
+			$this->assertQueryTrue( 'is_attachment', 'is_single', 'is_singular' );
+		}
+		$this->assertSame( 'attachment', $post->post_type );
 	}
 
 	/**
@@ -3146,7 +3151,7 @@ function test_attachment_permalinks_based_on_parent_status( $post_key, $expected
 	 *
 	 * @return array[] {
 	 *     @type string $post_key     Post as keyed in the shared fixture array.
-	 *     @type string $expected     Expected result.
+	 *     @type string $expected_url Expected permalink.
 	 *     $type bool   $expected_404 Whether the page is expected to return a 404 result.
 	 * }
 	 */
diff --git a/tests/phpunit/tests/post/isPostPubliclyViewable.php b/tests/phpunit/tests/post/isPostPubliclyViewable.php
new file mode 100644
index 0000000000..9d5d781e88
--- /dev/null
+++ b/tests/phpunit/tests/post/isPostPubliclyViewable.php
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * @group post
+ */
+class Tests_Post_IsPostPubliclyViewable extends WP_UnitTestCase {
+
+	/**
+	 * Array of post IDs to use as parents.
+	 *
+	 * @var array
+	 */
+	public static $parent_post_ids = array();
+
+	public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
+		$post_statuses = array( 'publish', 'private', 'future', 'trash', 'delete' );
+		foreach ( $post_statuses as $post_status ) {
+			$date          = '';
+			$actual_status = $post_status;
+			if ( 'future' === $post_status ) {
+				$date = strftime( '%Y-%m-%d %H:%M:%S', strtotime( '+1 year' ) );
+			} elseif ( in_array( $post_status, array( 'trash', 'delete' ), true ) ) {
+				$actual_status = 'publish';
+			}
+
+			self::$parent_post_ids[ $post_status ] = $factory->post->create(
+				array(
+					'post_status' => $actual_status,
+					'post_name'   => "$post_status-post",
+					'post_date'   => $date,
+					'post_type'   => 'page',
+				)
+			);
+		}
+
+		wp_trash_post( self::$parent_post_ids['trash'] );
+		wp_delete_post( self::$parent_post_ids['delete'], true );
+	}
+
+	/**
+	 * Unit tests for is_post_publicly_viewable().
+	 *
+	 * @dataProvider data_is_post_publicly_viewable
+	 * @ticket 49380
+	 *
+	 * @param string $post_type   The post type.
+	 * @param string $post_status The post status.
+	 * @param bool   $expected    The expected result of the function call.
+	 * @param string $parent_key  The parent key as set up in shared fixtures.
+	 */
+	public function test_is_post_publicly_viewable( $post_type, $post_status, $expected, $parent_key = '' ) {
+		$date = '';
+		if ( 'future' === $post_status ) {
+			$date = strftime( '%Y-%m-%d %H:%M:%S', strtotime( '+1 year' ) );
+		}
+
+		$post_id = $this->factory()->post->create(
+			array(
+				'post_type'   => $post_type,
+				'post_status' => $post_status,
+				'post_parent' => $parent_key ? self::$parent_post_ids[ $parent_key ] : 0,
+				'post_date'   => $date,
+			)
+		);
+
+		$this->assertSame( $expected, is_post_publicly_viewable( $post_id ) );
+	}
+
+	/**
+	 * Data provider for test_is_post_publicly_viewable().
+	 *
+	 * return array[] {
+	 *     @type string $post_type   The post type.
+	 *     @type string $post_status The post status.
+	 *     @type bool   $expected    The expected result of the function call.
+	 *     @type string $parent_key  The parent key as set up in shared fixtures.
+	 * }
+	 */
+	public function data_is_post_publicly_viewable() {
+		return array(
+			array( 'post', 'publish', true ),
+			array( 'post', 'private', false ),
+			array( 'post', 'future', false ),
+
+			array( 'page', 'publish', true ),
+			array( 'page', 'private', false ),
+			array( 'page', 'future', false ),
+
+			array( 'unregistered_cpt', 'publish', false ),
+			array( 'unregistered_cpt', 'private', false ),
+
+			array( 'post', 'unregistered_cps', false ),
+			array( 'page', 'unregistered_cps', false ),
+
+			array( 'attachment', 'inherit', true, 'publish' ),
+			array( 'attachment', 'inherit', false, 'private' ),
+			array( 'attachment', 'inherit', false, 'future' ),
+			array( 'attachment', 'inherit', true, 'trash' ),
+			array( 'attachment', 'inherit', true, 'delete' ),
+
+			array( 'page', 'publish', true, 'publish' ),
+			array( 'page', 'publish', true, 'private' ),
+			array( 'page', 'publish', true, 'future' ),
+			array( 'page', 'publish', true, 'trash' ),
+			array( 'page', 'publish', true, 'delete' ),
+		);
+	}
+}
diff --git a/tests/phpunit/tests/post/isPostStatusViewable.php b/tests/phpunit/tests/post/isPostStatusViewable.php
new file mode 100644
index 0000000000..9658ca2066
--- /dev/null
+++ b/tests/phpunit/tests/post/isPostStatusViewable.php
@@ -0,0 +1,171 @@
+<?php
+
+/**
+ * @group post
+ */
+class Tests_Post_IsPostStatusViewable extends WP_UnitTestCase {
+
+	/**
+	 * Remove the test status from the global when finished.
+	 *
+	 * @global $wp_post_statuses
+	 */
+	static function wpTearDownAfterClass() {
+		global $wp_post_statuses;
+		unset( $wp_post_statuses['wp_tests_ps'] );
+	}
+
+	/**
+	 * Test custom post status.
+	 *
+	 * This may include emulations of built in (_builtin) statuses.
+	 *
+	 * @ticket 49380
+	 * @dataProvider data_custom_post_statuses
+	 *
+	 * @param array $cps_args Registration arguments.
+	 * @param bool  $expected Expected result.
+	 */
+	public function test_custom_post_statuses( $cps_args, $expected ) {
+		register_post_status(
+			'wp_tests_ps',
+			$cps_args
+		);
+
+		// Test status passed as string.
+		$this->assertSame( $expected, is_post_status_viewable( 'wp_tests_ps' ) );
+		// Test status passed as object.
+		$this->assertSame( $expected, is_post_status_viewable( get_post_status_object( 'wp_tests_ps' ) ) );
+	}
+
+	/**
+	 * Data provider for custom post status tests.
+	 *
+	 * @return array[] {
+	 *     array CPS registration args.
+	 *     bool  Expected result.
+	 * }
+	 */
+	public function data_custom_post_statuses() {
+		return array(
+			// 0. False for non-publically queryable types.
+			array(
+				array(
+					'publicly_queryable' => false,
+					'_builtin'           => false,
+					'public'             => true,
+				),
+				false,
+			),
+			// 1. True for publically queryable types.
+			array(
+				array(
+					'publicly_queryable' => true,
+					'_builtin'           => false,
+					'public'             => false,
+				),
+				true,
+			),
+			// 2. False for built-in non-public types.
+			array(
+				array(
+					'publicly_queryable' => false,
+					'_builtin'           => true,
+					'public'             => false,
+				),
+				false,
+			),
+			// 3. False for non-built-in public types.
+			array(
+				array(
+					'publicly_queryable' => false,
+					'_builtin'           => false,
+					'public'             => true,
+				),
+				false,
+			),
+			// 4. True for built-in public types.
+			array(
+				array(
+					'publicly_queryable' => false,
+					'_builtin'           => true,
+					'public'             => true,
+				),
+				true,
+			),
+		);
+	}
+
+	/**
+	 * Test built-in and unregistered post status.
+	 *
+	 * @dataProvider data_built_unregistered_in_status_types
+	 * @ticket 49380
+	 *
+	 * @param mixed $status   Post status to check.
+	 * @param bool  $expected Expected viewable status.
+	 */
+	function test_built_unregistered_in_status_types( $status, $expected ) {
+		// Test status passed as string.
+		$this->assertSame( $expected, is_post_status_viewable( $status ) );
+		// Test status passed as object.
+		$this->assertSame( $expected, is_post_status_viewable( get_post_status_object( $status ) ) );
+	}
+
+	/**
+	 * Data provider for built-in and unregistered post status tests.
+	 *
+	 * @return array[] {
+	 *     @type mixed $status   Post status to check.
+	 *     @type bool  $expected Expected viewable status.
+	 * }
+	 */
+	public function data_built_unregistered_in_status_types() {
+		return array(
+			array( 'publish', true ),
+			array( 'future', false ),
+			array( 'draft', false ),
+			array( 'pending', false ),
+			array( 'private', false ),
+			array( 'trash', false ),
+			array( 'auto-draft', false ),
+			array( 'inherit', false ),
+			array( 'request-pending', false ),
+			array( 'request-confirmed', false ),
+			array( 'request-failed', false ),
+			array( 'request-completed', false ),
+
+			// Various unregistered statuses.
+			array( 'unregistered-status', false ),
+			array( false, false ),
+			array( true, false ),
+			array( 20, false ),
+			array( null, false ),
+			array( '', false ),
+		);
+	}
+
+	/**
+	 * Sanitize key should not be run when testing.
+	 *
+	 * @ticket 49380
+	 */
+	public function test_sanitize_key_not_run() {
+		register_post_status(
+			'WP_Tests_ps',
+			array(
+				'publicly_queryable' => true,
+				'_builtin'           => false,
+				'public'             => true,
+			)
+		);
+
+		// Sanitized key should return true.
+		$this->assertTrue( is_post_status_viewable( 'wp_tests_ps' ) );
+		$this->assertTrue( is_post_status_viewable( get_post_status_object( 'wp_tests_ps' ) ) );
+
+		// Unsanitized key should return false.
+		$this->assertFalse( is_post_status_viewable( 'WP_tests_ps' ) );
+		$this->assertFalse( is_post_status_viewable( get_post_status_object( 'WP_tests_ps' ) ) );
+	}
+}
