Renamed the Web package as Infrastructure (#25)
This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project. To provide further details about the work: * Infrastructure * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags. * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses. * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements. * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext. * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods. * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache. Reviewed-on: rock-n-code/loud-amsterdam#25 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("AssetExtension enumeration")
|
||||
struct AssetExtensionTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
Self.extensions,
|
||||
Self.contentTypes
|
||||
))
|
||||
func `content type`(
|
||||
for fileExtension: AssetExtension,
|
||||
expects contentType: String
|
||||
) {
|
||||
#expect(fileExtension.contentType == contentType)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
Self.extensions,
|
||||
Self.subdirectories
|
||||
))
|
||||
func `subdirectory`(
|
||||
for fileExtension: AssetExtension,
|
||||
expects subdirectory: String?
|
||||
) {
|
||||
#expect(fileExtension.subdirectory == subdirectory)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AssetExtensionTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let extensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js,
|
||||
.png,
|
||||
.ico,
|
||||
.svg,
|
||||
.txt,
|
||||
.webmanifest,
|
||||
.xml
|
||||
]
|
||||
static let contentTypes: [String] = [
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
"image/png",
|
||||
"image/vnd.microsoft.icon",
|
||||
"image/svg+xml",
|
||||
"text/plain",
|
||||
"application/manifest+json",
|
||||
"application/xml"
|
||||
]
|
||||
static let subdirectories: [String?] = [
|
||||
"css",
|
||||
"js",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("FingerprintAssets method")
|
||||
struct FingerprintAssetsTests {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let fingerprint = FingerprintAssets()
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `fingerprints the files under a directory`() throws {
|
||||
let directory = try makeDirectory(files: [
|
||||
"css/site.css": "body { margin: 0; }",
|
||||
"robots.txt": "User-agent: *"
|
||||
])
|
||||
|
||||
defer {
|
||||
removeDirectory(directory)
|
||||
}
|
||||
|
||||
let token = try #require(fingerprint(directory.path))
|
||||
|
||||
#expect(token.count == 16)
|
||||
#expect(token.allSatisfy { $0.isHexDigit })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `agrees across directories with identical contents`() throws {
|
||||
let files = [
|
||||
"css/site.css": "body { margin: 0; }",
|
||||
"js/site.js": "console.log(1);"
|
||||
]
|
||||
let first = try makeDirectory(files: files)
|
||||
let second = try makeDirectory(files: files)
|
||||
|
||||
defer {
|
||||
removeDirectory(first)
|
||||
removeDirectory(second)
|
||||
}
|
||||
|
||||
#expect(fingerprint(first.path) == fingerprint(second.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `changes the token when a file's contents change`() throws {
|
||||
let directory = try makeDirectory(files: [
|
||||
"css/site.css": "body { margin: 0; }"
|
||||
])
|
||||
|
||||
defer {
|
||||
removeDirectory(directory)
|
||||
}
|
||||
|
||||
let before = fingerprint(directory.path)
|
||||
|
||||
try "body { margin: 1px; }".write(
|
||||
to: directory.appendingPathComponent("css/site.css"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
#expect(fingerprint(directory.path) != before)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `changes the token when a file is renamed`() throws {
|
||||
let contents = "body { margin: 0; }"
|
||||
let first = try makeDirectory(files: ["css/site.css": contents])
|
||||
let second = try makeDirectory(files: ["css/main.css": contents])
|
||||
|
||||
defer {
|
||||
removeDirectory(first)
|
||||
removeDirectory(second)
|
||||
}
|
||||
|
||||
#expect(fingerprint(first.path) != fingerprint(second.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `changes the token when a file is added`() throws {
|
||||
let directory = try makeDirectory(files: [
|
||||
"css/site.css": "body { margin: 0; }"
|
||||
])
|
||||
|
||||
defer {
|
||||
removeDirectory(directory)
|
||||
}
|
||||
|
||||
let before = fingerprint(directory.path)
|
||||
|
||||
try "console.log(1);".write(
|
||||
to: directory.appendingPathComponent("site.js"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
#expect(fingerprint(directory.path) != before)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns nil for a directory without files`() throws {
|
||||
let directory = try makeDirectory(files: [:])
|
||||
|
||||
defer {
|
||||
removeDirectory(directory)
|
||||
}
|
||||
|
||||
#expect(fingerprint(directory.path) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns nil for a missing directory`() {
|
||||
let missing = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("FingerprintAssetsTests-missing-\(UUID().uuidString)")
|
||||
|
||||
#expect(fingerprint(missing.path) == nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension FingerprintAssetsTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Creates a unique temporary directory holding the given files, keyed by relative path.
|
||||
/// - Parameter files: the files to create, keyed by their path relative to the directory.
|
||||
/// - Returns: the URL of the created directory.
|
||||
func makeDirectory(
|
||||
files: [String: String]
|
||||
) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("FingerprintAssetsTests-\(UUID().uuidString)")
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
for (relativePath, contents) in files {
|
||||
let file = directory.appendingPathComponent(relativePath)
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: file.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try contents.write(
|
||||
to: file,
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
/// Removes a temporary directory created by ``makeDirectory(files:)``.
|
||||
/// - Parameter directory: the URL of the directory to remove.
|
||||
func removeDirectory(
|
||||
_ directory: URL
|
||||
) {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("LocalizationMiddleware middleware", .tags(.middleware))
|
||||
struct LocalizationMiddlewareTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let app: Application = .init(router: {
|
||||
let router = Router(context: StubRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware(bundle: .module)
|
||||
}
|
||||
|
||||
router.get("language") { _, context in
|
||||
context.language
|
||||
}
|
||||
|
||||
return router
|
||||
}())
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `negotiates a supported language from the header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `falls back to the default without a header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "en")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `falls back to the default for an unsupported language`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "fr-FR,fr;q=0.9"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "en")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("NotFoundMiddleware middleware", .tags(.middleware))
|
||||
struct NotFoundMiddlewareTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let app: Application = .init(router: {
|
||||
let router = Router(context: StubRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware(bundle: .module)
|
||||
NotFoundMiddleware(bundle: .module) {
|
||||
StubPage(locale: $0)
|
||||
}
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.get("boom") { _, _ -> String in
|
||||
throw HTTPError(.badRequest)
|
||||
}
|
||||
|
||||
return router
|
||||
}())
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders the error page for an unmatched request`() async throws {
|
||||
try await app.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(response.headers[.contentLanguage] == "en")
|
||||
#expect(response.headers[.vary] == "Accept-Language")
|
||||
#expect(body.contains("Stub content"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders the error page in the negotiated language`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.contentLanguage] == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a matched response through untouched`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.body == ByteBuffer(string: "Hello!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `rethrows a non-not-found error unchanged`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/boom",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .badRequest)
|
||||
#expect(!body.contains("Stub content"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders versioned asset URLs when given a version`() async throws {
|
||||
try await app(
|
||||
assetVersion: "0123456789abcdef"
|
||||
).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/stub.css?v=0123456789abcdef"))
|
||||
#expect(body.contains("/js/stub.js?v=0123456789abcdef"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders unversioned asset URLs by default`() async throws {
|
||||
try await app.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(#"href="/css/stub.css""#))
|
||||
#expect(!body.contains("?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves the error page 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.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 `serves the full error page to a conditional request`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: "*"]
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .notFound)
|
||||
#expect(body.contains("Stub content"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension NotFoundMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose not-found middleware appends the given version token to the
|
||||
/// error page's asset URLs.
|
||||
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
|
||||
/// - Returns: the configured application.
|
||||
func app(
|
||||
assetVersion: String?
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router(context: StubRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware(bundle: .module)
|
||||
NotFoundMiddleware(bundle: .module) {
|
||||
StubPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("RateLimitMiddleware middleware", .tags(.middleware))
|
||||
struct RateLimitMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `admits requests within the limit`() async throws {
|
||||
try await app(
|
||||
configuration: .init(limit: 3)
|
||||
).test(.router) { client in
|
||||
for _ in 1 ... 3 {
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `rejects a request over the limit with a retry-after header`() async throws {
|
||||
try await app(
|
||||
configuration: .init(limit: 2)
|
||||
).test(.router) { client in
|
||||
for _ in 1 ... 2 {
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .tooManyRequests)
|
||||
|
||||
let retryAfter = try #require(response.headers[.retryAfter])
|
||||
|
||||
#expect(try #require(Int(retryAfter)) >= 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `admits requests again once the window resets`() async throws {
|
||||
try await app(
|
||||
configuration: .init(
|
||||
limit: 1,
|
||||
window: .milliseconds(50)
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .tooManyRequests)
|
||||
}
|
||||
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `separates clients by their forwarded address when trusted`() async throws {
|
||||
try await app(
|
||||
configuration: .init(
|
||||
limit: 1,
|
||||
trustForwardedFor: true
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedFor: "203.0.113.7"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedFor: "203.0.113.8"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
// The first entry names the client; the appended proxy hop must not change its key.
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedFor: "203.0.113.7, 10.0.0.1"]
|
||||
) { response in
|
||||
#expect(response.status == .tooManyRequests)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores the forwarded address when not trusted`() async throws {
|
||||
try await app(
|
||||
configuration: .init(limit: 1)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedFor: "203.0.113.7"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
// Without trust (and without a connection address in router-only testing), every client
|
||||
// shares one bucket, so a rotated header must not mint a fresh budget.
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedFor: "203.0.113.8"]
|
||||
) { response in
|
||||
#expect(response.status == .tooManyRequests)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RateLimitMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the rate-limit middleware ahead of a single
|
||||
/// `/hello` route returning a plain body.
|
||||
func app(
|
||||
configuration: RateLimitMiddleware<BasicRequestContext>.Configuration
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
RateLimitMiddleware(configuration: configuration)
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware))
|
||||
struct SecurityHeadersMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `applies the default security headers to a response`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
|
||||
#expect(response.headers[.frameOptions] == .Security.frameOptions)
|
||||
#expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
|
||||
#expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits strict-transport-security by default`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.strictTransportSecurity] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies strict-transport-security when configured`() async throws {
|
||||
let value = "max-age=31536000; includeSubDomains"
|
||||
|
||||
try await app(
|
||||
configuration: .init(strictTransportSecurity: value)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.strictTransportSecurity] == value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies a custom header value`() async throws {
|
||||
let value = "default-src 'none'"
|
||||
|
||||
try await app(
|
||||
configuration: .init(contentSecurityPolicy: value)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.contentSecurityPolicy] == value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits a header whose configured value is nil`() async throws {
|
||||
try await app(
|
||||
configuration: .init(contentTypeOptions: nil)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.xContentTypeOptions] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replaces an existing header value set downstream`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/weak",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies the security headers to an error response`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/throws",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .badRequest)
|
||||
#expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
|
||||
#expect(response.headers[.frameOptions] == .Security.frameOptions)
|
||||
#expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
|
||||
#expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SecurityHeadersMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the security-headers middleware ahead of three
|
||||
/// routes: `/hello` returns a plain body, `/weak` returns a response that already carries a
|
||||
/// deliberately weak `X-Content-Type-Options` value for the middleware to override, and
|
||||
/// `/throws` fails with an `HTTPError` the way the controllers do on invalid input.
|
||||
func app(
|
||||
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
SecurityHeadersMiddleware(configuration: configuration)
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.get("weak") { _, _ -> Response in
|
||||
var response = Response(status: .ok)
|
||||
|
||||
response.headers[.xContentTypeOptions] = "weak"
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
router.get("throws") { _, _ -> Response in
|
||||
throw HTTPError(.badRequest)
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("VaryMiddleware middleware", .tags(.middleware))
|
||||
struct VaryMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `adds the default field to a response without a vary header`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/plain",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.vary] == "Accept-Encoding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `appends to an existing vary header`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/localized",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.vary] == "Accept-Language, Accept-Encoding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not duplicate a name already present`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/encoded",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.vary] == "Accept-Encoding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches an existing name regardless of its casing`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/lowercased",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.vary] == "accept-encoding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `normalizes the whitespace of an existing list`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/spaced",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.vary] == "Accept-Language, User-Agent, Accept-Encoding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `appends every configured field`() async throws {
|
||||
try await app(
|
||||
fields: [.acceptEncoding, .acceptLanguage]
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/plain",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.vary] == "Accept-Encoding, Accept-Language")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension VaryMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the vary middleware ahead of routes whose
|
||||
/// responses carry different `Vary` starting points: `/plain` none, `/localized` an
|
||||
/// `Accept-Language`, `/encoded` an `Accept-Encoding` already, `/lowercased` a lowercase
|
||||
/// `accept-encoding`, and `/spaced` a list with irregular whitespace.
|
||||
func app(
|
||||
fields: [HTTPField.Name] = [.acceptEncoding]
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
VaryMiddleware(fields: fields)
|
||||
}
|
||||
|
||||
router.get("plain") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
for (path, vary) in [
|
||||
("localized", "Accept-Language"),
|
||||
("encoded", "Accept-Encoding"),
|
||||
("lowercased", "accept-encoding"),
|
||||
("spaced", "Accept-Language , User-Agent"),
|
||||
] {
|
||||
router.get(RouterPath(path)) { _, _ -> Response in
|
||||
var response = Response(status: .ok)
|
||||
|
||||
response.headers[.vary] = vary
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("Asset protocol")
|
||||
struct AssetTests {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let image = StubAsset(
|
||||
fileExtensions: [.png],
|
||||
fileName: "icon"
|
||||
)
|
||||
private let shared = StubAsset(
|
||||
fileExtensions: [.css, .js],
|
||||
fileName: "shared"
|
||||
)
|
||||
|
||||
// MARK: Method tests
|
||||
|
||||
@Test
|
||||
func `relative path nests the file inside its extension's sub-directory`() {
|
||||
#expect(shared.relativePath(for: .css) == "css/shared.css")
|
||||
#expect(shared.relativePath(for: .js) == "js/shared.js")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `relative path keeps the file at the root without a sub-directory`() {
|
||||
#expect(image.relativePath(for: .png) == "icon.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `url path prefixes the relative path with a slash`() {
|
||||
#expect(shared.urlPath(for: .css) == "/css/shared.css")
|
||||
#expect(image.urlPath(for: .png) == "/icon.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `url path appends a version token as a query parameter`() {
|
||||
#expect(shared.urlPath(
|
||||
for: .css,
|
||||
version: "0123456789abcdef"
|
||||
) == "/css/shared.css?v=0123456789abcdef")
|
||||
}
|
||||
|
||||
@Test(arguments: [nil, ""] as [String?])
|
||||
func `url path without a version`(
|
||||
version: String?
|
||||
) {
|
||||
#expect(shared.urlPath(
|
||||
for: .css,
|
||||
version: version
|
||||
) == "/css/shared.css")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"",
|
||||
".",
|
||||
"Resources/Static"
|
||||
])
|
||||
func `path relative to`(
|
||||
_ basePath: String
|
||||
) {
|
||||
for fileExtension in shared.fileExtensions {
|
||||
let pathRelativeToBasePath = shared.path(
|
||||
relativeTo: basePath,
|
||||
for: fileExtension
|
||||
)
|
||||
let relativePath = shared.relativePath(for: fileExtension)
|
||||
|
||||
if basePath.isEmpty {
|
||||
#expect(pathRelativeToBasePath == relativePath)
|
||||
} else {
|
||||
#expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite("Page protocol", .tags(.page))
|
||||
struct PageTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `assembles the document around the page's parts`() {
|
||||
let html = StubPage().render()
|
||||
|
||||
#expect(html.contains("<title>Stub Page</title>"))
|
||||
#expect(html.contains(#"lang="en""#))
|
||||
#expect(html.contains(#"name="viewport""#))
|
||||
#expect(html.contains(#"<meta name="stub" content="marker">"#))
|
||||
#expect(html.contains(#"<link rel="stylesheet" href="/css/stub.css">"#))
|
||||
#expect(html.contains(#"<script src="/js/stub.js"></script>"#))
|
||||
#expect(html.contains("Stub content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `places the metadata between the viewport and the stylesheets`() throws {
|
||||
let html = StubPage().render()
|
||||
|
||||
let viewport = try #require(html.range(of: #"name="viewport""#))
|
||||
let metadata = try #require(html.range(of: #"name="stub""#))
|
||||
let stylesheet = try #require(html.range(of: "/css/stub.css"))
|
||||
|
||||
#expect(viewport.lowerBound < metadata.lowerBound)
|
||||
#expect(metadata.lowerBound < stylesheet.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders the scripts after the content`() throws {
|
||||
let html = StubPage().render()
|
||||
|
||||
let content = try #require(html.range(of: "Stub content"))
|
||||
let script = try #require(html.range(of: "/js/stub.js"))
|
||||
|
||||
#expect(content.lowerBound < script.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `appends the version token to the asset URLs`() {
|
||||
let html = StubPage(assetVersion: "0123456789abcdef").render()
|
||||
|
||||
#expect(html.contains("/css/stub.css?v=0123456789abcdef"))
|
||||
#expect(html.contains("/js/stub.js?v=0123456789abcdef"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `derives the document language from the locale`() {
|
||||
let html = StubPage(locale: .init(identifier: "de-DE")).render()
|
||||
|
||||
#expect(html.contains(#"lang="de""#))
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user