This commit contains the latest updates from the generic Website template, which rework the compression and localization: - Reworked the compression and localization in the Infrastructure package. (3c568e4) - Adopted the reworked compression and localization in the Website service. (08cf3e3) The template commit that only touched the root README (53676ed) was left out, as this project no longer carries that file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
486 lines
16 KiB
Swift
486 lines
16 KiB
Swift
import CompressNIO
|
|
import Configuration
|
|
import Foundation
|
|
import Hummingbird
|
|
import HummingbirdTesting
|
|
import Infrastructure
|
|
import NIOCore
|
|
import Testing
|
|
|
|
@testable import Website
|
|
@testable import WebsiteLibrary
|
|
|
|
@Suite("App executable")
|
|
struct AppTests {
|
|
|
|
// MARK: Constants
|
|
|
|
// Referenced through fingerprinted URLs, or an immutable subset file in the case of a font, so all of these are served immutable.
|
|
private let immutableExtensions: [AssetExtension] = [
|
|
.css,
|
|
.jpg,
|
|
.js,
|
|
.mp4,
|
|
.webp,
|
|
.woff2
|
|
]
|
|
|
|
// Absolute path to the copy of the package's "Resources/Static" folder made into the test bundle
|
|
// at build time — the repository tree itself is off limits to Xcode's test runner.
|
|
private let staticFilesPath: String = {
|
|
guard let url = Bundle.module.url(
|
|
forResource: "Static",
|
|
withExtension: nil
|
|
) else {
|
|
preconditionFailure("The static files are missing from the test bundle.")
|
|
}
|
|
|
|
return url.path
|
|
}()
|
|
|
|
// MARK: Functional tests
|
|
|
|
@Test
|
|
func `landing page to be served at root`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
let body = String(buffer: response.body)
|
|
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
|
#expect(body.contains("Hello world!"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `landing page to answer a head request`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .head
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
|
#expect(response.body.readableBytes == 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `health check to be served at the health path`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/health",
|
|
method: .get
|
|
) { response in
|
|
let body = String(buffer: response.body)
|
|
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentType] == "application/json")
|
|
#expect(body == #"{"status":"ok"}"#)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `readiness check to be served at the readiness path`() async throws {
|
|
// Live mode runs the application's service group, so the `Fluent` service starts before the
|
|
// request and shuts its connection pool down after — the router-only mode never would.
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.live) { client in
|
|
try await client.execute(
|
|
uri: "/health/ready",
|
|
method: .get
|
|
) { response in
|
|
let body = String(buffer: response.body)
|
|
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentType] == "application/json")
|
|
#expect(body == #"{"status":"ready"}"#)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(arguments: StaticFile.all)
|
|
func `static files to be served`(
|
|
staticFile file: StaticFile
|
|
) async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
for fileExtension in file.fileExtensions {
|
|
try await client.execute(
|
|
uri: "/\(file.relativePath(for: fileExtension))",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentType] == fileExtension.contentType)
|
|
|
|
let cacheControl = try #require(response.headers[.cacheControl])
|
|
|
|
#expect(cacheControl.contains("public") == true)
|
|
#expect(cacheControl.contains("max-age=") == true)
|
|
|
|
if immutableExtensions.contains(fileExtension) {
|
|
#expect(cacheControl.contains("immutable") == true)
|
|
} else if fileExtension == .txt {
|
|
#expect(cacheControl.contains("must-revalidate") == true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `versioned asset URL to be served`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/css/shared.css?v=0123456789abcdef",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentType] == "text/css")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `landing page to reference fingerprinted assets`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
let body = String(buffer: response.body)
|
|
|
|
#expect(body.contains("/css/shared.css?v="))
|
|
#expect(body.contains("/js/shared.js?v="))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `landing page to revalidate with an entity tag`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
let eTag = try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.headers[.cacheControl] == "public, no-cache")
|
|
|
|
return try #require(response.headers[.eTag])
|
|
}
|
|
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get,
|
|
headers: [.ifNoneMatch: eTag]
|
|
) { response in
|
|
#expect(response.status == .notModified)
|
|
#expect(response.body.readableBytes == 0)
|
|
#expect(response.headers[.eTag] == eTag)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `responses to vary on language and encoding`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
let vary = try #require(response.headers[.vary])
|
|
|
|
#expect(vary.contains("Accept-Language"))
|
|
#expect(vary.contains("Accept-Encoding"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `response to be compressed when the client supports it`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get,
|
|
headers: [.acceptEncoding: "gzip"]
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentEncoding] == "gzip")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `response to not be compressed when the client does not support it`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentEncoding] == nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `CompressionMiddleware` stands in for Hummingbird's `ResponseCompressionMiddleware`, which appends to `Content-Encoding` without
|
|
/// checking for one: a pre-compressed page would ship as `gzip, gzip`. Decoding makes it visible — a doubly compressed page decodes once,
|
|
/// into bytes that are not HTML.
|
|
@Test
|
|
func `page to be served pre-compressed, and only once`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get,
|
|
headers: [.acceptEncoding: "gzip, deflate, br"]
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[values: .contentEncoding] == ["gzip"])
|
|
|
|
var body = response.body
|
|
let decoded = try body.decompress(with: .gzip())
|
|
|
|
#expect(String(buffer: decoded).hasPrefix("<!DOCTYPE html>"))
|
|
// Complete before the first byte is written, so it is sized rather than chunked.
|
|
#expect(response.headers[.contentLength] == String(response.body.readableBytes))
|
|
#expect(response.body.readableBytes < decoded.readableBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The compressed copy is only handed to a client that asked for it; everyone else gets the rendered bytes.
|
|
@Test
|
|
func `page to be served uncompressed when gzip is not accepted`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get,
|
|
headers: [.acceptEncoding: "identity"]
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentEncoding] == nil)
|
|
#expect(String(buffer: response.body).hasPrefix("<!DOCTYPE html>"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `error page to be served when not found`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/this-path-does-not-exist",
|
|
method: .get
|
|
) { response in
|
|
let body = String(buffer: response.body)
|
|
|
|
#expect(response.status == .notFound)
|
|
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
|
#expect(body.contains("Page Not Found"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `error page to reference fingerprinted assets`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/this-path-does-not-exist",
|
|
method: .get
|
|
) { response in
|
|
let body = String(buffer: response.body)
|
|
|
|
#expect(body.contains("/css/not-found.css?v="))
|
|
#expect(body.contains("/js/shared.js?v="))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `error page to be served without revalidation headers`() async throws {
|
|
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
|
// invite revalidation with an entity tag or a cache policy.
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/this-path-does-not-exist",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.status == .notFound)
|
|
#expect(response.headers[.eTag] == nil)
|
|
#expect(response.headers[.cacheControl] == nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `error page to vary on language and encoding`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/this-path-does-not-exist",
|
|
method: .get
|
|
) { response in
|
|
let vary = try #require(response.headers[.vary])
|
|
|
|
#expect(vary.contains("Accept-Language"))
|
|
#expect(vary.contains("Accept-Encoding"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `landing page to revalidate a conditional head request`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
let eTag = try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
try #require(response.headers[.eTag])
|
|
}
|
|
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .head,
|
|
headers: [.ifNoneMatch: eTag]
|
|
) { response in
|
|
#expect(response.status == .notModified)
|
|
#expect(response.body.readableBytes == 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `security headers to be applied to the landing page`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
|
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
|
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
|
|
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
|
|
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
|
|
#expect(response.headers[.strictTransportSecurity] == nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `security headers to be applied to the error page`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/this-path-does-not-exist",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.status == .notFound)
|
|
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
|
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `strict-transport-security to be applied when configured`() async throws {
|
|
try await app(
|
|
staticFilesPath: staticFilesPath,
|
|
strictTransportSecurity: "max-age=31536000; includeSubDomains"
|
|
).test(.router) { client in
|
|
try await client.execute(
|
|
uri: "/",
|
|
method: .get
|
|
) { response in
|
|
#expect(response.status == .ok)
|
|
#expect(response.headers[.strictTransportSecurity] == "max-age=31536000; includeSubDomains")
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private extension AppTests {
|
|
|
|
// MARK: Methods
|
|
|
|
func app(
|
|
staticFilesPath: String,
|
|
strictTransportSecurity: String? = nil
|
|
) async throws -> some ApplicationProtocol {
|
|
try await application(
|
|
reader: reader(
|
|
staticFilesPath: staticFilesPath,
|
|
strictTransportSecurity: strictTransportSecurity
|
|
)
|
|
)
|
|
}
|
|
|
|
func reader(
|
|
staticFilesPath: String,
|
|
strictTransportSecurity: String? = nil
|
|
) -> ConfigReader {
|
|
ConfigReader(providers: [{
|
|
if let strictTransportSecurity {
|
|
InMemoryProvider(values: [
|
|
.HTTP.host: "127.0.0.1",
|
|
.HTTP.port: "0",
|
|
.Log.level: "trace",
|
|
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
|
.Security.strictTransportSecurity: .init(stringLiteral: strictTransportSecurity)
|
|
])
|
|
} else {
|
|
InMemoryProvider(values: [
|
|
.HTTP.host: "127.0.0.1",
|
|
.HTTP.port: "0",
|
|
.Log.level: "trace",
|
|
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
|
])
|
|
}
|
|
}()])
|
|
}
|
|
|
|
}
|