feat(api): add Gemini CLI auth import/export API routes + schemas (PR2)

This commit is contained in:
diegosouzapw
2026-05-18 01:46:07 -03:00
parent c6a51605ce
commit 877aafdb99
7 changed files with 753 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
import { GeminiAuthFileError, writeGeminiAuthFileToLocalCli } from "@/lib/oauth/utils/geminiAuthFile";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
function toErrorResponse(error: unknown) {
if (error instanceof GeminiAuthFileError) {
return NextResponse.json(
{
error: error.message,
code: error.code,
},
{ status: error.status }
);
}
const message = sanitizeErrorMessage(error) || "Failed to apply Gemini auth file";
return NextResponse.json({ error: message }, { status: 500 });
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard, code: "writes_disabled" }, { status: 403 });
}
const { id } = await params;
const result = await writeGeminiAuthFileToLocalCli(id);
logAuditEvent({
action: "provider.credentials.applied",
actor: "admin",
target: id,
resourceType: "provider_credentials",
status: "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider: "gemini-cli",
authPath: result.authPath,
accountsPath: result.accountsPath,
savedBakPath: result.savedBakPath,
centralizedBackupPath: result.centralizedBackupPath,
googleAccountsUpdated: result.googleAccountsUpdated,
},
});
return NextResponse.json({
success: true,
connectionId: id,
connectionLabel: result.connectionLabel,
email: result.email,
authPath: result.authPath,
accountsPath: result.accountsPath,
savedBakPath: result.savedBakPath,
savedAccountsBakPath: result.savedAccountsBakPath,
centralizedBackupPath: result.centralizedBackupPath,
googleAccountsUpdated: result.googleAccountsUpdated,
writtenAt: new Date().toISOString(),
});
} catch (error) {
console.error("[Gemini Auth Apply] Failed:", error);
return toErrorResponse(error);
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { buildGeminiAuthFile, GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
function toErrorResponse(error: unknown) {
if (error instanceof GeminiAuthFileError) {
return NextResponse.json(
{
error: error.message,
code: error.code,
},
{ status: error.status }
);
}
const message = error instanceof Error ? error.message : "Failed to export Gemini auth file";
return NextResponse.json({ error: message }, { status: 500 });
}
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(_request);
if (authError) return authError;
try {
const { id } = await params;
const built = await buildGeminiAuthFile(id);
return new Response(built.content, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${built.fileName}"`,
"Cache-Control": "no-store, max-age=0",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[Gemini Auth Export] Failed:", error);
return toErrorResponse(error);
}
}

View File

@@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile";
import {
parseAndValidateGeminiAuth,
enrichWithLoadCodeAssist,
createConnectionFromAuthFile,
} from "@/lib/oauth/utils/geminiAuthImport";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderAuditTarget } from "@/lib/compliance/providerAudit";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { importGeminiAuthBulkSchema } from "@/shared/validation/schemas";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
function sanitizeConnectionForResponse(connection: Record<string, unknown>) {
const safe = { ...connection };
delete safe.accessToken;
delete safe.refreshToken;
delete safe.idToken;
delete safe.apiKey;
if (safe.providerSpecificData) {
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData);
}
return safe;
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsedBody = validateBody(importGeminiAuthBulkSchema, body);
if (isValidationFailure(parsedBody)) {
return NextResponse.json({ error: parsedBody.error }, { status: 400 });
}
const { entries, overwriteExisting } = parsedBody.data;
const created: Record<string, unknown>[] = [];
const errors: { index: number; name: string; message: string }[] = [];
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
const label = e.name || `entry ${i + 1}`;
try {
const parsed = parseAndValidateGeminiAuth(e.json);
const enriched = await enrichWithLoadCodeAssist(parsed);
const { connection } = await createConnectionFromAuthFile(enriched, {
name: e.name,
email: e.email,
overwriteExisting,
});
const safe = sanitizeConnectionForResponse(connection as Record<string, unknown>);
created.push(safe);
logAuditEvent({
action: "provider.credentials.imported",
actor: "admin",
target: getProviderAuditTarget(connection),
resourceType: "provider_credentials",
status: "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider: "gemini-cli",
email: enriched.email || e.email,
bulkIndex: i,
},
});
} catch (err) {
let message: string;
if (err instanceof GeminiAuthFileError) {
message = err.message;
} else {
message = sanitizeErrorMessage(err) || "Failed to import";
}
errors.push({ index: i, name: label, message });
}
}
logAuditEvent({
action: "provider.credentials.bulk_imported",
actor: "admin",
target: "gemini-cli",
resourceType: "provider_credentials",
status: errors.length === entries.length ? "failure" : "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider: "gemini-cli",
total: entries.length,
success: created.length,
failed: errors.length,
},
});
return NextResponse.json({
success: created.length,
failed: errors.length,
total: entries.length,
created,
errors,
});
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile";
import {
parseAndValidateGeminiAuth,
enrichWithLoadCodeAssist,
createConnectionFromAuthFile,
} from "@/lib/oauth/utils/geminiAuthImport";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderAuditTarget } from "@/lib/compliance/providerAudit";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { importGeminiAuthSchema } from "@/shared/validation/schemas";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
function sanitizeConnectionForResponse(connection: Record<string, unknown>) {
const safe = { ...connection };
delete safe.accessToken;
delete safe.refreshToken;
delete safe.idToken;
delete safe.apiKey;
if (safe.providerSpecificData) {
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData);
}
return safe;
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsedBody = validateBody(importGeminiAuthSchema, body);
if (isValidationFailure(parsedBody)) {
return NextResponse.json({ error: parsedBody.error }, { status: 400 });
}
const { source, name, email, overwriteExisting } = parsedBody.data;
let rawJson: unknown;
try {
rawJson = source.kind === "json" ? source.json : JSON.parse(source.text);
} catch {
return NextResponse.json(
{ error: "Could not parse the content as JSON", code: "invalid_json" },
{ status: 400 }
);
}
try {
const parsed = parseAndValidateGeminiAuth(rawJson);
const enriched = await enrichWithLoadCodeAssist(parsed);
const { connection, created } = await createConnectionFromAuthFile(enriched, {
name,
email,
overwriteExisting,
});
logAuditEvent({
action: "provider.credentials.imported",
actor: "admin",
target: getProviderAuditTarget(connection),
resourceType: "provider_credentials",
status: "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider: "gemini-cli",
created,
email: enriched.email || email,
hasProjectId: !!enriched.projectId,
},
});
return NextResponse.json({
connection: sanitizeConnectionForResponse(connection as Record<string, unknown>),
created,
});
} catch (error) {
if (error instanceof GeminiAuthFileError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.status }
);
}
return NextResponse.json(
{ error: sanitizeErrorMessage(error) || "Failed to import Gemini auth" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { extractGeminiAuthZip } from "@/lib/oauth/utils/geminiAuthZipExtract";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const ZIP_BODY_LIMIT = 11 * 1024 * 1024; // 11 MB — slightly above the 10 MB extracted limit
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const contentLength = Number(request.headers.get("content-length") || "0");
if (contentLength > ZIP_BODY_LIMIT) {
return NextResponse.json(
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
{ status: 413 }
);
}
let buffer: Buffer;
try {
const arrayBuffer = await request.arrayBuffer();
if (arrayBuffer.byteLength > ZIP_BODY_LIMIT) {
return NextResponse.json(
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
{ status: 413 }
);
}
buffer = Buffer.from(arrayBuffer);
} catch {
return NextResponse.json({ error: "Failed to read request body" }, { status: 400 });
}
try {
const files = extractGeminiAuthZip(buffer);
const entries = files.map((f) => {
try {
return { name: f.name, json: JSON.parse(f.content), parseError: null };
} catch {
return { name: f.name, json: null, parseError: "Not valid JSON" };
}
});
return NextResponse.json({ entries });
} catch (error) {
return NextResponse.json(
{ error: sanitizeErrorMessage(error) || "Failed to extract ZIP", code: "extract_failed" },
{ status: 400 }
);
}
}

View File

@@ -353,6 +353,37 @@ export const importCodexAuthBulkSchema = z.object({
overwriteExisting: z.boolean().optional(),
});
// ──── Gemini CLI Auth Import Schema ────
export const importGeminiAuthSchema = z.object({
source: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("json"), json: z.unknown() }),
z.object({
kind: z.literal("text"),
text: z.string().max(256 * 1024, "oauth_creds.json content exceeds 256KB"),
}),
]),
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
overwriteExisting: z.boolean().optional(),
});
// ──── Gemini CLI Auth Import Bulk Schema ────
export const importGeminiAuthBulkSchema = z.object({
entries: z
.array(
z.object({
json: z.unknown(),
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
})
)
.min(1, "At least one entry is required")
.max(50, "At most 50 entries per bulk import"),
overwriteExisting: z.boolean().optional(),
});
// ──── API Key Schemas ────
export const createKeySchema = z.object({

View File

@@ -0,0 +1,343 @@
import test from "node:test";
import assert from "node:assert/strict";
import { z } from "zod";
// Local reimplementation of importGeminiAuthSchema — avoids importing Next.js deps from schemas.ts.
const importGeminiAuthSchema = z.object({
source: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("json"), json: z.unknown() }),
z.object({
kind: z.literal("text"),
text: z.string().max(256 * 1024, "oauth_creds.json content exceeds 256KB"),
}),
]),
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
overwriteExisting: z.boolean().optional(),
});
// Local reimplementation of importGeminiAuthBulkSchema.
const importGeminiAuthBulkSchema = z.object({
entries: z
.array(
z.object({
json: z.unknown(),
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
})
)
.min(1, "At least one entry is required")
.max(50, "At most 50 entries per bulk import"),
overwriteExisting: z.boolean().optional(),
});
function parseSingle(body: unknown) {
return importGeminiAuthSchema.safeParse(body);
}
function parseBulk(body: unknown) {
return importGeminiAuthBulkSchema.safeParse(body);
}
// ──── Helpers ─────────────────────────────────────────────────────────────────
function encodeJwtPayload(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.fake-sig`;
}
function makeIdToken(email: string): string {
return encodeJwtPayload({ sub: "12345", email, iat: 1000000, exp: 9999999 });
}
function makeValidGeminiPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
access_token: "fake-goog-access-token",
refresh_token: "test-refresh-token",
id_token: makeIdToken("user@example.com"),
expiry_date: Date.now() + 3600 * 1000,
scope: "https://www.googleapis.com/auth/cloud-platform",
token_type: "Bearer",
...overrides,
};
}
// Inline parse logic (mirrors parseAndValidateGeminiAuth) to allow pure-logic tests
// without importing DB-coupled modules.
function parseGeminiAuthLocal(raw: unknown): {
accessToken: string;
refreshToken: string;
idToken: string;
scope: string;
tokenType: string;
expiresAt: string | null;
email: string | null;
} {
const doc = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
function toStr(v: unknown): string | null {
if (typeof v !== "string") return null;
const t = v.trim();
return t || null;
}
const accessToken = toStr(doc.access_token);
const refreshToken = toStr(doc.refresh_token);
const idToken = toStr(doc.id_token);
if (!accessToken) throw Object.assign(new Error("access_token is missing or empty in the oauth_creds.json"), { code: "missing_access_token", status: 400 });
if (!refreshToken) throw Object.assign(new Error("refresh_token is missing or empty in the oauth_creds.json"), { code: "missing_refresh_token", status: 400 });
if (!idToken) throw Object.assign(new Error("id_token is missing or empty in the oauth_creds.json"), { code: "missing_id_token", status: 400 });
const expiryDateMs = doc.expiry_date;
let expiresAt: string | null = null;
if (typeof expiryDateMs === "number" && Number.isFinite(expiryDateMs)) {
expiresAt = new Date(expiryDateMs).toISOString();
}
const scope = toStr(doc.scope) ?? "";
const tokenType = toStr(doc.token_type) ?? "Bearer";
// Extract email from JWT id_token
let email: string | null = null;
try {
const parts = idToken.split(".");
if (parts.length === 3) {
const payload = Buffer.from(parts[1], "base64url").toString("utf8");
const p = JSON.parse(payload) as Record<string, unknown>;
const e = p.email;
email = typeof e === "string" && e.trim() ? e.trim() : null;
}
} catch {
// ignore
}
return { accessToken, refreshToken, idToken, scope, tokenType, expiresAt, email };
}
// ──── Schema single: valid cases ──────────────────────────────────────────────
test("schema single: valid json source", () => {
const result = parseSingle({ source: { kind: "json", json: { access_token: "t" } } });
assert.ok(result.success);
assert.equal(result.data.source.kind, "json");
});
test("schema single: valid text source", () => {
const result = parseSingle({
source: { kind: "text", text: JSON.stringify({ access_token: "t" }) },
});
assert.ok(result.success);
assert.equal(result.data.source.kind, "text");
});
test("schema single: optional fields are optional", () => {
const result = parseSingle({ source: { kind: "json", json: {} } });
assert.ok(result.success);
assert.equal(result.data.name, undefined);
assert.equal(result.data.email, undefined);
assert.equal(result.data.overwriteExisting, undefined);
});
// ──── Schema single: invalid cases ───────────────────────────────────────────
test("schema single: kind 'file' fails", () => {
const result = parseSingle({ source: { kind: "file" } });
assert.ok(!result.success);
});
test("schema single: invalid email fails", () => {
const result = parseSingle({
source: { kind: "json", json: {} },
email: "not-an-email",
});
assert.ok(!result.success);
const issue = result.error.issues.find((i) => i.path.includes("email"));
assert.ok(issue, "expected email validation issue");
});
test("schema single: empty name fails", () => {
const result = parseSingle({ source: { kind: "json", json: {} }, name: "" });
assert.ok(!result.success);
});
test("schema single: text above 256KB fails", () => {
const bigText = "x".repeat(256 * 1024 + 1);
const result = parseSingle({ source: { kind: "text", text: bigText } });
assert.ok(!result.success);
});
test("schema single: text exactly at 256KB passes", () => {
const maxText = "x".repeat(256 * 1024);
const result = parseSingle({ source: { kind: "text", text: maxText } });
assert.ok(result.success);
});
// ──── Schema bulk: valid cases ────────────────────────────────────────────────
test("schema bulk: entries with 1 item passes", () => {
const result = parseBulk({ entries: [{ json: {} }] });
assert.ok(result.success);
assert.equal(result.data.entries.length, 1);
});
test("schema bulk: entries with 50 items passes", () => {
const entries = Array.from({ length: 50 }, () => ({ json: {} }));
const result = parseBulk({ entries });
assert.ok(result.success);
assert.equal(result.data.entries.length, 50);
});
// ──── Schema bulk: invalid cases ─────────────────────────────────────────────
test("schema bulk: empty entries array fails", () => {
const result = parseBulk({ entries: [] });
assert.ok(!result.success);
});
test("schema bulk: entries with 51 items fails", () => {
const entries = Array.from({ length: 51 }, () => ({ json: {} }));
const result = parseBulk({ entries });
assert.ok(!result.success);
});
test("schema bulk: invalid email in entry fails", () => {
const result = parseBulk({ entries: [{ json: {}, email: "bad-email" }] });
assert.ok(!result.success);
});
// ──── Parse logic: valid payload ──────────────────────────────────────────────
test("parse: accepts valid Google OAuth2 payload", () => {
const payload = makeValidGeminiPayload();
const parsed = parseGeminiAuthLocal(payload);
assert.equal(parsed.accessToken, "fake-goog-access-token");
assert.equal(parsed.refreshToken, "test-refresh-token");
assert.ok(parsed.idToken.startsWith("eyJ"));
assert.equal(parsed.scope, "https://www.googleapis.com/auth/cloud-platform");
assert.equal(parsed.tokenType, "Bearer");
});
test("parse: rejects empty access_token", () => {
const payload = makeValidGeminiPayload({ access_token: "" });
assert.throws(
() => parseGeminiAuthLocal(payload),
(err: NodeJS.ErrnoException & { code?: string }) => {
assert.equal(err.code, "missing_access_token");
return true;
}
);
});
test("parse: rejects empty refresh_token", () => {
const payload = makeValidGeminiPayload({ refresh_token: "" });
assert.throws(
() => parseGeminiAuthLocal(payload),
(err: NodeJS.ErrnoException & { code?: string }) => {
assert.equal(err.code, "missing_refresh_token");
return true;
}
);
});
test("parse: rejects empty id_token", () => {
const payload = makeValidGeminiPayload({ id_token: "" });
assert.throws(
() => parseGeminiAuthLocal(payload),
(err: NodeJS.ErrnoException & { code?: string }) => {
assert.equal(err.code, "missing_id_token");
return true;
}
);
});
test("parse: converts expiry_date ms to ISO string", () => {
const expiryMs = 1700000000000;
const payload = makeValidGeminiPayload({ expiry_date: expiryMs });
const parsed = parseGeminiAuthLocal(payload);
assert.equal(parsed.expiresAt, new Date(expiryMs).toISOString());
});
test("parse: scope absent yields empty string", () => {
const payload = makeValidGeminiPayload({ scope: undefined });
const parsed = parseGeminiAuthLocal(payload);
assert.equal(parsed.scope, "");
});
test("parse: token_type absent yields Bearer", () => {
const payload = makeValidGeminiPayload({ token_type: undefined });
const parsed = parseGeminiAuthLocal(payload);
assert.equal(parsed.tokenType, "Bearer");
});
test("parse: extracts email from JWT id_token", () => {
const idToken = makeIdToken("alice@example.com");
const payload = makeValidGeminiPayload({ id_token: idToken });
const parsed = parseGeminiAuthLocal(payload);
assert.equal(parsed.email, "alice@example.com");
});
test("parse: source kind 'text' passes JSON before parse", () => {
const inner = makeValidGeminiPayload();
const text = JSON.stringify(inner);
const rawJson = JSON.parse(text);
const parsed = parseGeminiAuthLocal(rawJson);
assert.equal(parsed.accessToken, "fake-goog-access-token");
});
// ──── apply-local response shape ──────────────────────────────────────────────
test("apply-local: response shape includes googleAccountsUpdated boolean", () => {
const fakeResult = {
success: true,
connectionId: "conn-1",
connectionLabel: "user@example.com",
email: "user@example.com",
authPath: "/home/user/.gemini/oauth_creds.json",
accountsPath: "/home/user/.gemini/google_accounts.json",
savedBakPath: null,
savedAccountsBakPath: null,
centralizedBackupPath: null,
googleAccountsUpdated: true,
writtenAt: new Date().toISOString(),
};
assert.equal(typeof fakeResult.googleAccountsUpdated, "boolean");
assert.equal(fakeResult.googleAccountsUpdated, true);
});
test("apply-local: response shape includes accountsPath string", () => {
const fakeResult = {
accountsPath: "/home/user/.gemini/google_accounts.json",
};
assert.equal(typeof fakeResult.accountsPath, "string");
assert.ok(fakeResult.accountsPath.length > 0);
});
test("apply-local: audit metadata includes provider gemini-cli", () => {
const metadata = {
provider: "gemini-cli",
authPath: "/home/user/.gemini/oauth_creds.json",
accountsPath: "/home/user/.gemini/google_accounts.json",
savedBakPath: null,
centralizedBackupPath: null,
googleAccountsUpdated: false,
};
assert.equal(metadata.provider, "gemini-cli");
});
// ──── ZIP extract entries schema ──────────────────────────────────────────────
test("zip-extract: returned entry has name, json, parseError fields", () => {
const validEntry = { name: "oauth_creds.json", json: { access_token: "t" }, parseError: null };
assert.equal(typeof validEntry.name, "string");
assert.ok(validEntry.json !== undefined);
assert.equal(validEntry.parseError, null);
});
test("zip-extract: entry with parse error has null json and non-null parseError", () => {
const failedEntry = { name: "bad.json", json: null, parseError: "Not valid JSON" };
assert.equal(failedEntry.json, null);
assert.equal(typeof failedEntry.parseError, "string");
assert.ok(failedEntry.parseError.length > 0);
});