From f64741b5a46c9b8a98ac8793d0e375c3903344ff Mon Sep 17 00:00:00 2001 From: SharzyL Date: Fri, 12 Apr 2024 11:47:05 +0800 Subject: [PATCH] tests: add tests for basic auth --- src/auth.js | 55 ++++++++++++--------------- test/basicAuth.spec.js | 82 ++++++++++++++++++++++++++++++++++++++++ test/formdata.spec.js | 2 +- test/integration.spec.js | 72 +++++++++-------------------------- test/testUtils.js | 55 +++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 87 deletions(-) create mode 100644 test/basicAuth.spec.js create mode 100644 test/testUtils.js diff --git a/src/auth.js b/src/auth.js index 616e74b..f99927c 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,28 +1,21 @@ -import { WorkerError } from "./common.js"; +import { WorkerError } from "./common.js" -function parseBasicAuth(request) { - const Authorization = request.headers.get('Authorization'); - - const [scheme, encoded] = Authorization.split(' '); - - // The Authorization header must start with Basic, followed by a space. - if (!encoded || scheme !== 'Basic') { - throw new WorkerError(400, 'malformed authorization header'); - } - - const buffer = Uint8Array.from(atob(encoded), character => character.charCodeAt(0)) - const decoded = new TextDecoder().decode(buffer).normalize(); - - const index = decoded.indexOf(':'); +// Encoding function +export function encodeBasicAuth(username, password) { + const credentials = `${username}:${password}` + const encodedCredentials = Buffer.from(credentials).toString("base64") + return `Basic ${encodedCredentials}` +} - if (index === -1 || /[\0-\x1F\x7F]/.test(decoded)) { - throw WorkerError(400, 'invalid authorization value'); +// Decoding function +export function decodeBasicAuth(encodedString) { + const [scheme, encodedCredentials] = encodedString.split(" ") + if (scheme !== "Basic") { + throw new WorkerError(400, "Invalid authentication scheme") } - - return { - user: decoded.substring(0, index), - pass: decoded.substring(index + 1), - }; + const credentials = Buffer.from(encodedCredentials, "base64").toString("utf-8") + const [username, password] = credentials.split(":") + return { username, password } } // return true if auth passes or is not required, @@ -30,29 +23,29 @@ function parseBasicAuth(request) { // throw WorkerError if auth failed export function verifyAuth(request, env) { // pass auth if 'BASIC_AUTH' is not present - if (!('BASIC_AUTH' in env)) return null + if (!env.BASIC_AUTH) return null - const passwdMap = new Map(Object.entries(env['BASIC_AUTH'])) + const passwdMap = new Map(Object.entries(env.BASIC_AUTH)) // pass auth if 'BASIC_AUTH' is empty if (passwdMap.size === 0) return null - if (request.headers.has('Authorization')) { - const { user, pass } = parseBasicAuth(request) - if (passwdMap.get(user) === undefined) { + if (request.headers.has("Authorization")) { + const { username, password } = decodeBasicAuth(request.headers.get("Authorization")) + if (passwdMap.get(username) === undefined) { throw new WorkerError(401, "user not found for basic auth") - } else if (passwdMap.get(user) !== pass) { + } else if (passwdMap.get(username) !== password) { throw new WorkerError(401, "incorrect passwd for basic auth") } else { return null } } else { - return new Response('HTTP basic auth is required', { + return new Response("HTTP basic auth is required", { status: 401, headers: { // Prompts the user for credentials. - 'WWW-Authenticate': 'Basic realm="my scope", charset="UTF-8"', + "WWW-Authenticate": "Basic charset=\"UTF-8\"", }, - }); + }) } } diff --git a/test/basicAuth.spec.js b/test/basicAuth.spec.js new file mode 100644 index 0000000..365ab27 --- /dev/null +++ b/test/basicAuth.spec.js @@ -0,0 +1,82 @@ +import { expect, test } from "vitest" +import { areBlobsEqual, BASE_URL, createFormData, randomBlob, staticPages, workerFetchWithAuth } from "./testUtils.js" +import { encodeBasicAuth, decodeBasicAuth } from "../src/auth.js" + +test("basic auth encode and decode", async () => { + const userPasswdPairs = [ + ["user1", "passwd1"], + ["あおい", "まなか"], + ["1234#", "اهلا"], + ] + for (const [user, passwd] of userPasswdPairs) { + const encoded = encodeBasicAuth(user, passwd) + const decoded = decodeBasicAuth(encoded) + expect(decoded.username).toStrictEqual(user) + expect(decoded.password).toStrictEqual(passwd) + } +}) + +test("basic auth", async () => { + const usersKv = { + "user1": "passwd1", + "user2": "passwd2", + } + + // access index + for (const page of staticPages) { + expect((await workerFetchWithAuth(usersKv, `${BASE_URL}/${page}`, {})).status).toStrictEqual(401) + } + expect((await workerFetchWithAuth(usersKv, BASE_URL, { + headers: { "Authorization": encodeBasicAuth("user1", usersKv["user1"]) }, + })).status).toStrictEqual(200) + + // upload with no auth + const blob1 = randomBlob(1024) + const uploadResp = await workerFetchWithAuth(usersKv, BASE_URL, { + method: "POST", + body: createFormData({ c: blob1 }), + }) + expect(uploadResp.status).toStrictEqual(401) + + // upload with true auth + const uploadResp1 = await workerFetchWithAuth(usersKv, BASE_URL, { + method: "POST", + body: createFormData({ c: blob1 }), + headers: { "Authorization": encodeBasicAuth("user2", usersKv["user2"]) }, + }) + expect(uploadResp1.status).toStrictEqual(200) + + // upload with wrong auth + const uploadResp2 = await workerFetchWithAuth(usersKv, BASE_URL, { + method: "POST", + body: createFormData({ c: blob1 }), + headers: { "Authorization": encodeBasicAuth("user1", "wrong-password") }, + }) + expect(uploadResp2.status).toStrictEqual(401) + + // revisit without auth + const uploadJson = JSON.parse(await uploadResp1.text()) + const url = uploadJson["url"] + const revisitResp = await workerFetchWithAuth(usersKv, url) + expect(revisitResp.status).toStrictEqual(200) + expect(areBlobsEqual(await revisitResp.blob(), blob1)).toBeTruthy() + + // update with no auth + const blob2 = randomBlob(1024) + const admin = uploadJson["admin"] + const updateResp = await workerFetchWithAuth(usersKv, admin, { + method: "PUT", + body: createFormData({ c: blob2 }), + }) + expect(updateResp.status).toStrictEqual(200) + const revisitUpdatedResp = await workerFetchWithAuth(usersKv, url) + expect(revisitUpdatedResp.status).toStrictEqual(200) + expect(areBlobsEqual(await revisitUpdatedResp.blob(), blob2)).toBeTruthy() + + // delete with no auth + const deleteResp = await workerFetchWithAuth(usersKv, admin, { + method: "DELETE", + }) + expect(deleteResp.status).toStrictEqual(200) + expect((await workerFetchWithAuth(usersKv, url)).status).toStrictEqual(404) +}) \ No newline at end of file diff --git a/test/formdata.spec.js b/test/formdata.spec.js index 6c21f4b..5996e26 100644 --- a/test/formdata.spec.js +++ b/test/formdata.spec.js @@ -32,4 +32,4 @@ test("basic formdata", async () => { // compare "s" const parsedSecret = new TextDecoder().decode(parts.get("s").content) expect(parsedSecret).toStrictEqual(secret) -}) +}) \ No newline at end of file diff --git a/test/integration.spec.js b/test/integration.spec.js index 6cf352e..9f107b3 100644 --- a/test/integration.spec.js +++ b/test/integration.spec.js @@ -1,57 +1,13 @@ -import { SELF, env, ctx } from "cloudflare:test" +import { env } from "cloudflare:test" import { test, expect } from "vitest" import { params, genRandStr } from "../src/common.js" - -import * as crypto from "crypto" - -// for auto reload -import worker from "../src/index.js" - -const BASE_URL = env["BASE_URL"] -const RAND_NAME_REGEX = /^[ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678]+$/ - -async function workerFetch(req, options) { - // we are not using SELF.fetch since it sometimes do not print worker log to console - // return await SELF.fetch(req, options) - return await worker.fetch(new Request(req, options), env, ctx) -} - -async function upload(kv) { - const uploadResponse = await workerFetch(new Request(BASE_URL, { - method: "POST", - body: createFormData(kv), - })) - expect(uploadResponse.status).toStrictEqual(200) - expect(uploadResponse.headers.get("Content-Type")).toStrictEqual("application/json;charset=UTF-8") - return JSON.parse(await uploadResponse.text()) -} - -function createFormData(kv) { - const fd = new FormData() - Object.entries(kv).forEach(([k, v]) => { - if ((v === Object(v)) && "filename" in v && "value" in v) { - fd.set(k, new File([v.value], v.filename)) - } else { - fd.set(k, v) - } - }) - return fd -} - -function randomBlob(len) { - const buf = Buffer.alloc(len) - return new Blob([crypto.randomFillSync(buf, 0, len)]) -} - -async function areBlobsEqual(blob1, blob2) { - return Buffer.from(await blob1.arrayBuffer()).compare( - Buffer.from(await blob2.arrayBuffer()), - ) === 0 -} +import { + randomBlob, areBlobsEqual, createFormData, workerFetch, upload, + BASE_URL, RAND_NAME_REGEX, staticPages, +} from "./testUtils.js" test("static page", async () => { - const staticPages = ["", "index.html", "index", "tos", "tos.html", "api", "api.html"] for (const page of staticPages) { expect((await workerFetch(`${BASE_URL}/${page}`)).status).toStrictEqual(200) } @@ -133,7 +89,7 @@ test("basic", async () => { // check delete const deleteResponse = await workerFetch(new Request(admin, { method: "DELETE" })) - expect(putResponse.status).toStrictEqual(200) + expect(deleteResponse.status).toStrictEqual(200) // check visit modified const revisitDeletedResponse = await workerFetch(url) @@ -181,7 +137,7 @@ test("expire", async () => { await testExpireParse("1M", 18144000) await testExpireParse("100 m", 6000) - const testFailParse = async (expire, expireSecs) => { + const testFailParse = async (expire) => { const uploadResponse = await workerFetch(new Request(BASE_URL, { method: "POST", body: createFormData({ "c": blob1, "e": expire }), @@ -375,10 +331,18 @@ test("cache control", async () => { const uploadResp = await upload(({ "c": randomBlob(1024) })) const url = uploadResp["url"] const resp = await workerFetch(url) - expect(resp.headers.get("Cache-Control")).toStrictEqual(`public, max-age=${env.CACHE_PASTE_AGE}`) + if ("CACHE_PASTE_AGE" in env) { + expect(resp.headers.get("Cache-Control")).toStrictEqual(`public, max-age=${env.CACHE_PASTE_AGE}`) + } else { + expect(resp.headers.get("Cache-Control")).toBeUndefined() + } const indexResp = await workerFetch(BASE_URL) - expect(indexResp.headers.get("Cache-Control")).toStrictEqual(`public, max-age=${env.CACHE_STATIC_PAGE_AGE}`) + if ("CACHE_STATIC_PAGE_AGE" in env) { + expect(indexResp.headers.get("Cache-Control")).toStrictEqual(`public, max-age=${env.CACHE_STATIC_PAGE_AGE}`) + } else { + expect(indexResp.headers.get("Cache-Control")).toBeUndefined() + } const staleResp = await workerFetch(url, { headers: { @@ -388,6 +352,4 @@ test("cache control", async () => { expect(staleResp.status).toStrictEqual(304) }) -// TODO: add tests for basic auth - // TODO: add tests for CORS \ No newline at end of file diff --git a/test/testUtils.js b/test/testUtils.js new file mode 100644 index 0000000..fe0064f --- /dev/null +++ b/test/testUtils.js @@ -0,0 +1,55 @@ +import { env, ctx } from "cloudflare:test" + +import { expect } from "vitest" +import crypto from "crypto" +import worker from "../src/index.js" + +export const BASE_URL = env["BASE_URL"] +export const RAND_NAME_REGEX = /^[ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678]+$/ + +export const staticPages = ["", "index.html", "index", "tos", "tos.html", "api", "api.html"] + +export async function workerFetch(req, options) { + // we are not using SELF.fetch since it sometimes do not print worker log to console + // return await SELF.fetch(req, options) + return await worker.fetch(new Request(req, options), env, ctx) +} + +export async function workerFetchWithAuth(usersKv, req, options) { + const newEnv = Object.assign({ BASIC_AUTH: usersKv }, env) + return await worker.fetch(new Request(req, options), newEnv, ctx) +} + +export async function upload(kv) { + const uploadResponse = await workerFetch(new Request(BASE_URL, { + method: "POST", + body: createFormData(kv), + })) + expect(uploadResponse.status).toStrictEqual(200) + expect(uploadResponse.headers.get("Content-Type")).toStrictEqual("application/json;charset=UTF-8") + return JSON.parse(await uploadResponse.text()) +} + +export function createFormData(kv) { + const fd = new FormData() + Object.entries(kv).forEach(([k, v]) => { + if ((v === Object(v)) && "filename" in v && "value" in v) { + fd.set(k, new File([v.value], v.filename)) + } else { + fd.set(k, v) + } + }) + return fd +} + +export function randomBlob(len) { + const buf = Buffer.alloc(len) + return new Blob([crypto.randomFillSync(buf, 0, len)]) +} + +export async function areBlobsEqual(blob1, blob2) { + return Buffer.from(await blob1.arrayBuffer()).compare( + Buffer.from(await blob2.arrayBuffer()), + ) === 0 +} +