From 9e776ded427fda54cd94a6c6338f83fbc6646c83 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Wed, 17 Jun 2026 17:40:57 -0500 Subject: [PATCH] Initial commit. --- .gitattributes | 52 ++++++++++++++ .gitignore | 29 ++++++++ README.md | 104 +++++++++++++++++++++++++++ index.js | 35 ++++++++++ lib/action.js | 139 +++++++++++++++++++++++++++++++++++++ lib/config.js | 39 +++++++++++ lib/constants.js | 36 ++++++++++ lib/postizClient.js | 36 ++++++++++ lib/secretsAtRest.js | 96 +++++++++++++++++++++++++ package.json | 21 ++++++ test/e2e.js | 162 +++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 749 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.js create mode 100644 lib/action.js create mode 100644 lib/config.js create mode 100644 lib/constants.js create mode 100644 lib/postizClient.js create mode 100644 lib/secretsAtRest.js create mode 100644 package.json create mode 100644 test/e2e.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2a74e24 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,52 @@ +# Default line-ending handling for text files: normalize to LF in the repo, +# check out native on each platform. +* text=auto + +# Executable shell scripts must stay LF so the shebang works on Unix even when +# checked out on Windows. +*.sh text eol=lf + +# Collapse the lockfile in diffs / PR reviews and mark it generated. +package-lock.json linguist-generated=true -diff + +# Git LFS. Run `git lfs install` once per clone to activate the filters below. +# This plugin is currently code-only; these patterns are a safety net so any +# binary blob dropped into the tree is tracked correctly without anyone having +# to remember to update this file. + +# Archives +*.zip filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tar.gz filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.7z filter=lfs diff=lfs merge=lfs -text + +# Databases / snapshots +*.sqlite filter=lfs diff=lfs merge=lfs -text +*.sqlite3 filter=lfs diff=lfs merge=lfs -text +*.db filter=lfs diff=lfs merge=lfs -text + +# Images +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text + +# Documents +*.pdf filter=lfs diff=lfs merge=lfs -text + +# Fonts +*.ttf filter=lfs diff=lfs merge=lfs -text +*.otf filter=lfs diff=lfs merge=lfs -text +*.woff filter=lfs diff=lfs merge=lfs -text +*.woff2 filter=lfs diff=lfs merge=lfs -text + +# Audio / video +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.webm filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03d82a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Installed npm dependencies. Restored from package.json on install; never +# committed. (This plugin has no third-party runtime deps -- it uses Node 20's +# global fetch -- but the entry guards against a future dependency slipping in.) +node_modules/ + +# npm/yarn diagnostics and `npm pack` output. +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.tgz + +# Test coverage output. +coverage/ +.nyc_output/ + +# Local environment overrides / secrets. The Postiz base URL and public API key +# are entered in the Saltcorn plugin config UI; if a local .env is used to drive +# the live test gate it must never be committed. +.env +.env.* + +# Editor / OS junk. +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..010da0a --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# postiz-poster + +A Saltcorn plugin that publishes table rows to social networks through a +[Postiz](https://github.com/gitroomhq/postiz-app) instance -- self-hosted or +Postiz Cloud. Postiz handles the per-platform fan-out (Mastodon, Bluesky, X, +LinkedIn, Facebook, Instagram, Threads, Telegram, and more); this plugin just +maps a Saltcorn row into a Postiz post and calls Postiz's public API. + +## How it works + +- **Plugin config** holds the Postiz connection: a base URL and a public API + key. Self-hosted and cloud use the *same* `/public/v1` API, so switching + between them is only a base-URL change -- no code change. +- **One action**, `post_to_postiz`, is attached to any trigger (row Insert / + Update, or a button). When it fires it reads the post text (and an optional + media URL) from the row, optionally uploads the media to Postiz, and creates + the post against the channels you selected. +- The channel picker in the action config is **populated from the live Postiz + instance** (`GET /public/v1/integrations`), so admins choose real connected + accounts instead of pasting ids. If Postiz is unreachable at config time the + field degrades to a free-text id list. + +## Layout + +``` +postiz/ + index.js plugin entry: wires config workflow + the action + lib/ + constants.js plugin name/version, Postiz endpoints, post types + postizClient.js the only module that speaks HTTP to Postiz + config.js configuration_workflow (base URL + API key) + action.js post_to_postiz: configFields, payload builder, run + secretsAtRest.js AES-256-GCM seal/open for the API key (shared helper) + test/ + e2e.js payload + secrets gates, plus optional live gate +``` + +## Secrets at rest + +The Postiz API key is configured through the normal plugin UI but is **not** +left plaintext in the database. Saltcorn stores plugin configuration unencrypted +in `_sc_plugins.configuration`, which would otherwise expose the key to DB +backups, admin reads, and dev-deploy's journal sync. Instead: + +- `lib/secretsAtRest.js` seals the key with AES-256-GCM under a key derived + (HKDF-SHA256) from Saltcorn's `session_secret` -- which lives in the env / + config file, not the database. Ciphertext in the DB is useless without it. +- Because Saltcorn's config form autosaves raw values, `onLoad` re-seals any + plaintext key the moment the plugin (re)loads after a save; the action + decrypts only at the point of use. +- **Threat model:** this protects DB-only exposure (backups, sync, config-table + reads). It does not protect a host that leaks both the database and the env / + config file. **Rotating `session_secret` invalidates the sealed key** -- you + must re-enter it in the plugin config (the action raises a clear error saying + so). +- For the same reason, the sealed key is environment-specific: it will not + decrypt on a peer with a different `session_secret`, so exclude it from any + dev-deploy sync and set it per environment. + +## Configuring + +1. In Postiz, connect your social accounts, then create a key under + **Settings -> Developers -> Public API**. +2. In Saltcorn: **Settings -> Plugins -> postiz-poster**, set: + - **Postiz base URL** -- self-hosted `https://postiz.example.com`, or cloud + `https://api.postiz.com`. + - **Public API key**. +3. Create a trigger on the table you want to publish from, choose action + `post_to_postiz`, then pick the content field, optional media field, target + channels, and timing (now / schedule / draft). + +## Testing + +``` +node test/e2e.js # pure payload gate only +POSTIZ_BASE_URL=http://localhost:5000 \ +POSTIZ_API_KEY=xxxxxxxx node test/e2e.js # + live gate vs a real Postiz +``` + +The live gate lists integrations and creates a **draft** (safe to delete) using +the first connected channel. With no env vars it is skipped, not failed. + +## To confirm before production + +These are sketched against the documented Postiz API and isolated to one or two +spots each, but verify against [docs.postiz.com/public-api](https://docs.postiz.com/public-api), +which has changed across Postiz's weekly releases: + +- **Create-post body shape** (`type` / `date` / `posts[].integration` / + `value[].content` / `value[].image`) -- see `buildPostPayload` in + `lib/action.js`. +- **Auth header format** -- bare key vs `Bearer` (`AUTH_HEADER` in + `lib/constants.js`). +- **Media upload** -- whether `/upload` accepts a URL fetch or wants multipart, + and the id/path field it returns (`uploadMedia` in `lib/postizClient.js`). + +## Notes + +- Postiz removes the *subscription* cost (self-hosted) but not the *gatekeeping*: + you still register your own developer apps with each walled-garden platform + (Meta App Review, LinkedIn Community Management API, separate Threads app) and + own the OAuth token upkeep. Mastodon / Bluesky / X are straightforward. +- Underlying platform fees still pass through (e.g. X per-post pricing). Postiz + brokers the call; it does not make any platform's API free. diff --git a/index.js b/index.js new file mode 100644 index 0000000..8aafe96 --- /dev/null +++ b/index.js @@ -0,0 +1,35 @@ +// postiz-poster: Saltcorn plugin that publishes table rows to social networks +// through a Postiz instance (self-hosted or cloud). Exposes one trigger action, +// post_to_postiz, configured per-trigger. The Postiz connection itself lives in +// the plugin configuration (see lib/config.js); the API key is sealed at rest +// (see lib/secretsAtRest.js). + +const { PLUGIN_NAME } = require("./lib/constants"); +const { configurationWorkflow } = require("./lib/config"); +const { makePostAction, sealStoredApiKey } = require("./lib/action"); + + +// Runs each time the plugin loads -- including immediately after a config save +// (Saltcorn calls Plugin.loadPlugin after upsert). Seals any plaintext apiKey +// the autoSave form left in the database. +const onLoad = async () => { + try { + await sealStoredApiKey(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[${PLUGIN_NAME}] sealing stored secrets failed:`, err); + } +}; + + +module.exports = { + sc_plugin_api_version: 1, + plugin_name: PLUGIN_NAME, + configuration_workflow: configurationWorkflow, + onLoad: onLoad, + // Saltcorn calls capability keys with the saved plugin config when a + // configuration_workflow is present (see saltcorn-data db/state.ts). + actions: (pluginCfg) => ({ + post_to_postiz: makePostAction(pluginCfg || {}) + }) +}; diff --git a/lib/action.js b/lib/action.js new file mode 100644 index 0000000..e83622d --- /dev/null +++ b/lib/action.js @@ -0,0 +1,139 @@ +// The post_to_postiz action. Attach it to any Saltcorn trigger (row Insert / +// Update, or a button): it maps the triggering row into a Postiz post and +// publishes through the configured channels. +// +// The Postiz API key is stored sealed in the plugin config (see +// lib/secretsAtRest.js); it is decrypted only at the point of use here. + +const { makePostizClient } = require("./postizClient"); +const { encryptSecret, decryptSecret, isEncryptedBlob } = require("./secretsAtRest"); +const { PLUGIN_NAME, POST_TYPES } = require("./constants"); + +// Domain label that ties the sealed apiKey to this plugin + field (key +// derivation). Changing it would orphan already-sealed keys. +const API_KEY_INFO = `${PLUGIN_NAME}:apiKey`; + + +// Resolve the usable plaintext API key from saved config -- whether it is +// sealed (normal) or still plaintext (transiently, right after a config save +// and before onLoad re-seals it). Throws a clear, actionable error if a sealed +// key cannot be opened (e.g. session_secret was rotated). +const resolveApiKey = (pluginCfg) => { + const raw = pluginCfg && pluginCfg.apiKey; + if (!raw) { + return ""; + } + if (!isEncryptedBlob(raw)) { + return String(raw); + } + const opened = decryptSecret(raw, API_KEY_INFO); + if (opened === null) { + throw new Error(`${PLUGIN_NAME}: stored Postiz API key could not be decrypted (was SALTCORN_SESSION_SECRET rotated?). Re-enter it in the plugin config.`); + } + return opened; +}; + + +// Build a Postiz client from saved config with the key decrypted for use. +const clientFor = (pluginCfg) => { + return makePostizClient({ + baseUrl: pluginCfg && pluginCfg.baseUrl, + apiKey: resolveApiKey(pluginCfg) + }); +}; + + +// onLoad migration: Saltcorn's autoSave writes raw form values, so a freshly +// entered apiKey lands in the DB as plaintext. Re-seal it in place so the +// database never retains the plaintext beyond the load that follows the save. +const sealStoredApiKey = async () => { + const Plugin = require("@saltcorn/data/models/plugin"); + const plugin = await Plugin.findOne({ name: PLUGIN_NAME }); + if (!plugin || !plugin.configuration) { + return; + } + const key = plugin.configuration.apiKey; + if (!key || isEncryptedBlob(key)) { + return; + } + plugin.configuration = { ...plugin.configuration, apiKey: encryptSecret(key, API_KEY_INFO) }; + await plugin.upsert(); +}; + + +// Pure: (action config, row, media ids, now) -> Postiz create-post body. Kept +// side-effect free so the test suite can assert the shape without a live +// server. NOTE: verify this body against docs.postiz.com/public-api -- the +// schema has churned across Postiz's weekly releases. +const buildPostPayload = (configuration, row, mediaIds, nowIso) => { + const content = row[configuration.contentField]; + const integrationIds = String(configuration.integrationIds || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const value = [{ content, image: mediaIds.map((id) => ({ id })) }]; + const date = configuration.when === POST_TYPES.SCHEDULE && configuration.scheduleField + ? new Date(row[configuration.scheduleField]).toISOString() + : nowIso; + + return { + type: configuration.when, + date: date, + tags: [], + posts: integrationIds.map((id) => ({ + integration: { id }, + value: value, + settings: {} + })) + }; +}; + + +// Builds the per-action config form. Populates the channel picker from the live +// Postiz instance so the admin selects real connected accounts; if Postiz is +// unreachable at config time the field falls back to a free-text id list. +const buildConfigFields = (pluginCfg) => async ({ table }) => { + const strFields = (table && table.fields ? table.fields : []) + .filter((f) => f.type && f.type.name === "String") + .map((f) => f.name); + + let channelOptions = []; + try { + const integrations = await clientFor(pluginCfg).listIntegrations(); + channelOptions = (integrations || []).map((i) => ({ + label: `${i.name} (${i.identifier || i.providerIdentifier || "?"})`, + value: i.id + })); + } catch (e) { + // Postiz unreachable / key unset at config time -> admin types ids by hand. + } + + return [ + { name: "contentField", label: "Content field", type: "String", required: true, attributes: { options: strFields } }, + { name: "mediaUrlField", label: "Media URL field (optional)", type: "String", attributes: { options: strFields } }, + { name: "integrationIds", label: "Post to channels", type: "String", required: true, attributes: { options: channelOptions } }, + { name: "when", label: "Timing", type: "String", required: true, attributes: { options: Object.values(POST_TYPES) } }, + { name: "scheduleField", label: "Schedule date field", type: "String", attributes: { options: strFields }, showIf: { when: POST_TYPES.SCHEDULE } } + ]; +}; + + +const makePostAction = (pluginCfg) => ({ + configFields: buildConfigFields(pluginCfg), + run: async ({ row, configuration }) => { + const client = clientFor(pluginCfg); + + // Postiz wants media uploaded first, then referenced by id. + let mediaIds = []; + if (configuration.mediaUrlField && row[configuration.mediaUrlField]) { + const uploaded = await client.uploadMedia({ url: row[configuration.mediaUrlField] }); + mediaIds = uploaded && uploaded.id ? [uploaded.id] : []; + } + + const payload = buildPostPayload(configuration, row, mediaIds, new Date().toISOString()); + return await client.createPost(payload); + } +}); + + +module.exports = { makePostAction, buildPostPayload, buildConfigFields, resolveApiKey, sealStoredApiKey, API_KEY_INFO }; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..209c348 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,39 @@ +// Plugin-level configuration: the Postiz connection (base URL + API key). Set +// once per Saltcorn site under Settings -> Plugins -> postiz-poster. Every +// trigger action reuses this saved connection. + +const Workflow = require("@saltcorn/data/models/workflow"); +const Form = require("@saltcorn/data/models/form"); + + +const configurationWorkflow = () => + new Workflow({ + steps: [ + { + name: "Postiz connection", + form: async () => + new Form({ + fields: [ + { + name: "baseUrl", + label: "Postiz base URL", + type: "String", + required: true, + sublabel: "Self-hosted: https://postiz.example.com | Cloud: https://api.postiz.com" + }, + { + name: "apiKey", + label: "Public API key", + type: "String", + required: true, + sublabel: "Postiz -> Settings -> Developers -> Public API", + attributes: { input_type: "password" } + } + ] + }) + } + ] + }); + + +module.exports = { configurationWorkflow }; diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..2a7933f --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,36 @@ +// Compile-time constants for the postiz-poster plugin. + +const PLUGIN_NAME = "postiz-poster"; +const PLUGIN_VERSION = "0.0.1"; + +// Postiz public REST API. The surface is identical for self-hosted and cloud; +// only the base URL (plugin config) differs. Endpoint paths below are appended +// to . +const PATH_BASE = "/public/v1"; + +const ENDPOINTS = { + INTEGRATIONS: "/integrations", // GET -> list connected channels + UPLOAD: "/upload", // POST -> media, returns { id, path } + POSTS: "/posts" // POST -> create / schedule a post +}; + +// Postiz post "type": publish now, schedule for a date, or save as a draft. +const POST_TYPES = { + NOW: "now", + SCHEDULE: "schedule", + DRAFT: "draft" +}; + +// Header carrying the Postiz public API key. Some versions expect a bare key +// rather than a Bearer prefix; isolated here so a change is one edit, not many. +// Confirm against docs.postiz.com/public-api before shipping. +const AUTH_HEADER = "Authorization"; + +module.exports = { + PLUGIN_NAME, + PLUGIN_VERSION, + PATH_BASE, + ENDPOINTS, + POST_TYPES, + AUTH_HEADER +}; diff --git a/lib/postizClient.js b/lib/postizClient.js new file mode 100644 index 0000000..596efa0 --- /dev/null +++ b/lib/postizClient.js @@ -0,0 +1,36 @@ +// Single source of truth for talking to the Postiz public API. One factory, +// built from the plugin's saved { baseUrl, apiKey }. Self-hosted vs cloud is +// purely the baseUrl -- no other code in the plugin branches on deployment. + +const { PATH_BASE, ENDPOINTS, AUTH_HEADER } = require("./constants"); + + +const makePostizClient = (pluginCfg) => { + const baseUrl = String((pluginCfg && pluginCfg.baseUrl) || "").replace(/\/+$/, ""); + const apiKey = String((pluginCfg && pluginCfg.apiKey) || ""); + + const request = async (method, endpoint, body) => { + const url = `${baseUrl}${PATH_BASE}${endpoint}`; + const headers = { [AUTH_HEADER]: apiKey }; + const init = { method, headers }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Postiz ${method} ${endpoint} -> ${res.status} ${text}`); + } + return res.status === 204 ? null : res.json(); + }; + + return { + createPost: (payload) => request("POST", ENDPOINTS.POSTS, payload), + listIntegrations: () => request("GET", ENDPOINTS.INTEGRATIONS), + uploadMedia: (payload) => request("POST", ENDPOINTS.UPLOAD, payload) + }; +}; + + +module.exports = { makePostizClient }; diff --git a/lib/secretsAtRest.js b/lib/secretsAtRest.js new file mode 100644 index 0000000..e397a50 --- /dev/null +++ b/lib/secretsAtRest.js @@ -0,0 +1,96 @@ +// Encryption-at-rest for plugin secrets kept in _sc_plugins.configuration. +// +// Saltcorn persists plugin configuration PLAINTEXT in the database, so a DB +// dump, a backup, or a dev-deploy journal sync would otherwise expose any API +// key or signing secret stored there. This module seals such fields with +// AES-256-GCM under a key derived (HKDF-SHA256) from Saltcorn's session_secret, +// which lives in the env / config file -- NOT the database. Ciphertext in the +// DB is therefore useless without the separately-held key. +// +// Threat model: this protects DB-only exposure (backups, peer sync, an admin +// reading the config table). It does NOT protect an attacker who also holds the +// host env / config file -- they have the key. Rotating session_secret +// invalidates every sealed value (the secret must be re-entered in the UI). +// +// Shareable verbatim across plugins: callers pass a domain label (info) so each +// plugin/field derives a distinct key. + +const crypto = require("crypto"); + +const BLOB_PREFIX = "scs1:"; // saltcorn-secret, format v1 +const HASH = "sha256"; +const GCM = "aes-256-gcm"; +const IV_BYTES = 12; +const TAG_BYTES = 16; +const KEY_BYTES = 32; + +const kekCache = new Map(); // info label -> 32-byte derived key + + +const getSessionSecret = () => { + const fromEnv = process.env.SALTCORN_SESSION_SECRET; + if (fromEnv && fromEnv.length > 0) { + return fromEnv; + } + // Fall back to the value Saltcorn loaded into its config state. + try { + const { getState } = require("@saltcorn/data/db/state"); + const v = getState().getConfig("session_secret"); + if (v) { + return v; + } + } catch (e) { + // state not available (e.g. unit tests) -> fall through to throw + } + throw new Error("secretsAtRest: SALTCORN_SESSION_SECRET unavailable; cannot derive key"); +}; + + +const getKek = (info) => { + const cached = kekCache.get(info); + if (cached) { + return cached; + } + const ikm = Buffer.from(getSessionSecret(), "utf8"); + const salt = Buffer.from(`${BLOB_PREFIX}${info}`, "utf8"); + const kek = Buffer.from(crypto.hkdfSync(HASH, ikm, salt, Buffer.from(info, "utf8"), KEY_BYTES)); + kekCache.set(info, kek); + return kek; +}; + + +const isEncryptedBlob = (value) => { + return typeof value === "string" && value.startsWith(BLOB_PREFIX); +}; + + +const encryptSecret = (plaintext, info) => { + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv(GCM, getKek(info), iv); + const ct = Buffer.concat([cipher.update(Buffer.from(String(plaintext), "utf8")), cipher.final()]); + const tag = cipher.getAuthTag(); + return BLOB_PREFIX + Buffer.concat([iv, tag, ct]).toString("base64"); +}; + + +// Returns the plaintext, or null if the blob can't be opened (wrong/rotated +// key, tampering, corruption). Callers treat null as "re-enter the secret". +const decryptSecret = (blob, info) => { + if (!isEncryptedBlob(blob)) { + return null; + } + try { + const raw = Buffer.from(blob.slice(BLOB_PREFIX.length), "base64"); + const iv = raw.subarray(0, IV_BYTES); + const tag = raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); + const ct = raw.subarray(IV_BYTES + TAG_BYTES); + const decipher = crypto.createDecipheriv(GCM, getKek(info), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); + } catch (e) { + return null; + } +}; + + +module.exports = { encryptSecret, decryptSecret, isEncryptedBlob }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..25bed36 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "postiz-poster", + "version": "0.0.1", + "description": "Saltcorn plugin: publish table rows to social networks (Mastodon, Bluesky, X, LinkedIn, Facebook, Instagram, Threads, and more) through a Postiz instance -- self-hosted or cloud -- via a single per-trigger action. Channels are chosen from the live Postiz integration list; post content and optional media come from row fields.", + "main": "index.js", + "scripts": { + "test": "node test/e2e.js" + }, + "keywords": [ + "saltcorn", + "postiz", + "social-media", + "publishing", + "scheduling" + ], + "engines": { + "node": ">=20" + }, + "author": "Scott Duensing", + "license": "MIT" +} diff --git a/test/e2e.js b/test/e2e.js new file mode 100644 index 0000000..7472f37 --- /dev/null +++ b/test/e2e.js @@ -0,0 +1,162 @@ +// postiz-poster integration test suite. +// +// Two gates: +// 1. payloadGate -- pure assertions on buildPostPayload (always runs, no net). +// 2. liveGate -- drives a real Postiz instance; runs only when both +// POSTIZ_BASE_URL and POSTIZ_API_KEY are set. +// +// Run from the postiz/ directory: +// +// node test/e2e.js # payloadGate only +// POSTIZ_BASE_URL=http://localhost:5000 \ +// POSTIZ_API_KEY=xxxxxxxx node test/e2e.js # + liveGate +// +// Without the env vars the live gate is skipped (not failed), so the pure gate +// still runs anywhere with no Postiz available. Assertion failures are caught +// and counted; one failure does not stop the suite. + +const assert = require("node:assert/strict"); + +const { buildPostPayload } = require("../lib/action"); +const { makePostizClient } = require("../lib/postizClient"); +const { encryptSecret, decryptSecret, isEncryptedBlob } = require("../lib/secretsAtRest"); +const { POST_TYPES } = require("../lib/constants"); + +let passed = 0; +let failed = 0; + + +const check = (name, fn) => { + try { + fn(); + passed += 1; + console.log(`ok - ${name}`); + } catch (err) { + failed += 1; + console.log(`FAIL - ${name}: ${err && err.message ? err.message : err}`); + } +}; + + +const checkAsync = async (name, fn) => { + try { + await fn(); + passed += 1; + console.log(`ok - ${name}`); + } catch (err) { + failed += 1; + console.log(`FAIL - ${name}: ${err && err.message ? err.message : err}`); + } +}; + + +const payloadGate = () => { + const nowIso = "2026-06-08T20:00:00.000Z"; + + check("now post targets every channel id, content carried through", () => { + const cfg = { contentField: "body", integrationIds: "a, b ,c", when: POST_TYPES.NOW }; + const out = buildPostPayload(cfg, { body: "hi" }, [], nowIso); + assert.equal(out.type, POST_TYPES.NOW); + assert.equal(out.date, nowIso); + assert.equal(out.posts.length, 3); + assert.deepEqual(out.posts.map((p) => p.integration.id), ["a", "b", "c"]); + assert.equal(out.posts[0].value[0].content, "hi"); + }); + + check("media ids attach as image refs", () => { + const cfg = { contentField: "body", integrationIds: "a", when: POST_TYPES.NOW }; + const out = buildPostPayload(cfg, { body: "pic" }, ["m1", "m2"], nowIso); + assert.deepEqual(out.posts[0].value[0].image, [{ id: "m1" }, { id: "m2" }]); + }); + + check("scheduled post reads the date field, not now", () => { + const cfg = { contentField: "body", integrationIds: "a", when: POST_TYPES.SCHEDULE, scheduleField: "at" }; + const out = buildPostPayload(cfg, { body: "later", at: "2026-12-25T09:00:00Z" }, [], nowIso); + assert.equal(out.type, POST_TYPES.SCHEDULE); + assert.equal(out.date, "2026-12-25T09:00:00.000Z"); + }); + + check("blank / whitespace channel ids are dropped", () => { + const cfg = { contentField: "body", integrationIds: " , ,", when: POST_TYPES.DRAFT }; + const out = buildPostPayload(cfg, { body: "x" }, [], nowIso); + assert.equal(out.posts.length, 0); + }); +}; + + +const secretsGate = () => { + // secretsAtRest reads the key root from this env var (see getSessionSecret). + process.env.SALTCORN_SESSION_SECRET = process.env.SALTCORN_SESSION_SECRET || "test-session-secret-do-not-use"; + const info = "postiz-poster:apiKey"; + + check("seal -> open round-trips the plaintext", () => { + const blob = encryptSecret("super-secret-key", info); + assert.ok(isEncryptedBlob(blob), "blob should carry the scs1: prefix"); + assert.notEqual(blob, "super-secret-key"); + assert.equal(decryptSecret(blob, info), "super-secret-key"); + }); + + check("ciphertext differs each call (random IV)", () => { + assert.notEqual(encryptSecret("x", info), encryptSecret("x", info)); + }); + + check("wrong domain label cannot open the blob", () => { + const blob = encryptSecret("k", info); + assert.equal(decryptSecret(blob, "postiz-poster:other"), null); + }); + + check("tampered blob fails GCM auth -> null", () => { + const blob = encryptSecret("k", info); + const i = "scs1:".length + 2; + const flipped = blob[i] === "A" ? "B" : "A"; + const tampered = blob.slice(0, i) + flipped + blob.slice(i + 1); + assert.equal(decryptSecret(tampered, info), null); + }); + + check("plaintext is not mistaken for an encrypted blob", () => { + assert.equal(isEncryptedBlob("plain"), false); + assert.equal(decryptSecret("plain", info), null); + }); +}; + + +const liveGate = async () => { + const baseUrl = process.env.POSTIZ_BASE_URL; + const apiKey = process.env.POSTIZ_API_KEY; + if (!baseUrl || !apiKey) { + console.log("skip - liveGate (set POSTIZ_BASE_URL + POSTIZ_API_KEY to enable)"); + return; + } + + const client = makePostizClient({ baseUrl, apiKey }); + let firstChannel = null; + + await checkAsync("listIntegrations returns an array", async () => { + const integrations = await client.listIntegrations(); + assert.ok(Array.isArray(integrations), "expected an array of integrations"); + firstChannel = integrations[0] || null; + }); + + await checkAsync("createPost (draft) is accepted", async () => { + if (!firstChannel) { + console.log(" (no channels connected -- connect one in Postiz to exercise createPost)"); + return; + } + const cfg = { contentField: "body", integrationIds: String(firstChannel.id), when: POST_TYPES.DRAFT }; + const payload = buildPostPayload(cfg, { body: "postiz-poster e2e draft -- safe to delete" }, [], new Date().toISOString()); + const res = await client.createPost(payload); + assert.ok(res, "expected a create-post response"); + }); +}; + + +const main = async () => { + payloadGate(); + secretsGate(); + await liveGate(); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +}; + + +main();