Skip to content
Merged
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
7 changes: 7 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ class CreateWithdrawPay(BaseModel):
pay_link: str


class AuthorizeAtm(BaseModel):
password: str = Field(..., min_length=1, max_length=256)

class Config:
extra = "forbid"


class CreateTposInvoiceTabSettlement(BaseModel):
tab_id: str = Field(..., min_length=1)
amount: float = Field(..., gt=0)
Expand Down
69 changes: 51 additions & 18 deletions static/js/tpos.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ window.app = Vue.createApp({
tipAmount: 0.0,
tipRounding: null,
hasNFC: false,
atmBox: false,
hidePin: true,
atmDialog: {
show: false,
loading: false,
password: ''
},
atmMode: false,
atmToken: '',
nfcTagReading: false,
Expand Down Expand Up @@ -801,10 +804,32 @@ window.app = Vue.createApp({
},
exitAtmMode() {
this.atmMode = false
this.atmToken = ''
if (this.connectionWithdraw) {
this.connectionWithdraw.close()
this.connectionWithdraw = null
}
this.getRates()
this.cancelAddAmount()
},
startAtmMode() {
async startAtmMode() {
try {
const {data} = await LNbits.api.request(
'POST',
`/tpos/api/v1/atm/${this.tposId}/create`
)
this.enterAtmMode(data)
} catch (error) {
if ([401, 403].includes(error.response?.status)) {
this.atmDialog.show = true
} else {
LNbits.utils.notifyApiError(error)
}
}
},
enterAtmMode(data) {
if (data.claimed != false) return
this.atmToken = data.id
if (!this.showPoS) {
this.showPoS = true
}
Expand All @@ -813,16 +838,26 @@ window.app = Vue.createApp({
if (this.atmPremium > 0) {
this.exchangeRate = this.exchangeRate / (1 + this.atmPremium)
}
LNbits.api
.request('POST', `/tpos/api/v1/atm/${this.tposId}/create`)
.then(res => {
this.atmToken = res.data.id
if (res.data.claimed == false) {
this.atmBox = false
this.atmMode = true
}
})
.catch(LNbits.utils.notifyApiError)
this.atmMode = true
this.atmDialog.show = false
this.atmDialog.password = ''
},
async atmLogin() {
this.atmDialog.loading = true
try {
const {data} = await LNbits.api.request(
'POST',
`/tpos/api/v1/atm/${this.tposId}/authorize`,
null,
{password: this.atmDialog.password}
)
this.enterAtmMode(data)
} catch (error) {
LNbits.utils.notifyApiError(error)
this.atmDialog.password = ''
} finally {
this.atmDialog.loading = false
}
},
lnaddressSubmit() {
LNbits.api
Expand Down Expand Up @@ -894,10 +929,8 @@ window.app = Vue.createApp({
this.connectionWithdraw.onmessage = e => {
if (e.data == 'paid') {
this.invoiceDialog.show = false
this.atmToken = ''
this.exitAtmMode()
this.showComplete()
this.atmMode = false
this.connectionWithdraw.close()
}
}
this.getRates()
Expand Down Expand Up @@ -1997,8 +2030,8 @@ window.app = Vue.createApp({
// do nothing if the event was already processed
if (event.defaultPrevented) return

// active only in the the PoS mode, not in the Cart mode or ATM pin
if (!this.showPoS || this.atmBox) return
// active only in the PoS mode, not in the Cart mode or ATM login
if (!this.showPoS || this.atmDialog.show) return

// prevent weird behaviour when setting round tip
if (this.tipDialog.show) return
Expand Down
41 changes: 26 additions & 15 deletions templates/tpos/dialogs.html
Original file line number Diff line number Diff line change
Expand Up @@ -280,28 +280,39 @@ <h5 class="q-mt-none q-mb-sm">
@delete="deleteHeldCart"
></tpos-held-carts-dialog>

<q-dialog v-model="atmBox" @hide="atmPin = null">
<q-card>
<q-dialog
v-model="atmDialog.show"
:persistent="atmDialog.loading"
@hide="atmDialog.password = ''"
>
<q-card class="q-pa-md">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Withdraw PIN</div>
<div class="text-h6">Unlock ATM</div>
</q-card-section>
<q-card-section>
<q-form @submit="atmSubmit" class="q-gutter-md">
<q-form @submit="atmLogin" autocomplete="off" class="q-gutter-md">
<q-input
autofocus
filled
:type="hidePin ? 'password' : 'number'"
v-model.number="atmPin"
inputmode="numeric"
><template v-slot:append>
<q-icon
:name="hidePin ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="hidePin = !hidePin"
></q-icon> </template
type="password"
label="Password"
autocomplete="off"
v-model="atmDialog.password"
></q-input>
<div>
<q-btn label="Submit" type="submit" color="primary"></q-btn>
<div class="row q-gutter-sm">
<q-btn
label="Cancel"
flat
color="grey"
:disable="atmDialog.loading"
v-close-popup
></q-btn>
<q-btn
label="Enter ATM"
type="submit"
color="primary"
:loading="atmDialog.loading"
></q-btn>
</div>
</q-form>
</q-card-section>
Expand Down
13 changes: 13 additions & 0 deletions templates/tpos/tpos.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ <h5 class="text-italic">
{% include "tpos/dialogs.html" %}
</q-page>
</q-page-container>
<Teleport to="body">
<div v-if="atmMode" class="atm-mode-border" aria-hidden="true"></div>
</Teleport>
<template v-if="enablePrint">
<Teleport to="body">
<div
Expand Down Expand Up @@ -169,6 +172,16 @@ <h5 class="text-italic">
padding: 8px;
text-align: left;
}

.atm-mode-border {
position: fixed;
inset: 0;
border: 5px solid #d32f2f;
box-sizing: border-box;
pointer-events: none;
z-index: 10000;
}

@media screen {
.receipt,
.receipt-text {
Expand Down
88 changes: 78 additions & 10 deletions tests/e2e/tpos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,80 @@ test('admin can create a terminal and add an item through the extracted dialogs'
await expect(page.getByText(itemName, {exact: true})).toBeVisible()
})

test('ATM uses an owner session or password fallback and exits locally', async ({
page,
lnbitsServer
}) => {
await login(page, lnbitsServer)
const merchantWallet = await superuserWallet(page)
const terminal = await createTpos(page, merchantWallet, {
name: `ATM terminal ${randomHex()}`,
currency: 'sats',
wallet: merchantWallet.id,
withdraw_limit: 100,
enable_remote: false
})

await page.goto(`/tpos/${terminal.id}`)
const directCreateRequest = page.waitForRequest(
request =>
request.method() === 'POST' &&
request.url().includes(`/tpos/api/v1/atm/${terminal.id}/create`)
)
await page
.locator('.q-fab:visible')
.first()
.getByRole('button')
.first()
.click()
await page.getByRole('button', {name: /^ATM$/}).click()
await directCreateRequest
await expect(
page.locator('.q-dialog').filter({hasText: 'Unlock ATM'})
).toHaveCount(0)
await expect(page.locator('.atm-mode-border')).toBeVisible()
await page.getByRole('button', {name: /EXIT ATM/i}).click()
await expect(page.locator('.atm-mode-border')).toHaveCount(0)

await page.context().clearCookies()
await page.reload()
const unauthenticatedResponse = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
response.url().endsWith(`/tpos/api/v1/atm/${terminal.id}/create`) &&
response.status() === 401
)
await page
.locator('.q-fab:visible')
.first()
.getByRole('button')
.first()
.click()
await page.getByRole('button', {name: /^ATM$/}).click()
await unauthenticatedResponse
const dialog = page
.locator('.q-dialog')
.filter({hasText: 'Unlock ATM'})
.last()
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('Password')).toBeVisible()
await expect(dialog.getByLabel('Username')).toHaveCount(0)

const authorizeRequest = page.waitForRequest(
request =>
request.method() === 'POST' &&
request.url().includes(`/tpos/api/v1/atm/${terminal.id}/authorize`)
)
await dialog.getByLabel('Password').fill(lnbitsServer.password)
await dialog.getByRole('button', {name: 'Enter ATM'}).click()
const authorize = await authorizeRequest
expect(authorize.postDataJSON()).toEqual({password: lnbitsServer.password})
await expect(page.locator('.atm-mode-border')).toBeVisible()

await page.getByRole('button', {name: /EXIT ATM/i}).click()
await expect(page.locator('.atm-mode-border')).toHaveCount(0)
})

test('public item checkout completes through Lightning with FakeWallet', async ({
page,
lnbitsServer
Expand Down Expand Up @@ -96,7 +170,9 @@ test('public item checkout completes through Lightning with FakeWallet', async (
fallbackTile.evaluate(element => getComputedStyle(element).width)
)
.toBe('150px')
const tileSizeSlider = pos.locator('[aria-label="Tile size"]')
const tileSizeSlider = pos
.getByRole('slider', {name: 'Tile size'})
.locator('.q-slider__track-container')
await expect(tileSizeSlider).toBeVisible()
await tileSizeSlider.focus()
await tileSizeSlider.press('ArrowRight', {delay: 50})
Expand All @@ -112,14 +188,6 @@ test('public item checkout completes through Lightning with FakeWallet', async (
})
)
.toEqual({height: '200px', width: '200px'})
await expect
.poll(() =>
page.evaluate(key => {
const value = window.localStorage.getItem(key)
return value === null ? null : JSON.parse(value)
}, `lnbits.tpos.${terminal.id}.tileSize`)
)
.toBe(200)
await page.reload()
const restoredTile = pos
.locator('div.flex.justify-center.gt-xs > div')
Expand Down Expand Up @@ -215,7 +283,7 @@ test('public item checkout completes through Lightning with FakeWallet', async (

await page.setViewportSize({width: 500, height: 900})
await page.reload()
await expect(pos.locator('[aria-label="Tile size"]')).toHaveCount(0)
await expect(pos.getByRole('slider', {name: 'Tile size'})).toHaveCount(0)
await expect(pos.getByText(itemName, {exact: true}).last()).toBeVisible()
})

Expand Down
54 changes: 54 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,60 @@ async def fake_pay_tribute(withdraw_amount, wallet_id, percent=0.5):
assert "already been claimed" in claimed_again.json()["reason"]


@pytest.mark.asyncio
async def test_atm_create_requires_authenticated_tpos_owner(client: AsyncClient):
owner_account = Account(id=uuid4().hex, username=f"atmowner_{uuid4().hex[:8]}")
owner_account.hash_password("secret1234")
owner = await create_user_account_no_ckeck(account=owner_account)
other_account = Account(id=uuid4().hex, username=f"atmother_{uuid4().hex[:8]}")
other = await create_user_account_no_ckeck(account=other_account)

create = await client.post(
"/tpos/api/v1/tposs",
json=_tpos_payload(withdraw_limit=100),
headers={"X-API-KEY": owner.wallets[0].adminkey},
)
assert create.status_code == 201
tpos = create.json()

owner_create = await client.post(
f"/tpos/api/v1/atm/{tpos['id']}/create?usr={owner.id}"
)
assert owner_create.status_code == 200

authorize = await client.post(
f"/tpos/api/v1/atm/{tpos['id']}/authorize",
json={"password": "secret1234"},
)
assert authorize.status_code == 200

invalid = await client.post(
f"/tpos/api/v1/atm/{tpos['id']}/authorize",
json={"password": "wrong"},
)
assert invalid.status_code == 401
assert invalid.json()["detail"] == "Invalid credentials."
for _ in range(4):
invalid = await client.post(
f"/tpos/api/v1/atm/{tpos['id']}/authorize",
json={"password": "wrong"},
)
assert invalid.status_code == 401
locked = await client.post(
f"/tpos/api/v1/atm/{tpos['id']}/authorize",
json={"password": "secret1234"},
)
assert locked.status_code == 429

other_create = await client.post(
f"/tpos/api/v1/atm/{tpos['id']}/create?usr={other.id}"
)
assert other_create.status_code == 403
assert other_create.json()["detail"] == (
"You do not have access to this TPoS wallet."
)


@pytest.mark.asyncio
async def test_atm_pay_endpoint(client: AsyncClient, monkeypatch):
user, wallet = await _user_with_tabs("atmpayuser")
Expand Down
Loading
Loading