test(auth): harden Task 15 OIDC smoke evidence
This commit is contained in:
+172
-37
@@ -7,7 +7,13 @@
|
|||||||
* needed to trust the provider from a spawned backend process.
|
* needed to trust the provider from a spawned backend process.
|
||||||
*/
|
*/
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { createHash, generateKeyPairSync, randomBytes, sign as signRsa } from "node:crypto";
|
import {
|
||||||
|
createHash,
|
||||||
|
generateKeyPairSync,
|
||||||
|
randomBytes,
|
||||||
|
sign as signRsa,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { createServer } from "node:https";
|
import { createServer } from "node:https";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -17,6 +23,9 @@ import { fileURLToPath } from "node:url";
|
|||||||
const LOOPBACK_HOST = "127.0.0.1";
|
const LOOPBACK_HOST = "127.0.0.1";
|
||||||
const ISSUER_PATH = "/application/o/thothii";
|
const ISSUER_PATH = "/application/o/thothii";
|
||||||
const MAX_BODY_BYTES = 32 * 1024;
|
const MAX_BODY_BYTES = 32 * 1024;
|
||||||
|
const MAX_STATE_TTL_MS = 5 * 60 * 1_000;
|
||||||
|
const MAX_STATE_LIMIT = 1_024;
|
||||||
|
const MAX_DEVICE_POLLS = 32;
|
||||||
const VALID_IDENTITIES = new Set([
|
const VALID_IDENTITIES = new Set([
|
||||||
"ordinary",
|
"ordinary",
|
||||||
"admin",
|
"admin",
|
||||||
@@ -39,6 +48,43 @@ function safeError(code) {
|
|||||||
return new Error(code);
|
return new Error(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boundedInteger(value, fallback, maximum, code) {
|
||||||
|
const selected = value ?? fallback;
|
||||||
|
if (!Number.isSafeInteger(selected) || selected < 1 || selected > maximum) throw safeError(code);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedNonNegativeInteger(value, fallback, maximum, code) {
|
||||||
|
const selected = value ?? fallback;
|
||||||
|
if (!Number.isSafeInteger(selected) || selected < 0 || selected > maximum) throw safeError(code);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlledString(value, code) {
|
||||||
|
if (typeof value !== "string" || value.length < 1 || value.length > 512 || /[\r\n]/u.test(value)) {
|
||||||
|
throw safeError(code);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactParameter(values, name) {
|
||||||
|
const matches = values.getAll(name);
|
||||||
|
return matches.length === 1 && matches[0].length > 0 ? matches[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretMatches(actual, expected) {
|
||||||
|
if (typeof actual !== "string") return false;
|
||||||
|
const actualDigest = createHash("sha256").update(actual).digest();
|
||||||
|
const expectedDigest = createHash("sha256").update(expected).digest();
|
||||||
|
return timingSafeEqual(actualDigest, expectedDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpired(records, currentTime) {
|
||||||
|
for (const [key, record] of records) {
|
||||||
|
if (record.expiresAt <= currentTime) records.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensurePrivateDirectory(directory) {
|
function ensurePrivateDirectory(directory) {
|
||||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
chmodSync(directory, 0o700);
|
chmodSync(directory, 0o700);
|
||||||
@@ -85,9 +131,9 @@ async function requestBody(request) {
|
|||||||
return Buffer.concat(chunks).toString("utf8");
|
return Buffer.concat(chunks).toString("utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function jwt(privateKey, issuer, audience, nonce, identity) {
|
function jwt(privateKey, issuer, audience, nonce, identity, currentTime) {
|
||||||
const claims = identityClaims[identity];
|
const claims = identityClaims[identity];
|
||||||
const now = Math.floor(Date.now() / 1_000);
|
const now = Math.floor(currentTime / 1_000);
|
||||||
const payload = {
|
const payload = {
|
||||||
iss: issuer,
|
iss: issuer,
|
||||||
sub: claims.subject,
|
sub: claims.subject,
|
||||||
@@ -118,6 +164,42 @@ function authorizationIdentity(identity) {
|
|||||||
export async function startFakeOidcProvider(options = {}) {
|
export async function startFakeOidcProvider(options = {}) {
|
||||||
const host = options.host ?? LOOPBACK_HOST;
|
const host = options.host ?? LOOPBACK_HOST;
|
||||||
if (host !== LOOPBACK_HOST) throw safeError("oidc_fixture_loopback_required");
|
if (host !== LOOPBACK_HOST) throw safeError("oidc_fixture_loopback_required");
|
||||||
|
const registration = options.registration;
|
||||||
|
if (!registration || typeof registration !== "object") throw safeError("oidc_fixture_registration_required");
|
||||||
|
const clientId = controlledString(registration.clientId, "oidc_fixture_client_id_invalid");
|
||||||
|
const clientSecret = controlledString(registration.clientSecret, "oidc_fixture_client_secret_invalid");
|
||||||
|
const redirectUri = controlledString(registration.redirectUri, "oidc_fixture_redirect_invalid");
|
||||||
|
let parsedRedirect;
|
||||||
|
try {
|
||||||
|
parsedRedirect = new URL(redirectUri);
|
||||||
|
} catch {
|
||||||
|
throw safeError("oidc_fixture_redirect_invalid");
|
||||||
|
}
|
||||||
|
if (parsedRedirect.protocol !== "http:" || parsedRedirect.hostname !== LOOPBACK_HOST
|
||||||
|
|| parsedRedirect.username || parsedRedirect.password || parsedRedirect.hash) {
|
||||||
|
throw safeError("oidc_fixture_redirect_invalid");
|
||||||
|
}
|
||||||
|
const apiToken = controlledString(options.apiToken, "oidc_fixture_api_token_required");
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
if (typeof now !== "function") throw safeError("oidc_fixture_clock_invalid");
|
||||||
|
const authorizationStateTtlMs = boundedInteger(
|
||||||
|
options.authorizationStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_authorization_ttl_invalid",
|
||||||
|
);
|
||||||
|
const authorizationStateLimit = boundedInteger(
|
||||||
|
options.authorizationStateLimit, 64, MAX_STATE_LIMIT, "oidc_fixture_authorization_limit_invalid",
|
||||||
|
);
|
||||||
|
const deviceStateTtlMs = boundedInteger(
|
||||||
|
options.deviceStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_device_ttl_invalid",
|
||||||
|
);
|
||||||
|
const deviceStateLimit = boundedInteger(
|
||||||
|
options.deviceStateLimit, 32, MAX_STATE_LIMIT, "oidc_fixture_device_limit_invalid",
|
||||||
|
);
|
||||||
|
const devicePendingPolls = boundedNonNegativeInteger(
|
||||||
|
options.devicePendingPolls, 0, MAX_DEVICE_POLLS, "oidc_fixture_device_pending_polls_invalid",
|
||||||
|
);
|
||||||
|
const devicePollLimit = boundedInteger(
|
||||||
|
options.devicePollLimit, 5, MAX_DEVICE_POLLS, "oidc_fixture_device_poll_limit_invalid",
|
||||||
|
);
|
||||||
const ownsDirectory = options.directory === undefined;
|
const ownsDirectory = options.directory === undefined;
|
||||||
const directory = options.directory ?? mkdtempSync(join(tmpdir(), "thothii-oidc-fixture-"));
|
const directory = options.directory ?? mkdtempSync(join(tmpdir(), "thothii-oidc-fixture-"));
|
||||||
ensurePrivateDirectory(directory);
|
ensurePrivateDirectory(directory);
|
||||||
@@ -134,6 +216,19 @@ export async function startFakeOidcProvider(options = {}) {
|
|||||||
let issuer = undefined;
|
let issuer = undefined;
|
||||||
let baseUrl = undefined;
|
let baseUrl = undefined;
|
||||||
|
|
||||||
|
function currentTime() {
|
||||||
|
const value = now();
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) throw safeError("oidc_fixture_clock_invalid");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateClient(request, values) {
|
||||||
|
if (request.headers.authorization !== undefined) return false;
|
||||||
|
const suppliedClientId = exactParameter(values, "client_id");
|
||||||
|
const suppliedClientSecret = exactParameter(values, "client_secret");
|
||||||
|
return secretMatches(suppliedClientId, clientId) && secretMatches(suppliedClientSecret, clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
const server = createServer({ key: certificate.key, cert: certificate.cert }, async (request, reply) => {
|
const server = createServer({ key: certificate.key, cert: certificate.cert }, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
if (!issuer || !baseUrl || !request.url) {
|
if (!issuer || !baseUrl || !request.url) {
|
||||||
@@ -154,7 +249,7 @@ export async function startFakeOidcProvider(options = {}) {
|
|||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
subject_types_supported: ["public"],
|
subject_types_supported: ["public"],
|
||||||
grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
|
grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||||
code_challenge_methods_supported: ["S256"],
|
code_challenge_methods_supported: ["S256"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
});
|
});
|
||||||
@@ -165,33 +260,33 @@ export async function startFakeOidcProvider(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === "GET" && path === `${ISSUER_PATH}/authorize`) {
|
if (request.method === "GET" && path === `${ISSUER_PATH}/authorize`) {
|
||||||
const redirectUri = url.searchParams.get("redirect_uri");
|
const suppliedRedirectUri = exactParameter(url.searchParams, "redirect_uri");
|
||||||
const clientId = url.searchParams.get("client_id");
|
const suppliedClientId = exactParameter(url.searchParams, "client_id");
|
||||||
const state = url.searchParams.get("state");
|
const state = exactParameter(url.searchParams, "state");
|
||||||
const nonce = url.searchParams.get("nonce");
|
const nonce = exactParameter(url.searchParams, "nonce");
|
||||||
const challenge = url.searchParams.get("code_challenge");
|
const challenge = exactParameter(url.searchParams, "code_challenge");
|
||||||
if (url.searchParams.get("response_type") !== "code" || !redirectUri || !clientId || !state || !nonce
|
if (exactParameter(url.searchParams, "response_type") !== "code"
|
||||||
|| !challenge || url.searchParams.get("code_challenge_method") !== "S256") {
|
|| !secretMatches(suppliedRedirectUri, redirectUri)
|
||||||
|
|| !secretMatches(suppliedClientId, clientId) || !state || !nonce || !challenge
|
||||||
|
|| exactParameter(url.searchParams, "code_challenge_method") !== "S256") {
|
||||||
sendJson(reply, 400, { error: "invalid_request" });
|
sendJson(reply, 400, { error: "invalid_request" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let callback;
|
const reservationTime = currentTime();
|
||||||
try {
|
pruneExpired(authorizations, reservationTime);
|
||||||
callback = new URL(redirectUri);
|
if (authorizations.size >= authorizationStateLimit) {
|
||||||
if (callback.protocol !== "http:" || callback.hostname !== LOOPBACK_HOST || callback.username || callback.password) {
|
sendJson(reply, 503, { error: "temporarily_unavailable" });
|
||||||
throw safeError("oidc_fixture_redirect_invalid");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
sendJson(reply, 400, { error: "invalid_request" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const callback = new URL(redirectUri);
|
||||||
const code = randomBytes(32).toString("base64url");
|
const code = randomBytes(32).toString("base64url");
|
||||||
authorizations.set(code, {
|
authorizations.set(code, {
|
||||||
challenge,
|
challenge,
|
||||||
clientId,
|
clientId,
|
||||||
|
redirectUri,
|
||||||
identity: activeIdentity,
|
identity: activeIdentity,
|
||||||
nonce,
|
nonce,
|
||||||
used: false,
|
expiresAt: reservationTime + authorizationStateTtlMs,
|
||||||
});
|
});
|
||||||
lastAuthorization = { identity: activeIdentity, codeChallengeMethod: "S256", pkceVerified: false };
|
lastAuthorization = { identity: activeIdentity, codeChallengeMethod: "S256", pkceVerified: false };
|
||||||
callback.searchParams.set("code", code);
|
callback.searchParams.set("code", code);
|
||||||
@@ -201,48 +296,80 @@ export async function startFakeOidcProvider(options = {}) {
|
|||||||
}
|
}
|
||||||
if (request.method === "POST" && path === `${ISSUER_PATH}/device_authorization`) {
|
if (request.method === "POST" && path === `${ISSUER_PATH}/device_authorization`) {
|
||||||
const values = new URLSearchParams(await requestBody(request));
|
const values = new URLSearchParams(await requestBody(request));
|
||||||
const clientId = values.get("client_id");
|
if (!authenticateClient(request, values)) {
|
||||||
if (!clientId) {
|
sendJson(reply, 401, { error: "invalid_client" });
|
||||||
sendJson(reply, 400, { error: "invalid_request" });
|
return;
|
||||||
|
}
|
||||||
|
const reservationTime = currentTime();
|
||||||
|
pruneExpired(deviceCodes, reservationTime);
|
||||||
|
if (deviceCodes.size >= deviceStateLimit) {
|
||||||
|
sendJson(reply, 503, { error: "temporarily_unavailable" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deviceCode = randomBytes(32).toString("base64url");
|
const deviceCode = randomBytes(32).toString("base64url");
|
||||||
const userCode = "FIXTURE-CODE";
|
const userCode = "FIXTURE-CODE";
|
||||||
deviceCodes.set(deviceCode, { clientId, identity: activeIdentity, nonce: "device", used: false });
|
deviceCodes.set(deviceCode, {
|
||||||
|
clientId,
|
||||||
|
identity: activeIdentity,
|
||||||
|
nonce: "device",
|
||||||
|
expiresAt: reservationTime + deviceStateTtlMs,
|
||||||
|
polls: 0,
|
||||||
|
});
|
||||||
sendJson(reply, 200, {
|
sendJson(reply, 200, {
|
||||||
device_code: deviceCode,
|
device_code: deviceCode,
|
||||||
user_code: userCode,
|
user_code: userCode,
|
||||||
verification_uri: `${issuer}device`,
|
verification_uri: `${issuer}device`,
|
||||||
verification_uri_complete: `${issuer}device?user_code=${userCode}`,
|
verification_uri_complete: `${issuer}device?user_code=${userCode}`,
|
||||||
expires_in: 60,
|
expires_in: Math.max(1, Math.floor(deviceStateTtlMs / 1_000)),
|
||||||
interval: 1,
|
interval: 1,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === "POST" && path === `${ISSUER_PATH}/token`) {
|
if (request.method === "POST" && path === `${ISSUER_PATH}/token`) {
|
||||||
const values = new URLSearchParams(await requestBody(request));
|
const values = new URLSearchParams(await requestBody(request));
|
||||||
const grantType = values.get("grant_type");
|
if (!authenticateClient(request, values)) {
|
||||||
|
sendJson(reply, 401, { error: "invalid_client" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tokenTime = currentTime();
|
||||||
|
pruneExpired(authorizations, tokenTime);
|
||||||
|
pruneExpired(deviceCodes, tokenTime);
|
||||||
|
const grantType = exactParameter(values, "grant_type");
|
||||||
let record;
|
let record;
|
||||||
if (grantType === "authorization_code") {
|
if (grantType === "authorization_code") {
|
||||||
const code = values.get("code") ?? "";
|
const code = exactParameter(values, "code") ?? "";
|
||||||
record = authorizations.get(code);
|
record = authorizations.get(code);
|
||||||
const verifier = values.get("code_verifier") ?? "";
|
if (record !== undefined) authorizations.delete(code);
|
||||||
const verified = record !== undefined && !record.used
|
const verifier = exactParameter(values, "code_verifier") ?? "";
|
||||||
&& createHash("sha256").update(verifier).digest("base64url") === record.challenge;
|
const suppliedRedirectUri = exactParameter(values, "redirect_uri");
|
||||||
|
const suppliedClientId = exactParameter(values, "client_id");
|
||||||
|
const verified = record !== undefined
|
||||||
|
&& secretMatches(suppliedClientId, record.clientId)
|
||||||
|
&& secretMatches(suppliedRedirectUri, record.redirectUri)
|
||||||
|
&& secretMatches(createHash("sha256").update(verifier).digest("base64url"), record.challenge);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
sendJson(reply, 400, { error: "invalid_grant" });
|
sendJson(reply, 400, { error: "invalid_grant" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
record.used = true;
|
|
||||||
if (lastAuthorization) lastAuthorization = { ...lastAuthorization, pkceVerified: true };
|
if (lastAuthorization) lastAuthorization = { ...lastAuthorization, pkceVerified: true };
|
||||||
} else if (grantType === "urn:ietf:params:oauth:grant-type:device_code") {
|
} else if (grantType === "urn:ietf:params:oauth:grant-type:device_code") {
|
||||||
const deviceCode = values.get("device_code") ?? "";
|
const deviceCode = exactParameter(values, "device_code") ?? "";
|
||||||
record = deviceCodes.get(deviceCode);
|
record = deviceCodes.get(deviceCode);
|
||||||
if (record === undefined || record.used) {
|
if (record === undefined) {
|
||||||
sendJson(reply, 400, { error: "invalid_grant" });
|
sendJson(reply, 400, { error: "invalid_grant" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
record.used = true;
|
record.polls += 1;
|
||||||
|
if (record.polls >= devicePollLimit && record.polls <= devicePendingPolls) {
|
||||||
|
deviceCodes.delete(deviceCode);
|
||||||
|
sendJson(reply, 400, { error: "expired_token" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (record.polls <= devicePendingPolls) {
|
||||||
|
sendJson(reply, 400, { error: "authorization_pending" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deviceCodes.delete(deviceCode);
|
||||||
} else {
|
} else {
|
||||||
sendJson(reply, 400, { error: "unsupported_grant_type" });
|
sendJson(reply, 400, { error: "unsupported_grant_type" });
|
||||||
return;
|
return;
|
||||||
@@ -251,12 +378,13 @@ export async function startFakeOidcProvider(options = {}) {
|
|||||||
access_token: randomBytes(32).toString("base64url"),
|
access_token: randomBytes(32).toString("base64url"),
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: 60,
|
expires_in: 60,
|
||||||
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity),
|
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity, tokenTime),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === "GET" && path === "/api/v3/core/groups/") {
|
if (request.method === "GET" && path === "/api/v3/core/groups/") {
|
||||||
if (!request.headers.authorization?.startsWith("Bearer ")) {
|
const authorization = request.headers.authorization;
|
||||||
|
if (!secretMatches(authorization, `Bearer ${apiToken}`)) {
|
||||||
sendJson(reply, 401, { detail: "authentication required" });
|
sendJson(reply, 401, { detail: "authentication required" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -317,7 +445,14 @@ export async function startFakeOidcProvider(options = {}) {
|
|||||||
|
|
||||||
const currentFile = fileURLToPath(import.meta.url);
|
const currentFile = fileURLToPath(import.meta.url);
|
||||||
if (process.argv[1] && resolve(process.argv[1]) === currentFile) {
|
if (process.argv[1] && resolve(process.argv[1]) === currentFile) {
|
||||||
const provider = await startFakeOidcProvider();
|
const provider = await startFakeOidcProvider({
|
||||||
|
registration: {
|
||||||
|
clientId: "fixture-standalone-client",
|
||||||
|
clientSecret: "fixture-standalone-secret-not-production",
|
||||||
|
redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback",
|
||||||
|
},
|
||||||
|
apiToken: "fixture-standalone-api-token-not-production",
|
||||||
|
});
|
||||||
process.stdout.write('{"status":"ready"}\n');
|
process.stdout.write('{"status":"ready"}\n');
|
||||||
const close = async () => {
|
const close = async () => {
|
||||||
await provider.close();
|
await provider.close();
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { request as httpsRequest } from "node:https";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { startFakeOidcProvider } from "./fixtures/oidc-provider.mjs";
|
||||||
|
|
||||||
|
const registration = Object.freeze({
|
||||||
|
clientId: "fixture-client",
|
||||||
|
clientSecret: "fixture-client-secret-not-production",
|
||||||
|
redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback",
|
||||||
|
});
|
||||||
|
const apiToken = "fixture-api-token-not-production";
|
||||||
|
const verifier = "fixture-pkce-verifier-0123456789-abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||||
|
|
||||||
|
let provider;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await provider?.close();
|
||||||
|
provider = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start(options = {}) {
|
||||||
|
provider = await startFakeOidcProvider({ registration, apiToken, ...options });
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exchange(target, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = options.body ?? "";
|
||||||
|
const request = httpsRequest(target, {
|
||||||
|
method: options.method ?? "GET",
|
||||||
|
ca: readFileSync(provider.caFile),
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
...(body.length === 0 ? {} : {
|
||||||
|
"content-length": String(Buffer.byteLength(body)),
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
}, (response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
response.once("error", reject);
|
||||||
|
response.once("end", () => {
|
||||||
|
const text = Buffer.concat(chunks).toString("utf8");
|
||||||
|
const parsed = text.length === 0 ? {} : JSON.parse(text);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
if (Object.hasOwn(parsed, "access_token")) parsed.access_token = "[redacted]";
|
||||||
|
if (Object.hasOwn(parsed, "id_token")) parsed.id_token = "[redacted]";
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
status: response.statusCode ?? 0,
|
||||||
|
location: response.headers.location,
|
||||||
|
body: parsed,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.once("error", reject);
|
||||||
|
request.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function form(entries) {
|
||||||
|
return new URLSearchParams(entries).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorize(overrides = {}) {
|
||||||
|
const target = new URL(`${provider.issuer}authorize`);
|
||||||
|
const values = {
|
||||||
|
response_type: "code",
|
||||||
|
client_id: registration.clientId,
|
||||||
|
redirect_uri: registration.redirectUri,
|
||||||
|
state: "fixture-state",
|
||||||
|
nonce: "fixture-nonce",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
for (const [name, value] of Object.entries(values)) target.searchParams.set(name, value);
|
||||||
|
return exchange(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeFrom(response) {
|
||||||
|
return new URL(response.location).searchParams.get("code");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenBody(code, overrides = {}) {
|
||||||
|
return form({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: registration.clientId,
|
||||||
|
client_secret: registration.clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: registration.redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceAuthorizationBody(overrides = {}) {
|
||||||
|
return form({
|
||||||
|
client_id: registration.clientId,
|
||||||
|
client_secret: registration.clientSecret,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceTokenBody(deviceCode, overrides = {}) {
|
||||||
|
return form({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
client_id: registration.clientId,
|
||||||
|
client_secret: registration.clientSecret,
|
||||||
|
device_code: deviceCode,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("loopback OIDC fixture security contract", () => {
|
||||||
|
test("advertises only production client_secret_post and rejects missing, wrong, basic, or duplicate client credentials", async () => {
|
||||||
|
await start();
|
||||||
|
const discovery = await exchange(`${provider.issuer}.well-known/openid-configuration`);
|
||||||
|
expect(discovery.body.token_endpoint_auth_methods_supported).toEqual(["client_secret_post"]);
|
||||||
|
|
||||||
|
const endpoint = `${provider.issuer}token`;
|
||||||
|
const requests = [
|
||||||
|
form({ grant_type: "authorization_code", code: "unknown" }),
|
||||||
|
form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: "wrong" }),
|
||||||
|
`${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_secret=duplicate`,
|
||||||
|
`${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_id=duplicate`,
|
||||||
|
form({ grant_type: "authorization_code", code: "unknown", client_id: "wrong", client_secret: registration.clientSecret }),
|
||||||
|
];
|
||||||
|
for (const body of requests) {
|
||||||
|
const response = await exchange(endpoint, { method: "POST", body });
|
||||||
|
expect(response).toMatchObject({ status: 401, body: { error: "invalid_client" } });
|
||||||
|
}
|
||||||
|
const basic = Buffer.from(`${registration.clientId}:${registration.clientSecret}`).toString("base64");
|
||||||
|
await expect(exchange(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: form({ grant_type: "authorization_code", code: "unknown" }),
|
||||||
|
headers: { authorization: `Basic ${basic}` },
|
||||||
|
})).resolves.toMatchObject({ status: 401, body: { error: "invalid_client" } });
|
||||||
|
|
||||||
|
await expect(exchange(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: tokenBody("unknown"),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires the exact Authentik bearer token", async () => {
|
||||||
|
await start();
|
||||||
|
const target = `${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`;
|
||||||
|
await expect(exchange(target)).resolves.toMatchObject({ status: 401 });
|
||||||
|
await expect(exchange(target, { headers: { authorization: "Bearer wrong" } }))
|
||||||
|
.resolves.toMatchObject({ status: 401 });
|
||||||
|
await expect(exchange(target, { headers: { authorization: `Bearer ${apiToken}` } }))
|
||||||
|
.resolves.toMatchObject({ status: 200, body: { results: [{ name: "fixture-users" }] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("binds authorization codes to registration, redirect URI, and PKCE and consumes terminal attempts", async () => {
|
||||||
|
await start();
|
||||||
|
await expect(authorize({ client_id: "wrong" })).resolves.toMatchObject({ status: 400 });
|
||||||
|
await expect(authorize({ redirect_uri: "http://127.0.0.1:8787/wrong" })).resolves.toMatchObject({ status: 400 });
|
||||||
|
|
||||||
|
const redirectCode = codeFrom(await authorize());
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST",
|
||||||
|
body: tokenBody(redirectCode, { redirect_uri: "http://127.0.0.1:8787/wrong" }),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(redirectCode) }))
|
||||||
|
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
|
||||||
|
const pkceCode = codeFrom(await authorize());
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST",
|
||||||
|
body: tokenBody(pkceCode, { code_verifier: "wrong-verifier" }),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(pkceCode) }))
|
||||||
|
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
|
||||||
|
const successfulCode = codeFrom(await authorize());
|
||||||
|
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) }))
|
||||||
|
.resolves.toMatchObject({ status: 200 });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) }))
|
||||||
|
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prunes expired authorization state before enforcing a fail-closed cardinality cap", async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
await start({ now: () => now, authorizationStateTtlMs: 1_000, authorizationStateLimit: 1 });
|
||||||
|
const first = await authorize();
|
||||||
|
expect(first.status).toBe(302);
|
||||||
|
await expect(authorize({ state: "capacity" }))
|
||||||
|
.resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } });
|
||||||
|
now += 1_001;
|
||||||
|
await expect(authorize({ state: "after-expiry" })).resolves.toMatchObject({ status: 302 });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(codeFrom(first)) }))
|
||||||
|
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bounds device state, polling, expiry, and replay", async () => {
|
||||||
|
let now = 5_000;
|
||||||
|
await start({
|
||||||
|
now: () => now,
|
||||||
|
deviceStateTtlMs: 1_000,
|
||||||
|
deviceStateLimit: 1,
|
||||||
|
devicePendingPolls: 1,
|
||||||
|
devicePollLimit: 3,
|
||||||
|
});
|
||||||
|
const device = await exchange(`${provider.issuer}device_authorization`, {
|
||||||
|
method: "POST", body: deviceAuthorizationBody(),
|
||||||
|
});
|
||||||
|
expect(device.status).toBe(200);
|
||||||
|
await expect(exchange(`${provider.issuer}device_authorization`, {
|
||||||
|
method: "POST", body: deviceAuthorizationBody(),
|
||||||
|
})).resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||||
|
})).resolves.toMatchObject({ status: 200 });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
|
||||||
|
const expired = await exchange(`${provider.issuer}device_authorization`, {
|
||||||
|
method: "POST", body: deviceAuthorizationBody(),
|
||||||
|
});
|
||||||
|
now += 1_001;
|
||||||
|
await expect(exchange(`${provider.issuer}device_authorization`, {
|
||||||
|
method: "POST", body: deviceAuthorizationBody(),
|
||||||
|
})).resolves.toMatchObject({ status: 200 });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST", body: deviceTokenBody(expired.body.device_code),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes a device grant when its polling limit is exhausted", async () => {
|
||||||
|
await start({ devicePendingPolls: 10, devicePollLimit: 2 });
|
||||||
|
const device = await exchange(`${provider.issuer}device_authorization`, {
|
||||||
|
method: "POST", body: deviceAuthorizationBody(),
|
||||||
|
});
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "expired_token" } });
|
||||||
|
await expect(exchange(`${provider.issuer}token`, {
|
||||||
|
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||||
|
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,33 @@ test("the loopback fixture exposes signed OIDC discovery, device authorization,
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the production OIDC client rejects a wrong registration and wrong mounted client secret", async ({ page }) => {
|
||||||
|
await stack.useOidcMode("ordinary", "wrong-client-id");
|
||||||
|
await page.goto(stack.publicUrl);
|
||||||
|
await signInWithOidc(page);
|
||||||
|
await expect(page.locator("body")).toContainText("invalid_request");
|
||||||
|
await expect(page.getByTestId("app-shell")).toHaveCount(0);
|
||||||
|
|
||||||
|
await stack.useOidcMode("ordinary", "wrong-client-secret");
|
||||||
|
await page.goto(stack.publicUrl);
|
||||||
|
await signInWithOidc(page);
|
||||||
|
await expectOidcCallbackDenied(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("production diagnostics reject a wrong group token and pass with the correct mounted secrets", async () => {
|
||||||
|
await stack.useOidcMode("ordinary", "wrong-api-token");
|
||||||
|
await expect(stack.authDiagnostics()).resolves.toMatchObject({
|
||||||
|
status: 1,
|
||||||
|
report: { ready: false, checks: [{ code: "oidc_group_catalog_unauthorized" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await stack.useOidcMode("ordinary", "correct");
|
||||||
|
await expect(stack.authDiagnostics()).resolves.toMatchObject({
|
||||||
|
status: 0,
|
||||||
|
report: { ready: true, checks: [{ code: "auth_ready" }] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function expectShell(page: Page): Promise<void> {
|
async function expectShell(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ const harnessRoot = join(repositoryRoot, "harness");
|
|||||||
const thtRoot = join(repositoryRoot, "tools", "tht");
|
const thtRoot = join(repositoryRoot, "tools", "tht");
|
||||||
const fakePi = join(frontendRoot, "e2e", "fixtures", "fake-pi.mjs");
|
const fakePi = join(frontendRoot, "e2e", "fixtures", "fake-pi.mjs");
|
||||||
const fakeTht = join(frontendRoot, "e2e", "fixtures", "fake-tht.mjs");
|
const fakeTht = join(frontendRoot, "e2e", "fixtures", "fake-tht.mjs");
|
||||||
|
const FIXTURE_CLIENT_ID = "thothii-e2e-client";
|
||||||
|
const FIXTURE_CLIENT_SECRET = "e2e-client-secret-not-a-production-secret";
|
||||||
|
const FIXTURE_API_TOKEN = "e2e-group-catalog-token-not-a-production-secret";
|
||||||
|
const WRONG_CLIENT_ID = "thothii-e2e-unregistered-client";
|
||||||
|
const WRONG_CLIENT_SECRET = "e2e-wrong-client-secret-not-production";
|
||||||
|
const WRONG_API_TOKEN = "e2e-wrong-api-token-not-production";
|
||||||
|
const OIDC_CREDENTIAL_VARIANTS = new Set([
|
||||||
|
"correct", "wrong-client-id", "wrong-client-secret", "wrong-api-token",
|
||||||
|
]);
|
||||||
|
|
||||||
function safeError(code) {
|
function safeError(code) {
|
||||||
return new Error(code);
|
return new Error(code);
|
||||||
@@ -247,6 +256,48 @@ function managedProcess(command, args, options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oneShotJson(command, args, options) {
|
||||||
|
return new Promise((resolveCommand) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
let output = "";
|
||||||
|
let outputValid = true;
|
||||||
|
let settled = false;
|
||||||
|
const finish = (status) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = outputValid ? JSON.parse(output) : undefined;
|
||||||
|
} catch {
|
||||||
|
report = undefined;
|
||||||
|
}
|
||||||
|
resolveCommand({
|
||||||
|
status: Number.isInteger(status) ? status : 1,
|
||||||
|
report: report && typeof report === "object"
|
||||||
|
? report
|
||||||
|
: { ready: false, mode: "none", checks: [{ code: "fixture_command_invalid" }] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
if (!outputValid) return;
|
||||||
|
output += String(chunk);
|
||||||
|
if (Buffer.byteLength(output) > 64 * 1024) {
|
||||||
|
output = "";
|
||||||
|
outputValid = false;
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("error", () => finish(1));
|
||||||
|
child.once("exit", (status) => finish(status));
|
||||||
|
const timer = setTimeout(() => child.kill("SIGKILL"), 35_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForOk(url, processHandle) {
|
async function waitForOk(url, processHandle) {
|
||||||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||||||
if (processHandle.exited()) {
|
if (processHandle.exited()) {
|
||||||
@@ -303,7 +354,15 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
|||||||
const publicUrl = `http://127.0.0.1:${frontendPort}`;
|
const publicUrl = `http://127.0.0.1:${frontendPort}`;
|
||||||
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
||||||
const workspace = withF1Workspace ? await prepareF1Workspace(root) : undefined;
|
const workspace = withF1Workspace ? await prepareF1Workspace(root) : undefined;
|
||||||
const provider = await startFakeOidcProvider({ directory: providerRoot });
|
const provider = await startFakeOidcProvider({
|
||||||
|
directory: providerRoot,
|
||||||
|
registration: {
|
||||||
|
clientId: FIXTURE_CLIENT_ID,
|
||||||
|
clientSecret: FIXTURE_CLIENT_SECRET,
|
||||||
|
redirectUri: new URL("/api/auth/oidc/callback", publicUrl).href,
|
||||||
|
},
|
||||||
|
apiToken: FIXTURE_API_TOKEN,
|
||||||
|
});
|
||||||
await buildAuthenticationStorageBridge(authStorageBinary);
|
await buildAuthenticationStorageBridge(authStorageBinary);
|
||||||
const localPassword = "e2e-local-password";
|
const localPassword = "e2e-local-password";
|
||||||
const passwordHash = await testPasswordHash(localPassword);
|
const passwordHash = await testPasswordHash(localPassword);
|
||||||
@@ -334,11 +393,17 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
writeSecure(secretsFile, [
|
function writeOidcSecrets(variant) {
|
||||||
"THT_OIDC_CLIENT_SECRET=e2e-client-secret-not-a-production-secret",
|
if (!OIDC_CREDENTIAL_VARIANTS.has(variant)) throw safeError("e2e_oidc_credential_variant_invalid");
|
||||||
"THT_AUTHENTIK_API_TOKEN=e2e-group-catalog-token-not-a-production-secret",
|
const selectedClientSecret = variant === "wrong-client-secret" ? WRONG_CLIENT_SECRET : FIXTURE_CLIENT_SECRET;
|
||||||
"",
|
const selectedApiToken = variant === "wrong-api-token" ? WRONG_API_TOKEN : FIXTURE_API_TOKEN;
|
||||||
].join("\n"));
|
writeSecure(secretsFile, [
|
||||||
|
`THT_OIDC_CLIENT_SECRET=${selectedClientSecret}`,
|
||||||
|
`THT_AUTHENTIK_API_TOKEN=${selectedApiToken}`,
|
||||||
|
"",
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
writeOidcSecrets("correct");
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
writeSecure(fixtureDwhPasswordFile, "fixture-password-not-a-secret\n");
|
writeSecure(fixtureDwhPasswordFile, "fixture-password-not-a-secret\n");
|
||||||
writeSecure(fixtureDwhCaFile, "fixture-ca-not-a-production-certificate\n");
|
writeSecure(fixtureDwhCaFile, "fixture-ca-not-a-production-certificate\n");
|
||||||
@@ -378,7 +443,7 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
|||||||
},
|
},
|
||||||
local: { usersFile: "users.yaml" },
|
local: { usersFile: "users.yaml" },
|
||||||
});
|
});
|
||||||
const oidcConfig = () => ({
|
const oidcConfig = (variant = "correct") => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
mode: "oidc",
|
mode: "oidc",
|
||||||
publicUrl,
|
publicUrl,
|
||||||
@@ -389,7 +454,7 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
|||||||
},
|
},
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: provider.issuer,
|
issuer: provider.issuer,
|
||||||
clientId: "thothii-e2e-client",
|
clientId: variant === "wrong-client-id" ? WRONG_CLIENT_ID : FIXTURE_CLIENT_ID,
|
||||||
clientSecretRef: "THT_OIDC_CLIENT_SECRET",
|
clientSecretRef: "THT_OIDC_CLIENT_SECRET",
|
||||||
scopes: ["openid", "profile", "groups"],
|
scopes: ["openid", "profile", "groups"],
|
||||||
groupsClaim: "groups",
|
groupsClaim: "groups",
|
||||||
@@ -407,42 +472,46 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function backendEnvironment() {
|
||||||
|
return cleanBackendEnvironment({
|
||||||
|
NODE_ENV: "test",
|
||||||
|
HOST: "127.0.0.1",
|
||||||
|
PORT: String(backendPort),
|
||||||
|
PI_BIN: fakePi,
|
||||||
|
THT_BIN: fakeTht,
|
||||||
|
THT_AUTH_STORAGE_BIN: authStorageBinary,
|
||||||
|
THT_HARNESS_DIR: harnessRoot,
|
||||||
|
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||||
|
THT_AUTH_STATE_ROOT: stateRoot,
|
||||||
|
THT_SECRETS_FILE: secretsFile,
|
||||||
|
NODE_EXTRA_CA_CERTS: provider.caFile,
|
||||||
|
SETTINGS_FILE: settingsFile,
|
||||||
|
THT_MAINTENANCE_FILE: maintenanceFile,
|
||||||
|
THT_WORKSPACE_REGISTRY_ROOT: registryRoot,
|
||||||
|
THT_WORKSPACE_SECRET_STORE_ROOT: workspaceSecretRoot,
|
||||||
|
THT_WORKSPACE_SECRET_RUNTIME_ROOT: workspaceRuntimeRoot,
|
||||||
|
THT_WORKSPACE_INSTALLATION_ID: "e2e",
|
||||||
|
THT_DATA_ROOT: join(root, "data"),
|
||||||
|
...(qdrant ? { THT_INTERNAL_QDRANT_URL: qdrant.baseUrl } : {}),
|
||||||
|
...(workspace ? {
|
||||||
|
THT_WORKSPACE_GIT_REMOTE: workspace.remote,
|
||||||
|
THT_WORKSPACE_GIT_BRANCH: "main",
|
||||||
|
THT_WORKSPACE_SECRET_ROOTS: fixtureSecretRoot,
|
||||||
|
THT_WS_FIXTURE_WORKSPACE_DWH_TRANSPORT: "postgres_direct",
|
||||||
|
THT_WS_FIXTURE_WORKSPACE_DWH_HOST: "127.0.0.1",
|
||||||
|
THT_WS_FIXTURE_WORKSPACE_DWH_PORT: "5432",
|
||||||
|
THT_WS_FIXTURE_WORKSPACE_DWH_USER: "fixture",
|
||||||
|
THT_WS_FIXTURE_WORKSPACE_DWH_PASSWORD_FILE: fixtureDwhPasswordFile,
|
||||||
|
THT_WS_FIXTURE_WORKSPACE_DWH_TLS_CA_FILE: fixtureDwhCaFile,
|
||||||
|
} : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function startBackend() {
|
async function startBackend() {
|
||||||
if (mode === undefined) throw safeError("e2e_auth_mode_not_configured");
|
if (mode === undefined) throw safeError("e2e_auth_mode_not_configured");
|
||||||
backend = managedProcess(join(backendRoot, "node_modules", ".bin", "tsx"), ["src/server.ts"], {
|
backend = managedProcess(join(backendRoot, "node_modules", ".bin", "tsx"), ["src/server.ts"], {
|
||||||
cwd: backendRoot,
|
cwd: backendRoot,
|
||||||
env: cleanBackendEnvironment({
|
env: backendEnvironment(),
|
||||||
NODE_ENV: "test",
|
|
||||||
HOST: "127.0.0.1",
|
|
||||||
PORT: String(backendPort),
|
|
||||||
PI_BIN: fakePi,
|
|
||||||
THT_BIN: fakeTht,
|
|
||||||
THT_AUTH_STORAGE_BIN: authStorageBinary,
|
|
||||||
THT_HARNESS_DIR: harnessRoot,
|
|
||||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
|
||||||
THT_AUTH_STATE_ROOT: stateRoot,
|
|
||||||
THT_SECRETS_FILE: secretsFile,
|
|
||||||
NODE_EXTRA_CA_CERTS: provider.caFile,
|
|
||||||
SETTINGS_FILE: settingsFile,
|
|
||||||
THT_MAINTENANCE_FILE: maintenanceFile,
|
|
||||||
THT_WORKSPACE_REGISTRY_ROOT: registryRoot,
|
|
||||||
THT_WORKSPACE_SECRET_STORE_ROOT: workspaceSecretRoot,
|
|
||||||
THT_WORKSPACE_SECRET_RUNTIME_ROOT: workspaceRuntimeRoot,
|
|
||||||
THT_WORKSPACE_INSTALLATION_ID: "e2e",
|
|
||||||
THT_DATA_ROOT: join(root, "data"),
|
|
||||||
...(qdrant ? { THT_INTERNAL_QDRANT_URL: qdrant.baseUrl } : {}),
|
|
||||||
...(workspace ? {
|
|
||||||
THT_WORKSPACE_GIT_REMOTE: workspace.remote,
|
|
||||||
THT_WORKSPACE_GIT_BRANCH: "main",
|
|
||||||
THT_WORKSPACE_SECRET_ROOTS: fixtureSecretRoot,
|
|
||||||
THT_WS_FIXTURE_WORKSPACE_DWH_TRANSPORT: "postgres_direct",
|
|
||||||
THT_WS_FIXTURE_WORKSPACE_DWH_HOST: "127.0.0.1",
|
|
||||||
THT_WS_FIXTURE_WORKSPACE_DWH_PORT: "5432",
|
|
||||||
THT_WS_FIXTURE_WORKSPACE_DWH_USER: "fixture",
|
|
||||||
THT_WS_FIXTURE_WORKSPACE_DWH_PASSWORD_FILE: fixtureDwhPasswordFile,
|
|
||||||
THT_WS_FIXTURE_WORKSPACE_DWH_TLS_CA_FILE: fixtureDwhCaFile,
|
|
||||||
} : {}),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
await waitForOk(`${backendUrl}/health`, backend);
|
await waitForOk(`${backendUrl}/health`, backend);
|
||||||
}
|
}
|
||||||
@@ -468,15 +537,23 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
|||||||
const jwks = await providerJson(`${provider.issuer}jwks`, provider.caFile);
|
const jwks = await providerJson(`${provider.issuer}jwks`, provider.caFile);
|
||||||
const device = await providerJson(`${provider.issuer}device_authorization`, provider.caFile, {
|
const device = await providerJson(`${provider.issuer}device_authorization`, provider.caFile, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: "client_id=thothii-e2e-client",
|
body: new URLSearchParams({
|
||||||
|
client_id: FIXTURE_CLIENT_ID,
|
||||||
|
client_secret: FIXTURE_CLIENT_SECRET,
|
||||||
|
}).toString(),
|
||||||
});
|
});
|
||||||
const deviceCode = typeof device.body?.device_code === "string" ? device.body.device_code : "";
|
const deviceCode = typeof device.body?.device_code === "string" ? device.body.device_code : "";
|
||||||
const deviceToken = deviceCode.length === 0 ? { status: 0, body: {} } : await providerJson(`${provider.issuer}token`, provider.caFile, {
|
const deviceToken = deviceCode.length === 0 ? { status: 0, body: {} } : await providerJson(`${provider.issuer}token`, provider.caFile, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=${encodeURIComponent(deviceCode)}`,
|
body: new URLSearchParams({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
client_id: FIXTURE_CLIENT_ID,
|
||||||
|
client_secret: FIXTURE_CLIENT_SECRET,
|
||||||
|
device_code: deviceCode,
|
||||||
|
}).toString(),
|
||||||
});
|
});
|
||||||
const groups = await providerJson(`${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`, provider.caFile, {
|
const groups = await providerJson(`${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`, provider.caFile, {
|
||||||
headers: { authorization: "Bearer e2e-fixture" },
|
headers: { authorization: `Bearer ${FIXTURE_API_TOKEN}` },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
discovery: discovery.status === 200 && discovery.body?.issuer === provider.issuer,
|
discovery: discovery.status === 200 && discovery.body?.issuer === provider.issuer,
|
||||||
@@ -495,12 +572,22 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {}
|
|||||||
mode = "local";
|
mode = "local";
|
||||||
await restartBackend();
|
await restartBackend();
|
||||||
},
|
},
|
||||||
async useOidcMode(identity) {
|
async useOidcMode(identity, variant = "correct") {
|
||||||
|
if (!OIDC_CREDENTIAL_VARIANTS.has(variant)) throw safeError("e2e_oidc_credential_variant_invalid");
|
||||||
provider.setIdentity(identity);
|
provider.setIdentity(identity);
|
||||||
writeSecure(authConfigFile, JSON.stringify(oidcConfig()));
|
writeOidcSecrets(variant);
|
||||||
|
writeSecure(authConfigFile, JSON.stringify(oidcConfig(variant)));
|
||||||
mode = "oidc";
|
mode = "oidc";
|
||||||
await restartBackend();
|
await restartBackend();
|
||||||
},
|
},
|
||||||
|
async authDiagnostics() {
|
||||||
|
if (mode !== "oidc") throw safeError("e2e_oidc_mode_required");
|
||||||
|
return oneShotJson(
|
||||||
|
join(backendRoot, "node_modules", ".bin", "tsx"),
|
||||||
|
["src/auth/diagnostic-command.ts", "--json"],
|
||||||
|
{ cwd: backendRoot, env: backendEnvironment() },
|
||||||
|
);
|
||||||
|
},
|
||||||
restartBackend,
|
restartBackend,
|
||||||
async close() {
|
async close() {
|
||||||
await backend?.close();
|
await backend?.close();
|
||||||
|
|||||||
@@ -29,6 +29,98 @@ task13_sha256_text() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
task13_record_image_evidence() {
|
||||||
|
local reference="$1" role="$2" image_id verified_id repo_digests
|
||||||
|
[[ "$role" =~ ^[a-z0-9-]+$ ]] || task13_fail "invalid image evidence role"
|
||||||
|
image_id="$(docker image inspect --format '{{.Id}}' "$reference")"
|
||||||
|
[[ "$image_id" =~ ^sha256:[0-9a-f]{64}$ ]] \
|
||||||
|
|| task13_fail "Docker image identity was not resolved"
|
||||||
|
verified_id="$(docker image inspect --format '{{.Id}}' "$image_id")"
|
||||||
|
[[ "$verified_id" == "$image_id" ]] \
|
||||||
|
|| task13_fail "Docker image identity changed during evidence capture"
|
||||||
|
if ! repo_digests="$({ docker image inspect --format '{{json .RepoDigests}}' "$image_id"; } | node -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
let value = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||||
|
if (value === null) value = [];
|
||||||
|
if (!Array.isArray(value)) process.exit(1);
|
||||||
|
const digests = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item !== "string" || !/@sha256:[0-9a-f]{64}$/.test(item)) process.exit(1);
|
||||||
|
digests.push(item.slice(item.lastIndexOf("@") + 1));
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify([...new Set(digests)].sort()));
|
||||||
|
')"; then
|
||||||
|
task13_fail "Docker repository digest evidence was invalid"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s\t%s\t%s\n' "$image_id" "$role" "$repo_digests" >>"$TASK13_IMAGE_EVIDENCE_RECORDS"
|
||||||
|
}
|
||||||
|
|
||||||
|
task13_record_project_image_evidence() {
|
||||||
|
local container_id image_id count=0
|
||||||
|
while IFS= read -r container_id; do
|
||||||
|
[[ -n "$container_id" ]] || continue
|
||||||
|
image_id="$(docker container inspect --format '{{.Image}}' "$container_id")"
|
||||||
|
task13_record_image_evidence "$image_id" compose-runtime
|
||||||
|
count=$((count + 1))
|
||||||
|
done < <(docker container ls -aq --filter "label=com.docker.compose.project=$TASK13_PROJECT")
|
||||||
|
[[ "$count" -gt 0 ]] || task13_fail "no Compose runtime images were available for evidence capture"
|
||||||
|
}
|
||||||
|
|
||||||
|
task13_write_image_evidence() {
|
||||||
|
local image_id verified_id output_dir temporary
|
||||||
|
while IFS= read -r image_id; do
|
||||||
|
[[ -n "$image_id" ]] || continue
|
||||||
|
verified_id="$(docker image inspect --format '{{.Id}}' "$image_id")"
|
||||||
|
[[ "$verified_id" == "$image_id" ]] \
|
||||||
|
|| task13_fail "Docker image identity was unavailable before cleanup"
|
||||||
|
done < <(cut -f1 "$TASK13_IMAGE_EVIDENCE_RECORDS" | LC_ALL=C sort -u)
|
||||||
|
|
||||||
|
output_dir="$(dirname "$TASK13_IMAGE_EVIDENCE_OUTPUT")"
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
temporary="$(mktemp "$output_dir/.unified-docker-images.XXXXXX")"
|
||||||
|
if ! node - "$TASK13_IMAGE_EVIDENCE_RECORDS" "$TASK13_SOURCE_COMMIT" "$TASK13_RUN_ID" >"$temporary" <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const [recordsFile, sourceCommit, runId] = process.argv.slice(2);
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(sourceCommit) || !/^[0-9A-Za-z-]+$/.test(runId)) process.exit(1);
|
||||||
|
const images = new Map();
|
||||||
|
for (const line of fs.readFileSync(recordsFile, "utf8").split("\n").filter(Boolean)) {
|
||||||
|
const fields = line.split("\t");
|
||||||
|
if (fields.length !== 3) process.exit(1);
|
||||||
|
const [id, role, encodedDigests] = fields;
|
||||||
|
if (!/^sha256:[0-9a-f]{64}$/.test(id) || !/^[a-z0-9-]+$/.test(role)) process.exit(1);
|
||||||
|
const repoDigests = JSON.parse(encodedDigests);
|
||||||
|
if (!Array.isArray(repoDigests)
|
||||||
|
|| repoDigests.some((digest) => typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest))) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const current = images.get(id) ?? { id, roles: new Set(), repo_digests: new Set() };
|
||||||
|
current.roles.add(role);
|
||||||
|
for (const digest of repoDigests) current.repo_digests.add(digest);
|
||||||
|
images.set(id, current);
|
||||||
|
}
|
||||||
|
if (images.size === 0) process.exit(1);
|
||||||
|
const document = {
|
||||||
|
gate: "unified-deployment-smoke",
|
||||||
|
source_commit: sourceCommit,
|
||||||
|
run_id: runId,
|
||||||
|
images: [...images.values()].sort((left, right) => left.id.localeCompare(right.id)).map((image) => ({
|
||||||
|
id: image.id,
|
||||||
|
roles: [...image.roles].sort(),
|
||||||
|
repo_digests: [...image.repo_digests].sort(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
then
|
||||||
|
rm -f "$temporary"
|
||||||
|
task13_fail "could not serialize sanitized Docker image evidence"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
chmod 0600 "$temporary"
|
||||||
|
mv "$temporary" "$TASK13_IMAGE_EVIDENCE_OUTPUT"
|
||||||
|
}
|
||||||
|
|
||||||
task13_registry_filesystem_fingerprint() {
|
task13_registry_filesystem_fingerprint() {
|
||||||
python3 - "$1" <<'PY'
|
python3 - "$1" <<'PY'
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -1470,6 +1562,7 @@ task13_prepare_bad_candidate() {
|
|||||||
TASK13_BAD_CANDIDATE_ID="$(docker image inspect --format '{{.Id}}' "$TASK13_BAD_CANDIDATE_IMAGE")"
|
TASK13_BAD_CANDIDATE_ID="$(docker image inspect --format '{{.Id}}' "$TASK13_BAD_CANDIDATE_IMAGE")"
|
||||||
[[ "$TASK13_BAD_CANDIDATE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] \
|
[[ "$TASK13_BAD_CANDIDATE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] \
|
||||||
|| task13_fail "bad candidate image identity was not resolved"
|
|| task13_fail "bad candidate image identity was not resolved"
|
||||||
|
task13_record_image_evidence "$TASK13_BAD_CANDIDATE_IMAGE" rollback-candidate
|
||||||
task13_run_logged "prove bad candidate exits" docker run \
|
task13_run_logged "prove bad candidate exits" docker run \
|
||||||
--name "$TASK13_BAD_CANDIDATE_CONTAINER" \
|
--name "$TASK13_BAD_CANDIDATE_CONTAINER" \
|
||||||
--label "io.thothii.task13.run=$TASK13_RUN_ID" \
|
--label "io.thothii.task13.run=$TASK13_RUN_ID" \
|
||||||
@@ -1934,6 +2027,67 @@ task13_self_test_transaction_image_cleanup() {
|
|||||||
rm -f "$calls" "$foreign_error"
|
rm -f "$calls" "$foreign_error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
task13_self_test_image_evidence() (
|
||||||
|
local fixture records output
|
||||||
|
fixture="$(mktemp -d "${TMPDIR:-/tmp}/thothii-task15-image-evidence.XXXXXX")"
|
||||||
|
records="$fixture/records.tsv"
|
||||||
|
output="$fixture/images.json"
|
||||||
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
|
: >"$records"
|
||||||
|
TASK13_RUN_ID="task15-image-run"
|
||||||
|
TASK13_SOURCE_COMMIT="0123456789abcdef0123456789abcdef01234567"
|
||||||
|
TASK13_IMAGE_EVIDENCE_RECORDS="$records"
|
||||||
|
TASK13_IMAGE_EVIDENCE_OUTPUT="$output"
|
||||||
|
TASK13_PROJECT="task15-image-project"
|
||||||
|
|
||||||
|
docker() {
|
||||||
|
case "$*" in
|
||||||
|
"container ls -aq --filter label=com.docker.compose.project=task15-image-project")
|
||||||
|
printf '%s\n' container-one container-two
|
||||||
|
;;
|
||||||
|
"container inspect --format {{.Image}} container-one")
|
||||||
|
printf '%s\n' 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
;;
|
||||||
|
"container inspect --format {{.Image}} container-two")
|
||||||
|
printf '%s\n' 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||||
|
;;
|
||||||
|
"image inspect --format {{.Id}} fixture-candidate@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")
|
||||||
|
printf '%s\n' 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
|
||||||
|
;;
|
||||||
|
"image inspect --format {{.Id}} sha256:"*)
|
||||||
|
printf '%s\n' "${*: -1}"
|
||||||
|
;;
|
||||||
|
"image inspect --format {{json .RepoDigests}} sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
printf '%s\n' '["fixture/core@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"]'
|
||||||
|
;;
|
||||||
|
"image inspect --format {{json .RepoDigests}} sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||||
|
printf '%s\n' '[]'
|
||||||
|
;;
|
||||||
|
"image inspect --format {{json .RepoDigests}} sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")
|
||||||
|
printf '%s\n' '["fixture/candidate@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]'
|
||||||
|
;;
|
||||||
|
*) task13_fail "unexpected image evidence Docker command" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
task13_record_project_image_evidence
|
||||||
|
task13_record_image_evidence \
|
||||||
|
'fixture-candidate@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' \
|
||||||
|
'rollback-candidate'
|
||||||
|
task13_write_image_evidence
|
||||||
|
unset -f docker
|
||||||
|
|
||||||
|
node - "$output" <<'NODE'
|
||||||
|
const manifest = JSON.parse(require("node:fs").readFileSync(process.argv[2], "utf8"));
|
||||||
|
if (manifest.source_commit !== "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
|| manifest.run_id !== "task15-image-run" || manifest.images.length !== 3) process.exit(1);
|
||||||
|
const ids = manifest.images.map((image) => image.id);
|
||||||
|
if (new Set(ids).size !== 3 || ids.some((id) => !/^sha256:[0-9a-f]{64}$/.test(id))) process.exit(1);
|
||||||
|
if (!manifest.images.some((image) => image.roles.includes("rollback-candidate")
|
||||||
|
&& image.repo_digests[0] === "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) process.exit(1);
|
||||||
|
NODE
|
||||||
|
)
|
||||||
|
|
||||||
task13_self_test_rollback_fixture_contract() {
|
task13_self_test_rollback_fixture_contract() {
|
||||||
[[ "${TASK13_BAD_CANDIDATE_BEHAVIOR:-}" == stopped ]] \
|
[[ "${TASK13_BAD_CANDIDATE_BEHAVIOR:-}" == stopped ]] \
|
||||||
|| task13_fail "rollback candidate is not declared as guaranteed stopped"
|
|| task13_fail "rollback candidate is not declared as guaranteed stopped"
|
||||||
@@ -2181,6 +2335,7 @@ task13_self_test() {
|
|||||||
task13_self_test_cleanup_ownership
|
task13_self_test_cleanup_ownership
|
||||||
task13_self_test_image_cleanup_ownership
|
task13_self_test_image_cleanup_ownership
|
||||||
task13_self_test_transaction_image_cleanup
|
task13_self_test_transaction_image_cleanup
|
||||||
|
task13_self_test_image_evidence
|
||||||
task13_self_test_rollback_fixture_contract
|
task13_self_test_rollback_fixture_contract
|
||||||
task13_self_test_runtime_binding_fixture
|
task13_self_test_runtime_binding_fixture
|
||||||
task13_self_test_server_runtime_binding_fixture
|
task13_self_test_server_runtime_binding_fixture
|
||||||
@@ -2202,6 +2357,7 @@ task13_self_test_case() {
|
|||||||
runtime-bindings) task13_self_test_runtime_binding_fixture ;;
|
runtime-bindings) task13_self_test_runtime_binding_fixture ;;
|
||||||
server-bindings) task13_self_test_server_runtime_binding_fixture ;;
|
server-bindings) task13_self_test_server_runtime_binding_fixture ;;
|
||||||
cleanup) task13_self_test_stopped_project_containers ;;
|
cleanup) task13_self_test_stopped_project_containers ;;
|
||||||
|
image-evidence) task13_self_test_image_evidence ;;
|
||||||
timeout-group) task13_self_test_timeout_process_group ;;
|
timeout-group) task13_self_test_timeout_process_group ;;
|
||||||
timeout-nested) task13_self_test_nested_timeout_process_group ;;
|
timeout-nested) task13_self_test_nested_timeout_process_group ;;
|
||||||
timeout-public) task13_self_test_public_timeout_contract ;;
|
timeout-public) task13_self_test_public_timeout_contract ;;
|
||||||
@@ -2258,6 +2414,7 @@ task13_fixtures_only() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
task13_initialize() {
|
task13_initialize() {
|
||||||
|
local source_status
|
||||||
umask 077
|
umask 077
|
||||||
TASK13_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
TASK13_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||||
TASK13_TMP_PARENT="$(cd "${TMPDIR:-/tmp}" && pwd -P)"
|
TASK13_TMP_PARENT="$(cd "${TMPDIR:-/tmp}" && pwd -P)"
|
||||||
@@ -2269,6 +2426,14 @@ task13_initialize() {
|
|||||||
trap 'task13_cleanup $?' EXIT
|
trap 'task13_cleanup $?' EXIT
|
||||||
trap 'exit 130' INT TERM HUP
|
trap 'exit 130' INT TERM HUP
|
||||||
TASK13_RUN_ID="$(date -u +%Y%m%d%H%M%S)-$$-${RANDOM:-0}"
|
TASK13_RUN_ID="$(date -u +%Y%m%d%H%M%S)-$$-${RANDOM:-0}"
|
||||||
|
TASK13_SOURCE_COMMIT="$(git -C "$TASK13_ROOT" rev-parse --verify HEAD)"
|
||||||
|
[[ "$TASK13_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || task13_fail "source commit was not resolved"
|
||||||
|
source_status="$(git -C "$TASK13_ROOT" status --porcelain --untracked-files=normal \
|
||||||
|
| sed -e '/^?? \.playwright-cli\/$/d' -e '/^?? \.thothctl\/$/d')"
|
||||||
|
[[ -z "$source_status" ]] || task13_fail "source must be clean for image traceability"
|
||||||
|
TASK13_IMAGE_EVIDENCE_RECORDS="$TASK13_TMP/image-evidence.tsv"
|
||||||
|
: >"$TASK13_IMAGE_EVIDENCE_RECORDS"
|
||||||
|
TASK13_IMAGE_EVIDENCE_OUTPUT="$TASK13_ROOT/.artifacts/task-15/unified-docker-images.json"
|
||||||
TASK13_PROFILE="local"
|
TASK13_PROFILE="local"
|
||||||
TASK13_INSTALLATION="$TASK13_TMP/thothii-installation.yaml"
|
TASK13_INSTALLATION="$TASK13_TMP/thothii-installation.yaml"
|
||||||
TASK13_PROJECT="thothii-$(task13_sha256_text "$TASK13_INSTALLATION" | cut -c1-12)"
|
TASK13_PROJECT="thothii-$(task13_sha256_text "$TASK13_INSTALLATION" | cut -c1-12)"
|
||||||
@@ -2339,6 +2504,8 @@ task13_smoke_main() {
|
|||||||
task13_build_tht
|
task13_build_tht
|
||||||
task13_configure_local_authentication
|
task13_configure_local_authentication
|
||||||
task13_start_stack
|
task13_start_stack
|
||||||
|
task13_record_project_image_evidence
|
||||||
|
task13_record_image_evidence "$TASK13_CORE_IMAGE" fixture-runtime
|
||||||
task13_assert_project_ownership
|
task13_assert_project_ownership
|
||||||
task13_assert_built_image_ownership
|
task13_assert_built_image_ownership
|
||||||
task13_assert_runtime
|
task13_assert_runtime
|
||||||
@@ -2348,6 +2515,8 @@ task13_smoke_main() {
|
|||||||
task13_registry_lifecycle
|
task13_registry_lifecycle
|
||||||
fi
|
fi
|
||||||
task13_update_rollback
|
task13_update_rollback
|
||||||
|
task13_record_project_image_evidence
|
||||||
|
task13_write_image_evidence
|
||||||
printf 'Task 13 %s deployment smoke passed.\n' "$mode"
|
printf 'Task 13 %s deployment smoke passed.\n' "$mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2360,10 +2529,14 @@ task13_server_smoke_main() {
|
|||||||
task13_build_tht
|
task13_build_tht
|
||||||
task13_configure_server_oidc_authentication
|
task13_configure_server_oidc_authentication
|
||||||
task13_start_server_stack
|
task13_start_server_stack
|
||||||
|
task13_record_project_image_evidence
|
||||||
|
task13_record_image_evidence "$TASK13_CORE_IMAGE" fixture-runtime
|
||||||
task13_assert_project_ownership
|
task13_assert_project_ownership
|
||||||
task13_assert_built_image_ownership
|
task13_assert_built_image_ownership
|
||||||
task13_assert_server_runtime
|
task13_assert_server_runtime
|
||||||
task13_assert_server_oidc_restore_verification
|
task13_assert_server_oidc_restore_verification
|
||||||
|
task13_record_project_image_evidence
|
||||||
|
task13_write_image_evidence
|
||||||
printf 'Task 13 Linux server deployment smoke passed.\n'
|
printf 'Task 13 Linux server deployment smoke passed.\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user