std/http/file_server_test.ts

1109 lines
35 KiB
TypeScript
Raw Normal View History

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import {
assert,
assertAlmostEquals,
assertEquals,
assertFalse,
assertMatch,
assertStringIncludes,
} from "@std/assert";
import { stub } from "@std/testing/mock";
import { serveDir, type ServeDirOptions, serveFile } from "./file_server.ts";
import { eTag } from "./etag.ts";
import {
basename,
dirname,
fromFileUrl,
join,
resolve,
toFileUrl,
} from "@std/path";
import denoConfig from "./deno.json" with { type: "json" };
import { MINUTE } from "@std/datetime/constants";
import { getAvailablePort } from "@std/net/get-available-port";
import { concat } from "@std/bytes/concat";
const moduleDir = dirname(fromFileUrl(import.meta.url));
const testdataDir = resolve(moduleDir, "testdata");
const serveDirOptions: ServeDirOptions = {
quiet: true,
fsRoot: testdataDir,
showDirListing: true,
showDotfiles: true,
enableCors: true,
};
2023-12-01 00:02:18 +00:00
const TEST_FILE_PATH = join(testdataDir, "test_file.txt");
const TEST_FILE_STAT = await Deno.stat(TEST_FILE_PATH);
const TEST_FILE_SIZE = TEST_FILE_STAT.size;
const TEST_FILE_ETAG = await eTag(TEST_FILE_STAT) as string;
const TEST_FILE_LAST_MODIFIED = TEST_FILE_STAT.mtime instanceof Date
? new Date(TEST_FILE_STAT.mtime).toUTCString()
: "";
const TEST_FILE_TEXT = await Deno.readTextFile(TEST_FILE_PATH);
const LOCALHOST = Deno.build.os === "windows" ? "localhost" : "0.0.0.0";
/* HTTP GET request allowing arbitrary paths */
async function fetchExactPath(
hostname: string,
port: number,
path: string,
): Promise<Response> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const conn = await Deno.connect({ hostname, port });
await conn.write(encoder.encode("GET " + path + " HTTP/1.1\r\n\r\n"));
let currentResult = "";
let contentLength = -1;
let startOfBody = -1;
for await (const chunk of conn.readable) {
currentResult += decoder.decode(chunk);
if (contentLength === -1) {
const match = /^content-length: (.*)$/m.exec(currentResult);
if (match && match[1]) {
contentLength = Number(match[1]);
}
}
if (startOfBody === -1) {
const ind = currentResult.indexOf("\r\n\r\n");
if (ind !== -1) {
startOfBody = ind + 4;
}
}
if (startOfBody !== -1 && contentLength !== -1) {
const byteLen = encoder.encode(currentResult).length;
if (byteLen >= contentLength + startOfBody) {
break;
}
}
}
const status = /^HTTP\/1.1 (...)/.exec(currentResult);
let statusCode = 0;
if (status && status[1]) {
statusCode = Number(status[1]);
}
const body = currentResult.slice(startOfBody);
const headersStr = currentResult.slice(0, startOfBody);
const headersReg = /^(.*): (.*)$/mg;
const headersObj: { [i: string]: string } = {};
let match = headersReg.exec(headersStr);
while (match !== null) {
if (match[1] && match[2]) {
headersObj[match[1]] = match[2];
}
match = headersReg.exec(headersStr);
}
return new Response(body, {
status: statusCode,
headers: new Headers(headersObj),
});
}
Deno.test("serveDir() sets last-modified header", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt");
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
const lastModifiedHeader = res.headers.get("last-modified") as string;
const lastModifiedTime = Date.parse(lastModifiedHeader);
const expectedTime = TEST_FILE_STAT.mtime instanceof Date
? TEST_FILE_STAT.mtime.getTime()
: Number.NaN;
assertAlmostEquals(lastModifiedTime, expectedTime, 5 * MINUTE);
});
Deno.test("serveDir() sets date header", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt");
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
const dateHeader = res.headers.get("date") as string;
const date = Date.parse(dateHeader);
const expectedTime =
TEST_FILE_STAT.atime && TEST_FILE_STAT.atime instanceof Date
? TEST_FILE_STAT.atime.getTime()
: Number.NaN;
assertAlmostEquals(date, expectedTime, 5 * MINUTE);
});
Deno.test("serveDir()", async () => {
const req = new Request("http://localhost/hello.html");
const res = await serveDir(req, serveDirOptions);
const downloadedFile = await res.text();
const localFile = await Deno.readTextFile(join(testdataDir, "hello.html"));
assertEquals(res.status, 200);
assertEquals(downloadedFile, localFile);
assertEquals(res.headers.get("content-type"), "text/html; charset=UTF-8");
});
Deno.test("serveDir() with hash symbol in filename", async () => {
const filePath = join(testdataDir, "file#2.txt");
const text = "Plain text";
await Deno.writeTextFile(filePath, text);
const req = new Request("http://localhost/file%232.txt");
const res = await serveDir(req, serveDirOptions);
const downloadedFile = await res.text();
assertEquals(res.status, 200);
assertEquals(
res.headers.get("content-type"),
"text/plain; charset=UTF-8",
);
assertEquals(downloadedFile, text);
await Deno.remove(filePath);
});
Deno.test("serveDir() with space in filename", async () => {
const filePath = join(testdataDir, "test file.txt");
const text = "Plain text";
await Deno.writeTextFile(filePath, text);
const req = new Request("http://localhost/test%20file.txt");
const res = await serveDir(req, serveDirOptions);
const downloadedFile = await res.text();
assertEquals(res.status, 200);
assertEquals(
res.headers.get("content-type"),
"text/plain; charset=UTF-8",
);
assertEquals(downloadedFile, text);
await Deno.remove(filePath);
});
Deno.test("serveDir() serves directory index", async () => {
const filePath = join(testdataDir, "%25A.txt");
await Deno.writeTextFile(filePath, "25A");
const req = new Request("http://localhost/");
const res = await serveDir(req, serveDirOptions);
const page = await res.text();
assertEquals(res.status, 200);
assertStringIncludes(page, '<a href="/hello.html">hello.html</a>');
assertStringIncludes(page, '<a href="/tls/">tls/</a>');
assertStringIncludes(page, "%2525A.txt");
// `Deno.FileInfo` is not completely compatible with Windows yet
// TODO(bartlomieju): `mode` should work correctly in the future.
// Correct this test case accordingly.
if (Deno.build.os === "windows") {
assertMatch(page, /<td class="mode">(\s)*\(unknown mode\)(\s)*<\/td>/);
} else {
assertMatch(page, /<td class="mode">(\s)*[a-zA-Z- ]{14}(\s)*<\/td>/);
}
await Deno.remove(filePath);
});
Deno.test("serveDir() serves directory index with file containing space in the filename", async () => {
const filePath = join(testdataDir, "test file.txt");
await Deno.writeTextFile(filePath, "25A");
const req = new Request("http://localhost/");
const res = await serveDir(req, serveDirOptions);
const page = await res.text();
assertEquals(res.status, 200);
assertStringIncludes(page, '<a href="/hello.html">hello.html</a>');
assertStringIncludes(page, '<a href="/tls/">tls/</a>');
assertStringIncludes(page, "test%20file.txt");
// `Deno.FileInfo` is not completely compatible with Windows yet
// TODO(bartlomieju): `mode` should work correctly in the future.
// Correct this test case accordingly.
if (Deno.build.os === "windows") {
assertMatch(page, /<td class="mode">(\s)*\(unknown mode\)(\s)*<\/td>/);
} else {
assertMatch(page, /<td class="mode">(\s)*[a-zA-Z- ]{14}(\s)*<\/td>/);
}
await Deno.remove(filePath);
});
Deno.test("serveDir() serves directory index with file's mode is 0", async () => {
const stat = Deno.stat;
using _stub = stub(
Deno,
"stat",
async (path: string | URL): Promise<Deno.FileInfo> => ({
...(await stat(path)),
mode: 0,
}),
);
const res = await serveDir(new Request("http://localhost/"), serveDirOptions);
const page = await res.text();
assertMatch(page, /<td class="mode">(\s)*- --- --- ---(\s)*<\/td>/);
});
Deno.test("serveDir() returns a response even if fileinfo is inaccessible", async () => {
// Note: Deno.stat for windows system files may be rejected with os error 32.
// Mock Deno.stat to test that the dirlisting page can be generated
// even if the fileInfo for a particular file cannot be obtained.
2023-12-01 00:02:18 +00:00
// Assuming that fileInfo of `test_file.txt` cannot be accessible
using denoStatStub = stub(Deno, "stat", (path): Promise<Deno.FileInfo> => {
2023-12-01 00:02:18 +00:00
if (path.toString().includes("test_file.txt")) {
return Promise.reject(new Error("__stubed_error__"));
}
return denoStatStub.original.call(Deno, path);
});
const req = new Request("http://localhost/");
const res = await serveDir(req, serveDirOptions);
const page = await res.text();
assertEquals(res.status, 200);
2023-12-01 00:02:18 +00:00
assertStringIncludes(page, "/test_file.txt");
});
Deno.test("serveDir() handles not found files", async () => {
const req = new Request("http://localhost/badfile.txt");
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.status, 404);
});
Deno.test("serveDir() handles incorrect method", async () => {
const req = new Request("http://localhost/", { method: "POST" });
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.status, 405);
assertEquals(res.statusText, "Method Not Allowed");
});
Deno.test("serveDir() traverses path correctly", async () => {
const req = new Request("http://localhost/../../../../../../../..");
const res = await serveDir(req, serveDirOptions);
const page = await res.text();
assertEquals(res.status, 200);
assertStringIncludes(page, "hello.html");
});
Deno.test("serveDir() traverses path", async () => {
const controller = new AbortController();
const port = 4507;
const server = Deno.serve(
{ port, signal: controller.signal },
async (req) => await serveDir(req, serveDirOptions),
);
const res1 = await fetchExactPath("127.0.0.1", port, "../../../..");
await res1.body?.cancel();
assertEquals(res1.status, 400);
const res2 = await fetchExactPath(
"127.0.0.1",
port,
"http://localhost/../../../..",
);
const page = await res2.text();
assertEquals(res2.status, 200);
assertStringIncludes(page, "hello.html");
controller.abort();
await server.finished;
});
Deno.test("serveDir() traverses encoded URI path", async () => {
const req = new Request(
"http://localhost/%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..",
);
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.status, 301);
assertEquals(res.headers.get("location"), "http://localhost/");
});
Deno.test("serveDir() serves unusual filename", async () => {
const filePath = join(testdataDir, "%");
using _file = await Deno.create(filePath);
const req1 = new Request("http://localhost/%25");
const res1 = await serveDir(req1, serveDirOptions);
await res1.body?.cancel();
assertEquals(res1.status, 200);
assert(res1.headers.has("access-control-allow-origin"));
assert(res1.headers.has("access-control-allow-headers"));
2023-12-01 00:02:18 +00:00
const req2 = new Request("http://localhost/test_file.txt");
const res2 = await serveDir(req2, serveDirOptions);
await res2.body?.cancel();
assertEquals(res2.status, 200);
assert(res2.headers.has("access-control-allow-origin"));
assert(res2.headers.has("access-control-allow-headers"));
await Deno.remove(filePath);
});
Deno.test("serveDir() supports CORS", async () => {
const req1 = new Request("http://localhost/");
const res1 = await serveDir(req1, serveDirOptions);
await res1.body?.cancel();
assertEquals(res1.status, 200);
assert(res1.headers.has("access-control-allow-origin"));
assert(res1.headers.has("access-control-allow-headers"));
const req2 = new Request("http://localhost/hello.html");
const res2 = await serveDir(req2, serveDirOptions);
await res2.body?.cancel();
assertEquals(res2.status, 200);
assert(res2.headers.has("access-control-allow-origin"));
assert(res2.headers.has("access-control-allow-headers"));
});
Deno.test("serveDir() script prints help", async () => {
const command = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--no-check",
"--quiet",
"--no-lock",
"http/file_server.ts",
"--help",
],
});
const { stdout } = await command.output();
const output = new TextDecoder().decode(stdout);
assert(output.includes(`Deno File Server ${denoConfig.version}`));
});
Deno.test("serveDir() script prints version", async () => {
const command = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--no-check",
"--quiet",
"--no-lock",
"http/file_server.ts",
"--version",
],
});
const { stdout } = await command.output();
const output = new TextDecoder().decode(stdout);
assert(output.includes(`Deno File Server ${denoConfig.version}`));
});
Deno.test("serveDir() ignores query params", async () => {
const req = new Request("http://localhost/hello.html?key=value");
const res = await serveDir(req, serveDirOptions);
const downloadedFile = await res.text();
const localFile = await Deno.readTextFile(join(testdataDir, "hello.html"));
assertEquals(res.status, 200);
assertEquals(downloadedFile, localFile);
});
Deno.test("serveDir() script fails with partial TLS args", async () => {
const command = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--no-check",
"--quiet",
"--allow-read",
"--allow-net",
"--no-lock",
"http/file_server.ts",
".",
"--host",
"localhost",
"--cert",
"./testdata/tls/localhost.crt",
"-p",
`4578`,
],
stderr: "null",
});
const { stdout, success } = await command.output();
assertFalse(success);
assertStringIncludes(
new TextDecoder().decode(stdout),
"--key and --cert are required for TLS",
);
});
Deno.test("serveDir() doesn't show directory listings", async () => {
const req = new Request("http://localhost/");
const res = await serveDir(req, {
...serveDirOptions,
showDirListing: false,
});
await res.body?.cancel();
assertEquals(res.status, 404);
});
Deno.test("serveDir() doesn't show dotfiles", async () => {
const req1 = new Request("http://localhost/");
const res1 = await serveDir(req1, {
...serveDirOptions,
showDotfiles: false,
});
const page1 = await res1.text();
assert(!page1.includes(".dotfile"));
const req2 = new Request("http://localhost/.dotfile");
const res2 = await serveDir(req2, {
...serveDirOptions,
showDotfiles: false,
});
const body = await res2.text();
assertEquals(body, "dotfile");
});
Deno.test("serveDir() shows .. if it makes sense", async () => {
const req1 = new Request("http://localhost/");
const res1 = await serveDir(req1, serveDirOptions);
const page1 = await res1.text();
assert(!page1.includes("../"));
assertStringIncludes(page1, "tls/");
const req2 = new Request("http://localhost/tls/");
const res2 = await serveDir(req2, serveDirOptions);
const page2 = await res2.text();
assertStringIncludes(page2, "../");
});
Deno.test("serveDir() handles range request (bytes=0-0)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=0-0" },
});
const res = await serveDir(req, serveDirOptions);
const text = await res.text();
assertEquals(text, "L");
});
Deno.test("serveDir() handles range request (bytes=0-100)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=0-100" },
});
const res = await serveDir(req, serveDirOptions);
assertEquals(
res.headers.get("content-range"),
`bytes 0-100/${TEST_FILE_SIZE}`,
);
assertEquals(res.status, 206);
assertEquals((await res.arrayBuffer()).byteLength, 101);
});
Deno.test("serveDir() handles range request (bytes=300-)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=300-" },
});
const res = await serveDir(req, serveDirOptions);
const text = await res.text();
assertEquals(
res.headers.get("content-range"),
`bytes 300-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`,
);
assertEquals(text, TEST_FILE_TEXT.substring(300));
});
Deno.test("serveDir() handles range request (bytes=-200)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=-200" },
});
const res = await serveDir(req, serveDirOptions);
assertEquals(await res.text(), TEST_FILE_TEXT.slice(-200));
assertEquals(
res.headers.get("content-range"),
`bytes ${TEST_FILE_SIZE - 200}-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`,
);
assertEquals(res.status, 206);
assertEquals(res.statusText, "Partial Content");
});
Deno.test("serveDir() clamps ranges that are too large (bytes=0-999999999)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=0-999999999" },
});
const res = await serveDir(req, serveDirOptions);
assertEquals(await res.text(), TEST_FILE_TEXT);
assertEquals(
res.headers.get("content-range"),
`bytes 0-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`,
);
assertEquals(res.status, 206);
assertEquals(res.statusText, "Partial Content");
});
Deno.test("serveDir() clamps ranges that are too large (bytes=-999999999)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
// This means the last 999999999 bytes. It is too big and should be clamped.
headers: { range: "bytes=-999999999" },
});
const res = await serveDir(req, serveDirOptions);
assertEquals(await res.text(), TEST_FILE_TEXT);
assertEquals(
res.headers.get("content-range"),
`bytes 0-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`,
);
assertEquals(res.status, 206);
assertEquals(res.statusText, "Partial Content");
});
Deno.test("serveDir() handles bad range request (bytes=500-200)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=500-200" },
});
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.headers.get("content-range"), `bytes */${TEST_FILE_SIZE}`);
assertEquals(res.status, 416);
assertEquals(res.statusText, "Range Not Satisfiable");
});
Deno.test("serveDir() handles bad range request (bytes=99999-999999)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=99999-999999" },
});
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.headers.get("content-range"), `bytes */${TEST_FILE_SIZE}`);
assertEquals(res.status, 416);
assertEquals(res.statusText, "Range Not Satisfiable");
});
Deno.test("serveDir() handles bad range request (bytes=99999)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=99999-" },
});
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.headers.get("content-range"), `bytes */${TEST_FILE_SIZE}`);
assertEquals(res.status, 416);
assertEquals(res.statusText, "Range Not Satisfiable");
});
Deno.test("serveDir() ignores bad range request (bytes=100)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=100" },
});
const res = await serveDir(req, serveDirOptions);
const text = await res.text();
assertEquals(text, TEST_FILE_TEXT);
assertEquals(res.status, 200);
assertEquals(res.statusText, "OK");
});
Deno.test("serveDir() ignores bad range request (bytes=a-b)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=a-b" },
});
const res = await serveDir(req, serveDirOptions);
const text = await res.text();
assertEquals(text, TEST_FILE_TEXT);
assertEquals(res.status, 200);
assertEquals(res.statusText, "OK");
});
Deno.test("serveDir() ignores bad multi-range request (bytes=0-10, 20-30)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { range: "bytes=0-10, 20-30" },
});
const res = await serveDir(req, serveDirOptions);
const text = await res.text();
assertEquals(text, TEST_FILE_TEXT);
assertEquals(res.status, 200);
assertEquals(res.statusText, "OK");
});
Deno.test("serveFile() serves ok response for empty file range request", async () => {
const req = new Request("http://localhost/test_empty_file.txt", {
headers: { range: "bytes=0-10, 20-30" },
});
const res = await serveDir(req, serveDirOptions);
const text = await res.text();
assertEquals(text, "");
assertEquals(res.status, 200);
assertEquals(res.statusText, "OK");
});
Deno.test("serveDir() sets accept-ranges header to bytes for directory listing", async () => {
const req = new Request("http://localhost/");
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.headers.get("accept-ranges"), "bytes");
});
Deno.test("serveDir() sets accept-ranges header to bytes for file response", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt");
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.headers.get("accept-ranges"), "bytes");
});
Deno.test("serveDir() sets headers if provided as arguments", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt");
const res = await serveDir(req, {
...serveDirOptions,
headers: ["cache-control:max-age=100", "x-custom-header:hi"],
});
await res.body?.cancel();
assertEquals(res.headers.get("cache-control"), "max-age=100");
assertEquals(res.headers.get("x-custom-header"), "hi");
});
Deno.test("serveDir() sets etag header", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt");
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.headers.get("etag"), TEST_FILE_ETAG);
});
Deno.test("serveDir() serves empty HTTP 304 response for if-none-match request of unmodified file", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { "if-none-match": TEST_FILE_ETAG },
});
const res = await serveDir(req, serveDirOptions);
assertEquals(await res.text(), "");
assertEquals(res.status, 304);
assertEquals(res.statusText, "Not Modified");
});
Deno.test("serveDir() serves HTTP 304 response for if-modified-since request of unmodified file", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: { "if-modified-since": TEST_FILE_LAST_MODIFIED },
});
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.status, 304);
assertEquals(res.statusText, "Not Modified");
});
/**
* When used in combination with If-None-Match, If-Modified-Since is ignored.
* If etag doesn't match, don't return 304 even if if-modified-since is a valid
* value.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since}
*/
Deno.test(
"serveDir() only uses if-none-match header if if-non-match and if-modified-since headers are provided",
async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/test_file.txt", {
headers: {
"if-none-match": "not match etag",
"if-modified-since": TEST_FILE_LAST_MODIFIED,
},
});
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.status, 200);
assertEquals(res.statusText, "OK");
},
);
Deno.test("serveFile() serves test file", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/testdata/test_file.txt");
const res = await serveFile(req, TEST_FILE_PATH);
assertEquals(res.status, 200);
assertEquals(await res.text(), TEST_FILE_TEXT);
});
Deno.test("serveFile() handles file not found", async () => {
const req = new Request("http://localhost/testdata/non_existent.txt");
const testdataPath = join(testdataDir, "non_existent.txt");
const res = await serveFile(req, testdataPath);
await res.body?.cancel();
assertEquals(res.status, 404);
assertEquals(res.statusText, "Not Found");
});
Deno.test("serveFile() handles method not allowed", async () => {
const req = new Request("http://localhost/testdata/test_file.txt", {
method: "POST",
});
const res = await serveFile(req, TEST_FILE_PATH);
await res.body?.cancel();
assertEquals(res.status, 405);
assertEquals(res.statusText, "Method Not Allowed");
});
Deno.test("serveFile() serves HTTP 404 when the path is a directory", async () => {
const req = new Request("http://localhost/testdata/");
const res = await serveFile(req, testdataDir);
await res.body?.cancel();
assertEquals(res.status, 404);
assertEquals(res.statusText, "Not Found");
});
Deno.test("serveFile() handles bad range request (bytes=200-500)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/testdata/test_file.txt", {
headers: { range: "bytes=200-500" },
});
const res = await serveFile(req, TEST_FILE_PATH);
assertEquals(res.status, 206);
assertEquals((await res.arrayBuffer()).byteLength, 301);
});
Deno.test("serveFile() handles bad range request (bytes=500-200)", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/testdata/test_file.txt", {
headers: { range: "bytes=500-200" },
});
const res = await serveFile(req, TEST_FILE_PATH);
await res.body?.cancel();
assertEquals(res.status, 416);
});
Deno.test("serveFile() serves HTTP 304 response for if-modified-since request of unmodified file", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/testdata/test_file.txt", {
headers: { "if-none-match": TEST_FILE_ETAG },
});
const res = await serveFile(req, TEST_FILE_PATH);
assertEquals(res.status, 304);
assertEquals(res.statusText, "Not Modified");
});
/**
* When used in combination with If-None-Match, If-Modified-Since is ignored.
* If etag doesn't match, don't return 304 even if if-modified-since is a valid
* value.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since}
*/
Deno.test("serveFile() only uses if-none-match header if if-non-match and if-modified-since headers are provided", async () => {
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/testdata/test_file.txt", {
headers: {
"if-none-match": "not match etag",
"if-modified-since": TEST_FILE_LAST_MODIFIED,
},
});
const res = await serveFile(req, TEST_FILE_PATH);
await res.body?.cancel();
assertEquals(res.status, 200);
assertEquals(res.statusText, "OK");
});
Deno.test("serveFile() etag value falls back to DENO_DEPLOYMENT_ID if fileInfo.mtime is not available", async () => {
const DENO_DEPLOYMENT_ID = "__THIS_IS_DENO_DEPLOYMENT_ID__";
const hashedDenoDeploymentId = await eTag(DENO_DEPLOYMENT_ID, {
weak: true,
});
// deno-fmt-ignore
const code = `
import { serveFile } from "${import.meta.resolve("./file_server.ts")}";
import { fromFileUrl } from "${import.meta.resolve("../path/mod.ts")}";
import { assertEquals } from "${import.meta.resolve("../assert/equals.ts")}";
2023-12-01 00:02:18 +00:00
const testdataPath = "${toFileUrl(join(testdataDir, "test_file.txt"))}";
const fileInfo = await Deno.stat(new URL(testdataPath));
fileInfo.mtime = null;
2023-12-01 00:02:18 +00:00
const req = new Request("http://localhost/testdata/test_file.txt");
const res = await serveFile(req, fromFileUrl(testdataPath), { fileInfo });
assertEquals(res.headers.get("etag"), \`${hashedDenoDeploymentId}\`);
`;
const command = new Deno.Command(Deno.execPath(), {
args: ["eval", "--no-lock", code],
stdout: "null",
stderr: "null",
env: { DENO_DEPLOYMENT_ID },
});
const { success } = await command.output();
assert(success);
});
Deno.test("serveDir() without options serves files in current directory", async () => {
const req = new Request("http://localhost/http/testdata/hello.html");
const res = await serveDir(req);
assertEquals(res.status, 200);
assertStringIncludes(await res.text(), "Hello World");
});
Deno.test("serveDir() with fsRoot and urlRoot option serves files in given directory", async () => {
const req = new Request(
"http://localhost/my-static-root/testdata/hello.html",
);
const res = await serveDir(req, {
fsRoot: "http",
urlRoot: "my-static-root",
});
assertEquals(res.status, 200);
assertStringIncludes(await res.text(), "Hello World");
});
Deno.test("serveDir() serves index.html when showIndex is true", async () => {
const url = "http://localhost/http/testdata/subdir-with-index/";
const expectedText = "This is subdir-with-index/index.html";
{
const res = await serveDir(new Request(url), { showIndex: true });
assertEquals(res.status, 200);
assertStringIncludes(await res.text(), expectedText);
}
{
// showIndex is true by default
const res = await serveDir(new Request(url));
assertEquals(res.status, 200);
assertStringIncludes(await res.text(), expectedText);
}
});
Deno.test("serveDir() doesn't serve index.html when showIndex is false", async () => {
const req = new Request(
"http://localhost/http/testdata/subdir-with-index/",
);
const res = await serveDir(req, { showIndex: false });
assertEquals(res.status, 404);
});
Deno.test(
"serveDir() redirects a directory URL not ending with a slash if it has an index",
async () => {
const url = "http://localhost/http/testdata/subdir-with-index";
const res = await serveDir(new Request(url), { showIndex: true });
assertEquals(res.status, 301);
assertEquals(
res.headers.get("Location"),
"http://localhost/http/testdata/subdir-with-index/",
);
},
);
Deno.test("serveDir() redirects a directory URL not ending with a slash correctly even with a query string", async () => {
const url = "http://localhost/http/testdata/subdir-with-index?test";
const res = await serveDir(new Request(url), { showIndex: true });
assertEquals(res.status, 301);
assertEquals(
res.headers.get("Location"),
"http://localhost/http/testdata/subdir-with-index/?test",
);
});
Deno.test("serveDir() redirects a file URL ending with a slash correctly even with a query string", async () => {
2023-12-01 00:02:18 +00:00
const url = "http://localhost/http/testdata/test_file.txt/?test";
const res = await serveDir(new Request(url), { showIndex: true });
assertEquals(res.status, 301);
assertEquals(
res.headers.get("Location"),
2023-12-01 00:02:18 +00:00
"http://localhost/http/testdata/test_file.txt?test",
);
});
Deno.test("serveDir() redirects non-canonical URLs", async () => {
2023-12-01 00:02:18 +00:00
const url = "http://localhost/http/testdata//////test_file.txt/////?test";
const res = await serveDir(new Request(url), { showIndex: true });
assertEquals(res.status, 301);
assertEquals(
res.headers.get("Location"),
2023-12-01 00:02:18 +00:00
"http://localhost/http/testdata/test_file.txt/?test",
);
});
Deno.test("serveDir() serves HTTP 304 for if-none-match requests with W/-prefixed etag", async () => {
const testurl = "http://localhost/desktop.ini";
const fileurl = new URL("./testdata/desktop.ini", import.meta.url);
const req1 = new Request(testurl, {
headers: { "accept-encoding": "gzip, deflate, br" },
});
const res1 = await serveDir(req1, serveDirOptions);
const etag = res1.headers.get("etag");
assertEquals(res1.status, 200);
assertEquals(res1.statusText, "OK");
assertEquals(await Deno.readTextFile(fileurl), await res1.text());
assert(typeof etag === "string");
assert(etag.length > 0);
assert(etag.startsWith("W/"));
const req2 = new Request(testurl, {
headers: { "if-none-match": etag },
});
const res2 = await serveDir(req2, serveDirOptions);
assertEquals(res2.status, 304);
assertEquals(res2.statusText, "Not Modified");
assertEquals("", await res2.text());
assert(
etag === res2.headers.get("etag") ||
etag === "W/" + res2.headers.get("etag"),
);
});
Deno.test("serveDir() resolves path correctly on Windows", {
ignore: Deno.build.os !== "windows",
}, async () => {
const req = new Request("http://localhost/");
const res = await serveDir(req, { ...serveDirOptions, fsRoot: "c:/" });
await res.body?.cancel();
assertEquals(res.status, 200);
});
Deno.test(
"serveDir() resolves empty sub-directory without asking for current directory read permissions on Windows",
{
ignore: Deno.build.os !== "windows",
permissions: {
read: [`${moduleDir}/testdata`],
write: true,
},
},
async () => {
const tempDir = await Deno.makeTempDir({
dir: `${moduleDir}/testdata`,
});
const req = new Request(`http://localhost/${basename(tempDir)}/`);
const res = await serveDir(req, serveDirOptions);
await res.body?.cancel();
assertEquals(res.status, 200);
await Deno.remove(tempDir);
},
);
Deno.test("file_server prints local and network urls", async () => {
const port = await getAvailablePort();
const process = spawnDeno([
"--allow-net",
"--allow-read",
"--allow-sys=networkInterfaces",
"http/file_server.ts",
"--port",
`${port}`,
]);
const output = await readUntilMatch(process.stdout, "Network:");
const networkAdress = Deno.networkInterfaces().find((i) =>
i.family === "IPv4" && !i.address.startsWith("127")
)?.address;
assertEquals(
output,
`Listening on:\n- Local: http://${LOCALHOST}:${port}\n- Network: http://${networkAdress}:${port}\n`,
);
process.stdout.cancel();
process.stderr.cancel();
process.kill();
await process.status;
});
Deno.test("file_server doesn't print local network url without --allow-sys", async () => {
const port = await getAvailablePort();
const process = spawnDeno([
"--allow-net",
"--allow-read",
"http/file_server.ts",
"--port",
`${port}`,
]);
const output = await readUntilMatch(process.stdout, "Local:");
assertEquals(
output,
`Listening on:\n- Local: http://${LOCALHOST}:${port}\n`,
);
process.stdout.cancel();
process.stderr.cancel();
process.kill();
await process.status;
});
Deno.test("file_server prints only local address on Deploy", async () => {
const port = await getAvailablePort();
const process = spawnDeno([
"--allow-net",
"--allow-read",
"--allow-sys=networkInterfaces",
"--allow-env=DENO_DEPLOYMENT_ID",
"http/file_server.ts",
"--port",
`${port}`,
], {
env: {
DENO_DEPLOYMENT_ID: "abcdef",
},
});
const output = await readUntilMatch(process.stdout, "Local:");
assertEquals(
output,
`Listening on:\n- Local: http://${LOCALHOST}:${port}\n`,
);
process.stdout.cancel();
process.stderr.cancel();
process.kill();
await process.status;
});
/** Spawn deno child process with the options convenient for testing */
function spawnDeno(args: string[], opts?: Deno.CommandOptions) {
const cmd = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--no-lock",
"--quiet",
...args,
],
stdout: "piped",
stderr: "piped",
...opts,
});
return cmd.spawn();
}
async function readUntilMatch(
source: ReadableStream,
match: string,
) {
const reader = source.getReader();
let buf = new Uint8Array(0);
const dec = new TextDecoder();
while (!dec.decode(buf).includes(match)) {
const { value } = await reader.read();
if (!value) {
break;
}
buf = concat([buf, value]);
}
reader.releaseLock();
return dec.decode(buf);
}