Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 35 additions & 15 deletions e2e/mock-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -429,18 +429,7 @@ let preAuthKeys;
let apiKeys;
let userNodeMap;

function resetState() {
_nodeId = 0;
users = structuredClone(baseUsers);
nodes = createNodes(users);
preAuthKeys = createPreAuthKeys(users);
apiKeys = createApiKeys();
userNodeMap = buildUserNodeMap(nodes, users);
}

resetState();

const policy = JSON.stringify({
const defaultPolicy = {
groups: {
'group:admin': ['alice', 'bob'],
'group:dev': ['carol', 'dave', 'eve', 'frank.garcia', 'grace'],
Expand Down Expand Up @@ -476,7 +465,21 @@ const policy = JSON.stringify({
{ action: 'accept', src: ['group:admin'], dst: ['*'], users: ['root', 'headscale-user'] },
{ action: 'accept', src: ['group:dev'], dst: ['tag:server'], users: ['deploy'] },
],
});
};

let policyDocumentText = JSON.stringify(defaultPolicy);

function resetState() {
_nodeId = 0;
users = structuredClone(baseUsers);
nodes = createNodes(users);
preAuthKeys = createPreAuthKeys(users);
apiKeys = createApiKeys();
userNodeMap = buildUserNodeMap(nodes, users);
policyDocumentText = JSON.stringify(defaultPolicy);
}

resetState();

const versionInfo = {
version: 'v0.28.0',
Expand Down Expand Up @@ -569,7 +572,7 @@ const server = createServer((req, res) => {
}
if (path === '/api/v1/preauthkey') return json(res, { preAuthKeys });
if (path === '/api/v1/apikey') return json(res, { apiKeys });
if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() });
if (path === '/api/v1/policy') return json(res, { policy: policyDocumentText, updatedAt: new Date().toISOString() });
if (path === '/api/v1/health') return json(res, healthInfo);

// Single node GET: /api/v1/node/{id}
Expand Down Expand Up @@ -653,7 +656,24 @@ const server = createServer((req, res) => {

// ── PUT endpoints ───────────────────────────────────────────────────────
if (method === 'PUT') {
if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() });
if (path === '/api/v1/policy') {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
const parsed = JSON.parse(body);
if (typeof parsed.policy === 'string') {
policyDocumentText = parsed.policy;
}
} catch {
// Keep existing policy content when payload is invalid.
}
return json(res, { policy: policyDocumentText, updatedAt: new Date().toISOString() });
});
return;
}
}

// ── DELETE endpoints ────────────────────────────────────────────────────
Expand Down
84 changes: 84 additions & 0 deletions e2e/policy-phase2.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, expect, type Page } from '@playwright/test';

const MOCK_URL = 'http://localhost:8081';
const API_KEY = 'test-api-key';

async function seedAuth(page: Page) {
await page.goto('/');
await page.evaluate(
([url, key]) => {
localStorage.setItem('apiUrl', JSON.stringify(url));
localStorage.setItem('apiKey', JSON.stringify(key));
localStorage.setItem(
'apiKeyInfo',
JSON.stringify({
authorized: true,
expires: new Date(Date.now() + 86_400_000 * 90).toISOString(),
informedUnauthorized: false,
informedExpiringSoon: false,
}),
);
},
[MOCK_URL, API_KEY],
);
await page.reload();
await page.locator('[data-testid="app-shell"]').waitFor({ timeout: 10000 });
}

test.describe('policy builder phase 2', () => {
test.beforeEach(async ({ page }) => {
await seedAuth(page);
});

test('create and persist grants/nodeAttrs/randomizeClientPort', async ({ page }) => {
await page.goto('/acls/');

await page.getByRole('tab', { name: 'Grants' }).click();
await page.getByRole('button', { name: 'Create Grant' }).click();
await expect(page.getByRole('heading', { name: 'Grant #1' })).toBeVisible();
await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click();
await page.getByRole('button', { name: 'Apply capability form' }).first().click();

await page.getByRole('tab', { name: 'Node Attributes' }).click();
await page.getByRole('button', { name: 'Create Node Attribute Rule' }).click();
await expect(page.getByRole('heading', { name: 'Node Attribute #1' })).toBeVisible();
await page.getByRole('button', { name: 'drive:share' }).first().click();

await page.getByRole('tab', { name: 'Policy Settings' }).click();
await page.getByLabel('randomizeClientPort').check();

await page.getByRole('tab', { name: 'Config' }).click();
await page.getByRole('button', { name: 'Save Config' }).click();

await page.reload();
await page.getByRole('tab', { name: 'Config' }).click();

await expect(page.getByText('"randomizeClientPort": true')).toBeVisible();
await expect(page.getByText('"nodeAttrs"')).toBeVisible();
await expect(page.getByText('tailscale.com/cap/drive')).toBeVisible();
});

test('delete grant and persist deletion', async ({ page }) => {
await page.goto('/acls/');

await page.getByRole('tab', { name: 'Grants' }).click();
await page.getByRole('button', { name: 'Create Grant' }).click();
await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click();
await page.getByRole('button', { name: 'Apply capability form' }).first().click();

await page.getByRole('tab', { name: 'Config' }).click();
await page.getByRole('button', { name: 'Save Config' }).click();

await page.getByRole('tab', { name: 'Grants' }).click();
await page.getByTestId('delete-button').first().click();
await page.getByTestId('confirm-delete').first().click();

await page.getByRole('tab', { name: 'Config' }).click();
await page.getByRole('button', { name: 'Save Config' }).click();

await page.reload();
await page.getByRole('tab', { name: 'Config' }).click();

await expect(page.getByText('tailscale.com/cap/drive')).not.toBeVisible();
});
});
15 changes: 15 additions & 0 deletions src/routes/acls/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
import RawMdiCodeJSON from '~icons/mdi/code-json';
import RawMdiConsole from '~icons/mdi/console';
import RawMdiDevices from '~icons/mdi/devices';
import RawMdiTune from '~icons/mdi/tune';
import RawMdiGroups from '~icons/mdi/account-group';
import RawMdiKeyChain from '~icons/mdi/key-chain-variant';
import RawMdiVectorPolyline from '~icons/mdi/vector-polyline';
import RawMdiSecurity from '~icons/mdi/security';
import RawMdiTag from '~icons/mdi/tag';

Expand All @@ -21,7 +24,10 @@
import Config from './Config.svelte';
import Groups from './Groups.svelte';
import Hosts from './Hosts.svelte';
import NodeAttrs from './NodeAttrs.svelte';
import PolicySettings from './PolicySettings.svelte';
import Policies from './Policies.svelte';
import Grants from './Grants.svelte';
import TagOwners from './TagOwners.svelte'
import SshRules from './SshRules.svelte';

Expand All @@ -37,6 +43,9 @@
{ name: 'tag-owners', title: 'Tag Owners', logo: RawMdiTag },
{ name: 'hosts', title: 'Hosts', logo: RawMdiDevices },
{ name: 'policies', title: 'Policies', logo: RawMdiSecurity },
{ name: 'grants', title: 'Grants', logo: RawMdiKeyChain },
{ name: 'node-attrs', title: 'Node Attributes', logo: RawMdiVectorPolyline },
{ name: 'policy-settings', title: 'Policy Settings', logo: RawMdiTune },
{ name: 'ssh', title: 'SSH', logo: RawMdiConsole },
{ name: 'config', title: 'Config', logo: RawMdiCodeJSON },
];
Expand Down Expand Up @@ -82,6 +91,12 @@
<Hosts bind:loading bind:acl />
{:else if tabs[tabSet].name == 'policies'}
<Policies bind:loading bind:acl />
{:else if tabs[tabSet].name == 'grants'}
<Grants bind:loading bind:acl />
{:else if tabs[tabSet].name == 'node-attrs'}
<NodeAttrs bind:loading bind:acl />
{:else if tabs[tabSet].name == 'policy-settings'}
<PolicySettings bind:loading bind:acl />
{:else if tabs[tabSet].name == 'ssh'}
<SshRules bind:loading bind:acl />
{:else if tabs[tabSet].name == 'config'}
Expand Down
Loading
Loading