Spaces:
Sleeping
Sleeping
nyk commited on
fix: prevent Docker build failure when pnpm lockfile is missing (#130)
Browse files* fix: make docker build resilient when lockfile is absent
* test: update e2e credentials for secure admin seed policy
- .env.test +1 -1
- Dockerfile +9 -4
- docs/deployment.md +7 -0
- tests/csrf-validation.spec.ts +4 -2
- tests/login-flow.spec.ts +4 -2
- tests/rate-limiting.spec.ts +3 -1
.env.test
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
AUTH_USER=testadmin
|
| 2 |
-
AUTH_PASS=
|
| 3 |
API_KEY=test-api-key-e2e-12345
|
| 4 |
AUTH_SECRET=test-legacy-secret
|
| 5 |
MC_ALLOW_ANY_HOST=1
|
|
|
|
| 1 |
AUTH_USER=testadmin
|
| 2 |
+
AUTH_PASS=testpass1234!
|
| 3 |
API_KEY=test-api-key-e2e-12345
|
| 4 |
AUTH_SECRET=test-legacy-secret
|
| 5 |
MC_ALLOW_ANY_HOST=1
|
Dockerfile
CHANGED
|
@@ -3,14 +3,19 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
FROM base AS deps
|
| 6 |
-
COPY package.json
|
|
|
|
| 7 |
# better-sqlite3 requires native compilation tools
|
| 8 |
RUN apt-get update && apt-get install -y python3 make g++ --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
| 9 |
-
RUN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
FROM base AS build
|
| 12 |
-
COPY --from=deps /app
|
| 13 |
-
COPY . .
|
| 14 |
RUN pnpm build
|
| 15 |
|
| 16 |
FROM node:20-slim AS runtime
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
FROM base AS deps
|
| 6 |
+
COPY package.json ./
|
| 7 |
+
COPY . .
|
| 8 |
# better-sqlite3 requires native compilation tools
|
| 9 |
RUN apt-get update && apt-get install -y python3 make g++ --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
| 10 |
+
RUN if [ -f pnpm-lock.yaml ]; then \
|
| 11 |
+
pnpm install --frozen-lockfile; \
|
| 12 |
+
else \
|
| 13 |
+
echo "WARN: pnpm-lock.yaml not found in build context; running non-frozen install"; \
|
| 14 |
+
pnpm install --no-frozen-lockfile; \
|
| 15 |
+
fi
|
| 16 |
|
| 17 |
FROM base AS build
|
| 18 |
+
COPY --from=deps /app ./
|
|
|
|
| 19 |
RUN pnpm build
|
| 20 |
|
| 21 |
FROM node:20-slim AS runtime
|
docs/deployment.md
CHANGED
|
@@ -99,6 +99,13 @@ rm -rf node_modules
|
|
| 99 |
pnpm install
|
| 100 |
```
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
### "Invalid ELF header" or "Mach-O" errors
|
| 103 |
|
| 104 |
The native binary was compiled on a different platform. Rebuild:
|
|
|
|
| 99 |
pnpm install
|
| 100 |
```
|
| 101 |
|
| 102 |
+
### "pnpm-lock.yaml not found" during Docker build
|
| 103 |
+
|
| 104 |
+
If your deployment context omits `pnpm-lock.yaml`, Docker build now falls back to
|
| 105 |
+
`pnpm install --no-frozen-lockfile`.
|
| 106 |
+
|
| 107 |
+
For reproducible builds, include `pnpm-lock.yaml` in the build context.
|
| 108 |
+
|
| 109 |
### "Invalid ELF header" or "Mach-O" errors
|
| 110 |
|
| 111 |
The native binary was compiled on a different platform. Rebuild:
|
tests/csrf-validation.spec.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { test, expect } from '@playwright/test'
|
|
| 6 |
*/
|
| 7 |
|
| 8 |
test.describe('CSRF Origin Validation (Issue #20)', () => {
|
|
|
|
|
|
|
| 9 |
test('POST with mismatched Origin is rejected', async ({ request }) => {
|
| 10 |
const res = await request.post('/api/auth/login', {
|
| 11 |
data: { username: 'test', password: 'test' },
|
|
@@ -21,7 +23,7 @@ test.describe('CSRF Origin Validation (Issue #20)', () => {
|
|
| 21 |
|
| 22 |
test('POST with matching Origin is allowed', async ({ request }) => {
|
| 23 |
const res = await request.post('/api/auth/login', {
|
| 24 |
-
data: { username: 'testadmin', password:
|
| 25 |
headers: {
|
| 26 |
'origin': 'http://127.0.0.1:3005',
|
| 27 |
'host': '127.0.0.1:3005'
|
|
@@ -33,7 +35,7 @@ test.describe('CSRF Origin Validation (Issue #20)', () => {
|
|
| 33 |
|
| 34 |
test('POST without Origin header is allowed (non-browser client)', async ({ request }) => {
|
| 35 |
const res = await request.post('/api/auth/login', {
|
| 36 |
-
data: { username: 'testadmin', password:
|
| 37 |
})
|
| 38 |
// No Origin = non-browser client, should be allowed through CSRF check
|
| 39 |
expect(res.status()).not.toBe(403)
|
|
|
|
| 6 |
*/
|
| 7 |
|
| 8 |
test.describe('CSRF Origin Validation (Issue #20)', () => {
|
| 9 |
+
const TEST_PASS = 'testpass1234!'
|
| 10 |
+
|
| 11 |
test('POST with mismatched Origin is rejected', async ({ request }) => {
|
| 12 |
const res = await request.post('/api/auth/login', {
|
| 13 |
data: { username: 'test', password: 'test' },
|
|
|
|
| 23 |
|
| 24 |
test('POST with matching Origin is allowed', async ({ request }) => {
|
| 25 |
const res = await request.post('/api/auth/login', {
|
| 26 |
+
data: { username: 'testadmin', password: TEST_PASS },
|
| 27 |
headers: {
|
| 28 |
'origin': 'http://127.0.0.1:3005',
|
| 29 |
'host': '127.0.0.1:3005'
|
|
|
|
| 35 |
|
| 36 |
test('POST without Origin header is allowed (non-browser client)', async ({ request }) => {
|
| 37 |
const res = await request.post('/api/auth/login', {
|
| 38 |
+
data: { username: 'testadmin', password: TEST_PASS },
|
| 39 |
})
|
| 40 |
// No Origin = non-browser client, should be allowed through CSRF check
|
| 41 |
expect(res.status()).not.toBe(403)
|
tests/login-flow.spec.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { test, expect } from '@playwright/test'
|
|
| 6 |
*/
|
| 7 |
|
| 8 |
test.describe('Login Flow', () => {
|
|
|
|
|
|
|
| 9 |
test('login page loads', async ({ page }) => {
|
| 10 |
await page.goto('/login')
|
| 11 |
await expect(page).toHaveURL(/\/login/)
|
|
@@ -18,7 +20,7 @@ test.describe('Login Flow', () => {
|
|
| 18 |
|
| 19 |
test('login API returns session cookie on success', async ({ request }) => {
|
| 20 |
const res = await request.post('/api/auth/login', {
|
| 21 |
-
data: { username: 'testadmin', password:
|
| 22 |
headers: { 'x-forwarded-for': '10.88.88.1' }
|
| 23 |
})
|
| 24 |
expect(res.status()).toBe(200)
|
|
@@ -39,7 +41,7 @@ test.describe('Login Flow', () => {
|
|
| 39 |
test('session cookie grants API access', async ({ request }) => {
|
| 40 |
// Login to get a session
|
| 41 |
const loginRes = await request.post('/api/auth/login', {
|
| 42 |
-
data: { username: 'testadmin', password:
|
| 43 |
headers: { 'x-forwarded-for': '10.88.88.2' }
|
| 44 |
})
|
| 45 |
expect(loginRes.status()).toBe(200)
|
|
|
|
| 6 |
*/
|
| 7 |
|
| 8 |
test.describe('Login Flow', () => {
|
| 9 |
+
const TEST_PASS = 'testpass1234!'
|
| 10 |
+
|
| 11 |
test('login page loads', async ({ page }) => {
|
| 12 |
await page.goto('/login')
|
| 13 |
await expect(page).toHaveURL(/\/login/)
|
|
|
|
| 20 |
|
| 21 |
test('login API returns session cookie on success', async ({ request }) => {
|
| 22 |
const res = await request.post('/api/auth/login', {
|
| 23 |
+
data: { username: 'testadmin', password: TEST_PASS },
|
| 24 |
headers: { 'x-forwarded-for': '10.88.88.1' }
|
| 25 |
})
|
| 26 |
expect(res.status()).toBe(200)
|
|
|
|
| 41 |
test('session cookie grants API access', async ({ request }) => {
|
| 42 |
// Login to get a session
|
| 43 |
const loginRes = await request.post('/api/auth/login', {
|
| 44 |
+
data: { username: 'testadmin', password: TEST_PASS },
|
| 45 |
headers: { 'x-forwarded-for': '10.88.88.2' }
|
| 46 |
})
|
| 47 |
expect(loginRes.status()).toBe(200)
|
tests/rate-limiting.spec.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { test, expect } from '@playwright/test'
|
|
| 6 |
*/
|
| 7 |
|
| 8 |
test.describe('Login Rate Limiting (Issue #8)', () => {
|
|
|
|
|
|
|
| 9 |
test('blocks login after 5 rapid failed attempts', async ({ request }) => {
|
| 10 |
const results: number[] = []
|
| 11 |
|
|
@@ -25,7 +27,7 @@ test.describe('Login Rate Limiting (Issue #8)', () => {
|
|
| 25 |
|
| 26 |
test('successful login is not blocked for fresh IP', async ({ request }) => {
|
| 27 |
const res = await request.post('/api/auth/login', {
|
| 28 |
-
data: { username: 'testadmin', password:
|
| 29 |
headers: { 'x-real-ip': '10.88.88.88' }
|
| 30 |
})
|
| 31 |
// Should succeed (200) or at least not be rate limited
|
|
|
|
| 6 |
*/
|
| 7 |
|
| 8 |
test.describe('Login Rate Limiting (Issue #8)', () => {
|
| 9 |
+
const TEST_PASS = 'testpass1234!'
|
| 10 |
+
|
| 11 |
test('blocks login after 5 rapid failed attempts', async ({ request }) => {
|
| 12 |
const results: number[] = []
|
| 13 |
|
|
|
|
| 27 |
|
| 28 |
test('successful login is not blocked for fresh IP', async ({ request }) => {
|
| 29 |
const res = await request.post('/api/auth/login', {
|
| 30 |
+
data: { username: 'testadmin', password: TEST_PASS },
|
| 31 |
headers: { 'x-real-ip': '10.88.88.88' }
|
| 32 |
})
|
| 33 |
// Should succeed (200) or at least not be rate limited
|