diff --git a/test/routes/app-cors.test.js b/test/routes/app-cors.test.js new file mode 100644 index 0000000..0b6b6a4 --- /dev/null +++ b/test/routes/app-cors.test.js @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import { afterEach, beforeEach, describe, it } from "node:test" +import request from "supertest" + +const originalOpenApiCors = process.env.OPEN_API_CORS + +beforeEach(() => { + process.env.OPEN_API_CORS = "true" +}) + +afterEach(() => { + process.env.OPEN_API_CORS = originalOpenApiCors +}) + +describe("App CORS middleware behavior. __core", () => { + it("Adds CORS headers when OPEN_API_CORS is enabled.", async () => { + const { default: app } = await import("../../app.js?test-cors-enabled") + + const response = await request(app) + .get("/index.html") + .set("Origin", "http://example.test") + + assert.equal(response.statusCode, 200) + assert.equal(response.header["access-control-allow-origin"], "*") + assert.equal(response.header["access-control-expose-headers"], "*") + }) +}) diff --git a/test/routes/create.test.js b/test/routes/create.test.js index a8572d0..8adb180 100644 --- a/test/routes/create.test.js +++ b/test/routes/create.test.js @@ -53,6 +53,18 @@ describe("Check that the request/response behavior of the TinyNode create route assert.equal(lastFetchOptions.headers["Content-Type"], "application/json;charset=utf-8", "Content-Type header mismatch") }) + it("Converts body id to _id before sending upstream.", async () => { + const response = await request(routeTester) + .post("/create") + .send({ id: "https://example.org/id/abc123", test: "item" }) + .set("Content-Type", "application/json") + + assert.equal(response.statusCode, 201) + const upstreamBody = JSON.parse(lastFetchOptions.body) + assert.equal(upstreamBody._id, "abc123") + assert.equal(upstreamBody.id, "https://example.org/id/abc123") + }) + it("Accepts application/ld+json content type.", async () => { const response = await request(routeTester) .post("/create") @@ -139,6 +151,39 @@ describe("Check that TinyNode create route propagates upstream and network error assert.equal(response.statusCode, 502) assert.match(response.text, /A RERUM error occurred/) }) + + it("Falls back to generic RERUM error text when upstream .text() throws.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => { + throw new Error("text stream consumed") + } + }) + + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) + + it("Maps successful upstream payload without id fields to 502.", async () => { + global.fetch = async () => ({ + ok: true, + json: async () => ({ test: "item" }) + }) + + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) }) describe("Check that the properly used create endpoints function and interact with RERUM. __e2e", () => { diff --git a/test/routes/delete.test.js b/test/routes/delete.test.js index d2d32bb..5803382 100644 --- a/test/routes/delete.test.js +++ b/test/routes/delete.test.js @@ -104,4 +104,68 @@ describe("Delete network failure and passthrough behavior. __rest __core", () = .delete("/delete/00000") assert.equal(response.statusCode, 502) }) + + it("Preserves upstream text error message for body delete when response is non-ok.", async () => { + global.fetch = async () => ({ + ok: false, + status: 503, + text: async () => "Upstream body delete failure" + }) + + const response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream body delete failure/) + }) + + it("Falls back to generic RERUM error text for body delete when upstream .text() throws.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => { + throw new Error("text stream consumed") + } + }) + + const response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) + + it("Preserves upstream text error message for path delete when response is non-ok.", async () => { + global.fetch = async () => ({ + ok: false, + status: 503, + text: async () => "Upstream path delete failure" + }) + + const response = await request(routeTester) + .delete("/delete/00000") + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream path delete failure/) + }) + + it("Falls back to generic RERUM error text for path delete when upstream .text() throws.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => { + throw new Error("text stream consumed") + } + }) + + const response = await request(routeTester) + .delete("/delete/00000") + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) }) diff --git a/test/routes/error-messenger.test.js b/test/routes/error-messenger.test.js index 3963b14..50f213a 100644 --- a/test/routes/error-messenger.test.js +++ b/test/routes/error-messenger.test.js @@ -13,6 +13,19 @@ function appWith(routeHandler) { } describe("Check shared error messenger behavior. __rest __core", () => { + it("Returns early when headers are already sent.", async () => { + const app = express() + app.get("/test", (req, res, next) => { + res.end("partial") + next(new Error("late error")) + }) + app.use(messenger) + + const response = await request(app).get("/test") + assert.equal(response.statusCode, 200) + assert.match(response.text, /partial/) + }) + it("Returns structured JSON error bodies when upstream responds with JSON.", async () => { const app = appWith((req, res, next) => { next({ @@ -38,6 +51,21 @@ describe("Check shared error messenger behavior. __rest __core", () => { assert.match(response.text, /boom/) }) + it("Uses statusCode and statusMessage fallback fields when present.", async () => { + const app = appWith((req, res, next) => { + next({ + statusCode: 499, + statusMessage: "Client closed request", + headers: { get: () => "text/plain" }, + text: async () => "" + }) + }) + + const response = await request(app).get("/test") + assert.equal(response.statusCode, 499) + assert.match(response.text, /Client closed request/) + }) + it("Uses fallback message if .text() throws.", async () => { const app = appWith((req, res, next) => { next({ @@ -55,6 +83,20 @@ describe("Check shared error messenger behavior. __rest __core", () => { assert.match(response.text, /Upstream unavailable/) }) + it("Sends plain text body from upstream text() when provided.", async () => { + const app = appWith((req, res, next) => { + next({ + status: 418, + headers: { get: () => "text/plain" }, + text: async () => "Teapot exploded" + }) + }) + + const response = await request(app).get("/test") + assert.equal(response.statusCode, 418) + assert.match(response.text, /Teapot exploded/) + }) + it("Returns structured payload when error carries payload.", async () => { const app = appWith((req, res, next) => { next({ @@ -68,3 +110,81 @@ describe("Check shared error messenger behavior. __rest __core", () => { assert.equal(response.body.code, "BAD_INPUT") }) }) + +function createMockRes(headersSent = false) { + const res = { + headersSent, + statusCode: null, + sentText: null, + sentJson: null, + setHeaders: {}, + status(code) { + this.statusCode = code + return this + }, + json(payload) { + this.sentJson = payload + return this + }, + send(text) { + this.sentText = text + return this + }, + set(name, value) { + this.setHeaders[name] = value + return this + } + } + return res +} + +describe("Check shared error messenger unit branches. __core", () => { + it("Returns immediately when headersSent is true.", async () => { + const res = createMockRes(true) + await messenger(new Error("ignored"), {}, res, () => {}) + assert.equal(res.statusCode, null) + assert.equal(res.sentText, null) + assert.equal(res.sentJson, null) + }) + + it("Sends payload JSON when payload is present.", async () => { + const res = createMockRes(false) + await messenger({ status: 409, payload: { code: "CONFLICT" } }, {}, res, () => {}) + assert.equal(res.statusCode, 409) + assert.equal(res.sentJson.code, "CONFLICT") + }) + + it("Uses upstream JSON response when content-type is JSON.", async () => { + const res = createMockRes(false) + await messenger( + { + status: 422, + headers: { get: () => "application/json; charset=utf-8" }, + json: async () => ({ detail: "bad request" }) + }, + {}, + res, + () => {} + ) + assert.equal(res.statusCode, 422) + assert.equal(res.sentJson.detail, "bad request") + }) + + it("Sends plain text and sets content-type for non-JSON upstream errors.", async () => { + const res = createMockRes(false) + await messenger( + { + status: 503, + message: "fallback", + headers: { get: () => "text/plain" }, + text: async () => "upstream text" + }, + {}, + res, + () => {} + ) + assert.equal(res.statusCode, 503) + assert.equal(res.sentText, "upstream text") + assert.equal(res.setHeaders["Content-Type"], "text/plain; charset=utf-8") + }) +}) diff --git a/test/routes/index.test.js b/test/routes/index.test.js index e00f637..9733886 100644 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -1,8 +1,13 @@ import "../helpers/env.js" import assert from "node:assert/strict" import { describe, it } from "node:test" +import express from "express" import request from "supertest" import app from "../../app.js" +import indexRoute from "../../routes/index.js" + +const routeTester = express() +routeTester.use("/", indexRoute) describe("Make sure TinyNode demo interface is present. __core", () => { it("/index.html", async () => { @@ -10,4 +15,12 @@ describe("Make sure TinyNode demo interface is present. __core", () => { assert.equal(response.statusCode, 200) assert.match(response.header["content-type"], /html/) }) + + it("Index router returns 405 for unsupported root methods.", async () => { + let response = await request(routeTester).get("/") + assert.equal(response.statusCode, 405) + + response = await request(routeTester).post("/") + assert.equal(response.statusCode, 405) + }) }) diff --git a/test/routes/overwrite.test.js b/test/routes/overwrite.test.js index ac9011e..e64d92d 100644 --- a/test/routes/overwrite.test.js +++ b/test/routes/overwrite.test.js @@ -181,6 +181,55 @@ describe("Overwrite network failure behavior. __rest __core", () => { .send({ "@id": rerumTinyTestObjId, testing: "item" }) assert.equal(response.statusCode, 502) }) + + it("Preserves upstream text error message for non-409 overwrite failures.", async () => { + global.fetch = async () => ({ + ok: false, + status: 503, + text: async () => "Upstream overwrite failure" + }) + + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream overwrite failure/) + }) + + it("Falls back to generic RERUM error text when overwrite upstream .text() throws.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => { + throw new Error("text stream consumed") + } + }) + + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) + + it("Maps successful overwrite payload without id fields to 502.", async () => { + global.fetch = async () => ({ + ok: true, + json: async () => ({ testing: "item" }) + }) + + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) }) describe("Check that the properly used overwrite endpoints function and interact with RERUM. __e2e", () => { diff --git a/test/routes/query.test.js b/test/routes/query.test.js index f732fb5..6b45b36 100644 --- a/test/routes/query.test.js +++ b/test/routes/query.test.js @@ -115,6 +115,56 @@ describe("Check that incorrect TinyNode query route usage results in expected RE }) }) +describe("Query upstream and network failure behavior. __rest __core", () => { + it("Preserves upstream text error message when query returns non-ok.", async () => { + global.fetch = async () => ({ + ok: false, + status: 503, + text: async () => "Upstream query failure" + }) + + const response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream query failure/) + }) + + it("Falls back to generic RERUM error text when upstream .text() throws.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => { + throw new Error("text stream consumed") + } + }) + + const response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) + + it("Maps rejected fetch to 502.", async () => { + global.fetch = async () => { + throw new Error("socket hang up") + } + + const response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) +}) + describe("Check that the properly used query endpoints function and interact with RERUM. __e2e", () => { it("'/query' route can save an object to RERUM.", async () => { const response = await request(routeTester) diff --git a/test/routes/rerum.test.js b/test/routes/rerum.test.js new file mode 100644 index 0000000..455fd52 --- /dev/null +++ b/test/routes/rerum.test.js @@ -0,0 +1,89 @@ +import "../helpers/env.js" +import assert from "node:assert/strict" +import { afterEach, beforeEach, describe, it } from "node:test" +import { fetchRerum } from "../../rerum.js" + +const originalFetch = global.fetch +const originalTimeout = process.env.RERUM_FETCH_TIMEOUT_MS +const originalSetTimeout = global.setTimeout +const originalClearTimeout = global.clearTimeout + +beforeEach(() => { + process.env.RERUM_FETCH_TIMEOUT_MS = "1" +}) + +afterEach(() => { + global.fetch = originalFetch + process.env.RERUM_FETCH_TIMEOUT_MS = originalTimeout + global.setTimeout = originalSetTimeout + global.clearTimeout = originalClearTimeout +}) + +describe("fetchRerum timeout behavior. __core", () => { + it("Maps timeout aborts to a 504 upstream timeout error.", async () => { + global.fetch = async (url, options = {}) => { + const { signal } = options + return await new Promise((resolve, reject) => { + signal?.addEventListener("abort", () => { + const err = new Error("request aborted") + err.name = "AbortError" + reject(err) + }) + }) + } + + await assert.rejects( + () => fetchRerum("https://example.org/rerum"), + err => { + assert.equal(err.status, 504) + assert.match(err.message, /did not respond within/i) + return true + } + ) + }) + + it("Maps non-timeout fetch failures to a 502 upstream network error.", async () => { + global.fetch = async () => { + throw new Error("socket hang up") + } + + await assert.rejects( + () => fetchRerum("https://example.org/rerum"), + err => { + assert.equal(err.status, 502) + assert.match(err.message, /A RERUM error occurred/) + return true + } + ) + }) + + it("Uses the provided signal path and still resolves successful responses.", async () => { + const externalController = new AbortController() + global.fetch = async (url, options = {}) => { + assert.ok(options.signal, "A signal should be forwarded to fetch") + return { ok: true, source: url } + } + + const response = await fetchRerum("https://example.org/rerum", { signal: externalController.signal }) + assert.equal(response.ok, true) + assert.equal(response.source, "https://example.org/rerum") + }) + + it("Falls back to default timeout when configured timeout is invalid.", async () => { + process.env.RERUM_FETCH_TIMEOUT_MS = "-10" + let capturedTimeoutMs = null + let timeoutFn + + global.setTimeout = (fn, ms) => { + timeoutFn = fn + capturedTimeoutMs = ms + return 1 + } + global.clearTimeout = () => {} + global.fetch = async () => ({ ok: true }) + + await fetchRerum("https://example.org/rerum") + assert.equal(capturedTimeoutMs, 30000) + assert.equal(typeof timeoutFn, "function") + }) +}) diff --git a/test/routes/rest.test.js b/test/routes/rest.test.js new file mode 100644 index 0000000..9e9d539 --- /dev/null +++ b/test/routes/rest.test.js @@ -0,0 +1,31 @@ +import "../helpers/env.js" +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import express from "express" +import request from "supertest" +import { verifyJsonContentType } from "../../rest.js" + +const routeTester = express() +routeTester.post("/verify", verifyJsonContentType, (req, res) => { + res.status(204).end() +}) + +describe("verifyJsonContentType edge behavior. __core", () => { + it("Returns 415 when Content-Type header is missing.", async () => { + const response = await request(routeTester) + .post("/verify") + + assert.equal(response.statusCode, 415) + assert.match(response.text, /Use application\/json or application\/ld\+json/) + }) + + it("Returns 415 when multiple Content-Type values are provided.", async () => { + const response = await request(routeTester) + .post("/verify") + .set("Content-Type", "application/json,text/plain") + .send("{}") + + assert.equal(response.statusCode, 415) + assert.match(response.text, /Multiple Content-Type values are not allowed/) + }) +}) diff --git a/test/routes/tokens.test.js b/test/routes/tokens.test.js new file mode 100644 index 0000000..99d61e6 --- /dev/null +++ b/test/routes/tokens.test.js @@ -0,0 +1,158 @@ +import "../helpers/env.js" +import assert from "node:assert/strict" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import checkAccessToken from "../../tokens.js" + +const originalAccessToken = process.env.ACCESS_TOKEN +const originalAccessTokenUrl = process.env.RERUM_ACCESS_TOKEN_URL +const originalRefreshToken = process.env.REFRESH_TOKEN +const originalFetch = global.fetch +const originalCwd = process.cwd() +const tempDirs = [] + +async function inTempCwd(run, envContent) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "tinynode-token-test-")) + tempDirs.push(tempDir) + if (envContent !== undefined) { + await fs.writeFile(path.join(tempDir, ".env"), envContent) + } + process.chdir(tempDir) + await run(tempDir) +} + +afterEach(async () => { + process.env.ACCESS_TOKEN = originalAccessToken + process.env.RERUM_ACCESS_TOKEN_URL = originalAccessTokenUrl + process.env.REFRESH_TOKEN = originalRefreshToken + global.fetch = originalFetch + process.chdir(originalCwd) + + while (tempDirs.length > 0) { + const tempDir = tempDirs.pop() + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +function jwtWithExp(expSeconds) { + const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString("base64") + return `header.${payload}.signature` +} + +describe("checkAccessToken middleware behavior. __core", () => { + it("Calls next when ACCESS_TOKEN is missing.", async () => { + delete process.env.ACCESS_TOKEN + let called = 0 + + await checkAccessToken({}, {}, err => { + assert.equal(err, undefined) + called += 1 + }) + + assert.equal(called, 1) + }) + + it("Treats malformed token as non-expired and calls next.", async () => { + process.env.ACCESS_TOKEN = "not-a-jwt" + let called = 0 + + await checkAccessToken({}, {}, err => { + assert.equal(err, undefined) + called += 1 + }) + + assert.equal(called, 1) + }) + + it("Calls next without refresh when token is valid and not expired.", async () => { + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) + 3600) + global.fetch = async () => { + throw new Error("fetch should not be called") + } + + let nextError + await checkAccessToken({}, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + }) + + it("Treats non-numeric exp payload as non-expired and skips refresh.", async () => { + const payload = Buffer.from(JSON.stringify({ exp: "not-a-number" })).toString("base64") + process.env.ACCESS_TOKEN = `header.${payload}.signature` + global.fetch = async () => { + throw new Error("fetch should not be called") + } + + let nextError + await checkAccessToken({}, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + }) + + it("Propagates refresh errors to next(err) when token is expired.", async () => { + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + global.fetch = async () => { + throw new Error("refresh failed") + } + + let receivedError + await checkAccessToken({}, {}, err => { + receivedError = err + }) + + assert.ok(receivedError) + assert.match(receivedError.message, /refresh failed/) + }) + + it("Refreshes an expired token and persists it to .env.", async () => { + await inTempCwd(async tempDir => { + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + + global.fetch = async (url, options) => { + assert.equal(url, "https://auth.example/token") + assert.equal(options.method, "POST") + return { + json: async () => ({ access_token: "new-access-token" }) + } + } + + let nextError + await checkAccessToken({}, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(process.env.ACCESS_TOKEN, "new-access-token") + const envText = await fs.readFile(path.join(tempDir, ".env"), "utf8") + assert.match(envText, /ACCESS_TOKEN=new-access-token/) + }, "ACCESS_TOKEN=old-token\n") + }) + + it("Continues when refresh succeeds but .env read fails.", async () => { + await inTempCwd(async () => { + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + + global.fetch = async () => ({ + json: async () => ({ access_token: "new-access-token-2" }) + }) + + let nextError + await checkAccessToken({}, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(process.env.ACCESS_TOKEN, "new-access-token-2") + }) + }) +}) diff --git a/test/routes/update.test.js b/test/routes/update.test.js index 9b531e0..a6b5949 100644 --- a/test/routes/update.test.js +++ b/test/routes/update.test.js @@ -116,6 +116,55 @@ describe("Update network failure behavior. __rest __core", () => { .send({ "@id": rerumUriOrig, testing: "item" }) assert.equal(response.statusCode, 502) }) + + it("Preserves upstream text error message when update returns non-ok.", async () => { + global.fetch = async () => ({ + ok: false, + status: 503, + text: async () => "Upstream update failure" + }) + + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream update failure/) + }) + + it("Falls back to generic RERUM error text when upstream .text() throws.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => { + throw new Error("text stream consumed") + } + }) + + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) + + it("Maps successful upstream payload without id fields to 502.", async () => { + global.fetch = async () => ({ + ok: true, + json: async () => ({ testing: "item" }) + }) + + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + }) }) describe("Check that the properly used update endpoints function and interact with RERUM. __e2e", () => {