logicsrc/packages/cli/src/teams.test.ts
Anthony Ettinger bb09c145cd
fix(cli): vault names join with "--", not "/" — the server rejects slashes (#112)
#109 addressed vaults as <project>/<env> and shipped broken: every push
failed with

    Vault name must be lowercase letters, numbers, and dashes.

The vault-create endpoint slugifies through /^[a-z0-9][a-z0-9-]{0,62}$/
(apps/pwa/src/routes/credshare.mjs), so a "/" join is refused outright.
Nothing in the CLI ever saw it, because the tests exercised vaultName and
splitVaultName in isolation and never made a request — the one assumption
that mattered, that the server takes an arbitrary vault name, was the one
left unverified.

Switches the separator to "--", which is inside the allowed character set
and still splits unambiguously since neither half may contain one. A
single dash would not: "a-b" + "c" and "a" + "b-c" would collide.

Also validates the joined name against the server's own regex before the
request, so a bad name fails locally with a useful message rather than a
422 after the .env has been read.

The tests now assert the produced name matches that regex, so the
separator cannot drift back out of the allowed set without failing.

Verified end to end against app.logicsrc.com: push, then pull into a
scratch file and diff — keys and values both round-trip losslessly. Then
49 repos pushed under the profullstack team; server reports 49 vaults,
169 secrets, 0 failures.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:32:07 -07:00

91 lines
3.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { splitVaultName, vaultName, VAULT_SEP } from "./teams.js";
// A vault is addressed as <project> <env> on the command line and stored as a
// single name server-side. Two things have to hold: the join must survive the
// server's own validation, and it must split back unambiguously — a wrong split
// would point a push at the wrong vault.
//
// The server slugifies vault names through /^[a-z0-9][a-z0-9-]{0,62}$/. The
// first cut of this used "/" as the separator, which that regex rejects, so
// every push failed with a 422 the unit tests never saw. SERVER_SLUG below is
// that regex, asserted directly, so the separator can't drift out of the
// allowed character set again without a test failing.
const SERVER_SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
describe("vaultName", () => {
it("joins project and env with the separator", () => {
expect(vaultName("web", "prod")).toBe(`web${VAULT_SEP}prod`);
});
it("produces a name the server will accept", () => {
expect(vaultName("web", "prod")).toMatch(SERVER_SLUG);
expect(vaultName("food-delivery-multivendor-enatega-multivendor-backend", "prod")).toMatch(SERVER_SLUG);
});
it("never uses a separator the server rejects", () => {
// The regression that shipped: "/" is not in [a-z0-9-].
expect(VAULT_SEP).toMatch(/^[a-z0-9-]+$/);
expect(vaultName("web", "prod")).not.toContain("/");
});
it("keeps distinct envs of one project apart", () => {
expect(vaultName("web", "staging")).not.toBe(vaultName("web", "prod"));
});
it("keeps distinct projects in one env apart", () => {
expect(vaultName("api", "prod")).not.toBe(vaultName("web", "prod"));
});
it("keeps a dashed project distinct from a dashed env", () => {
// "a-b" + "c" and "a" + "b-c" must not collide — the reason the separator
// is a double dash rather than a single one.
expect(vaultName("a-b", "c")).not.toBe(vaultName("a", "b-c"));
});
it("rejects the separator inside either half", () => {
expect(() => vaultName(`web${VAULT_SEP}api`, "prod")).toThrow(/cannot contain/);
expect(() => vaultName("web", `prod${VAULT_SEP}eu`)).toThrow(/cannot contain/);
});
it("rejects characters the server would refuse", () => {
expect(() => vaultName("web/api", "prod")).toThrow(/not a valid vault name/);
expect(() => vaultName("Web", "prod")).toThrow(/not a valid vault name/);
expect(() => vaultName("web_api", "prod")).toThrow(/not a valid vault name/);
expect(() => vaultName("-web", "prod")).toThrow(/not a valid vault name/);
});
it("rejects a combined name past the server's 63-character limit", () => {
expect(() => vaultName("a".repeat(60), "prod")).toThrow(/at most 63/);
});
it("rejects empty or blank halves", () => {
expect(() => vaultName("", "prod")).toThrow(/Missing project/);
expect(() => vaultName("web", "")).toThrow(/Missing env/);
expect(() => vaultName(" ", "prod")).toThrow(/Missing project/);
});
});
describe("splitVaultName", () => {
it("round-trips a name built by vaultName", () => {
expect(splitVaultName(vaultName("web", "prod"))).toEqual({ project: "web", env: "prod" });
});
it("round-trips halves that contain single dashes", () => {
expect(splitVaultName(vaultName("playground-encryptfiles-web", "prod")))
.toEqual({ project: "playground-encryptfiles-web", env: "prod" });
});
it("returns null for legacy single-word names", () => {
// Vaults created before the split are still listable; they just don't
// decompose, so `teams vaults` shows the raw name instead of guessing.
expect(splitVaultName("prod")).toBeNull();
expect(splitVaultName("web-prod")).toBeNull();
});
it("returns null rather than guessing at an ambiguous name", () => {
expect(splitVaultName(`a${VAULT_SEP}b${VAULT_SEP}c`)).toBeNull();
expect(splitVaultName(`${VAULT_SEP}prod`)).toBeNull();
expect(splitVaultName(`web${VAULT_SEP}`)).toBeNull();
});
});