cast/test/diff.test.ts
dan-claude-bot 4de89c360b feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).

An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.

Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.

The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.

custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.

The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.

Closes #76

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:38:03 +00:00

939 lines
30 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
import { computeDiff, placeholderConflicts, renderDiff } from "../src/diff.js";
// Wrap plain live values as Coolify's {value, realValue} pairs. Here the two
// agree — the ordinary case, where nothing was updated in place — so only
// `value` is set; diffEnv reads it per LiveEnvVar. The stale-`realValue` case
// (#78) is exercised with explicit pairs in its own test below.
const liveEnv = (
m: Record<string, string>,
): Record<string, { value: string }> =>
Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { value: v }]));
const desiredApp = {
kind: "application" as const,
name: "core-api",
fields: { build_pack: "nixpacks", domains: ["https://api.example.com"] },
env: {
vars: {
PORT: { value: "3000", secret: false },
MAILGUN_KEY: { value: "mk-123", secret: true },
},
},
};
describe("computeDiff", () => {
it("plans a create when live is missing", () => {
const r = computeDiff([desiredApp], [], "full");
expect(r.changes).toHaveLength(1);
expect(r.changes[0].op).toBe("create");
expect(r.clean).toBe(false);
});
it("is clean when live matches", () => {
const r = computeDiff(
[desiredApp],
[
{
kind: "application",
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: liveEnv({ PORT: "3000", MAILGUN_KEY: "mk-123" }),
},
],
"full",
);
expect(r.clean).toBe(true);
});
it("marks build_pack drift as non-updatable", () => {
const r = computeDiff(
[desiredApp],
[
{
kind: "application",
name: "core-api",
uuid: "u1",
fields: { build_pack: "static", domains: desiredApp.fields.domains },
env: liveEnv({ PORT: "3000", MAILGUN_KEY: "mk-123" }),
},
],
"full",
);
expect(r.changes[0].fieldDiffs).toEqual([
{
field: "build_pack",
desired: "nixpacks",
live: "static",
updatable: false,
},
]);
});
it("full mode diffs env; structural mode does not", () => {
const live = [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: liveEnv({ PORT: "3000", MAILGUN_KEY: "OLD", EXTRA: "x" }),
},
];
const full = computeDiff([desiredApp], live, "full");
expect(full.changes[0].envDiffs).toEqual([
{ key: "MAILGUN_KEY", state: "change", secret: true },
{ key: "EXTRA", state: "remove-candidate", secret: false },
]);
expect(computeDiff([desiredApp], live, "structural").clean).toBe(true);
});
it("reports orphans, never plans deletion", () => {
const r = computeDiff(
[],
[{ kind: "service", name: "old-thing", uuid: "u9", fields: {} }],
"full",
);
expect(r.changes).toHaveLength(0);
expect(r.orphans).toEqual([
{ kind: "service", name: "old-thing", uuid: "u9" },
]);
expect(r.clean).toBe(false);
});
// #78. Coolify leaves a non-secret var's `realValue` at the pre-update value
// after an in-place PATCH, while `value` is fresh. A flip that landed and
// redeployed must read clean, not re-propose forever — compare non-secrets
// against `value`.
it("does not re-propose a non-secret var flipped in place (stale realValue)", () => {
const r = computeDiff(
[desiredApp],
[
{
kind: "application",
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: {
PORT: { value: "3000", realValue: "3000" },
// Manifest wants MAILGUN_KEY = mk-123 (a secret); realValue carries
// the plaintext and agrees, so the secret stays clean too.
MAILGUN_KEY: { value: "mk-123", realValue: "mk-123" },
},
},
],
"full",
);
expect(r.changes).toHaveLength(0);
expect(r.clean).toBe(true);
});
// The same reproduction with the flip the incubator prod cutover hit: manifest
// "true", live value already "true", but realValue still the stale "false".
it("reads a flipped-in-place flag clean even when realValue is stale", () => {
const flag = {
kind: "application" as const,
name: "core-api",
fields: {},
env: { vars: { REPORTING_ENABLED: { value: "true", secret: false } } },
};
const r = computeDiff(
[flag],
[
{
kind: "application",
name: "core-api",
uuid: "u1",
fields: {},
env: { REPORTING_ENABLED: { value: "true", realValue: "false" } },
},
],
"full",
);
expect(r.changes).toHaveLength(0);
expect(r.clean).toBe(true);
});
// The regression guard the fix must not trip: a SECRET whose `value` is masked
// to a plain token still compares via `realValue`, so a genuine rotation is
// still caught (and a masked value is never mistaken for the desired plaintext).
it("still diffs a secret via realValue when value is masked", () => {
const r = computeDiff(
[desiredApp],
[
{
kind: "application",
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: {
PORT: { value: "3000" },
MAILGUN_KEY: { value: "**********", realValue: "mk-OLD" },
},
},
],
"full",
);
expect(r.changes[0].envDiffs).toEqual([
{ key: "MAILGUN_KEY", state: "change", secret: true },
]);
});
});
// The second pass of the two-pass bootstrap (#47). The store holds the literal
// `pending-coolify-generated` for a provider-generated secret; once the first
// apply has run, Coolify holds the real one. Every fixture here is that state.
const REAL_URL = "postgres://real:secret@db:5432/app";
const generatedApp = {
kind: "application" as const,
name: "core-api",
fields: {},
env: {
vars: {
// The env var KEY. In the real manifest the store REF behind it is
// `DATABASE_URL_PROD` — the two differ, which is why the guard is keyed
// on the store's VALUE and not on the `generated_secrets:` name list.
DATABASE_URL: { value: GENERATED_PLACEHOLDER, secret: true },
PORT: { value: "3000", secret: false },
},
},
};
const liveGenerated = (env: Record<string, string>) => [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: {},
env: liveEnv(env),
},
];
describe("computeDiff generated-secret placeholder", () => {
it("flags a placeholder-in-store vs real-value-live as a conflict, not a change", () => {
const r = computeDiff(
[generatedApp],
liveGenerated({ DATABASE_URL: REAL_URL, PORT: "3000" }),
"full",
);
expect(r.changes[0].envDiffs).toEqual([
{ key: "DATABASE_URL", state: "placeholder-conflict", secret: true },
]);
expect(r.clean).toBe(false);
expect(placeholderConflicts(r)).toEqual([
{ kind: "application", name: "core-api", key: "DATABASE_URL" },
]);
});
it("is clean when the live value is the placeholder too (first apply landed, resource not created yet)", () => {
const r = computeDiff(
[generatedApp],
liveGenerated({ DATABASE_URL: GENERATED_PLACEHOLDER, PORT: "3000" }),
"full",
);
expect(r.clean).toBe(true);
expect(placeholderConflicts(r)).toEqual([]);
});
it("is a plain add — not a conflict — when the var is absent live (the FIRST apply's path)", () => {
const r = computeDiff(
[generatedApp],
liveGenerated({ PORT: "3000" }),
"full",
);
expect(r.changes[0].envDiffs).toEqual([
{ key: "DATABASE_URL", state: "add", secret: true },
]);
expect(placeholderConflicts(r)).toEqual([]);
});
it("is a plain add on a create — Coolify replaces the placeholder when it makes the resource", () => {
const r = computeDiff([generatedApp], [], "full");
expect(r.changes[0].op).toBe("create");
expect(r.changes[0].envDiffs).toContainEqual({
key: "DATABASE_URL",
state: "add",
secret: true,
});
expect(placeholderConflicts(r)).toEqual([]);
});
it("leaves an ordinary secret rotation a plain change", () => {
const r = computeDiff(
[desiredApp],
[
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: liveEnv({ PORT: "3000", MAILGUN_KEY: "mk-OLD" }),
},
],
"full",
);
expect(r.changes[0].envDiffs).toEqual([
{ key: "MAILGUN_KEY", state: "change", secret: true },
]);
expect(placeholderConflicts(r)).toEqual([]);
});
// A non-secret template literal that happens to read `pending-coolify-generated`
// came from the template, not the store — nothing generated it, and writing it
// is what the manifest asked for.
it("does not flag a non-secret var whose literal value happens to be the placeholder", () => {
const r = computeDiff(
[
{
kind: "application" as const,
name: "core-api",
fields: {},
env: {
vars: { NOTE: { value: GENERATED_PLACEHOLDER, secret: false } },
},
},
],
liveGenerated({ NOTE: "something-else" }),
"full",
);
expect(r.changes[0].envDiffs).toEqual([
{ key: "NOTE", state: "change", secret: false },
]);
expect(placeholderConflicts(r)).toEqual([]);
});
it("is invisible to a structural diff, which reads no env at all", () => {
const r = computeDiff(
[generatedApp],
liveGenerated({ DATABASE_URL: REAL_URL }),
"structural",
);
expect(placeholderConflicts(r)).toEqual([]);
expect(r.clean).toBe(true);
});
});
describe("derived resource refs (#60)", () => {
const DERIVED_URL = "postgres://u:p@pg-uuid:5432/widget";
const derivedApp = {
kind: "application" as const,
name: "core-api",
fields: { build_pack: "nixpacks", domains: ["https://api.example.com"] },
env: {
vars: {
DATABASE_URL: {
value: DERIVED_URL,
secret: true,
derived: { resource: "postgres", attr: "url" },
},
},
},
};
const liveApp = (env: Record<string, string>) => [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: { ...derivedApp.fields },
env: liveEnv(env),
},
];
it("is clean when the app's live URL already equals the database's — no perpetual drift", () => {
const r = computeDiff(
[derivedApp],
liveApp({ DATABASE_URL: DERIVED_URL }),
"full",
);
expect(r.clean).toBe(true);
});
it("renders a drift as derived — not as a secret rotation — and never prints the value", () => {
const out = renderDiff(
computeDiff(
[derivedApp],
liveApp({ DATABASE_URL: "postgres://old" }),
"full",
),
);
expect(out).toContain(
"DATABASE_URL: derived from database postgres — live differs, apply will follow it",
);
expect(out).not.toContain("secret DATABASE_URL differs");
expect(out).not.toContain(DERIVED_URL);
expect(out).not.toContain("postgres://old");
});
it("renders a create as a derived add", () => {
const out = renderDiff(computeDiff([derivedApp], [], "full"));
expect(out).toContain(
"DATABASE_URL: derived from database postgres — apply will set it",
);
expect(out).not.toContain(DERIVED_URL);
});
});
describe("renderDiff generated-secret placeholder", () => {
it("says it in words no rotation prints, and never prints the live value", () => {
const out = renderDiff(
computeDiff(
[generatedApp],
liveGenerated({ DATABASE_URL: REAL_URL, PORT: "3000" }),
"full",
),
);
expect(out).toContain(
"secret DATABASE_URL: store holds the generated-secret PLACEHOLDER, live holds a real value — apply would OVERWRITE it",
);
// The old line — the one a rotation prints — must NOT be what this reports.
expect(out).not.toContain("secret DATABASE_URL differs");
// Loud in the tail as well: the summary is the line read before typing apply.
expect(out).toContain(
"1 generated-secret PLACEHOLDER conflict(s) — apply will REFUSE",
);
// Names the key, never the value — capture's rule (a secret printed to a
// terminal is a secret in a scrollback buffer).
expect(out).not.toContain(REAL_URL);
expect(out).not.toContain("real:secret");
});
});
// The destination can never be diffed the way a field is: Coolify 4.1.2 takes
// destination_uuid on write and returns destination_id on read, with nothing
// mapping between them. So it is REPORTED rather than compared — and the one
// thing that IS comparable (a project's live resources against each other)
// carries the check that matters.
//
// Placement is measured against the live side ALONE, so these fixtures pair each
// live resource with a matching desired one: otherwise every resource is an
// orphan, and `clean` would be false for reasons that have nothing to do with
// the destination.
const want = (name: string) => ({
kind: "application" as const,
name,
fields: {},
});
const got = (name: string, destinationId?: number) => ({
kind: "application" as const,
name,
uuid: `u-${name}`,
fields: {},
destinationId,
});
describe("computeDiff placement", () => {
it("is not split when every resource shares one destination", () => {
const r = computeDiff(
[want("a"), want("b")],
[got("a", 3), got("b", 3)],
"structural",
);
expect(r.placement.split).toBe(false);
expect(r.placement.groups).toEqual([
{ destinationId: 3, resources: ["application a", "application b"] },
]);
expect(r.clean).toBe(true);
});
// A project whose resources straddle two networks is a project whose
// isolation is broken — drift, not silence. Same disposition as an orphan:
// reported, counted, never repaired.
it("reports a split project as drift, and is not clean", () => {
const r = computeDiff(
[want("a"), want("b")],
[got("a", 3), got("b", 7)],
"structural",
);
expect(r.placement.split).toBe(true);
expect(r.placement.groups).toEqual([
{ destinationId: 3, resources: ["application a"] },
{ destinationId: 7, resources: ["application b"] },
]);
expect(r.clean).toBe(false);
// ...and apply is not offered a way to "fix" it.
expect(r.changes).toHaveLength(0);
});
// A resource Coolify reports no destination for is no evidence of a split.
it("ignores resources with no destination rather than grouping them", () => {
const r = computeDiff(
[want("a"), want("b")],
[got("a", 3), got("b")],
"structural",
);
expect(r.placement.split).toBe(false);
expect(r.placement.groups).toEqual([
{ destinationId: 3, resources: ["application a"] },
]);
expect(r.clean).toBe(true);
});
it("carries the declared destination through without comparing it", () => {
const r = computeDiff([want("a")], [got("a", 3)], "structural", {
declaredDestination: "dest-abc",
});
expect(r.placement.declared).toBe("dest-abc");
// Declaring one does not make the project dirty — there is nothing to
// compare it against, and a phantom "update" would never clear.
expect(r.clean).toBe(true);
expect(r.changes).toHaveLength(0);
});
});
describe("renderDiff placement", () => {
it("says out loud that a declared destination was NOT compared", () => {
const out = renderDiff(
computeDiff([want("a")], [got("a", 3)], "structural", {
declaredDestination: "dest-abc",
}),
);
expect(out).toContain("dest-abc");
expect(out).toMatch(/NOT compared/);
expect(out).toMatch(/placement: all resources on destination 3/);
});
// The whole reason placement is in the report at all: a destination that read
// back as absent rather than wrong is the failure shape #12/#14/#17/#18 are
// about.
//
// This test used to assert the opposite — that an undeclared, unsplit box says
// NOTHING about placement, on the grounds that a line on every diff is how a
// report stops being read. #41 reversed it. Declaring nothing is not the absence
// of a placement decision, it is a placement decision: cast sends no
// destination_uuid and Coolify picks. Leaving that inference in a source comment
// is what made it invisible until the day it was wrong — a first apply against a
// multi-destination server, 400ing after the run had already created the project
// and the environment. It is a fact about what the next create will do, so it is
// on screen while it is still true.
it("says out loud that an undeclared placement is the server's default", () => {
const out = renderDiff(
computeDiff([want("a")], [got("a", 3)], "structural"),
);
expect(out).toMatch(
/placement: server's default destination \(none declared\)/,
);
// ...and the consequence, which is the only part that can hurt: on a server
// with more than one destination this is not a default, it is a 400.
expect(out).toMatch(/refuses the create/);
// Still clean: an undeclared destination is an assumption, not drift.
expect(out).toContain("clean");
});
// The claim above is about the UNDECLARED case only. A declared destination
// makes the opposite statement (it was sent, and cannot be verified) and must
// never make both.
it("does not call a declared destination the server's default", () => {
const out = renderDiff(
computeDiff([want("a")], [got("a", 3)], "structural", {
declaredDestination: "dest-abc",
}),
);
expect(out).toMatch(/NOT compared/);
expect(out).not.toMatch(/none declared/);
});
it("names every resource on each side of a split", () => {
const out = renderDiff(
computeDiff(
[want("core"), want("landing")],
[got("core", 3), got("landing", 7)],
"structural",
),
);
expect(out).toMatch(/split placement: these resources sit on 2 different/);
expect(out).toContain("destination 3: application core");
expect(out).toContain("destination 7: application landing");
expect(out).toMatch(/apply never moves a live resource between networks/);
expect(out).toMatch(/split placement$/m);
});
});
describe("renderDiff", () => {
it("never prints secret values", () => {
const live = [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: liveEnv({ PORT: "3000", MAILGUN_KEY: "OLD-SECRET" }),
},
];
const out = renderDiff(computeDiff([desiredApp], live, "full"));
expect(out).toContain("secret MAILGUN_KEY differs");
expect(out).not.toContain("mk-123");
expect(out).not.toContain("OLD-SECRET");
});
it("structural mode says env was not compared", () => {
const out = renderDiff(computeDiff([desiredApp], [], "structural"));
expect(out).toMatch(/env vars not compared \(structural mode/);
});
});
// A database's backup schedule is a field like any other — the whole point of
// #51. These pin the four answers a live read can give, and above all pin the
// two that must never be confused: "there is no schedule" (drift, fixable) and
// "cast could not read the schedule" (not drift, not clean, said out loud).
describe("backup schedules", () => {
const wantBackup = {
kind: "database" as const,
name: "postgres",
fields: {
type: "postgresql",
backup: { frequency: "0 3 * * *", retention: 7 },
},
};
const liveDb = (fields: Record<string, unknown>, extra = {}) => ({
kind: "database" as const,
name: "postgres",
uuid: "db-1",
fields: { type: "postgresql", ...fields },
...extra,
});
it("is clean when the live schedule matches", () => {
const r = computeDiff(
[wantBackup],
[liveDb({ backup: { frequency: "0 3 * * *", retention: 7 } })],
"full",
);
expect(r.clean).toBe(true);
expect(r.backupsNotCompared).toEqual([]);
});
it("reports drift when the schedule differs", () => {
const r = computeDiff(
[wantBackup],
[liveDb({ backup: { frequency: "0 5 * * *", retention: 3 } })],
"full",
);
expect(r.changes[0].fieldDiffs).toEqual([
{
field: "backup",
desired: { frequency: "0 3 * * *", retention: 7 },
live: { frequency: "0 5 * * *", retention: 3 },
updatable: true,
},
]);
expect(r.clean).toBe(false);
});
// The defect in one test: a live database with NO schedule, declared in the
// manifest, used to be invisible. It is drift, and apply can fix it.
it("reports drift when the database has no schedule at all", () => {
const r = computeDiff([wantBackup], [liveDb({})], "full");
expect(r.clean).toBe(false);
expect(r.changes[0].fieldDiffs[0]).toMatchObject({
field: "backup",
live: undefined,
updatable: true,
});
});
// A schedule row that exists but is switched off backs nothing up. It must
// not read as clean, and it must not read as absent (apply PATCHes it rather
// than POSTing a second one).
it("reports drift when the schedule exists but is disabled", () => {
const r = computeDiff(
[wantBackup],
[
liveDb({
backup: { frequency: "0 3 * * *", retention: 7, enabled: false },
}),
],
"full",
);
expect(r.clean).toBe(false);
expect(r.changes[0].fieldDiffs[0].field).toBe("backup");
});
// The shape-mismatch path — the one that must not lie in EITHER direction.
it("invents no drift when the live schedule could not be read", () => {
const r = computeDiff(
[wantBackup],
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
"full",
);
// Not drift: cast read nothing, so it may claim nothing. In particular it
// must NOT diff the declared block against `undefined` and report a
// confident change on a database that may be perfectly backed up.
expect(r.changes).toEqual([]);
// And not silence either.
expect(r.backupsNotCompared).toEqual([
{ name: "postgres", reason: "unrecognized shape" },
]);
});
it("says so on screen, on a run that is otherwise clean", () => {
const out = renderDiff(
computeDiff(
[wantBackup],
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
"full",
),
);
expect(out).toContain(
"backup schedule for database postgres declared, NOT compared — verify in the Coolify UI",
);
expect(out).toContain("(unrecognized shape)");
// Reported, but not counted as drift — an absence of evidence is not
// evidence of drift, and a run that fails on it is a run operators learn
// to force past.
expect(out).toMatch(/^clean$/m);
});
it("says nothing about backups when none is declared", () => {
// An undeclared schedule is uncompared, not deleted: a live schedule on a
// database whose manifest is silent is left alone, and unremarked.
const out = renderDiff(
computeDiff(
[
{
kind: "database" as const,
name: "postgres",
fields: { type: "postgresql" },
},
],
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
"full",
),
);
expect(out).not.toContain("backup");
expect(out).toMatch(/^clean$/m);
});
});
// Basic auth, read-side (cast#76). The password is a field cast WRITES and
// cannot READ, and the three ways that could go wrong are all worse than saying
// so: reporting a false "no change", reporting drift cast has no evidence for,
// or printing the value it does have.
describe("computeDiff — basic auth is fail-honest about what it could read", () => {
const wantAuth = {
kind: "application" as const,
name: "admin",
fields: {
build_pack: "nixpacks",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret",
},
};
// What projectLiveFields produces at 4.1.2: the toggle and the username, never
// the password — plus the reason, which fetchLive attaches.
const liveApp = (
fields: Record<string, unknown>,
over: Record<string, unknown> = {},
) => ({
kind: "application" as const,
name: "admin",
uuid: "app-1",
fields: { build_pack: "nixpacks", ...fields },
...over,
});
const PW_REASON = "http_basic_auth_password is never read back";
it("compares the toggle and the username, and skips only the password", () => {
const r = computeDiff(
[wantAuth],
[
liveApp(
{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" },
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
// The two readable halves agree, so there is no drift to report — and the
// password does NOT become a phantom change against `undefined`.
expect(r.changes).toEqual([]);
expect(r.basicAuthNotCompared).toEqual([
{
name: "admin",
fields: ["http_basic_auth_password"],
reason: PW_REASON,
},
]);
});
// The case the whole feature exists for: somebody turned basic auth off in the
// UI. The password being unreadable must not make cast blind to that.
it("still catches a toggle flipped off on the box", () => {
const r = computeDiff(
[wantAuth],
[
liveApp(
{
is_http_basic_auth_enabled: false,
http_basic_auth_username: "ops",
},
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
expect(r.changes).toHaveLength(1);
expect(r.changes[0].fieldDiffs.map((f) => f.field)).toEqual([
"is_http_basic_auth_enabled",
]);
expect(r.clean).toBe(false);
});
it("catches a username changed on the box", () => {
const r = computeDiff(
[wantAuth],
[
liveApp(
{
is_http_basic_auth_enabled: true,
http_basic_auth_username: "someone-else",
},
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
expect(r.changes[0].fieldDiffs.map((f) => f.field)).toEqual([
"http_basic_auth_username",
]);
});
// The other shape: a read that returned none of it. Then cast claims nothing
// about any of the three — not "clean", not "drifted".
it("skips all three when the read returned no basic-auth state at all", () => {
const r = computeDiff(
[wantAuth],
[liveApp({}, { basicAuthNotCompared: "no toggle on this read" })],
"full",
);
expect(r.changes).toEqual([]);
expect(r.basicAuthNotCompared[0].fields).toEqual([
"is_http_basic_auth_enabled",
"http_basic_auth_username",
"http_basic_auth_password",
]);
});
it("says so on screen, on a run it still calls clean", () => {
const out = renderDiff(
computeDiff(
[wantAuth],
[
liveApp(
{
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
},
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
),
);
expect(out).toContain(
"basic_auth on application admin declared, http_basic_auth_password NOT compared — verify in the Coolify UI",
);
// Absence of evidence, not evidence of drift — the backup precedent.
expect(out).toMatch(/^clean$/m);
});
it("says nothing about basic auth for an application that declares none", () => {
const out = renderDiff(
computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: { build_pack: "nixpacks" },
},
],
[liveApp({}, { basicAuthNotCompared: PW_REASON })],
"full",
),
);
expect(out).not.toContain("basic_auth");
expect(out).toMatch(/^clean$/m);
});
// A live resource that reports the fields fine (a future Coolify, or a
// sensitive-token read path) must NOT be told it was uncompared.
it("reports nothing uncompared when the read supplied everything it needed", () => {
const r = computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: { build_pack: "nixpacks", is_http_basic_auth_enabled: false },
},
],
[
liveApp(
{ is_http_basic_auth_enabled: false },
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
expect(r.basicAuthNotCompared).toEqual([]);
});
});
// The password must not reach a terminal, on either side of the arrow, on any
// path — including a CREATE, where every desired field becomes a field diff.
describe("renderDiff — the basic-auth password is redacted (#76)", () => {
const create = () =>
renderDiff(
computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: {
build_pack: "nixpacks",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret-value",
},
},
],
[],
"full",
),
);
it("never prints the value, on a create plan", () => {
expect(create()).not.toContain("s3cret-value");
});
it("still says the field is being set — redacted is not silent", () => {
expect(create()).toContain(
"http_basic_auth_password: differs — apply will set it (secret; value not printed)",
);
});
it("prints the username in the clear — it is not a secret", () => {
expect(create()).toContain("http_basic_auth_username");
expect(create()).toContain("ops");
});
it("never prints the value on an update plan either", () => {
const out = renderDiff(
computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: {
build_pack: "nixpacks",
http_basic_auth_password: "s3cret-value",
},
},
],
// No basicAuthNotCompared: this live side CAN see the password (a future
// Coolify), so it is compared — and still not printed.
[
{
kind: "application" as const,
name: "admin",
uuid: "app-1",
fields: {
build_pack: "nixpacks",
http_basic_auth_password: "the-old-one",
},
},
],
"full",
),
);
expect(out).not.toContain("s3cret-value");
expect(out).not.toContain("the-old-one");
expect(out).toContain("http_basic_auth_password: differs");
});
});