mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 07:57:03 +00:00
feat: replace openapi hashing system with /version
Apparently I didn't use my brain cells and rely on the /version endpoint that Headscale has exposed since 0.26 (our lowest supported version). Switching to that significantly simplifies the API surface.
This commit is contained in:
@@ -43,7 +43,7 @@ describe("Login action validation", () => {
|
||||
const request = mockRequest(formData);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: { getRuntimeClient: vi.fn() },
|
||||
headscale: { client: vi.fn() },
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("Login action validation", () => {
|
||||
const request = mockRequest(formData);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: { getRuntimeClient: vi.fn() },
|
||||
headscale: { client: vi.fn() },
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
|
||||
@@ -87,8 +87,8 @@ describe("Login action validation", () => {
|
||||
.mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
@@ -114,8 +114,8 @@ describe("Login action validation", () => {
|
||||
.mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
@@ -141,8 +141,8 @@ describe("Login action validation", () => {
|
||||
.mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
@@ -173,8 +173,8 @@ describe("Login action validation", () => {
|
||||
const mockCreateSession = vi.fn().mockResolvedValue("session-cookie");
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: mockCreateSession },
|
||||
};
|
||||
|
||||
@@ -1,65 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HeadscaleApiInterface } from "~/server/headscale/api";
|
||||
import nodeEndpoints from "~/server/headscale/api/endpoints/nodes";
|
||||
import userEndpoints from "~/server/headscale/api/endpoints/users";
|
||||
import { capabilitiesFor } from "~/server/headscale/api/capabilities";
|
||||
import { makeNodeApi } from "~/server/headscale/api/resources/nodes";
|
||||
import { makeUserApi } from "~/server/headscale/api/resources/users";
|
||||
import { parseServerVersion } from "~/server/headscale/api/server-version";
|
||||
import type { Transport, TransportRequest } from "~/server/headscale/api/transport";
|
||||
|
||||
type ApiFetchArgs = Parameters<HeadscaleApiInterface["clientHelpers"]["apiFetch"]>;
|
||||
|
||||
function createApiClientRecorder() {
|
||||
const calls: Array<{
|
||||
method: ApiFetchArgs[0];
|
||||
apiPath: ApiFetchArgs[1];
|
||||
apiKey: ApiFetchArgs[2];
|
||||
bodyOrQuery: ApiFetchArgs[3];
|
||||
}> = [];
|
||||
|
||||
const client = {
|
||||
isAtleast: () => false,
|
||||
rawFetch: async () => {
|
||||
throw new Error("rawFetch should not be called");
|
||||
},
|
||||
apiFetch: async <T>(
|
||||
method: ApiFetchArgs[0],
|
||||
apiPath: ApiFetchArgs[1],
|
||||
apiKey: ApiFetchArgs[2],
|
||||
bodyOrQuery?: ApiFetchArgs[3],
|
||||
) => {
|
||||
calls.push({ method, apiPath, apiKey, bodyOrQuery });
|
||||
function createTransportRecorder() {
|
||||
const calls: TransportRequest[] = [];
|
||||
const transport: Transport = {
|
||||
async request<T>(opts: TransportRequest): Promise<T> {
|
||||
calls.push(opts);
|
||||
return undefined as T;
|
||||
},
|
||||
} as HeadscaleApiInterface["clientHelpers"];
|
||||
|
||||
return { calls, client };
|
||||
async getPublic<T>(): Promise<T> {
|
||||
throw new Error("getPublic should not be called");
|
||||
},
|
||||
async health() {
|
||||
throw new Error("health should not be called");
|
||||
},
|
||||
async dispose() {},
|
||||
};
|
||||
return { calls, transport };
|
||||
}
|
||||
|
||||
const capabilities = capabilitiesFor(parseServerVersion("0.28.0"));
|
||||
|
||||
describe("Headscale API path encoding", () => {
|
||||
test("encodes node rename names as a single URL segment", async () => {
|
||||
const { calls, client } = createApiClientRecorder();
|
||||
const { calls, transport } = createTransportRecorder();
|
||||
|
||||
await nodeEndpoints(client, "api-key").renameNode("2", "../../1/expire");
|
||||
await makeNodeApi(transport, capabilities, "api-key").rename("2", "../../1/expire");
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
apiPath: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
|
||||
path: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
|
||||
apiKey: "api-key",
|
||||
bodyOrQuery: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("encodes user rename names as a single URL segment", async () => {
|
||||
const { calls, client } = createApiClientRecorder();
|
||||
const { calls, transport } = createTransportRecorder();
|
||||
|
||||
await userEndpoints(client, "api-key").renameUser("3", "../../../user/4/rename/pwned-user@");
|
||||
await makeUserApi(transport, capabilities, "api-key").rename(
|
||||
"3",
|
||||
"../../../user/4/rename/pwned-user@",
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
apiPath: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40",
|
||||
path: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40",
|
||||
apiKey: "api-key",
|
||||
bodyOrQuery: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { capabilitiesFor } from "~/server/headscale/api/capabilities";
|
||||
import {
|
||||
formatServerVersion,
|
||||
gte,
|
||||
parseServerVersion,
|
||||
} from "~/server/headscale/api/server-version";
|
||||
|
||||
describe("parseServerVersion", () => {
|
||||
test("parses release versions with and without a leading v", () => {
|
||||
expect(parseServerVersion("0.28.0")).toMatchObject({
|
||||
major: 0,
|
||||
minor: 28,
|
||||
patch: 0,
|
||||
prerelease: undefined,
|
||||
unknown: false,
|
||||
});
|
||||
expect(parseServerVersion("v0.27.1")).toMatchObject({
|
||||
major: 0,
|
||||
minor: 27,
|
||||
patch: 1,
|
||||
unknown: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("captures prerelease and build metadata", () => {
|
||||
const v = parseServerVersion("v0.28.0-beta.1+abcdef");
|
||||
expect(v.major).toBe(0);
|
||||
expect(v.minor).toBe(28);
|
||||
expect(v.patch).toBe(0);
|
||||
expect(v.prerelease).toBe("beta.1");
|
||||
expect(v.build).toBe("abcdef");
|
||||
expect(v.unknown).toBe(false);
|
||||
});
|
||||
|
||||
test("flags unparseable versions (e.g. dev builds)", () => {
|
||||
const v = parseServerVersion("dev");
|
||||
expect(v.unknown).toBe(true);
|
||||
expect(v.raw).toBe("dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gte", () => {
|
||||
test("compares plain versions correctly", () => {
|
||||
const v = parseServerVersion("0.28.0");
|
||||
expect(gte(v, "0.27.0")).toBe(true);
|
||||
expect(gte(v, "0.28.0")).toBe(true);
|
||||
expect(gte(v, "0.29.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores prerelease tags so betas get modern capabilities", () => {
|
||||
// 0.28.0-beta.1 ships every wire format change that 0.28.0 does, so
|
||||
// strict semver (where 0.28.0-beta.1 < 0.28.0) would lock beta users
|
||||
// out of features their server actually supports.
|
||||
const beta = parseServerVersion("0.28.0-beta.1");
|
||||
expect(gte(beta, "0.28.0")).toBe(true);
|
||||
expect(gte(beta, "0.27.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("unknown versions are treated as the newest known release", () => {
|
||||
const dev = parseServerVersion("dev");
|
||||
expect(gte(dev, "9.9.9")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatServerVersion", () => {
|
||||
test("renders release and prerelease versions without a leading v", () => {
|
||||
expect(formatServerVersion(parseServerVersion("v0.28.0"))).toBe("0.28.0");
|
||||
expect(formatServerVersion(parseServerVersion("0.28.0-beta.1"))).toBe("0.28.0-beta.1");
|
||||
});
|
||||
|
||||
test("falls back to the raw string for unknown versions", () => {
|
||||
expect(formatServerVersion(parseServerVersion("dev"))).toBe("dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilitiesFor", () => {
|
||||
test("0.28.0 enables every modern flag", () => {
|
||||
const caps = capabilitiesFor(parseServerVersion("0.28.0"));
|
||||
expect(caps).toEqual({
|
||||
preAuthKeysHaveStableIds: true,
|
||||
nodeTagsAreFlat: true,
|
||||
nodeOwnerIsImmutable: true,
|
||||
policyErrorsUseModernFormat: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("0.28.0-beta.1 matches 0.28.0 capabilities (prerelease gating)", () => {
|
||||
expect(capabilitiesFor(parseServerVersion("0.28.0-beta.1"))).toEqual(
|
||||
capabilitiesFor(parseServerVersion("0.28.0")),
|
||||
);
|
||||
});
|
||||
|
||||
test("0.27.1 only has the policy-error format flag", () => {
|
||||
const caps = capabilitiesFor(parseServerVersion("0.27.1"));
|
||||
expect(caps.preAuthKeysHaveStableIds).toBe(false);
|
||||
expect(caps.nodeTagsAreFlat).toBe(false);
|
||||
expect(caps.nodeOwnerIsImmutable).toBe(false);
|
||||
expect(caps.policyErrorsUseModernFormat).toBe(true);
|
||||
});
|
||||
|
||||
test("0.26.1 has none of the modern capabilities", () => {
|
||||
const caps = capabilitiesFor(parseServerVersion("0.26.1"));
|
||||
expect(caps.preAuthKeysHaveStableIds).toBe(false);
|
||||
expect(caps.nodeTagsAreFlat).toBe(false);
|
||||
expect(caps.nodeOwnerIsImmutable).toBe(false);
|
||||
expect(caps.policyErrorsUseModernFormat).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user